14const testSshConn = async ({hostname, identityFile}: {hostname: string, identityFile: string}) => { 16 cmd: `ssh -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes -i ${identityFile} rancher@${hostname} exit`, 17 isQuiet: true, noOutCmd: true, timeoutAfterSec: 10, 22const getExistingIdentityFile = (sshConfig: string, hostname: string) => { 23 const hostIdx = sshConfig.indexOf(`Host ${hostname}\n`) 24 if (hostIdx === -1) return undefined 25 const afterHost = sshConfig.slice(hostIdx) 26 const blockEnd = afterHost.indexOf('\nHost ', 1) 27 const block = blockEnd === -1 ? afterHost : afterHost.slice(0, blockEnd) 28 const match = block.match(/IdentityFile\s+(.+)/) 29 return match?.[1]?.trim() 32const replaceSshConfigIdentityFile = async (sshConfig: string, hostname: string, newIdentityFile: string) => { 33 const hostIdx = sshConfig.indexOf(`Host ${hostname}\n`) 34 const afterHost = sshConfig.slice(hostIdx) 35 const blockEnd = afterHost.indexOf('\nHost ', 1) 36 const block = blockEnd === -1 ? afterHost : afterHost.slice(0, blockEnd) 37 const updatedBlock = block.replace(/IdentityFile\s+.+/, `IdentityFile ${newIdentityFile}`) 38 const updatedConfig = sshConfig.slice(0, hostIdx) + updatedBlock + (blockEnd === -1 ? '' : afterHost.slice(blockEnd)) 39 await fs1Promises.writeFile(tsAbsPath(sshConfigPath), updatedConfig) 42const ensureSshOptLine = (sshConfig: string, hn: string, optLine: string, optKey: string): string => { 43 const hostLine = `Host ${hn}\n` 44 const hostIdx = sshConfig.indexOf(hostLine) 45 if (hostIdx === -1) return sshConfig 46 const afterHost = sshConfig.slice(hostIdx) 47 const blockEnd = afterHost.indexOf('\nHost ', 1) 48 const block = blockEnd === -1 ? afterHost : afterHost.slice(0, blockEnd) 49 if (new RegExp(`${optKey}\\s`).test(block)) return sshConfig 50 return sshConfig.slice(0, hostIdx + hostLine.length) + optLine + '\n' + sshConfig.slice(hostIdx + hostLine.length) 53const ensureSshConfigEntries = async ({nodeHostnames}: {nodeHostnames: string[]}) => { 57 for (const hn of nodeHostnames) { 58 const ip = lanHostIpsH?.[hn] as string | undefined 59 const existingKeyPath = getExistingIdentityFile(sshConfig, hn) 60 if (!existingKeyPath) { 61 const entry = `\nHost ${hn}\n${ip ? ` HostName ${ip}\n` : ''} IdentitiesOnly yes\n IdentityFile ${sshPrivKeyPath}\n User rancher\n` 62 await fs1Promises.appendFile(tsAbsPath(sshConfigPath), entry) 64 console.log(`added ssh config for ${hn}`) 67 let updated = ensureSshOptLine(sshConfig, hn, ` IdentitiesOnly yes`, 'IdentitiesOnly') 68 if (ip) updated = ensureSshOptLine(updated, hn, ` HostName ${ip}`, 'HostName') 69 if (updated !== sshConfig) { 70 await fs1Promises.writeFile(tsAbsPath(sshConfigPath), updated) 74 if (existingKeyPath === sshPrivKeyPath) { 75 console.log(chalkGreen(`✓ ${hn} already in ~/.ssh/config`)) 79 const newKeyWorks = await testSshConn({hostname: hn, identityFile: sshPrivKeyPath}) 81 await replaceSshConfigIdentityFile(sshConfig, hn, sshPrivKeyPath) 86 const oldKeyWorks = await testSshConn({hostname: hn, identityFile: existingKeyPath}) 88 console.log(chalkGreen(`✓ ${hn} existing key works, keeping ${existingKeyPath}`)) 91 console.log(chalkYellow(`${hn}: node unreachable, skipping key validation`)) 95export const k8sHarvSsh = async ({nodeHostnames}: {nodeHostnames: string[]}) => { 96 await ensureSshConfigEntries({nodeHostnames}) 99 const nodeIpH = Object.fromEntries(nodeHostnames.map(hn => [hn, lanHostIpsH?.[hn] as string | undefined])) 100 // keyscan by IP when known: hostnames often don't resolve off-LAN/VPN (ssh-keyscan ignores ~/.ssh/config HostName) 101 const keyscanTargets = nodeHostnames.map(hn => nodeIpH[hn] || hn) 103 cmd: `ssh-keyscan ${keyscanTargets.join(' ')} 2>/dev/null`, 104 isQuiet: true, noOutCmd: true, timeoutAfterSec: 10, 106 const liveKeyLines = liveKeysRaw.split('\n').filter(Boolean) 107 const liveKeySet = new Set(liveKeyLines.map(l => l.split(' ').slice(1).join(' '))) 108 const reachedIps = new Set(liveKeyLines.map(l => l.split(' ')[0])) 112 const matchingHostLines = knownHostsLines.filter(line => 113 nodeHostnames.some(nh => line.includes(nh)) 115 if (matchingHostLines.length == 0) return 116 const keysFromHostLines = matchingHostLines.map(line => line.split(' ')[2]) 117 const staleLines = knownHostsLines.filter(line => { 118 const keyPub = line.split(' ')[2] 119 if (!keysFromHostLines.includes(keyPub)) return false 120 // only evaluate staleness for nodes we actually reached; a down/unreachable node's known_hosts entries are left as-is (can't confirm staleness, avoids false mass-stale when a node is offline) 121 const reached = nodeHostnames.some(hn => { 122 const ip = nodeIpH[hn] 123 return ip && reachedIps.has(ip) && (line.includes(hn) || line.includes(ip)) 125 if (!reached) return false 126 const keyPart = line.split(' ').slice(1).join(' ') 127 return !liveKeySet.has(keyPart) 129 if (staleLines.length == 0) return 130 const knownHostsRelPath = knownHostsPath.replace(envHome!, '~') 131 console.log({staleLines}, `removing stale keys^ from ${knownHostsRelPath}`) 133 const remaining = knownHostsLines.filter(line => !staleLines.includes(line)) 134 await fs1Promises.writeFile(tsAbsPath(knownHostsPath), remaining.join("\n"))