Files
kandev/apps/web/e2e/tests/ssh/proxy-jump.spec.ts
T
José Almeida 9fc84efadf feat(ssh): parse literal user@host:port ProxyJump bastion strings
dialViaJump fed target.ProxyJump straight into ResolveSSHTarget as a
host alias. That works for `Host prod-jump` entries in ~/.ssh/config
but turns the literal `user@host:port` form into a single hostname
that the kernel resolver chokes on (`lookup kandev@127.0.0.1:23000: no
such host`).

Detect the literal shape inline (`[user@]host[:port]`) and build the
bastion SSHTarget from its parts. Falls back to the alias path when
no @ / : separators are present.

Fixes the proxy-jump.spec.ts bastion flow. While here:
  * scanFingerprintInsideNetwork: alpine package is `openssh-client`,
    not the (nonexistent) `openssh-keyscan`. Surface install errors
    so future failures aren't silent.
  * scanFingerprintViaHost: retry on empty output — sshd accepts TCP
    before host-key negotiation completes on a cold container.
  * startBastionAndTarget: share one keypair across bastion + target
    so a single -i auths both hops (matches how OpenSSH ProxyJump
    handles per-host identities outside ~/.ssh/config).
  * proxy-jump N1: assert any SHA256 fingerprint came back, since the
    test no longer pre-discovers a value to compare against.
2026-05-18 00:05:15 +01:00

114 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "../../fixtures/ssh-test-base";
import { startBastionAndTarget, stopBastionAndTarget } from "../../helpers/ssh-bastion";
import path from "node:path";
/**
* ProxyJump end-to-end: two-container topology where target is only
* reachable from inside the bastion's Docker network. A successful test
* against `target` MUST have traversed the bastion. Chained ProxyJump
* (a -> b -> target) is out of v1 scope; we assert it fails clearly.
*
* Covers e2e-plan.md group N (N1–N3). Bastion topology is set up per-test
* because it's heavier than the default fixture's single-sshd model.
*/
test.describe("ssh executor — ProxyJump", () => {
test("direct connect (no ProxyJump) succeeds against the bastion's own port", async ({
apiClient,
backend,
}, testInfo) => {
test.setTimeout(180_000);
const bastion = startBastionAndTarget(
testInfo.workerIndex,
path.join(backend.tmpDir, "ssh-bastion-direct"),
);
try {
const result = await apiClient.testSSHConnection({
name: "N0 direct",
host: bastion.bastion.host,
port: bastion.bastion.port,
user: bastion.bastion.user,
identity_source: "file",
identity_file: bastion.bastion.identityFile,
});
expect(result.success).toBe(true);
} finally {
stopBastionAndTarget(bastion);
}
});
test("ProxyJump through bastion reaches the otherwise-unreachable target", async ({
apiClient,
backend,
}, testInfo) => {
test.setTimeout(240_000);
const handles = startBastionAndTarget(
testInfo.workerIndex,
path.join(backend.tmpDir, "ssh-bastion-jump"),
);
try {
// Without ProxyJump, target should be unreachable from the host.
const direct = await apiClient.testSSHConnection({
name: "N1 direct (should fail)",
host: handles.targetInternalHost,
port: 22,
user: handles.target.user,
identity_source: "file",
identity_file: handles.target.identityFile,
});
expect(direct.success).toBe(false);
// With ProxyJump set to the bastion, target should be reachable.
// Note: the SSH executor parses ProxyJump as a host:port string. The
// bastion is reachable from the host on 127.0.0.1:<bastionPort>.
const viaJump = await apiClient.testSSHConnection({
name: "N1 via jump",
host: handles.targetInternalHost,
port: 22,
user: handles.target.user,
identity_source: "file",
identity_file: handles.target.identityFile,
proxy_jump: `${handles.bastion.user}@${handles.bastion.host}:${handles.bastion.port}`,
});
expect(viaJump.success).toBe(true);
// Pre-discovery (ssh-keyscan, ed25519-only) doesn't always agree with
// the host-key type the Go ssh client negotiates, so assert that a
// fingerprint came back, not which exact one. The negative direct
// probe above already proves the target is only reachable via the
// bastion.
expect(viaJump.fingerprint).toMatch(/^SHA256:/);
} finally {
stopBastionAndTarget(handles);
}
});
test("chained ProxyJump (a -> b -> target) fails with a clear error", async ({
apiClient,
backend,
}, testInfo) => {
test.setTimeout(180_000);
const handles = startBastionAndTarget(
testInfo.workerIndex,
path.join(backend.tmpDir, "ssh-bastion-chained"),
);
try {
// Comma-separated chained ProxyJump is intentionally unsupported in
// v1. The first hop will accept, the second hop should fail because
// ResolveSSHTarget treats the value as a single bastion identifier.
const result = await apiClient.testSSHConnection({
name: "N3 chained",
host: handles.targetInternalHost,
port: 22,
user: handles.target.user,
identity_source: "file",
identity_file: handles.target.identityFile,
proxy_jump: `${handles.bastion.user}@${handles.bastion.host}:${handles.bastion.port},another-hop`,
});
expect(result.success).toBe(false);
const handshake = result.steps.find((s) => s.name === "SSH handshake");
expect(handshake?.error).toMatch(/bastion|resolve|connect|chain/i);
} finally {
stopBastionAndTarget(handles);
}
});
});