diff --git a/dist/index.js b/dist/index.js index fe6c47f..d961853 100644 --- a/dist/index.js +++ b/dist/index.js @@ -36630,6 +36630,59 @@ var import_build = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((expo })))(), 1); var { VERSION, YAML, argv, dotenv, echo, expBackoff, fetch, fs, glob, globby, minimist, nothrow, parseArgv, question, quiet, retry, sleep, spinner, stdin, tempdir, tempfile, tmpdir, tmpfile, updateArgv, version, versions, $, Fail, ProcessOutput, ProcessPromise, bus, cd, chalk, defaults, kill, log, os: os$1, path, ps, quote, quotePowerShell, resolveDefaults, syncProcessCwd, useBash, usePowerShell, usePwsh, which, within } = globalThis.Deno ? globalThis.require("./index.cjs") : import_build; //#endregion +//#region src/host-key-verification.ts +var HOST_KEY_VERIFICATION_FAILED = /host key verification failed/i; +/** Deployer wraps remote output as `[host] < …`. */ +var DEPLOYER_HOST_KEY_LINE = /^\[([^\]]+)\]\s*<.*host key verification failed/i; +var OPENSSH_HOST_KEY_CHANGED = /\bhost key for ([^\s]+) has changed/i; +var OPENSSH_HOST_KEY_UNKNOWN = /No \S+ host key is known for (\S+)/i; +var OPENSSH_THE_HOST_KEY = /The \S+ host key for ([^\s]+) has changed/i; +function isHostKeyVerificationFailure(output) { + return HOST_KEY_VERIFICATION_FAILED.test(output); +} +function parseHostsFromHostKeyFailure(output) { + const hosts = /* @__PURE__ */ new Set(); + for (const line of output.split(/\r?\n/)) { + const deployer = DEPLOYER_HOST_KEY_LINE.exec(line); + if (deployer) { + hosts.add(deployer[1]); + continue; + } + for (const pattern of [ + OPENSSH_HOST_KEY_CHANGED, + OPENSSH_HOST_KEY_UNKNOWN, + OPENSSH_THE_HOST_KEY + ]) { + const match = pattern.exec(line); + if (match) hosts.add(match[1].replace(/\.$/, "")); + } + } + return [...hosts]; +} +function formatHostKeyVerificationGuidance(hosts, knownHostsConfigured) { + const hostList = hosts.length > 0 ? hosts.join(", ") : "(not found in the log — check hosts in your Deployer recipe)"; + const scanExample = hosts.length === 1 ? ` ssh-keyscan -t rsa,ecdsa,ed25519 ${hosts[0]}` : " ssh-keyscan -t rsa,ecdsa,ed25519 YOUR_DEPLOY_HOST"; + const knownHostsHint = knownHostsConfigured ? "The known-hosts input is set, but this host is missing or its key no longer matches the server (for example after reinstall or key rotation)." : "When known-hosts is empty this action disables StrictHostKeyChecking; if you still see this, check ssh-config or other SSH settings."; + return [ + "SSH host key verification failed for the remote deployment server.", + `Remote host(s): ${hostList}`, + "", + "This refers to the remote server SSH host key (server identity), not your deploy private-key secret.", + "It is unrelated to github.com unless your Deployer recipe connects to GitHub over SSH.", + knownHostsHint, + "", + "Update the action known-hosts input with a current key, for example:", + scanExample, + "", + "https://github.com/deployphp/action/issues/61" + ].join("\n"); +} +function commandOutput(err) { + if (err !== null && typeof err === "object" && "stdall" in err && typeof err.stdall === "string") return err.stdall; + if (err instanceof Error) return err.message; + return String(err); +} +//#endregion //#region src/index.ts $.verbose = true; (async function main() { @@ -36728,6 +36781,8 @@ async function dep() { try { await $`${phpBin} ${bin} ${cmd} ${recipeArgs} --no-interaction ${ansi} ${verbosityArgs} ${options}`; } catch (err) { + const output = commandOutput(err); + if (isHostKeyVerificationFailure(output)) error(formatHostKeyVerificationGuidance(parseHostsFromHostKeyFailure(output), getInput("known-hosts") !== "")); setFailed(`Failed: dep ${cmd}`); } } diff --git a/package.json b/package.json index c773d4d..3e711a2 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "typecheck": "tsc --noEmit", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test": "node --experimental-strip-types --test src/**/*.test.ts" }, "dependencies": { "@actions/core": "^3.0.0", diff --git a/src/host-key-verification.test.ts b/src/host-key-verification.test.ts new file mode 100644 index 0000000..0cf15a4 --- /dev/null +++ b/src/host-key-verification.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + formatHostKeyVerificationGuidance, + isHostKeyVerificationFailure, + parseHostsFromHostKeyFailure, +} from './host-key-verification.ts' + +const deployerLog = ` +✈︎ Deploying deploy-test on 1.2.3.4 +[23.94.156.6] > echo $0 +[23.94.156.6] < ssh multiplexing initialization +[23.94.156.6] < Host key verification failed. +`.trim() + +describe('isHostKeyVerificationFailure', () => { + it('detects deployer host key failures', () => { + assert.equal(isHostKeyVerificationFailure(deployerLog), true) + }) + + it('ignores unrelated output', () => { + assert.equal(isHostKeyVerificationFailure('deploy finished'), false) + }) +}) + +describe('parseHostsFromHostKeyFailure', () => { + it('parses deployer bracket host lines', () => { + assert.deepEqual(parseHostsFromHostKeyFailure(deployerLog), ['23.94.156.6']) + }) + + it('parses OpenSSH host key changed messages', () => { + const output = `RSA host key for deploy.example.com has changed and you have requested strict checking. +Host key verification failed.` + assert.deepEqual(parseHostsFromHostKeyFailure(output), [ + 'deploy.example.com', + ]) + }) + + it('parses unknown host key messages', () => { + const output = `No ED25519 host key is known for staging.example.net. +Host key verification failed.` + assert.deepEqual(parseHostsFromHostKeyFailure(output), [ + 'staging.example.net', + ]) + }) + + it('deduplicates repeated hosts', () => { + const output = `[10.0.0.1] < Host key verification failed. +[10.0.0.1] < Host key verification failed.` + assert.deepEqual(parseHostsFromHostKeyFailure(output), ['10.0.0.1']) + }) +}) + +describe('formatHostKeyVerificationGuidance', () => { + it('names the remote host and ssh-keyscan command', () => { + const message = formatHostKeyVerificationGuidance(['1.2.3.4'], true) + assert.match(message, /Remote host\(s\): 1\.2\.3\.4/) + assert.match(message, /ssh-keyscan -t rsa,ecdsa,ed25519 1\.2\.3\.4/) + assert.match(message, /not your deploy private-key secret/) + assert.match(message, /unrelated to github\.com/) + }) + + it('mentions missing known-hosts match when configured', () => { + const message = formatHostKeyVerificationGuidance([], true) + assert.match(message, /known-hosts input is set/) + }) +}) diff --git a/src/host-key-verification.ts b/src/host-key-verification.ts new file mode 100644 index 0000000..bccd223 --- /dev/null +++ b/src/host-key-verification.ts @@ -0,0 +1,86 @@ +const HOST_KEY_VERIFICATION_FAILED = /host key verification failed/i + +/** Deployer wraps remote output as `[host] < …`. */ +const DEPLOYER_HOST_KEY_LINE = + /^\[([^\]]+)\]\s*<.*host key verification failed/i + +const OPENSSH_HOST_KEY_CHANGED = /\bhost key for ([^\s]+) has changed/i +const OPENSSH_HOST_KEY_UNKNOWN = /No \S+ host key is known for (\S+)/i +const OPENSSH_THE_HOST_KEY = /The \S+ host key for ([^\s]+) has changed/i + +export function isHostKeyVerificationFailure(output: string): boolean { + return HOST_KEY_VERIFICATION_FAILED.test(output) +} + +export function parseHostsFromHostKeyFailure(output: string): string[] { + const hosts = new Set() + + for (const line of output.split(/\r?\n/)) { + const deployer = DEPLOYER_HOST_KEY_LINE.exec(line) + if (deployer) { + hosts.add(deployer[1]) + continue + } + + for (const pattern of [ + OPENSSH_HOST_KEY_CHANGED, + OPENSSH_HOST_KEY_UNKNOWN, + OPENSSH_THE_HOST_KEY, + ]) { + const match = pattern.exec(line) + if (match) { + hosts.add(match[1].replace(/\.$/, '')) + } + } + } + + return [...hosts] +} + +export function formatHostKeyVerificationGuidance( + hosts: string[], + knownHostsConfigured: boolean, +): string { + const hostList = + hosts.length > 0 + ? hosts.join(', ') + : '(not found in the log — check hosts in your Deployer recipe)' + + const scanExample = + hosts.length === 1 + ? ` ssh-keyscan -t rsa,ecdsa,ed25519 ${hosts[0]}` + : ' ssh-keyscan -t rsa,ecdsa,ed25519 YOUR_DEPLOY_HOST' + + const knownHostsHint = knownHostsConfigured + ? 'The known-hosts input is set, but this host is missing or its key no longer matches the server (for example after reinstall or key rotation).' + : 'When known-hosts is empty this action disables StrictHostKeyChecking; if you still see this, check ssh-config or other SSH settings.' + + return [ + 'SSH host key verification failed for the remote deployment server.', + `Remote host(s): ${hostList}`, + '', + 'This refers to the remote server SSH host key (server identity), not your deploy private-key secret.', + 'It is unrelated to github.com unless your Deployer recipe connects to GitHub over SSH.', + knownHostsHint, + '', + 'Update the action known-hosts input with a current key, for example:', + scanExample, + '', + 'https://github.com/deployphp/action/issues/61', + ].join('\n') +} + +export function commandOutput(err: unknown): string { + if ( + err !== null && + typeof err === 'object' && + 'stdall' in err && + typeof (err as { stdall: unknown }).stdall === 'string' + ) { + return (err as { stdall: string }).stdall + } + if (err instanceof Error) { + return err.message + } + return String(err) +} diff --git a/src/index.ts b/src/index.ts index 3864887..31c571a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ import * as core from '@actions/core' import { $, fs, cd } from 'zx' +import { + commandOutput, + formatHostKeyVerificationGuidance, + isHostKeyVerificationFailure, + parseHostsFromHostKeyFailure, +} from './host-key-verification.js' $.verbose = true @@ -164,6 +170,16 @@ async function dep(): Promise { try { await $`${phpBin} ${bin} ${cmd} ${recipeArgs} --no-interaction ${ansi} ${verbosityArgs} ${options}` } catch (err) { + const output = commandOutput(err) + if (isHostKeyVerificationFailure(output)) { + const hosts = parseHostsFromHostKeyFailure(output) + core.error( + formatHostKeyVerificationGuidance( + hosts, + core.getInput('known-hosts') !== '', + ), + ) + } core.setFailed(`Failed: dep ${cmd}`) } } diff --git a/tsconfig.json b/tsconfig.json index 83c4c56..eb70948 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "lib": ["ES2023"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] }