31import { spawnSync, execSync } from 'child_process' 36const importMetaUrl = import.meta.url 37const defaultNodePortNo = 30022 39const getGitsshName = () => { 40 const name = gitsshCtx.getStore()?.name 44const getGitsshNodePort = () => gitsshCtx.getStore()?.nodePortNo || defaultNodePortNo 45const getRepoPath = () => `/home/git/repos/${gitsshCtx.getStore()?.repoName}.git` 47// Init bare repo on gitssh server (idempotent) 48export const initGitsshRepo = async ({cluster_name, repoName}: {cluster_name: string, repoName?: string}) => { 49 const gitsshName = getGitsshName() 50 const repo = repoName || gitsshCtx.getStore()?.repoName 52 const repoPath = `/home/git/repos/${repo}.git` 59 console.log(`gitssh pod not found, skipping repo init`) 63 // Run as git user (uid 1000) to avoid dubious ownership warnings 64 const initCmd = `git init --bare --initial-branch=main ${repoPath} && git -C ${repoPath} symbolic-ref HEAD refs/heads/main && git -C ${repoPath} config http.receivepack true` 65 const result = spawnSync('kubectl', ['exec', podName, '--', 'su', '-s', '/bin/sh', 'git', '-c', initCmd], { 67 env: {...process.env, KUBECONFIG: kubeConfigPath}, 70 if (result.status === 0) { 71 const output = result.stdout?.toString() || '' 72 console.log(output.includes('Initialized') ? `Initialized bare repo at ${repoPath}` : `Server repo exists at ${repoPath}`) 75 console.error(`Failed to init repo: ${result.stderr?.toString()}`) 79// Generate or load SSH host keys for gitssh (deterministic across deploys) 80const getOrGenHostKeys = (gitsshName: string) => { 81 const keyTypes = ['ed25519', 'rsa', 'ecdsa'] as const 82 const keysH: Record<string, string> = {} 83 for (const keyType of keyTypes) { 84 const privKeyName = `gitssh-${gitsshName}-hostkey-${keyType}` 86 if (!fs.existsSync(privKeyPath)) { 87 const keygenArgs = keyType === 'rsa' ? '-t rsa -b 4096' : keyType === 'ecdsa' ? '-t ecdsa -b 521' : '-t ed25519' 88 execSync(`ssh-keygen ${keygenArgs} -f "${privKeyPath}" -N "" -C "gitssh-${gitsshName}"`, {stdio: 'pipe'}) 89 fs.unlinkSync(privKeyPath + '.pub') // don't need pub key for host keys 91 keysH[`ssh_host_${keyType}_key`] = fs.readFileSync(privKeyPath, 'utf8') 96// Read codeserv public key from secret if it exists 97const getCodeservPubKey = async () => { 98 const pubKeyPath = pathDownJoin(secretsDir, 'codeserv-ssh-key-pub') 105const modVolsFnc = async ({volumeMounts, volumes, initContainers, resources, annotations}: {volumeMounts: any[], volumes: any[], initContainers: any[], resources: any[], annotations: Record<string, string>}) => { 107 const gitsshName = getGitsshName() 108 assertNonEmpty(gitsshCfg.authorizedPubKeyPaths) // must specify pub keys to authorize 110 // Read all configured pub keys 111 const authorizedKeysA: string[] = [] 112 for (const keyPath of gitsshCfg.authorizedPubKeyPaths) { 113 const expandedPath = keyPath.replace(/^~/, envHome!) 115 authorizedKeysA.push(key.trim()) 118 // codeserv container key for internal git clone (read from secret if available) 119 const codeservKey = await getCodeservPubKey() 120 if (codeservKey) authorizedKeysA.push(codeservKey) 121 const authorizedKeys = authorizedKeysA.join('\n') 123 // Get or generate deterministic SSH host keys 124 const hostKeysH = getOrGenHostKeys(gitsshName) 125 annotations.authorizedKeysHash = calcHash(authorizedKeys + JSON.stringify(hostKeysH)) 129 mountPath: `/ssh-keys` 133 mountPath: '/home/git' 137 mountPath: '/etc/ssh' 142 secretName: `${gitsshName}-ssh-keys`, 155 // Secret includes authorized_keys and host keys 157 'authorized_keys': Buffer.from(authorizedKeys).toString('base64'), 158 ...Object.fromEntries(Object.entries(hostKeysH).map(([k, v]) => [k, Buffer.from(v).toString('base64')])) 163 metadata: { name: `${gitsshName}-ssh-keys` }, 167 initContainers.push({ 169 image: 'alpine:latest', 170 command: ['/bin/sh', '-c'], 171 args: [`mkdir -p /home/git/.ssh && 172cp /ssh-keys/authorized_keys /home/git/.ssh/authorized_keys && 173cp /ssh-keys/ssh_host_* /etc/ssh/ && 174chmod 600 /etc/ssh/ssh_host_* && 175cat > /etc/ssh/sshd_config << 'EOF' 177HostKey /etc/ssh/ssh_host_ed25519_key 178HostKey /etc/ssh/ssh_host_rsa_key 179HostKey /etc/ssh/ssh_host_ecdsa_key 181PasswordAuthentication no 182PubkeyAuthentication yes 183AuthorizedKeysFile .ssh/authorized_keys 184Subsystem sftp /usr/lib/ssh/sftp-server 186echo '[safe]' > /home/git/.gitconfig && 187echo ' directory = *' >> /home/git/.gitconfig && 188echo '[uploadpack]' >> /home/git/.gitconfig && 189echo ' allowReachableSHA1InWant = true' >> /home/git/.gitconfig && 190mkdir -p /home/git/git-shell-commands && 191printf '#!/bin/sh\\ngit -C "/home/git/repos/$1.git" cat-file -e "$2" 2>/dev/null\\n' > /home/git/git-shell-commands/commitexists && 192chmod +x /home/git/git-shell-commands/commitexists && 193chown -R 1000:1000 /home/git && 194chmod 755 /home/git && 195chmod 700 /home/git/.ssh && 196 chmod 600 /home/git/.ssh/authorized_keys`], // alpine doesnt have git fyi 200 // HTTPS (Smart HTTP) git: nginx + git-http-backend, htpasswd (bcrypt) auth derived from a token secret 202 volumeMounts.push({name: 'http-shared', mountPath: '/shared'}) 203 volumes.push({name: 'http-shared', emptyDir: {}}) 204 volumes.push({name: 'http-token', secret: {secretName: `${gitsshName}-httptoken`}}) 205 resources.push({apiVersion: 'v1', kind: 'Secret', type: 'Opaque', metadata: {name: `${gitsshName}-httptoken`}, stringData: {token: httpToken}}) 206 initContainers.push({ 208 image: 'httpd:alpine', 209 command: ['/bin/sh', '-c'], 210 args: ['htpasswd -bnB git "$(cat /token/token)" > /shared/htpasswd && chmod 644 /shared/htpasswd'], 211 volumeMounts: [{name: 'http-shared', mountPath: '/shared'}, {name: 'http-token', mountPath: '/token', readOnly: true}], 215export const k8sGitSsh = async () => { 216 ptKubeCtx.enterWith({...ptKubeCtx.getStore(), modVolsFnc}) 218 const {cluster_name} = appCfg 222 // Register setup, setorigin and push actions for help display 223 const setup = async () => setupGitSshLocal({cluster_name}) 224 setup.cliDescript = 'configure local ssh/git and init server repo (idempotent)' 225 const setoriginArgs = () => { const a = getProcArgv().slice(3).filter(x => !x.startsWith('-')); return {repoName: a[0] as string | undefined, localRepoPath: a[1] as string | undefined} } 226 const setorigin = async () => { const {repoName, localRepoPath} = setoriginArgs(); return setoriginGitSshLocal(repoName, localRepoPath) } 227 setorigin.cliDescript = 'set origin remote to gitssh URL (idempotent): setorigin <repoName> [localPath]' 228 const pushArgs = () => { const a = getProcArgv().slice(3).filter(x => !x.startsWith('-')); return {localRepoPath: a[0], repoName: a[1] || gitsshCtx.getStore()?.repoName} } 229 const push = async () => pushToGitsshHttps(pushArgs()) 230 push.cliDescript = 'push a local repo to gitssh over HTTPS: push <localPath> [<repoName>]' 231 availActionsCtx.enterWith({...availActionsCtx.getStore(), setup, setorigin, push}) 233 // Handle setup/setorigin/push actions before eptSimpleDfDeploy (which would reject unknown actions) 234 if (action === 'setup') { 235 await setupGitSshLocal({cluster_name}) 238 if (action === 'setorigin') { 239 const {repoName, localRepoPath} = setoriginArgs() 240 await setoriginGitSshLocal(repoName, localRepoPath) 243 if (action === 'push') { 244 await pushToGitsshHttps(pushArgs()) 248 const gitsshName = getGitsshName() 249 const gitsshSizeGb = gitsshCtx.getStore()?.sizeGb || 10 250 const cfgStorClassName = gitsshCtx.getStore()?.pvcStorClassName 251 let pvcStorClassName: string | undefined 252 if (cfgStorClassName && action === 'apply') { 254 if (!pvcStorClassName) console.log(`Warning: storage class ${cfgStorClassName} not found, falling back to default`) 256 nextContCtx.enterWith({...nextContCtx.getStore(), ports: [{containerPort: 22}, {containerPort: 80}]}) 259 // https://semanticdiff.com/online-diff/json/ 260 mountPath: '/home/git/repos', sizeGb: gitsshSizeGb, 261 nodePortNo: getGitsshNodePort(), 265 ...(pvcStorClassName && {pvcStorClassName}), 268 const httpsHostname = gitsshCtx.getStore()?.gitsshHttpsHostname 271 kubeSvcTmpl({name: `${gitsshName}-http`, portNo: 80, svcName: gitsshName}), 272 genericIngressTmpl({name: `${gitsshName}-http`, svcName: `${gitsshName}-http`, hostname: httpsHostname, portNo: 80}), 273 ], action, cluster_name}) 274 if (action === 'apply') { 276 console.log(`\ngitssh HTTPS ready: https://git:${httpToken}@${httpsHostname}/git/${gitsshCtx.getStore()?.repoName}.git`) 280 if (action === 'apply') { 281 const initRepos = gitsshCtx.getStore()?.initRepos ?? [gitsshCtx.getStore()?.repoName] 282 for (const repo of initRepos) { 284 await initGitsshRepo({cluster_name, repoName: repo}) 291const getSshConfigBlock = ({sshHostAlias, hostname, sshKeyPath, nodePortNo}: {sshHostAlias: string, hostname: string, sshKeyPath: string, nodePortNo: number}) => ` 296 IdentityFile ${sshKeyPath} 299const getHostBlockRegex = (sshHostAlias: string) => new RegExp(`Host ${sshHostAlias}\\n(?: [^\\n]+\\n)+`) 301export const setoriginGitSshLocal = async (repoName?: string, localRepoPath?: string) => { 303 const repo = repoName || gitsshCfg.repoName 305 const execOpts = localRepoPath ? {cwd: localRepoPath} : undefined 306 let gitRemoteUrl: string, displayUrl: string 307 if (gitsshCfg.gitsshHttpsHostname) { 309 gitRemoteUrl = `https://git:${token}@${gitsshCfg.gitsshHttpsHostname}/git/${repo}.git` 310 displayUrl = `https://git:***@${gitsshCfg.gitsshHttpsHostname}/git/${repo}.git` 313 gitRemoteUrl = `ssh://${gitsshCfg.sshHostAlias}/home/git/repos/${repo}.git` 314 displayUrl = gitRemoteUrl 317 let existingUrl = null 319 const { stdout } = await doExec(`git remote get-url origin`, execOpts) 320 existingUrl = String(stdout).trim() 321 } catch { // catch:userapproved 322 // origin doesn't exist 325 if (existingUrl === gitRemoteUrl) { 326 console.log(`origin already set to ${displayUrl}, skipping`) 327 } else if (existingUrl) { 328 await doExec(`git remote set-url origin ${gitRemoteUrl}`, execOpts) 329 console.log(`Updated origin -> ${displayUrl}`) 331 await doExec(`git remote add origin ${gitRemoteUrl}`, execOpts) 332 console.log(`Added origin: ${displayUrl}`) 336export const setupGitSshLocal = async ({cluster_name}: {cluster_name: string}) => { 338 const {repoName, sshHostAlias, sshKeyPath} = gitsshCfg 342 const gitsshName = getGitsshName() 343 const nodePortNo = getGitsshNodePort() 344 const repoPath = `/home/git/repos/${repoName}.git` 345 const gitRemoteUrl = `ssh://${sshHostAlias}${repoPath}` 348 const { nodeIpsExtHost, lanNodeIp, clusterVip } = klustCfg 350 const onLan = await isOnSameLan({cluster_name, nodeIpsExtHost, clusterVip}) 351 const sshHostname = onLan ? lanNodeIp : nodeIpsExtHost 352 console.log(`Detected network: ${onLan ? 'LAN' : 'external'}, using ${sshHostname}`) 354 // 1. Setup ~/.ssh/config 358 } catch { // catch:userapproved 359 // file doesn't exist, will create 362 const sshConfigBlock = getSshConfigBlock({sshHostAlias, hostname: sshHostname!, sshKeyPath, nodePortNo}) 363 const hostBlockRegex = getHostBlockRegex(sshHostAlias) 364 const hasHostBlock = hostBlockRegex.test(sshConfig) 367 // Check if existing config has the right hostname AND port (within this specific host block) 368 const existingBlock = sshConfig.match(hostBlockRegex)?.[0] || '' 369 const hasCorrectHost = existingBlock.includes(`HostName ${sshHostname}`) 370 const hasCorrectPort = existingBlock.includes(`Port ${nodePortNo}`) 371 if (hasCorrectHost && hasCorrectPort) { 372 console.log(`~/.ssh/config already has Host ${sshHostAlias} with correct hostname, skipping`) 374 // Replace existing block with new one 375 const newConfig = sshConfig.replace(hostBlockRegex, sshConfigBlock + '\n') 376 await fs1Promises.writeFile(sshConfigPath, newConfig) 377 console.log(`Updated Host ${sshHostAlias} in ~/.ssh/config with hostname ${sshHostname} port ${nodePortNo}`) 380 const newConfig = sshConfig ? `${sshConfig.trimEnd()}\n\n${sshConfigBlock}\n` : `${sshConfigBlock}\n` 381 await fs1Promises.writeFile(sshConfigPath, newConfig) 382 console.log(`Added Host ${sshHostAlias} to ~/.ssh/config`) 385 // 2. Setup git remote (use cluster-specific name to avoid conflicts with 'origin') 386 const gitRemoteName = `${cluster_name}git` 387 let existingUrl = null 389 const { stdout } = await doExec(`git remote get-url ${gitRemoteName}`) 390 existingUrl = stdout.trim() 391 } catch { // catch:userapproved 392 // remote doesn't exist 395 if (existingUrl === gitRemoteUrl) { 396 console.log(`git remote ${gitRemoteName} already set to ${gitRemoteUrl}, skipping`) 397 } else if (existingUrl) { 398 await doExec(`git remote set-url ${gitRemoteName} ${gitRemoteUrl}`) 399 console.log(`Updated git remote ${gitRemoteName}: ${existingUrl} -> ${gitRemoteUrl}`) 401 await doExec(`git remote add ${gitRemoteName} ${gitRemoteUrl}`) 402 console.log(`Added git remote ${gitRemoteName}: ${gitRemoteUrl}`) 405 // 3. Init bare repo on server (idempotent - git init --bare is safe to run multiple times) 406 console.log(`Initializing repo on server...`) 407 const initOk = await initGitsshRepo({cluster_name}) 410 // 4. Update known_hosts with current host key (deterministic keys survive pod recreation, but stale entries may linger) 411 const hostEntry = `[${sshHostname}]:${nodePortNo}` 412 try { execSync(`ssh-keygen -R '${hostEntry}' 2>/dev/null`, {stdio: 'pipe'}) } catch {} // catch:userapproved 413 execSync(`ssh-keyscan -p ${nodePortNo} ${sshHostname} >> ~/.ssh/known_hosts 2>/dev/null`, {stdio: 'pipe'}) 415 console.log(`\nSetup complete. You can now run 'git push ${gitRemoteName} main' to push.`) 418export const pushToGitsshHttps = async ({localRepoPath, repoName}: {localRepoPath?: string, repoName?: string}) => { 421 const gitsshName = getGitsshName() 422 const httpsHostname = gitsshCtx.getStore()?.gitsshHttpsHostname 425 const remoteUrl = `https://git:${token}@${httpsHostname}/git/${repoName}.git` 427 console.log(`Pushing ${shortPtPath(localRepoPath)} (${gitSize}) to ${httpsHostname}/git/${repoName}.git...`) 428 await liveSpawnThrow({cmd: `/usr/bin/git push --force --all --progress ${remoteUrl}`, cwd: localRepoPath, noOutCmd: true}) 429 console.log(`Pushed ${repoName} to gitssh`) 432export const pushLocalRepoToGitssh = async ({localRepoPath, cluster_name}: {localRepoPath: string, cluster_name: string}) => { 434 const remoteUrl = `ssh://${sshHostAlias}/home/git/repos/${repoName}.git` 435 const gitRemoteName = `${cluster_name}git` 437 const {stdout: existingUrl} = await doExec(`git remote get-url ${gitRemoteName}`, {cwd: localRepoPath}).catch(() => ({stdout: ''})) 438 if (existingUrl.trim() !== remoteUrl) { 439 await doExec(`git remote remove ${gitRemoteName}`, {cwd: localRepoPath}).catch(() => {}) 440 await doExec(`git remote add ${gitRemoteName} ${remoteUrl}`, {cwd: localRepoPath}) 442 const gitDir = path.join(localRepoPath, '.git') 444 console.log(`Pushing ${shortPtPath(localRepoPath)} (${gitSize}) to ${remoteUrl}...`) 445 await liveSpawnThrow({cmd: `/usr/bin/git push ${gitRemoteName} --all --force --progress`, cwd: localRepoPath, noOutCmd: true}) 446 console.log(`Pushed to gitssh`)