mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-05 18:15:24 +00:00
793 lines
25 KiB
TypeScript
793 lines
25 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import ssh2 from "ssh2";
|
|
import { FakeSshClient } from "./pipeline-test-helpers.js";
|
|
|
|
vi.mock("ssh2", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("ssh2")>();
|
|
const { FakeSshClient: Fake } = await import("./pipeline-test-helpers.js");
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...(actual as unknown as { default: object }).default,
|
|
Client: Fake,
|
|
},
|
|
Client: Fake,
|
|
};
|
|
});
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
verifier: () => {},
|
|
createHostVerifier: vi.fn(),
|
|
applyCertificateAuth: vi.fn(),
|
|
forgetCert: vi.fn(),
|
|
applyAgentAuth: vi.fn(),
|
|
performPortKnocking: vi.fn(),
|
|
createJumpHostChain: vi.fn(),
|
|
createSocks5Connection: vi.fn(),
|
|
resolveHostById: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../../utils/logger.js", () => {
|
|
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
return { logger: log, sshLogger: log, authLogger: log, fileLogger: log };
|
|
});
|
|
vi.mock("../../../hosts/host-key-verifier.js", () => ({
|
|
SSHHostKeyVerifier: {
|
|
preloadHostData: async () => null,
|
|
createHostVerifier: mocks.createHostVerifier,
|
|
},
|
|
}));
|
|
vi.mock("@termix-ssh/plugin-sdk/ssh-certs", () => ({
|
|
applyCertificateAuth: mocks.applyCertificateAuth,
|
|
}));
|
|
vi.mock("../../../hosts/terminal-auth-helpers.js", () => ({
|
|
applyAgentAuth: mocks.applyAgentAuth,
|
|
performPortKnocking: mocks.performPortKnocking,
|
|
}));
|
|
vi.mock("../../../hosts/jump-host-chain.js", () => ({
|
|
createJumpHostChain: mocks.createJumpHostChain,
|
|
}));
|
|
vi.mock("../../../utils/socks5-helper.js", () => ({
|
|
createSocks5Connection: mocks.createSocks5Connection,
|
|
}));
|
|
vi.mock("../../../hosts/ssh-dns.js", () => ({
|
|
resolveSshConnectConfigHost: async (config: unknown) => config,
|
|
}));
|
|
vi.mock("../../../hosts/host-resolver.js", () => ({
|
|
resolveHostById: mocks.resolveHostById,
|
|
}));
|
|
vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
|
|
withConnection: async (
|
|
_key: string,
|
|
factory: () => Promise<unknown>,
|
|
fn: (client: unknown) => Promise<unknown>,
|
|
) => fn(await factory()),
|
|
}));
|
|
|
|
import {
|
|
buildConnectConfig,
|
|
getProfileDefaults,
|
|
} from "../../../hosts/connect/build-connect-config.js";
|
|
import {
|
|
connectHost,
|
|
getConnectionPoolKey,
|
|
SshConnectError,
|
|
withHostConnection,
|
|
} from "../../../hosts/connect/connect-host.js";
|
|
import {
|
|
getSshAuthProvider,
|
|
isQuickConnectAuthType,
|
|
listCredentialTypes,
|
|
registerSshAuthProvider,
|
|
setSshAuthTypeOwnerSource,
|
|
} from "../../../hosts/connect/auth-provider-registry.js";
|
|
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
|
import type { SshConnectHost } from "../../../hosts/connect/types.js";
|
|
|
|
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
|
|
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
|
|
passphrase: "secret",
|
|
cipher: "aes256-cbc",
|
|
}).private;
|
|
|
|
function host(overrides: Partial<SshConnectHost> = {}): SshConnectHost {
|
|
return {
|
|
id: 7,
|
|
ip: "10.0.0.7",
|
|
port: 22,
|
|
username: "root",
|
|
userId: "owner-1",
|
|
authType: "password",
|
|
password: "hunter2",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A plugin-style provider that needs a browser sign-in until it has a
|
|
* certificate, and forgets it when the host rejects it.
|
|
*/
|
|
function registerSignInProvider(hasCert: boolean) {
|
|
return registerSshAuthProvider({
|
|
type: "signin",
|
|
pluginId: "fixture-plugin",
|
|
labelKey: "fixture.label",
|
|
needsUserInteraction: true,
|
|
interaction: "signin",
|
|
prepare: async (config) => {
|
|
if (!hasCert) {
|
|
return {
|
|
status: "interaction-required",
|
|
interaction: "signin",
|
|
message: "Sign in first",
|
|
};
|
|
}
|
|
config.password = "cert";
|
|
return { status: "ready" };
|
|
},
|
|
onAuthFailed: (_host, env) => {
|
|
mocks.forgetCert(env.userId, env.hostId);
|
|
return {
|
|
status: "interaction-required",
|
|
interaction: "signin",
|
|
message: "Sign in again",
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
async function build(
|
|
target: SshConnectHost,
|
|
profile: Parameters<typeof buildConnectConfig>[1]["profile"] = "terminal",
|
|
) {
|
|
return buildConnectConfig(target, {
|
|
userId: "user-1",
|
|
purpose: "test",
|
|
profile,
|
|
client: new FakeSshClient() as never,
|
|
});
|
|
}
|
|
|
|
// Building a hop's config parses keys, which is slow while the whole suite
|
|
// runs, so give the multi hop steps more than vi.waitFor's 1 s.
|
|
const settle = (check: () => void) => vi.waitFor(check, { timeout: 10_000 });
|
|
vi.setConfig({ testTimeout: 30_000 });
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// clearAllMocks keeps queued Once values, so a test that stopped early
|
|
// would hand its leftovers to the next one.
|
|
for (const mock of [
|
|
mocks.resolveHostById,
|
|
mocks.createJumpHostChain,
|
|
mocks.createSocks5Connection,
|
|
mocks.applyCertificateAuth,
|
|
mocks.applyAgentAuth,
|
|
])
|
|
mock.mockReset();
|
|
FakeSshClient.nextBehaviour = [];
|
|
FakeSshClient.instances = [];
|
|
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
|
|
mocks.applyAgentAuth.mockImplementation(async (config) => {
|
|
config.agent = "agent-object";
|
|
return { socketPath: "/tmp/agent.sock" };
|
|
});
|
|
mocks.applyCertificateAuth.mockImplementation(async (config) => {
|
|
config.authHandler = "cert-handler";
|
|
});
|
|
});
|
|
|
|
describe("buildConnectConfig per auth type", () => {
|
|
it("password puts the password in the config", async () => {
|
|
const { config, outcome } = await build(host());
|
|
expect(outcome).toEqual({ status: "ready" });
|
|
expect(config).toMatchObject({
|
|
host: "10.0.0.7",
|
|
port: 22,
|
|
username: "root",
|
|
password: "hunter2",
|
|
tryKeyboard: true,
|
|
keepaliveInterval: 30000,
|
|
keepaliveCountMax: 5,
|
|
readyTimeout: 120000,
|
|
});
|
|
expect(config.hostVerifier).toBe(mocks.verifier);
|
|
});
|
|
|
|
it("password leaves the password out when keyboard-interactive is forced", async () => {
|
|
const { config, outcome } = await build(
|
|
host({ forceKeyboardInteractive: true }),
|
|
);
|
|
expect(outcome.status).toBe("ready");
|
|
expect(config.password).toBeUndefined();
|
|
});
|
|
|
|
it("password without a password is an error", async () => {
|
|
const { outcome } = await build(host({ password: null }));
|
|
expect(outcome).toMatchObject({ status: "error", code: "missing-secret" });
|
|
});
|
|
|
|
it("key sets privateKey, passphrase and keeps a password fallback", async () => {
|
|
const { config, outcome } = await build(
|
|
host({
|
|
authType: "key",
|
|
key: encryptedKey,
|
|
keyPassword: "secret",
|
|
password: "fallback",
|
|
}),
|
|
);
|
|
expect(outcome.status).toBe("ready");
|
|
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
|
expect(config.passphrase).toBe("secret");
|
|
expect(config.password).toBe("fallback");
|
|
expect(config.authHandler).toEqual([
|
|
"publickey",
|
|
"password",
|
|
"keyboard-interactive",
|
|
]);
|
|
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("key with a CA certificate grafts the certificate", async () => {
|
|
const { config } = await build(
|
|
host({
|
|
authType: "key",
|
|
key: plainKey,
|
|
password: null,
|
|
certPublicKey: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
|
}),
|
|
);
|
|
expect(mocks.applyCertificateAuth).toHaveBeenCalledOnce();
|
|
expect(mocks.applyCertificateAuth.mock.calls[0][2]).toMatchObject({
|
|
certificate: "ssh-ed25519-cert-v01@openssh.com AAAA",
|
|
});
|
|
expect(config.authHandler).toBe("cert-handler");
|
|
});
|
|
|
|
it("key with a failing CA certificate falls back to key only", async () => {
|
|
mocks.applyCertificateAuth.mockRejectedValueOnce(new Error("bad cert"));
|
|
const { outcome } = await build(
|
|
host({ authType: "key", key: plainKey, certPublicKey: "junk" }),
|
|
);
|
|
expect(outcome.status).toBe("ready");
|
|
});
|
|
|
|
it("an encrypted key without its passphrase asks for one", async () => {
|
|
const { outcome } = await build(
|
|
host({ authType: "key", key: encryptedKey }),
|
|
);
|
|
expect(outcome).toMatchObject({
|
|
status: "error",
|
|
code: "passphrase-required",
|
|
});
|
|
});
|
|
|
|
it("a broken key is an invalid-key error", async () => {
|
|
const { outcome } = await build(
|
|
host({ authType: "key", key: "not a key" }),
|
|
);
|
|
expect(outcome).toMatchObject({ status: "error", code: "invalid-key" });
|
|
});
|
|
|
|
it("credential behaves like whichever secret the resolver left", async () => {
|
|
const keyed = await build(
|
|
host({ authType: "credential", key: plainKey, password: null }),
|
|
);
|
|
expect(Buffer.isBuffer(keyed.config.privateKey)).toBe(true);
|
|
|
|
const passworded = await build(host({ authType: "credential" }));
|
|
expect(passworded.config.password).toBe("hunter2");
|
|
|
|
const empty = await build(host({ authType: "credential", password: null }));
|
|
expect(empty.outcome).toMatchObject({ code: "missing-secret" });
|
|
});
|
|
|
|
it("agent hands the config to the agent helper", async () => {
|
|
const { config, outcome } = await build(
|
|
host({
|
|
authType: "agent",
|
|
password: null,
|
|
sshOptions: { agentSocketPath: "/tmp/agent.sock" },
|
|
}),
|
|
);
|
|
expect(outcome.status).toBe("ready");
|
|
expect(config.agent).toBe("agent-object");
|
|
expect(mocks.applyAgentAuth).toHaveBeenCalledWith(config, {
|
|
agentSocketPath: "/tmp/agent.sock",
|
|
});
|
|
});
|
|
|
|
it("agent errors are reported", async () => {
|
|
mocks.applyAgentAuth.mockResolvedValueOnce({ error: "no socket" });
|
|
const { outcome } = await build(host({ authType: "agent" }));
|
|
expect(outcome).toEqual({
|
|
status: "error",
|
|
code: "failed",
|
|
message: "no socket",
|
|
});
|
|
});
|
|
|
|
it("none adds no credentials", async () => {
|
|
const { config, outcome } = await build(
|
|
host({ authType: "none", password: null }),
|
|
);
|
|
expect(outcome.status).toBe("ready");
|
|
expect(config.password).toBeUndefined();
|
|
expect(config.privateKey).toBeUndefined();
|
|
expect(config.tryKeyboard).toBe(true);
|
|
});
|
|
|
|
it("a stepca host without the step-ca plugin names it", async () => {
|
|
setSshAuthTypeOwnerSource(() => [
|
|
{ type: "stepca", pluginId: "step-ca", pluginName: "Step CA" },
|
|
]);
|
|
const { outcome } = await build(host({ authType: "stepca" }));
|
|
expect(outcome).toEqual({
|
|
status: "error",
|
|
code: "provider-missing",
|
|
message: "This host uses stepca, which needs the Step CA plugin",
|
|
});
|
|
setSshAuthTypeOwnerSource(() => []);
|
|
});
|
|
|
|
it("a vault host without the vault plugin names it", async () => {
|
|
setSshAuthTypeOwnerSource(() => [
|
|
{ type: "vault", pluginId: "vault", pluginName: "HashiCorp Vault" },
|
|
]);
|
|
const { outcome } = await build(host({ authType: "vault" }));
|
|
expect(outcome).toEqual({
|
|
status: "error",
|
|
code: "provider-missing",
|
|
message: "This host uses vault, which needs the HashiCorp Vault plugin",
|
|
});
|
|
setSshAuthTypeOwnerSource(() => []);
|
|
});
|
|
|
|
it("a type nobody provides names the plugin that would", async () => {
|
|
setSshAuthTypeOwnerSource(() => [
|
|
{ type: "made-up", pluginId: "made", pluginName: "Made Up" },
|
|
]);
|
|
const { outcome } = await build(host({ authType: "made-up" }));
|
|
expect(outcome).toEqual({
|
|
status: "error",
|
|
code: "provider-missing",
|
|
message: "This host uses made-up, which needs the Made Up plugin",
|
|
});
|
|
setSshAuthTypeOwnerSource(() => []);
|
|
});
|
|
|
|
it("jump hops skip keyboard-interactive for auth type none", async () => {
|
|
const { config } = await build(
|
|
host({ authType: "none", password: null }),
|
|
"jump",
|
|
);
|
|
expect(config.tryKeyboard).toBe(false);
|
|
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
|
|
7,
|
|
"10.0.0.7",
|
|
22,
|
|
null,
|
|
"user-1",
|
|
true,
|
|
null,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("profile defaults", () => {
|
|
it("keeps each transport's old keepalive numbers", () => {
|
|
expect(getProfileDefaults("terminal")).toMatchObject({
|
|
keepaliveIntervalMs: 30000,
|
|
keepaliveCountMax: 5,
|
|
});
|
|
expect(getProfileDefaults("session")).toMatchObject({
|
|
keepaliveIntervalMs: 60000,
|
|
keepaliveCountMax: 5,
|
|
});
|
|
expect(getProfileDefaults("background")).toMatchObject({
|
|
keepaliveIntervalMs: 30000,
|
|
keepaliveCountMax: 3,
|
|
});
|
|
});
|
|
|
|
it("uses the background profile when none is given", () => {
|
|
expect(getProfileDefaults()).toEqual(getProfileDefaults("background"));
|
|
});
|
|
|
|
it("host keepalive settings apply where the transport honoured them", async () => {
|
|
const withSettings = host({
|
|
sshOptions: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
|
});
|
|
const terminal = await build(withSettings, "terminal");
|
|
expect(terminal.config.keepaliveInterval).toBe(10000);
|
|
const fleet = await build(withSettings, "background");
|
|
expect(fleet.config.keepaliveInterval).toBe(30000);
|
|
});
|
|
});
|
|
|
|
describe("provider registry", () => {
|
|
it("registers and removes a plugin provider", async () => {
|
|
ensureCoreSshAuthProviders();
|
|
const dispose = registerSshAuthProvider({
|
|
type: "fixture",
|
|
pluginId: "fixture-plugin",
|
|
labelKey: "fixture.label",
|
|
credentialType: true,
|
|
prepare: async (config) => {
|
|
config.password = "from-plugin";
|
|
return { status: "ready" };
|
|
},
|
|
});
|
|
expect(listCredentialTypes()).toContain("fixture");
|
|
const { config } = await build(host({ authType: "fixture" }));
|
|
expect(config.password).toBe("from-plugin");
|
|
|
|
dispose();
|
|
expect(getSshAuthProvider("fixture")).toBeUndefined();
|
|
expect(listCredentialTypes()).not.toContain("fixture");
|
|
const { outcome } = await build(host({ authType: "fixture" }));
|
|
expect(outcome).toMatchObject({ code: "provider-missing" });
|
|
});
|
|
|
|
it("offers core types and opted-in plugin types to Quick Connect", () => {
|
|
ensureCoreSshAuthProviders();
|
|
for (const type of ["password", "key", "credential", "agent", "none"]) {
|
|
expect(isQuickConnectAuthType(type)).toBe(true);
|
|
}
|
|
const plain = registerSshAuthProvider({
|
|
type: "needs-saved-host",
|
|
pluginId: "fixture-plugin",
|
|
labelKey: "x",
|
|
prepare: async () => ({ status: "ready" }),
|
|
});
|
|
const quick = registerSshAuthProvider({
|
|
type: "works-unsaved",
|
|
pluginId: "fixture-plugin",
|
|
labelKey: "x",
|
|
quickConnect: true,
|
|
prepare: async () => ({ status: "ready" }),
|
|
});
|
|
expect(isQuickConnectAuthType("needs-saved-host")).toBe(false);
|
|
expect(isQuickConnectAuthType("works-unsaved")).toBe(true);
|
|
expect(isQuickConnectAuthType("missing")).toBe(false);
|
|
plain();
|
|
quick();
|
|
});
|
|
|
|
it("refuses to let a second plugin take over a type", () => {
|
|
ensureCoreSshAuthProviders();
|
|
expect(() =>
|
|
registerSshAuthProvider({
|
|
type: "password",
|
|
pluginId: "someone-else",
|
|
labelKey: "x",
|
|
prepare: async () => ({ status: "ready" }),
|
|
}),
|
|
).toThrow(/already provided by core/);
|
|
});
|
|
|
|
it("offers password and key as credential types", () => {
|
|
ensureCoreSshAuthProviders();
|
|
expect(listCredentialTypes()).toEqual(
|
|
expect.arrayContaining(["password", "key"]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("connectHost", () => {
|
|
it("resolves the host for the acting user and connects", async () => {
|
|
mocks.resolveHostById.mockResolvedValueOnce(host());
|
|
const connection = await connectHost(7, {
|
|
userId: "user-1",
|
|
purpose: "fleet",
|
|
});
|
|
expect(mocks.resolveHostById).toHaveBeenCalledWith(7, "user-1");
|
|
const client = connection.client as unknown as FakeSshClient;
|
|
expect(client.connectConfig).toMatchObject({ password: "hunter2" });
|
|
connection.dispose();
|
|
expect(client.ended).toBe(true);
|
|
});
|
|
|
|
it("uses a shared-override resolution exactly as the resolver returned it", async () => {
|
|
mocks.resolveHostById.mockResolvedValueOnce(
|
|
host({ authType: "key", key: plainKey, password: null, username: "me" }),
|
|
);
|
|
const connection = await connectHost(7, {
|
|
userId: "recipient",
|
|
purpose: "fleet",
|
|
});
|
|
const config = (connection.client as unknown as FakeSshClient)
|
|
.connectConfig!;
|
|
expect(config.username).toBe("me");
|
|
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
|
expect(config.password).toBeUndefined();
|
|
});
|
|
|
|
it("fails for an unknown or forbidden host", async () => {
|
|
mocks.resolveHostById.mockResolvedValueOnce(null);
|
|
await expect(
|
|
connectHost(9, { userId: "user-1", purpose: "fleet" }),
|
|
).rejects.toThrow("Host not found or access denied");
|
|
});
|
|
|
|
it("throws the auth outcome instead of connecting", async () => {
|
|
const dispose = registerSignInProvider(false);
|
|
const attempt = connectHost(host({ authType: "signin" }), {
|
|
userId: "user-1",
|
|
purpose: "tmux",
|
|
});
|
|
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
|
await expect(
|
|
connectHost(host({ authType: "signin" }), {
|
|
userId: "user-1",
|
|
purpose: "tmux",
|
|
}).catch((error) => error.code),
|
|
).resolves.toBe("signin-required");
|
|
dispose();
|
|
});
|
|
|
|
it("answers TOTP on key and password jump hops and closes the entire chain", async () => {
|
|
const { createJumpHostChain } = await vi.importActual<
|
|
typeof import("../../../hosts/jump-host-chain.js")
|
|
>("../../../hosts/jump-host-chain.js");
|
|
mocks.resolveHostById
|
|
.mockResolvedValueOnce(
|
|
host({ id: 2, authType: "key", key: plainKey, password: null }),
|
|
)
|
|
.mockResolvedValueOnce(host({ id: 3, password: "hop-password" }));
|
|
FakeSshClient.nextBehaviour = ["hang", "hang"];
|
|
const prompt = {
|
|
ask: vi
|
|
.fn()
|
|
.mockResolvedValueOnce("111111")
|
|
.mockResolvedValueOnce("222222"),
|
|
};
|
|
const pending = createJumpHostChain(
|
|
[{ hostId: 2 }, { hostId: 3 }],
|
|
"user-1",
|
|
prompt,
|
|
);
|
|
await settle(() =>
|
|
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
|
|
);
|
|
const first = FakeSshClient.instances[0];
|
|
expect(first.connectConfig?.privateKey).toBeTruthy();
|
|
const firstAnswer = vi.fn();
|
|
first.emit(
|
|
"keyboard-interactive",
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Verification code:", echo: false }],
|
|
firstAnswer,
|
|
);
|
|
await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"]));
|
|
first.emit("ready");
|
|
await settle(() =>
|
|
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
|
|
);
|
|
const second = FakeSshClient.instances[1];
|
|
const secondAnswer = vi.fn();
|
|
second.emit(
|
|
"keyboard-interactive",
|
|
"",
|
|
"",
|
|
"",
|
|
[
|
|
{ prompt: "Password:", echo: false },
|
|
{ prompt: "Verification code:", echo: false },
|
|
],
|
|
secondAnswer,
|
|
);
|
|
await settle(() =>
|
|
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
|
|
);
|
|
second.emit("ready");
|
|
expect(await pending).toBe(second);
|
|
expect(prompt.ask.mock.calls[0][0].prompt).toContain("Jump host 1/2");
|
|
expect(prompt.ask.mock.calls[1][0].prompt).toContain("Jump host 2/2");
|
|
second.emit("close");
|
|
expect(first.ended).toBe(true);
|
|
expect(second.ended).toBe(true);
|
|
});
|
|
|
|
it("passes the interactive prompt channel through to jump hosts", async () => {
|
|
const jumpClient = new FakeSshClient();
|
|
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
|
const prompt = { ask: vi.fn().mockResolvedValue("123456") };
|
|
const connection = await connectHost(host({ jumpHosts: [{ hostId: 2 }] }), {
|
|
userId: "user-1",
|
|
prompt,
|
|
});
|
|
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
|
[{ hostId: 2 }],
|
|
"owner-1",
|
|
prompt,
|
|
);
|
|
connection.dispose();
|
|
});
|
|
|
|
it("goes through the jump chain and forwards to the target", async () => {
|
|
const jumpClient = new FakeSshClient();
|
|
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
|
|
const connection = await connectHost(
|
|
host({ jumpHosts: [{ hostId: 2 }, { hostId: 3 }] }),
|
|
{ userId: "user-1", purpose: "fleet" },
|
|
);
|
|
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
|
|
[{ hostId: 2 }, { hostId: 3 }],
|
|
"owner-1",
|
|
undefined,
|
|
);
|
|
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
|
|
"127.0.0.1",
|
|
0,
|
|
"10.0.0.7",
|
|
22,
|
|
expect.any(Function),
|
|
);
|
|
const config = (connection.client as unknown as FakeSshClient)
|
|
.connectConfig!;
|
|
expect(config.sock).toEqual({ fakeStream: true });
|
|
connection.dispose();
|
|
expect(jumpClient.ended).toBe(true);
|
|
});
|
|
|
|
it("uses a SOCKS5 proxy when the host has one", async () => {
|
|
mocks.createSocks5Connection.mockResolvedValueOnce({ proxied: true });
|
|
const connection = await connectHost(
|
|
host({ useSocks5: true, socks5Host: "proxy", socks5Port: 1080 }),
|
|
{ userId: "user-1", purpose: "fleet" },
|
|
);
|
|
expect(mocks.createSocks5Connection).toHaveBeenCalledWith(
|
|
"10.0.0.7",
|
|
22,
|
|
expect.objectContaining({ socks5Host: "proxy", socks5Port: 1080 }),
|
|
);
|
|
expect(
|
|
(connection.client as unknown as FakeSshClient).connectConfig!.sock,
|
|
).toEqual({ proxied: true });
|
|
});
|
|
|
|
it("connects over a stream it is given and skips the transport", async () => {
|
|
const connection = await connectHost(
|
|
host({
|
|
jumpHosts: [{ hostId: 2 }],
|
|
useSocks5: true,
|
|
socks5Host: "proxy",
|
|
portKnockSequence: [{ port: 7000 }],
|
|
}),
|
|
{
|
|
userId: "user-1",
|
|
purpose: "tunnel",
|
|
sock: { throughSource: true } as never,
|
|
},
|
|
);
|
|
const config = (connection.client as unknown as FakeSshClient)
|
|
.connectConfig!;
|
|
expect(config.sock).toEqual({ throughSource: true });
|
|
expect(config.hostVerifier).toBe(mocks.verifier);
|
|
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
|
|
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
|
|
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
|
|
expect(connection.jumpClient).toBeNull();
|
|
});
|
|
|
|
it("knocks before connecting when the host has a sequence", async () => {
|
|
await connectHost(
|
|
host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }),
|
|
{ userId: "user-1", purpose: "fleet" },
|
|
);
|
|
expect(mocks.performPortKnocking).toHaveBeenCalledWith("10.0.0.7", [
|
|
{ port: 7000 },
|
|
{ port: 8000 },
|
|
]);
|
|
});
|
|
|
|
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
|
|
FakeSshClient.nextBehaviour = ["hang"];
|
|
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
|
|
await settle(() => {
|
|
const last = FakeSshClient.instances.at(-1)!;
|
|
expect(last.connectConfig).not.toBeNull();
|
|
});
|
|
const client = FakeSshClient.instances.at(-1)!;
|
|
const finish = vi.fn();
|
|
client.emit(
|
|
"keyboard-interactive",
|
|
"",
|
|
"",
|
|
"",
|
|
[
|
|
{ prompt: "Password: ", echo: false },
|
|
{ prompt: "Something else: ", echo: true },
|
|
],
|
|
finish,
|
|
);
|
|
expect(finish).toHaveBeenCalledWith(["hunter2", ""]);
|
|
client.emit("ready");
|
|
await expect(attempt).resolves.toMatchObject({ client });
|
|
});
|
|
|
|
it("asks through the prompt channel when there is one", async () => {
|
|
FakeSshClient.nextBehaviour = ["hang"];
|
|
const ask = vi.fn().mockResolvedValue("123456");
|
|
const attempt = connectHost(host(), {
|
|
userId: "user-1",
|
|
purpose: "file-manager",
|
|
prompt: { ask },
|
|
});
|
|
await settle(() =>
|
|
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
|
|
);
|
|
const client = FakeSshClient.instances.at(-1)!;
|
|
const finish = vi.fn();
|
|
client.emit(
|
|
"keyboard-interactive",
|
|
"",
|
|
"",
|
|
"",
|
|
[
|
|
{ prompt: "Password: ", echo: false },
|
|
{ prompt: "Verification code: ", echo: true },
|
|
],
|
|
finish,
|
|
);
|
|
await settle(() => expect(finish).toHaveBeenCalled());
|
|
expect(ask).toHaveBeenCalledWith({
|
|
kind: "totp",
|
|
prompt: "Verification code: ",
|
|
retry: false,
|
|
});
|
|
expect(finish).toHaveBeenCalledWith(["hunter2", "123456"]);
|
|
client.emit("ready");
|
|
await attempt;
|
|
});
|
|
|
|
it("times out when the server never answers", async () => {
|
|
FakeSshClient.nextBehaviour = ["hang"];
|
|
await expect(
|
|
connectHost(host(), {
|
|
userId: "user-1",
|
|
purpose: "fleet",
|
|
timeoutMs: 20,
|
|
}),
|
|
).rejects.toThrow("SSH connection timeout");
|
|
});
|
|
|
|
it("lets the provider react to an auth failure", async () => {
|
|
FakeSshClient.nextBehaviour = ["auth-fail"];
|
|
const dispose = registerSignInProvider(true);
|
|
await expect(
|
|
connectHost(host({ authType: "signin" }), {
|
|
userId: "user-1",
|
|
purpose: "fleet",
|
|
}),
|
|
).rejects.toThrow(/All configured authentication methods failed/);
|
|
await settle(() =>
|
|
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
|
|
);
|
|
dispose();
|
|
});
|
|
|
|
it("pools under the old key shape", async () => {
|
|
expect(getConnectionPoolKey("fleet", host())).toBe(
|
|
"fleet:owner-1:10.0.0.7:22:root",
|
|
);
|
|
expect(
|
|
getConnectionPoolKey(
|
|
"stats",
|
|
host({ useSocks5: true, socks5Host: "p", socks5Port: 1 }),
|
|
),
|
|
).toBe("stats:owner-1:10.0.0.7:22:root:socks5:p:1");
|
|
|
|
const result = await withHostConnection(
|
|
"fleet:x",
|
|
host(),
|
|
{ userId: "user-1", purpose: "fleet" },
|
|
async (client) => (client as unknown as FakeSshClient).connectConfig,
|
|
);
|
|
expect(result).toMatchObject({ password: "hunter2" });
|
|
});
|
|
});
|