🌳
pt0/deployF/harvF/setupHarvSshF.mts
6import { sshPrivKeyPath } from "./sshKeyF.mts"
7import { tsAbsPath } from '../../ptDirF.mts'
12const sshConfigPath = pathDownJoin(envHome!, '/.ssh/config')
14const testSshConn = async ({hostname, identityFile}: {hostname: string, identityFile: string}) => {
15 const {isSuccess} = await liveSpawn({
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,
18 })
19 return isSuccess
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[]}) => {
54 const lanHostIpsH = getNodePortsCfg(getKlusterCtx().cluster_name)?.lanHostIpsH
55 let sshConfig = await read1File(tsAbsPath(sshConfigPath))
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)
63 sshConfig += entry
64 console.log(`added ssh config for ${hn}`)
65 continue
66 }
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)
71 sshConfig = updated
72 console.log(chalkGreen(`updated ssh opts for ${hn}`))
73 }
74 if (existingKeyPath === sshPrivKeyPath) {
75 console.log(chalkGreen(`✓ ${hn} already in ~/.ssh/config`))
76 continue
77 }
78 console.log(chalkYellow(`${hn}: IdentityFile is ${existingKeyPath}, expected ${sshPrivKeyPath}`))
79 const newKeyWorks = await testSshConn({hostname: hn, identityFile: sshPrivKeyPath})
80 if (newKeyWorks) {
81 await replaceSshConfigIdentityFile(sshConfig, hn, sshPrivKeyPath)
82 sshConfig = await read1File(tsAbsPath(sshConfigPath))
83 console.log(chalkGreen(`updated ${hn} IdentityFile → ${sshPrivKeyPath} (verified working)`))
84 continue
85 }
86 const oldKeyWorks = await testSshConn({hostname: hn, identityFile: existingKeyPath})
87 if (oldKeyWorks) {
88 console.log(chalkGreen(`✓ ${hn} existing key works, keeping ${existingKeyPath}`))
89 continue
90 }
91 console.log(chalkYellow(`${hn}: node unreachable, skipping key validation`))
92 }
95export const k8sHarvSsh = async ({nodeHostnames}: {nodeHostnames: string[]}) => {
96 await ensureSshConfigEntries({nodeHostnames})
98 const lanHostIpsH = getNodePortsCfg(getKlusterCtx().cluster_name)?.lanHostIpsH
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)
102 const {stdout: liveKeysRaw} = await liveSpawn({
103 cmd: `ssh-keyscan ${keyscanTargets.join(' ')} 2>/dev/null`,
104 isQuiet: true, noOutCmd: true, timeoutAfterSec: 10,
105 })
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]))
110 const knownHostsPath = pathDownJoin(envHome!, '/.ssh/known_hosts')
111 const knownHostsLines = (await read1File(tsAbsPath(knownHostsPath))).split("\n")
112 const matchingHostLines = knownHostsLines.filter(line =>
113 nodeHostnames.some(nh => line.includes(nh))
114 )
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))
124 })
125 if (!reached) return false
126 const keyPart = line.split(' ').slice(1).join(' ')
127 return !liveKeySet.has(keyPart)
128 })
129 if (staleLines.length == 0) return
130 const knownHostsRelPath = knownHostsPath.replace(envHome!, '~')
131 console.log({staleLines}, `removing stale keys^ from ${knownHostsRelPath}`)
132 throwIf(() => staleLines.length > 3)
133 const remaining = knownHostsLines.filter(line => !staleLines.includes(line))
134 await fs1Promises.writeFile(tsAbsPath(knownHostsPath), remaining.join("\n"))