mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: add auth extension points and one ssh connect pipeline
This commit is contained in:
1 parent
d3c209af13
commit
d774df8b41
159 files changed
+43243
-7451
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { vi } from "vitest";
|
||||
|
||||
/** Stands in for ssh2's Client: records connect() and lets a test drive events. */
|
||||
export class FakeSshClient extends EventEmitter {
|
||||
connectConfig: Record<string, unknown> | null = null;
|
||||
ended = false;
|
||||
forwardOut = vi.fn(
|
||||
(
|
||||
_srcIp: string,
|
||||
_srcPort: number,
|
||||
_dstIp: string,
|
||||
_dstPort: number,
|
||||
cb: (err: Error | undefined, stream: unknown) => void,
|
||||
) => cb(undefined, { fakeStream: true }),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
FakeSshClient.instances.push(this);
|
||||
}
|
||||
|
||||
connect(config: Record<string, unknown>) {
|
||||
this.connectConfig = config;
|
||||
const behaviour = FakeSshClient.nextBehaviour.shift() ?? "ready";
|
||||
queueMicrotask(() => {
|
||||
if (behaviour === "ready") this.emit("ready");
|
||||
else if (behaviour === "auth-fail")
|
||||
this.emit(
|
||||
"error",
|
||||
new Error("All configured authentication methods failed"),
|
||||
);
|
||||
else if (behaviour === "hang") return;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
static nextBehaviour: Array<"ready" | "auth-fail" | "hang"> = [];
|
||||
static instances: FakeSshClient[] = [];
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
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(),
|
||||
getOPKSSHToken: vi.fn(),
|
||||
invalidateOPKSSHToken: vi.fn(),
|
||||
setupOPKSSHCertAuth: vi.fn(),
|
||||
setupCACertAuth: vi.fn(),
|
||||
getVaultCert: vi.fn(),
|
||||
deleteVaultCert: 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("../../../hosts/opkssh-auth.js", () => ({
|
||||
getOPKSSHToken: mocks.getOPKSSHToken,
|
||||
invalidateOPKSSHToken: mocks.invalidateOPKSSHToken,
|
||||
}));
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupOPKSSHCertAuth: mocks.setupOPKSSHCertAuth,
|
||||
setupCACertAuth: mocks.setupCACertAuth,
|
||||
}));
|
||||
vi.mock("../../../hosts/vault-signer-auth.js", () => ({
|
||||
getVaultCert: mocks.getVaultCert,
|
||||
deleteVaultCert: mocks.deleteVaultCert,
|
||||
}));
|
||||
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,
|
||||
getPurposeDefaults,
|
||||
} from "../../../hosts/connect/build-connect-config.js";
|
||||
import {
|
||||
connectHost,
|
||||
getConnectionPoolKey,
|
||||
SshConnectError,
|
||||
withHostConnection,
|
||||
} from "../../../hosts/connect/connect-host.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listCredentialTypes,
|
||||
registerSshAuthProvider,
|
||||
setSshAuthTypeOwnerSource,
|
||||
} from "../../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
||||
import { TAILSCALE_CHECK_TIMEOUT_MS } from "../../../auth/legacy-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,
|
||||
};
|
||||
}
|
||||
|
||||
async function build(
|
||||
target: SshConnectHost,
|
||||
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
|
||||
) {
|
||||
return buildConnectConfig(target, {
|
||||
userId: "user-1",
|
||||
purpose,
|
||||
client: new FakeSshClient() as never,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
FakeSshClient.nextBehaviour = [];
|
||||
FakeSshClient.instances = [];
|
||||
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
|
||||
mocks.applyAgentAuth.mockImplementation(async (config) => {
|
||||
config.agent = "agent-object";
|
||||
return { socketPath: "/tmp/agent.sock" };
|
||||
});
|
||||
mocks.setupOPKSSHCertAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "cert-handler";
|
||||
});
|
||||
mocks.setupCACertAuth.mockImplementation(async (config) => {
|
||||
config.authHandler = "ca-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(mocks.setupCACertAuth).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.setupCACertAuth).toHaveBeenCalledOnce();
|
||||
expect(config.authHandler).toBe("ca-handler");
|
||||
});
|
||||
|
||||
it("key with a failing CA certificate falls back to key only", async () => {
|
||||
mocks.setupCACertAuth.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,
|
||||
terminalConfig: { 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("opkssh without a cached token asks for a sign-in", async () => {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
|
||||
const { outcome } = await build(host({ authType: "opkssh" }));
|
||||
expect(outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "opkssh",
|
||||
});
|
||||
expect(mocks.getOPKSSHToken).toHaveBeenCalledWith("user-1", 7);
|
||||
});
|
||||
|
||||
it("opkssh and stepca use the cached certificate", async () => {
|
||||
for (const authType of ["opkssh", "stepca"]) {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const { config, outcome } = await build(host({ authType }));
|
||||
expect(outcome.status).toBe("ready");
|
||||
expect(config.authHandler).toBe("cert-handler");
|
||||
}
|
||||
expect(mocks.setupOPKSSHCertAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("vault needs a profile, then a cached certificate", async () => {
|
||||
const noProfile = await build(host({ authType: "vault" }));
|
||||
expect(noProfile.outcome).toMatchObject({ status: "error" });
|
||||
|
||||
mocks.getVaultCert.mockResolvedValueOnce(null);
|
||||
const noCert = await build(
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } }),
|
||||
);
|
||||
// The file manager turns the flag into requiresVaultAuth in its 401.
|
||||
expect(noCert.outcome).toMatchObject({
|
||||
status: "interaction-required",
|
||||
interaction: "vault",
|
||||
flag: "requiresVaultAuth",
|
||||
});
|
||||
|
||||
mocks.getVaultCert.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
const ready = await build(
|
||||
host({ authType: "vault", vaultProfile: { id: 3 } }),
|
||||
);
|
||||
expect(ready.outcome.status).toBe("ready");
|
||||
expect(mocks.getVaultCert).toHaveBeenLastCalledWith("user-1", 3);
|
||||
});
|
||||
|
||||
it("tailscale turns off keyboard-interactive, with long timeouts in the terminal", async () => {
|
||||
const terminal = await build(host({ authType: "tailscale" }));
|
||||
expect(terminal.config).toMatchObject({
|
||||
tryKeyboard: false,
|
||||
readyTimeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
timeout: TAILSCALE_CHECK_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const fleet = await build(host({ authType: "tailscale" }), "fleet");
|
||||
expect(fleet.config.tryKeyboard).toBe(false);
|
||||
expect(fleet.config.readyTimeout).toBe(30000);
|
||||
});
|
||||
|
||||
it("tailscale retries once with +password, then reports the failure", async () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
const provider = getSshAuthProvider("tailscale")!;
|
||||
const env = {} as never;
|
||||
const failure = new Error("All configured authentication methods failed");
|
||||
|
||||
const first = provider.onAuthFailed!(host({ authType: "tailscale" }), env, {
|
||||
error: failure,
|
||||
retries: 0,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
});
|
||||
expect(first).toMatchObject({
|
||||
status: "retry",
|
||||
patch: { username: "root+password", tryKeyboard: false },
|
||||
});
|
||||
|
||||
const second = provider.onAuthFailed!(
|
||||
host({ authType: "tailscale" }),
|
||||
env,
|
||||
{
|
||||
error: failure,
|
||||
retries: 1,
|
||||
canRetry: true,
|
||||
methodNotAvailable: false,
|
||||
},
|
||||
);
|
||||
expect(second).toMatchObject({ status: "error" });
|
||||
});
|
||||
|
||||
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-host",
|
||||
);
|
||||
expect(config.tryKeyboard).toBe(false);
|
||||
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
|
||||
7,
|
||||
"10.0.0.7",
|
||||
22,
|
||||
null,
|
||||
"user-1",
|
||||
true,
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("purpose defaults", () => {
|
||||
it("keeps each transport's old keepalive numbers", () => {
|
||||
expect(getPurposeDefaults("terminal")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getPurposeDefaults("file-manager")).toMatchObject({
|
||||
keepaliveIntervalMs: 60000,
|
||||
keepaliveCountMax: 5,
|
||||
});
|
||||
expect(getPurposeDefaults("fleet")).toMatchObject({
|
||||
keepaliveIntervalMs: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("host keepalive settings apply where the transport honoured them", async () => {
|
||||
const withSettings = host({
|
||||
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
|
||||
});
|
||||
const terminal = await build(withSettings, "terminal");
|
||||
expect(terminal.config.keepaliveInterval).toBe(10000);
|
||||
const fleet = await build(withSettings, "fleet");
|
||||
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("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 () => {
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce(null);
|
||||
const attempt = connectHost(host({ authType: "opkssh" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
});
|
||||
await expect(attempt).rejects.toBeInstanceOf(SshConnectError);
|
||||
await expect(
|
||||
connectHost(host({ authType: "opkssh" }), {
|
||||
userId: "user-1",
|
||||
purpose: "tmux",
|
||||
}).catch((error) => error.code),
|
||||
).resolves.toBe("opkssh-required");
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
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("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 vi.waitFor(() => {
|
||||
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 vi.waitFor(() =>
|
||||
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 vi.waitFor(() => 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"];
|
||||
mocks.getOPKSSHToken.mockResolvedValueOnce({
|
||||
privateKey: "k",
|
||||
sshCert: "c",
|
||||
});
|
||||
await expect(
|
||||
connectHost(host({ authType: "opkssh" }), {
|
||||
userId: "user-1",
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.invalidateOPKSSHToken).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
7,
|
||||
"SSH auth failed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,17 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findCredentialByIdForUser: async () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { SSHAuthManager } from "../../hosts/auth-manager.js";
|
||||
import { SSHAuthManager } from "../../../hosts/connect/terminal-prompt.js";
|
||||
|
||||
function createManager() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setupCACertAuth = vi.fn();
|
||||
const warn = vi.fn();
|
||||
|
||||
vi.mock("../../../hosts/opkssh-cert-auth.js", () => ({
|
||||
setupCACertAuth: (...args: unknown[]) => setupCACertAuth(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
fileLogger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const { applyCACertIfPresent } =
|
||||
await import("../../../hosts/file-manager/ca-cert-auth.js");
|
||||
|
||||
/**
|
||||
* `setupCACertAuth` had no call site in the file manager at all, while its
|
||||
* sibling `setupOPKSSHCertAuth` had two. So a host whose key is paired with a
|
||||
* user-managed CA-signed certificate authenticated in the terminal and failed
|
||||
* over SFTP, and OPKSSH certificates — going through the other helper — worked
|
||||
* in both places.
|
||||
*/
|
||||
describe("applyCACertIfPresent", () => {
|
||||
const client = {} as never;
|
||||
const key = Buffer.from("private-key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("attaches a certificate the host carries", async () => {
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
await applyCACertIfPresent(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "ssh-rsa-cert-v01@openssh.com AAAA" },
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
|
||||
expect(setupCACertAuth).toHaveBeenCalledWith(
|
||||
config,
|
||||
client,
|
||||
key,
|
||||
"ssh-rsa-cert-v01@openssh.com AAAA",
|
||||
"root",
|
||||
"passphrase",
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when there is no certificate", async () => {
|
||||
// Most hosts. Touching the connection here would change key-only auth.
|
||||
for (const certPublicKey of [undefined, null, "", " "]) {
|
||||
await applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey },
|
||||
"root",
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
expect(setupCACertAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the connection usable when the certificate is unusable", async () => {
|
||||
// The private key on its own may still be accepted — which is what
|
||||
// happened before this was wired up. Failing the connection here would
|
||||
// turn a working setup into a broken one.
|
||||
setupCACertAuth.mockRejectedValueOnce(new Error("bad cert format"));
|
||||
|
||||
await expect(
|
||||
applyCACertIfPresent(
|
||||
{},
|
||||
client,
|
||||
key,
|
||||
{ certPublicKey: "garbage" },
|
||||
"root",
|
||||
undefined,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("CA certificate setup failed"),
|
||||
expect.objectContaining({ operation: "sftp_ca_cert_auth_failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("file manager Vault authentication", () => {
|
||||
it("configures the cached Vault certificate before connecting", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../hosts/file-manager/index.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const vaultBranch = source.indexOf(
|
||||
'resolvedCredentials.authType === "vault"',
|
||||
);
|
||||
const agentBranch = source.indexOf(
|
||||
'resolvedCredentials.authType === "agent"',
|
||||
vaultBranch,
|
||||
);
|
||||
const body = source.slice(vaultBranch, agentBranch);
|
||||
|
||||
expect(vaultBranch).toBeGreaterThan(-1);
|
||||
expect(agentBranch).toBeGreaterThan(vaultBranch);
|
||||
expect(body).toContain("setupVaultSshSignerAuth");
|
||||
expect(body).toContain("requiresVaultAuth: true");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getTmuxAuthBehavior } from "../../../hosts/tmux/auth-utils.js";
|
||||
|
||||
describe("getTmuxAuthBehavior", () => {
|
||||
it("uses credentialless non-interactive authentication for Tailscale SSH", () => {
|
||||
expect(getTmuxAuthBehavior("tailscale")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves keyboard-interactive fallback for none authentication", () => {
|
||||
expect(getTmuxAuthBehavior("none")).toEqual({
|
||||
credentialless: true,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat password authentication as credentialless", () => {
|
||||
expect(getTmuxAuthBehavior("password")).toEqual({
|
||||
credentialless: false,
|
||||
tryKeyboard: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user