From 192df0ecf306749dcc811901d69006b5cc7d2e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= Date: Sun, 17 May 2026 11:47:26 +0100 Subject: [PATCH] fix(e2e): unlock sshd account, derive fingerprint from kandev probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs surfaced by actually running the SSH e2e suite (KANDEV_E2E_CONTAINERS=1 against the containers project): 1. Alpine's `adduser -D kandev` leaves the account password-locked (`!` in /etc/shadow). sshd then refuses every auth method for that user — including pubkey — with "User kandev not allowed because account is locked". Fix: `usermod -p '*' kandev` in the Dockerfile so the account is open for key-based login but still has no valid password. Also adds the `shadow` apk package for `usermod`. 2. ssh-keyscan reports the ed25519 host fingerprint, but Go's ssh client may negotiate a different host key type when sshd offers multiple — meaning the fingerprint the fixture pre-scanned didn't match the one kandev observed at handshake time. Fix: the fixture now hits /api/v1/ssh/test with a permissive callback right after the container is up and uses that fingerprint as the pinned value. Same code path real users hit; eliminates the negotiation skew by construction. 3. ssh.ts had a CPU-spin sleep helper and an nc-dependent TCP probe, both fragile. Replaced with a proper sleep subprocess and a bash /dev/tcp probe, plus retry-with-stderr on the fingerprint scan so failures are diagnosable. Also tightens test-endpoint.spec.ts F4: the test was using identity_source=agent to "fail fast", but that fails inside buildAuthMethods before the handshake step ever runs. Switched to identity_source=file against a refused port so the test exercises the actual TCP+handshake failure path. After these fixes 4/6 test-endpoint cases pass against a real sshd. --- apps/web/e2e/fixtures/ssh-image.ts | 6 +++ apps/web/e2e/fixtures/ssh-test-base.ts | 25 +++++++++ apps/web/e2e/helpers/ssh.ts | 57 +++++++++++++------- apps/web/e2e/tests/ssh/test-endpoint.spec.ts | 5 +- 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/apps/web/e2e/fixtures/ssh-image.ts b/apps/web/e2e/fixtures/ssh-image.ts index 061cd0731a..5232dd7b07 100644 --- a/apps/web/e2e/fixtures/ssh-image.ts +++ b/apps/web/e2e/fixtures/ssh-image.ts @@ -56,11 +56,17 @@ RUN apk add --no-cache \\ git \\ bash \\ sudo \\ + shadow \\ coreutils \\ ca-certificates \\ iproute2 \\ iptables \\ && adduser -D -s /bin/bash kandev \\ + # Alpine's adduser -D leaves the account "locked" (password = ! in /etc/shadow). + # sshd refuses ALL auth methods — including pubkey — for locked users, so we + # unlock by setting the password hash to '*' (no valid password, but the + # account itself is open for key-based login). + && usermod -p '*' kandev \\ && echo "kandev ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers \\ && mkdir -p /home/kandev/.ssh /var/empty /var/run/sshd \\ && chown -R kandev:kandev /home/kandev \\ diff --git a/apps/web/e2e/fixtures/ssh-test-base.ts b/apps/web/e2e/fixtures/ssh-test-base.ts index 37f100983b..406d1ab661 100644 --- a/apps/web/e2e/fixtures/ssh-test-base.ts +++ b/apps/web/e2e/fixtures/ssh-test-base.ts @@ -61,6 +61,31 @@ export const sshTest = backendFixture.extend< const sshWorkDir = path.join(backend.tmpDir, "ssh"); const sshTarget = startSSHServer(workerInfo.workerIndex, SSH_E2E_IMAGE_TAG, sshWorkDir); + // ssh-keyscan reports the ed25519 fingerprint, but the Go ssh client may + // negotiate a different key type — meaning the pinned fingerprint must + // be the one *kandev* observes, not the one we scanned. Hit the + // permissive test endpoint once to capture the canonical value. + try { + const observed = await apiClient.testSSHConnection({ + name: "ssh-fixture-observe", + host: sshTarget.host, + port: sshTarget.port, + user: sshTarget.user, + identity_source: "file", + identity_file: sshTarget.identityFile, + }); + if (!observed.success || !observed.fingerprint) { + throw new Error( + `SSH fixture: probe failed (${observed.error ?? "no error"}); ` + + `steps=${JSON.stringify(observed.steps)}`, + ); + } + sshTarget.hostFingerprint = observed.fingerprint; + } catch (e) { + stopSSHServer(sshTarget); + throw e; + } + try { const seed = await seedSSHWorkspace(apiClient, backend, sshTarget); await use({ ...seed, sshTarget }); diff --git a/apps/web/e2e/helpers/ssh.ts b/apps/web/e2e/helpers/ssh.ts index 3ea82121b3..ac61ccaaeb 100644 --- a/apps/web/e2e/helpers/ssh.ts +++ b/apps/web/e2e/helpers/ssh.ts @@ -244,28 +244,49 @@ function removeContainerIfExists(name: string): void { spawnSync("docker", ["rm", "-f", name], { stdio: "ignore" }); } -function waitForTCPOpen(host: string, port: number, timeoutMs = 15_000): void { +function waitForTCPOpen(host: string, port: number, timeoutMs = 30_000): void { const deadline = Date.now() + timeoutMs; + let lastErr = ""; while (Date.now() < deadline) { - const res = spawnSync("nc", ["-z", host, String(port)], { stdio: "ignore" }); + // Prefer bash's /dev/tcp probe — always available on POSIX, no `nc` dep. + const res = spawnSync("bash", ["-c", `exec 3<>/dev/tcp/${host}/${port} && exec 3<&-`], { + stdio: ["ignore", "ignore", "pipe"], + encoding: "utf8", + }); if (res.status === 0) return; - sleep(150); + lastErr = (res.stderr ?? "").trim(); + sleep(250); } - throw new Error(`sshd at ${host}:${port} did not open within ${timeoutMs}ms`); + throw new Error(`sshd at ${host}:${port} did not open within ${timeoutMs}ms: ${lastErr}`); } function scanHostFingerprint(host: string, port: number): string { - // ssh-keyscan prints the host's public key; ssh-keygen -lf computes its - // SHA256 fingerprint. Output shape: "256 SHA256:abc... no comment (ED25519)". - const keyOut = spawnSync("ssh-keyscan", ["-p", String(port), "-t", "ed25519", host], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - if (keyOut.status !== 0 || !keyOut.stdout.trim()) { - throw new Error(`ssh-keyscan ${host}:${port} returned nothing`); + // ssh-keyscan prints the host's public key on stdout; `# host SSH-2.0-...` + // comment lines go to stderr. We retry briefly because the first scan can + // race the container's first sshd accept. + const deadline = Date.now() + 15_000; + let stdout = ""; + let stderr = ""; + while (Date.now() < deadline) { + const keyOut = spawnSync( + "ssh-keyscan", + ["-p", String(port), "-t", "ed25519", "-T", "5", host], + { + encoding: "utf8", + }, + ); + stdout = keyOut.stdout ?? ""; + stderr = keyOut.stderr ?? ""; + if (keyOut.status === 0 && stdout.trim()) break; + sleep(300); + } + if (!stdout.trim()) { + throw new Error( + `ssh-keyscan ${host}:${port} returned nothing after retries; stderr=${stderr.trim()}`, + ); } const tmpKey = path.join(os.tmpdir(), `kandev-e2e-keyscan-${process.pid}-${port}`); - fs.writeFileSync(tmpKey, keyOut.stdout); + fs.writeFileSync(tmpKey, stdout); try { const fp = spawnSync("ssh-keygen", ["-lf", tmpKey], { encoding: "utf8" }); const match = fp.stdout.match(/SHA256:\S+/); @@ -281,10 +302,8 @@ function shellQuote(s: string): string { } function sleep(ms: number): void { - const end = Date.now() + ms; - // Busy-wait with a small `nc`-style cost gate — fine for our short polling - // loops and lets us avoid pulling in setTimeout-on-sync semantics. - while (Date.now() < end) { - spawnSync("true"); - } + // Synchronous sleep via the system `sleep` binary. Subprocess overhead is + // dwarfed by the ms we're waiting, and we get an honest wall-clock pause + // rather than CPU spin. + spawnSync("sleep", [(ms / 1000).toFixed(3)]); } diff --git a/apps/web/e2e/tests/ssh/test-endpoint.spec.ts b/apps/web/e2e/tests/ssh/test-endpoint.spec.ts index 10083220fb..2b6cddd9a8 100644 --- a/apps/web/e2e/tests/ssh/test-endpoint.spec.ts +++ b/apps/web/e2e/tests/ssh/test-endpoint.spec.ts @@ -65,13 +65,14 @@ test.describe("ssh test-endpoint contract", () => { }); // F4 — handshake against a closed port surfaces verbatim, no probe steps emitted. - test("handshake failure short-circuits with no probe steps", async ({ apiClient }) => { + test("handshake failure short-circuits with no probe steps", async ({ apiClient, seedData }) => { const result = await apiClient.testSSHConnection({ name: "F4", host: "127.0.0.1", port: 1, // reserved, refused user: "kandev", - identity_source: "agent", + identity_source: "file", + identity_file: seedData.sshTarget.identityFile, }); expect(result.success).toBe(false);