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:
@@ -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 \\
|
||||
|
||||
@@ -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
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user