9fc84efadf
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.
114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|