fix(e2e): unlock sshd account, derive fingerprint from kandev probe

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.
This commit is contained in:
José Almeida
2026-05-17 11:47:26 +01:00
parent c5d678a37a
commit 192df0ecf3
4 changed files with 72 additions and 21 deletions
+6
View File
@@ -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 \\
+25
View File
@@ -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 });
+38 -19
View File
@@ -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)]);
}
+3 -2
View File
@@ -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);