feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

-452
View File
@@ -1,452 +0,0 @@
import type { WebSocket } from "ws";
import { resolveExternalSecretRefs } from "./external-secrets.js";
import { sshLogger, authLogger } from "../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
interface ResolvedCredentials {
username: string;
password?: string;
key?: Buffer;
keyPassword?: string;
authType?: string;
certPublicKey?: string;
}
interface HostConfig {
id: number;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
useWarpgate?: boolean;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
}
interface AuthContext {
userId: string;
ws: WebSocket;
hostId: number;
isKeyboardInteractive: boolean;
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
export class SSHAuthManager {
public context: AuthContext;
constructor(context: AuthContext) {
this.context = context;
}
async resolveCredentials(
hostConfig: HostConfig,
): Promise<ResolvedCredentials> {
let resolvedCredentials: ResolvedCredentials = {
username: hostConfig.username,
authType: hostConfig.authType || "none",
};
if (hostConfig.credentialId) {
const cred =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
hostConfig.credentialId,
this.context.userId,
);
if (cred) {
// Credentials may hold secret references instead of secrets.
await resolveExternalSecretRefs(
cred as unknown as Record<string, unknown>,
this.context.userId,
);
resolvedCredentials = {
username: (cred.username as string) || hostConfig.username,
password: (cred.password as string) || undefined,
key:
cred.key || cred.privateKey
? Buffer.from((cred.key || cred.privateKey) as string)
: undefined,
keyPassword: (cred.keyPassword as string) || undefined,
authType: (cred.authType as string) || "none",
certPublicKey: (cred.certPublicKey as string) || undefined,
};
}
} else {
if (hostConfig.password) {
resolvedCredentials.password = hostConfig.password;
resolvedCredentials.authType = "password";
}
if (hostConfig.key) {
resolvedCredentials.key = Buffer.from(hostConfig.key, "utf8");
resolvedCredentials.authType = "key";
if (hostConfig.keyPassword) {
resolvedCredentials.keyPassword = hostConfig.keyPassword;
}
}
}
return resolvedCredentials;
}
handleKeyboardInteractive(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostConfig,
): void {
this.context.isKeyboardInteractive = true;
const promptTexts = prompts.map((p) => p.prompt);
const warpgatePattern = /warpgate\s+authentication/i;
const isWarpgate =
warpgatePattern.test(name) ||
warpgatePattern.test(instructions) ||
promptTexts.some((p) => warpgatePattern.test(p));
if (isWarpgate) {
this.handleWarpgateAuth(name, instructions, promptTexts, finish);
return;
}
// FortiToken-style prompt: asks the user to type a code OR the literal
// word "push" (e.g. "Enter your Token or type 'push' to receive a push
// notification:"). Unlike JumpCloud/DUO's confirm-only push, this needs a
// text field, so it must not fall into the empty-answerable push pattern
// below. Checked first since its text also contains "push notification".
const fortiTokenPattern = /type\s+['"]?push['"]?/i;
const isFortiTokenPrompt = promptTexts.some((p) =>
fortiTokenPattern.test(p),
);
// JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push,
// or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to
// send Push request:"). Checked before the TOTP regex because the menu
// prompt's own text ("...or [2] TOTP:") would otherwise match it and get
// misrouted into the numeric-code flow.
const pushPromptPattern =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const isPushPrompt =
!isFortiTokenPrompt && promptTexts.some((p) => pushPromptPattern.test(p));
if (isPushPrompt) {
sshLogger.info("Push/menu MFA prompt detected", {
operation: "ssh_keyboard_interactive_push",
hostId: this.context.hostId,
});
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
return;
}
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
this.handleTotpAuth(
prompts,
totpPromptIndex,
finish,
resolvedCredentials,
);
return;
}
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
}
private handleWarpgateAuth(
name: string,
instructions: string,
promptTexts: string[],
finish: (responses: string[]) => void,
): void {
const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`;
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
const keyMatch = fullText.match(
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
);
if (urlMatch) {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
instructions: instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
sshLogger.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
}),
);
}
}, 300000);
}
}
private handleTotpAuth(
prompts: Array<{ prompt: string; echo: boolean }>,
totpPromptIndex: number,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
): void {
if (this.context.totpPromptSent) {
sshLogger.warn("TOTP prompt asked again - invalid code", {
operation: "ssh_keyboard_interactive_totp_retry",
hostId: this.context.hostId,
});
authLogger.warn("TOTP verification failed for SSH session", {
operation: "terminal_totp_failed",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.sendLog("auth", "warning", "Invalid TOTP code");
this.context.ws.send(
JSON.stringify({
type: "totp_retry",
}),
);
return;
}
this.context.totpPromptSent = true;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (totpResponses: string[]) => {
const totpCode = (totpResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === totpPromptIndex) {
return totpCode;
}
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
};
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
}
this.context.totpTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
sshLogger.warn("TOTP prompt timeout", {
operation: "totp_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "TOTP verification timeout. Please reconnect.",
}),
);
}
}, 180000);
this.sendLog("auth", "info", "TOTP verification required");
authLogger.info("TOTP verification prompt sent to client", {
operation: "terminal_totp_prompt",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[totpPromptIndex].prompt,
}),
);
}
private handlePasswordAuth(
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostConfig,
): void {
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
// Warpgate sends a password prompt before its browser-verification round; we must
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
if (hostConfig?.useWarpgate) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
return;
}
const hasStoredPassword =
resolvedCredentials.password && resolvedCredentials.authType !== "none";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
// that don't say "password" (e.g. "Passcode or option (1-N):").
const firstUnansweredIndex = prompts.findIndex((p) => {
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
return true;
});
if (firstUnansweredIndex !== -1) {
if (this.context.keyboardInteractiveResponded) {
return;
}
this.context.keyboardInteractiveResponded = true;
const promptIndex =
passwordPromptIndex !== -1 && !hasStoredPassword
? passwordPromptIndex
: firstUnansweredIndex;
const pushPromptPattern =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const isPushPrompt = pushPromptPattern.test(prompts[promptIndex].prompt);
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
const userInput = (userResponses[0] || "").trim();
const responses = prompts.map((p, index) => {
if (index === promptIndex) {
return userInput;
}
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
};
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
}
this.context.totpTimeout = setTimeout(
() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.keyboardInteractiveResponded = false;
sshLogger.warn("Password prompt timeout", {
operation: "password_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Password verification timeout. Please reconnect.",
}),
);
}
},
isPushPrompt ? 300000 : 180000,
);
this.sendLog("auth", "info", "Password authentication required");
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
echo: prompts[promptIndex].echo,
}),
);
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
}
sendLog(
stage: string,
level: string,
message: string,
details?: Record<string, unknown>,
): void {
this.context.ws.send(
JSON.stringify({
type: "connection_log",
data: {
stage,
level,
message,
details,
},
}),
);
}
cleanup(): void {
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.keyboardInteractiveResponded = false;
}
}
@@ -0,0 +1,122 @@
/**
* Every SSH auth type the server knows how to connect with.
*
* Core registers password, key, credential, agent and none. The rest come from
* plugins through ctx.auth.registerSshAuthProvider, or for 2.9.0 from
* src/backend/auth/legacy-providers.ts. A type with no provider fails the
* connect with a message naming the plugin that would provide it.
*/
import type {
KeyboardInteractiveInterceptor,
SshAuthProvider,
} from "./types.js";
const providers = new Map<string, SshAuthProvider>();
const interceptors = new Map<string, KeyboardInteractiveInterceptor>();
export interface SshAuthTypeOwner {
pluginId: string;
pluginName: string;
}
/**
* Which plugin declares which auth type, from every manifest on disk
* including disabled ones. Set by the plugin runtime.
*/
let ownerSource: () => Array<SshAuthTypeOwner & { type: string }> = () => [];
export function setSshAuthTypeOwnerSource(
source: () => Array<SshAuthTypeOwner & { type: string }>,
): void {
ownerSource = source;
}
export function listSshAuthTypeOwners(): Array<
SshAuthTypeOwner & { type: string }
> {
return ownerSource();
}
export function findSshAuthTypeOwner(type: string): SshAuthTypeOwner | null {
const owner = ownerSource().find((entry) => entry.type === type);
return owner
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
: null;
}
export class SshAuthProviderMissingError extends Error {
readonly code = "SSH_AUTH_PROVIDER_MISSING";
constructor(
readonly authType: string,
readonly owner: SshAuthTypeOwner | null,
) {
super(
owner
? `This host uses ${authType}, which needs the ${owner.pluginName} plugin`
: `This host uses ${authType}, which no enabled plugin provides`,
);
this.name = "SshAuthProviderMissingError";
}
}
export function registerSshAuthProvider(provider: SshAuthProvider): () => void {
const existing = providers.get(provider.type);
if (existing && existing.pluginId !== provider.pluginId) {
throw new Error(
`SSH auth type "${provider.type}" is already provided by ${existing.pluginId}`,
);
}
providers.set(provider.type, provider);
return () => {
if (providers.get(provider.type) === provider) {
providers.delete(provider.type);
}
};
}
export function getSshAuthProvider(type: string): SshAuthProvider | undefined {
return providers.get(type);
}
/** Throws SshAuthProviderMissingError when nothing provides the type. */
export function requireSshAuthProvider(type: string): SshAuthProvider {
const provider = providers.get(type);
if (!provider) {
throw new SshAuthProviderMissingError(type, findSshAuthTypeOwner(type));
}
return provider;
}
export function listSshAuthProviders(): SshAuthProvider[] {
return [...providers.values()];
}
/** Types a stored credential may use. */
export function listCredentialTypes(): string[] {
return listSshAuthProviders()
.filter((provider) => provider.credentialType)
.map((provider) => provider.type);
}
export function registerKeyboardInteractiveInterceptor(
interceptor: KeyboardInteractiveInterceptor,
): () => void {
interceptors.set(interceptor.id, interceptor);
return () => {
if (interceptors.get(interceptor.id) === interceptor) {
interceptors.delete(interceptor.id);
}
};
}
export function listKeyboardInteractiveInterceptors(): KeyboardInteractiveInterceptor[] {
return [...interceptors.values()];
}
/** Test helper. */
export function resetSshAuthRegistryForTests(): void {
providers.clear();
interceptors.clear();
ownerSource = () => [];
}
@@ -0,0 +1,291 @@
import type { Client } from "ssh2";
import type { WebSocket } from "ws";
import { buildSSHAlgorithms } from "../../utils/ssh-algorithms.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { resolveSshKeepalive } from "../ssh-keepalive.js";
import {
SshAuthProviderMissingError,
requireSshAuthProvider,
} from "./auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "./core-providers.js";
import type {
MutableConnectConfig,
SshAuthEnv,
SshAuthLog,
SshAuthOutcome,
SshAuthProvider,
SshConnectHost,
SshConnectPurpose,
} from "./types.js";
interface PurposeDefaults {
keepaliveIntervalMs: number;
keepaliveCountMax: number;
/** Let terminalConfig.keepalive* override the defaults. */
hostKeepalive: boolean;
readyTimeout: number;
/** Socket idle timeout, when the transport set one. */
socketTimeout?: number;
env: Record<string, string> | null;
}
const TERMINAL_ENV: Record<string, string> = {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
};
const BASIC_ENV: Record<string, string> = {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
};
// The values each transport used before it moved onto the pipeline.
const PURPOSE_DEFAULTS: Record<SshConnectPurpose, PurposeDefaults> = {
terminal: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 120000,
socketTimeout: 120000,
env: TERMINAL_ENV,
},
"file-manager": {
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 60000,
env: TERMINAL_ENV,
},
"file-transfer": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 120,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
tmux: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
metrics: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
fleet: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
tunnel: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: true,
readyTimeout: 60000,
env: null,
},
docker: {
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
hostKeepalive: true,
readyTimeout: 60000,
env: TERMINAL_ENV,
},
"docker-console": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 120,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
proxmox: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 20000,
env: null,
},
snippet: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: null,
},
"credential-deploy": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: null,
},
"jump-host": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
"remote-desktop": {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 60000,
env: null,
},
plugin: {
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
hostKeepalive: false,
readyTimeout: 30000,
env: BASIC_ENV,
},
};
export function getPurposeDefaults(
purpose: SshConnectPurpose,
): PurposeDefaults {
return PURPOSE_DEFAULTS[purpose];
}
export interface BuildConnectConfigOptions {
/** Acting user: host key trust, certificates and tokens are per user. */
userId: string;
purpose: SshConnectPurpose;
client: Client;
/** Server-side host id when it differs from host.id (sync-id lookups). */
serverHostId?: number;
/** Terminal socket for the changed-host-key prompt. */
hostKeySocket?: WebSocket | null;
interactive?: boolean;
log?: SshAuthLog;
/** Applied after the purpose defaults and before auth is prepared. */
overrides?: Partial<MutableConnectConfig>;
}
export interface BuiltConnectConfig {
config: MutableConnectConfig;
provider: SshAuthProvider | null;
outcome: SshAuthOutcome;
env: SshAuthEnv;
}
const noopLog: SshAuthLog = () => {};
export function stripIpv6Brackets(ip: string): string {
return ip?.replace(/^\[|\]$/g, "") || ip;
}
/**
* Builds the ssh2 config for one host: base settings for the purpose, the
* host verifier, then the auth provider's prepare step. Never connects.
*/
export async function buildConnectConfig(
host: SshConnectHost,
options: BuildConnectConfigOptions,
): Promise<BuiltConnectConfig> {
ensureCoreSshAuthProviders();
const defaults = PURPOSE_DEFAULTS[options.purpose];
const authType = host.authType || "none";
const serverHostId = options.serverHostId ?? host.id;
const isJumpHost = options.purpose === "jump-host";
const terminalConfig = (host.terminalConfig ?? undefined) as
Record<string, unknown> | undefined;
const keepalive = defaults.hostKeepalive
? resolveSshKeepalive(
terminalConfig?.keepaliveInterval as number | undefined,
terminalConfig?.keepaliveCountMax as number | undefined,
defaults.keepaliveIntervalMs,
defaults.keepaliveCountMax,
)
: {
keepaliveInterval: defaults.keepaliveIntervalMs,
keepaliveCountMax: defaults.keepaliveCountMax,
};
const preloaded = serverHostId
? await SSHHostKeyVerifier.preloadHostData(serverHostId)
: null;
const config: MutableConnectConfig = {
host: stripIpv6Brackets(host.ip),
port: host.port || 22,
username: host.username,
// A jump hop with no auth never answered prompts before either.
tryKeyboard: isJumpHost ? authType !== "none" : true,
...keepalive,
readyTimeout: defaults.readyTimeout,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
serverHostId,
host.ip,
host.port || 22,
options.hostKeySocket ?? null,
options.userId,
isJumpHost,
preloaded,
),
algorithms: buildSSHAlgorithms(
terminalConfig?.allowLegacyAlgorithms !== false,
),
};
if (defaults.env) config.env = { ...defaults.env };
if (defaults.socketTimeout) config.timeout = defaults.socketTimeout;
const env: SshAuthEnv = {
client: options.client,
userId: options.userId,
hostId: serverHostId,
purpose: options.purpose,
interactive: options.interactive ?? false,
log: options.log ?? noopLog,
};
let provider: SshAuthProvider;
try {
provider = requireSshAuthProvider(authType);
} catch (error) {
if (error instanceof SshAuthProviderMissingError) {
return {
config,
provider: null,
env,
outcome: {
status: "error",
code: "provider-missing",
message: error.message,
},
};
}
throw error;
}
if (provider.connectOptions) {
Object.assign(config, provider.connectOptions(host, options.purpose));
}
if (options.overrides) {
Object.assign(config, options.overrides);
}
const outcome = await provider.prepare(config, host, env);
return { config, provider, outcome, env };
}
@@ -0,0 +1,213 @@
/**
* The SSH auth types core always provides: password, key, credential, agent
* and none. Everything else is a plugin (or, in 2.9.0, a legacy provider).
*/
import ssh2 from "ssh2";
import { getErrorMessage } from "../../utils/error-message.js";
import {
isPrivateKeyPassphraseError,
preparePrivateKeyForSSH2,
} from "../../utils/ssh-key-utils.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js";
import { registerSshAuthProvider } from "./auth-provider-registry.js";
import type {
MutableConnectConfig,
SshAuthEnv,
SshAuthOutcome,
SshAuthProvider,
SshConnectHost,
} from "./types.js";
function keyText(key: SshConnectHost["key"]): string {
if (!key) return "";
return Buffer.isBuffer(key) ? key.toString("utf8") : key;
}
async function preparePassword(
config: MutableConnectConfig,
host: SshConnectHost,
env: SshAuthEnv,
): Promise<SshAuthOutcome> {
if (!host.password) {
return {
status: "error",
code: "missing-secret",
message: "Password authentication requested but no password provided",
};
}
// Forced keyboard-interactive answers the password prompt from the same
// value instead of offering it up front.
if (!host.forceKeyboardInteractive) {
config.password = host.password;
}
env.log("info", "Using password authentication");
return { status: "ready" };
}
async function prepareKey(
config: MutableConnectConfig,
host: SshConnectHost,
env: SshAuthEnv,
): Promise<SshAuthOutcome> {
const key = keyText(host.key);
if (!key.trim()) {
return {
status: "error",
code: "missing-secret",
message: "SSH key authentication requested but no key provided",
};
}
const passphrase = host.keyPassword || undefined;
try {
config.privateKey = preparePrivateKeyForSSH2(key, passphrase);
} catch (error) {
if (isPrivateKeyPassphraseError(error)) {
return {
status: "error",
code: "passphrase-required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
};
}
return {
status: "error",
code: "invalid-key",
message: `SSH key format error: ${getErrorMessage(error, "Invalid private key format")}`,
};
}
// A key that parses in our own helper can still be encrypted with no
// passphrase given; ssh2 only notices at connect time otherwise.
const parsed = ssh2.utils.parseKey(config.privateKey as Buffer, passphrase);
if (parsed instanceof Error) {
if (isPrivateKeyPassphraseError(parsed)) {
return {
status: "error",
code: "passphrase-required",
message:
"The SSH key is encrypted. Please enter the passphrase to unlock it.",
};
}
return {
status: "error",
code: "invalid-key",
message: `SSH key format error: ${parsed.message}`,
};
}
if (passphrase) config.passphrase = passphrase;
// Lets a server that wants key then password (or password only) still work.
if (host.password) config.password = host.password;
const certPublicKey = host.certPublicKey;
if (certPublicKey && certPublicKey.trim()) {
try {
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
await setupCACertAuth(
config,
env.client,
config.privateKey as Buffer,
certPublicKey,
host.username,
passphrase,
);
env.log("info", "Using SSH key authentication with CA certificate");
return { status: "ready" };
} catch (certError) {
env.log(
"warning",
`CA certificate setup failed, falling back to key only: ${getErrorMessage(certError)}`,
);
}
}
env.log("info", "Using SSH key authentication");
return { status: "ready" };
}
export const passwordProvider: SshAuthProvider = {
type: "password",
pluginId: "core",
labelKey: "hosts.filterAuthPassword",
credentialType: true,
requiresSecret: true,
prepare: preparePassword,
};
export const keyProvider: SshAuthProvider = {
type: "key",
pluginId: "core",
labelKey: "hosts.filterAuthKey",
credentialType: true,
requiresSecret: true,
prepare: prepareKey,
};
/**
* The resolver expands a credential into key or password before connect, so
* this only runs when that expansion found something odd, like an unresolved
* reference. It behaves like whichever secret is present.
*/
export const credentialProvider: SshAuthProvider = {
type: "credential",
pluginId: "core",
labelKey: "hosts.filterAuthCredential",
requiresSecret: true,
prepare: async (config, host, env) => {
if (keyText(host.key).trim()) return prepareKey(config, host, env);
if (host.password) return preparePassword(config, host, env);
return {
status: "error",
code: "missing-secret",
message: "The stored credential for this host could not be resolved",
};
},
};
export const agentProvider: SshAuthProvider = {
type: "agent",
pluginId: "core",
labelKey: "hosts.filterAuthAgent",
requiresSecret: true,
prepare: async (config, host, env) => {
const result = await applyAgentAuth(
config,
host.terminalConfig as Record<string, unknown> | undefined,
);
if ("error" in result) {
return { status: "error", code: "failed", message: result.error };
}
env.log("info", `SSH agent configured (socket: ${result.socketPath})`);
return { status: "ready" };
},
};
export const noneProvider: SshAuthProvider = {
type: "none",
pluginId: "core",
labelKey: "hosts.filterAuthNone",
// Keyboard-interactive only: a background poll would fire a live prompt
// (a RADIUS or Duo push) with nobody there to answer it.
supportsBackground: false,
prepare: async (_config, _host, env) => {
env.log("info", "Using keyboard-interactive authentication");
return { status: "ready" };
},
};
export const BUILTIN_SSH_AUTH_PROVIDERS: readonly SshAuthProvider[] = [
passwordProvider,
keyProvider,
credentialProvider,
agentProvider,
noneProvider,
];
export function registerBuiltinSshAuthProviders(): () => void {
const disposers = BUILTIN_SSH_AUTH_PROVIDERS.map((provider) =>
registerSshAuthProvider(provider),
);
return () => disposers.forEach((dispose) => dispose());
}
+208
View File
@@ -0,0 +1,208 @@
/**
* connectHost: the one way core and plugins open an SSH connection.
*
* Resolve the host (shared overrides, external secrets and owner decryption
* all happen in resolveHostById), build the config through the host's auth
* provider, open the transport, then connect with a keyboard-interactive
* handler that either asks through a prompt channel or fills in the stored
* password.
*/
import { Client } from "ssh2";
import { withConnection } from "../ssh-connection-pool.js";
import { resolveHostById } from "../host-resolver.js";
import { buildConnectConfig } from "./build-connect-config.js";
import {
createAutoKeyboardInteractiveHandler,
createPromptKeyboardInteractiveHandler,
type KeyboardInteractiveListener,
} from "./keyboard-interactive.js";
import { openSshTransport, type OpenTransportOptions } from "./transport.js";
import type {
MutableConnectConfig,
SshAuthLog,
SshAuthOutcome,
SshConnectHost,
SshConnectPurpose,
SshPromptChannel,
} from "./types.js";
export class SshConnectError extends Error {
readonly code: string;
constructor(readonly outcome: Exclude<SshAuthOutcome, { status: "ready" }>) {
super(outcome.message);
this.name = "SshConnectError";
this.code =
outcome.status === "error"
? outcome.code
: outcome.status === "interaction-required"
? `${outcome.interaction}-required`
: "retry";
}
}
export class SshHostNotFoundError extends Error {
constructor(readonly hostId: number) {
super("Host not found or access denied");
this.name = "SshHostNotFoundError";
}
}
export interface ConnectHostOptions {
/** Acting user. Access and credentials are resolved for them. */
userId: string;
purpose: SshConnectPurpose;
/** Ask a person for keyboard-interactive answers. Without it, auto-fill. */
prompt?: SshPromptChannel;
/** Replaces the keyboard-interactive handler entirely. */
keyboardInteractive?: KeyboardInteractiveListener;
/** Config fields to force, e.g. a longer readyTimeout. */
overrides?: Partial<MutableConnectConfig>;
/** Overall timeout for reaching "ready". Defaults to 30s. */
timeoutMs?: number;
/** Use this client instead of a new one. */
client?: Client;
transport?: OpenTransportOptions;
log?: SshAuthLog;
}
export interface SshConnection {
client: Client;
jumpClient: Client | null;
host: SshConnectHost;
config: MutableConnectConfig;
/** Ends the connection and its jump chain. Safe to call twice. */
dispose: () => void;
}
export async function resolveConnectHost(
target: number | SshConnectHost,
userId: string,
): Promise<SshConnectHost> {
if (typeof target !== "number") return target;
const host = await resolveHostById(target, userId);
if (!host) throw new SshHostNotFoundError(target);
return host as unknown as SshConnectHost;
}
export async function connectHost(
target: number | SshConnectHost,
options: ConnectHostOptions,
): Promise<SshConnection> {
const host = await resolveConnectHost(target, options.userId);
const client = options.client ?? new Client();
const { config, provider, outcome, env } = await buildConnectConfig(host, {
userId: options.userId,
purpose: options.purpose,
client,
hostKeySocket: options.prompt?.hostKeySocket ?? null,
interactive: !!options.prompt,
log: options.log,
overrides: options.overrides,
});
if (outcome.status !== "ready") {
throw new SshConnectError(outcome);
}
const { jumpClient } = await openSshTransport(host, config, {
log: options.log,
...options.transport,
});
const keyboardInteractive =
options.keyboardInteractive ??
(options.prompt
? createPromptKeyboardInteractiveHandler(host, options.prompt)
: createAutoKeyboardInteractiveHandler(host));
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
try {
client.end();
} catch {
// already closed
}
jumpClient?.end();
};
await new Promise<void>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
dispose();
reject(new Error("SSH connection timeout"));
}, options.timeoutMs ?? 30000);
client.on("ready", () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve();
});
client.on("error", (error: Error) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient?.end();
const authFailed = /All configured authentication methods failed/i.test(
error.message,
);
if (authFailed && provider?.onAuthFailed) {
try {
provider.onAuthFailed(host, env, {
error,
retries: 0,
canRetry: false,
methodNotAvailable: false,
});
} catch {
// cleanup hooks never mask the connect error
}
}
reject(error);
});
client.on("close", () => {
jumpClient?.end();
});
client.on("keyboard-interactive", keyboardInteractive);
client.connect(config);
});
return { client, jumpClient, host, config, dispose };
}
/** Pool key for a host and purpose, matching the keys the transports used. */
export function getConnectionPoolKey(
prefix: string,
host: SshConnectHost,
): string {
const socks5Key = host.useSocks5
? `:socks5:${host.socks5Host}:${host.socks5Port}`
: "";
return `${prefix}:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
}
/**
* Runs fn on a pooled connection. The pool calls the factory only when it
* has no idle connection for the key.
*/
export async function withHostConnection<T>(
poolKey: string,
target: number | SshConnectHost,
options: ConnectHostOptions,
fn: (client: Client) => Promise<T>,
): Promise<T> {
const host = await resolveConnectHost(target, options.userId);
return withConnection(
poolKey,
async () => (await connectHost(host, options)).client,
fn,
);
}
@@ -0,0 +1,20 @@
import { registerLegacySshAuthProviders } from "../../auth/legacy-providers.js";
import { registerBuiltinSshAuthProviders } from "./builtin-providers.js";
let registered = false;
/**
* Registers core's providers once. Called lazily by the pipeline so tests and
* scripts get the same set as a running server without a start-up hook.
*/
export function ensureCoreSshAuthProviders(): void {
if (registered) return;
registered = true;
registerBuiltinSshAuthProviders();
registerLegacySshAuthProviders();
}
/** Test helper, pairs with resetSshAuthRegistryForTests. */
export function resetCoreSshAuthProvidersForTests(): void {
registered = false;
}
@@ -0,0 +1,200 @@
/**
* One classifier for keyboard-interactive rounds, shared by every transport.
*
* It only decides what a round is. Answering is up to the transport: the
* terminal asks over its socket, the file manager parks the connection, and
* background work fills in the stored password and nothing else.
*/
import { listKeyboardInteractiveInterceptors } from "./auth-provider-registry.js";
import type {
KeyboardInteractiveDecision,
KeyboardInteractivePrompt,
SshConnectHost,
SshPromptChannel,
} from "./types.js";
const PASSWORD_PATTERN = /password/i;
// FortiToken asks for a code OR the word "push", so it needs a text field and
// must not be treated as a confirm-only push. Checked before the push pattern
// because its text also says "push notification".
const FORTI_TOKEN_PATTERN = /type\s+['"]?push['"]?/i;
// JumpCloud Protect / DUO: a menu choice, then an empty-answerable confirm.
// Checked before TOTP because "...or [2] TOTP:" would match that pattern.
export const PUSH_PROMPT_PATTERN =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const TOTP_PATTERN =
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
export interface KeyboardInteractiveRound {
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
}
/** Stored password for every password prompt, empty for the rest. */
export function autoResponses(
prompts: KeyboardInteractivePrompt[],
password: string | null | undefined,
): string[] {
return prompts.map((p) =>
PASSWORD_PATTERN.test(p.prompt) && password ? password : "",
);
}
/** Puts the user's answer at one index and auto-fills the rest. */
export function responsesWithAnswer(
prompts: KeyboardInteractivePrompt[],
answerIndex: number,
answer: string,
password: string | null | undefined,
): string[] {
return prompts.map((p, index) => {
if (index === answerIndex) return answer;
if (PASSWORD_PATTERN.test(p.prompt) && password) return password;
return "";
});
}
function shouldAutoAnswerPasswords(host: SshConnectHost): boolean {
return listKeyboardInteractiveInterceptors().some(
(interceptor) => interceptor.autoAnswerPasswords?.(host) === true,
);
}
export function classifyKeyboardInteractive(
round: KeyboardInteractiveRound,
host: SshConnectHost,
): KeyboardInteractiveDecision {
for (const interceptor of listKeyboardInteractiveInterceptors()) {
const decision = interceptor.detect(round, host);
if (decision) return decision;
}
const { prompts } = round;
const texts = prompts.map((p) => p.prompt);
const isFortiToken = texts.some((p) => FORTI_TOKEN_PATTERN.test(p));
const isPush =
!isFortiToken && texts.some((p) => PUSH_PROMPT_PATTERN.test(p));
if (!isPush) {
const totpIndex = prompts.findIndex((p) => TOTP_PATTERN.test(p.prompt));
if (totpIndex !== -1) return { kind: "totp", promptIndex: totpIndex };
}
if (shouldAutoAnswerPasswords(host)) {
return { kind: "auto", responses: autoResponses(prompts, host.password) };
}
const hasStoredPassword = !!host.password && host.authType !== "none";
const passwordIndex = prompts.findIndex((p) =>
PASSWORD_PATTERN.test(p.prompt),
);
// DUO/PAM challenges often don't say "password" at all.
const firstUnanswered = prompts.findIndex(
(p) => !(PASSWORD_PATTERN.test(p.prompt) && hasStoredPassword),
);
if (firstUnanswered === -1) {
return { kind: "auto", responses: autoResponses(prompts, host.password) };
}
const promptIndex =
passwordIndex !== -1 && !hasStoredPassword
? passwordIndex
: firstUnanswered;
return {
kind: "input",
promptIndex,
isPush: PUSH_PROMPT_PATTERN.test(prompts[promptIndex].prompt),
};
}
export type KeyboardInteractiveListener = (
name: string,
instructions: string,
lang: string,
prompts: KeyboardInteractivePrompt[],
finish: (responses: string[]) => void,
) => void;
/**
* The handler for connections with nobody to ask: the stored password goes
* to password prompts, everything else gets an empty answer. That is what
* every background path did before the pipeline.
*/
export function createAutoKeyboardInteractiveHandler(
host: SshConnectHost,
): KeyboardInteractiveListener {
return (_name, _instructions, _lang, prompts, finish) => {
finish(autoResponses(prompts, host.password));
};
}
/**
* The handler for connections with a prompt channel. One question per round;
* a second TOTP round means the last code was wrong.
*/
export function createPromptKeyboardInteractiveHandler(
host: SshConnectHost,
channel: SshPromptChannel,
): KeyboardInteractiveListener {
let totpAsked = false;
return (name, instructions, _lang, prompts, finish) => {
const decision = classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
const ask = async (): Promise<string[]> => {
switch (decision.kind) {
case "auto":
return decision.responses;
case "warpgate": {
await channel.ask({
kind: "warpgate",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
});
return prompts.map(() => "");
}
case "totp": {
const answer = await channel.ask({
kind: "totp",
prompt: prompts[decision.promptIndex].prompt,
retry: totpAsked,
});
totpAsked = true;
return responsesWithAnswer(
prompts,
decision.promptIndex,
(answer ?? "").trim(),
host.password,
);
}
case "input": {
const prompt = prompts[decision.promptIndex];
const answer = await channel.ask({
kind: "input",
prompt: prompt.prompt,
echo: prompt.echo,
isPush: decision.isPush,
});
return responsesWithAnswer(
prompts,
decision.promptIndex,
(answer ?? "").trim(),
host.password,
);
}
}
};
ask().then(finish, () => finish(prompts.map(() => "")));
};
}
@@ -0,0 +1,277 @@
/**
* Keyboard-interactive answering over the terminal WebSocket.
*
* The classifier decides what a round is; this keeps the terminal's message
* protocol (totp_required, totp_retry, password_required,
* warpgate_auth_required) and its timeouts exactly as the client expects.
*/
import type { WebSocket } from "ws";
import { sshLogger, authLogger } from "../../utils/logger.js";
import { ensureCoreSshAuthProviders } from "./core-providers.js";
import {
classifyKeyboardInteractive,
responsesWithAnswer,
} from "./keyboard-interactive.js";
import type { KeyboardInteractivePrompt, SshConnectHost } from "./types.js";
interface ResolvedCredentials {
username: string;
password?: string;
authType?: string;
}
interface HostFlags {
useWarpgate?: boolean;
[key: string]: unknown;
}
interface AuthContext {
userId: string;
ws: WebSocket;
hostId: number;
isKeyboardInteractive: boolean;
keyboardInteractiveResponded: boolean;
keyboardInteractiveFinish: ((responses: string[]) => void) | null;
totpPromptSent: boolean;
warpgateAuthPromptSent: boolean;
totpTimeout: NodeJS.Timeout | null;
warpgateAuthTimeout: NodeJS.Timeout | null;
totpAttempts: number;
}
const TOTP_TIMEOUT_MS = 180000;
const PUSH_TIMEOUT_MS = 300000;
const WARPGATE_TIMEOUT_MS = 300000;
export class SSHAuthManager {
public context: AuthContext;
constructor(context: AuthContext) {
this.context = context;
}
handleKeyboardInteractive(
name: string,
instructions: string,
_instructionsLang: string,
prompts: KeyboardInteractivePrompt[],
finish: (responses: string[]) => void,
resolvedCredentials: ResolvedCredentials,
hostConfig?: HostFlags,
): void {
ensureCoreSshAuthProviders();
this.context.isKeyboardInteractive = true;
const host = {
...(hostConfig ?? {}),
id: this.context.hostId,
ip: "",
port: 22,
username: resolvedCredentials.username,
password: resolvedCredentials.password,
authType: resolvedCredentials.authType,
} as SshConnectHost;
const decision = classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
switch (decision.kind) {
case "auto":
finish(decision.responses);
return;
case "warpgate":
this.handleWarpgate(decision, finish);
return;
case "totp":
this.handleTotp(prompts, decision.promptIndex, finish, host.password);
return;
case "input":
this.handleInput(
prompts,
decision.promptIndex,
decision.isPush,
finish,
host.password,
);
return;
}
}
private handleWarpgate(
decision: { url: string | null; securityKey: string; instructions: string },
finish: (responses: string[]) => void,
): void {
this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
this.context.warpgateAuthPromptSent = true;
this.sendLog("auth", "info", "Warpgate authentication required");
this.context.ws.send(
JSON.stringify({
type: "warpgate_auth_required",
url: decision.url,
securityKey: decision.securityKey,
instructions: decision.instructions,
}),
);
this.context.warpgateAuthTimeout = setTimeout(() => {
if (this.context.keyboardInteractiveFinish) {
this.context.keyboardInteractiveFinish = null;
this.context.warpgateAuthPromptSent = false;
sshLogger.warn("Warpgate authentication timeout", {
operation: "warpgate_timeout",
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "error",
message: "Warpgate authentication timeout. Please reconnect.",
}),
);
}
}, WARPGATE_TIMEOUT_MS);
}
private handleTotp(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.totpPromptSent) {
sshLogger.warn("TOTP prompt asked again - invalid code", {
operation: "ssh_keyboard_interactive_totp_retry",
hostId: this.context.hostId,
});
authLogger.warn("TOTP verification failed for SSH session", {
operation: "terminal_totp_failed",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.sendLog("auth", "warning", "Invalid TOTP code");
this.context.ws.send(JSON.stringify({ type: "totp_retry" }));
return;
}
this.context.totpPromptSent = true;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(TOTP_TIMEOUT_MS, "totp");
this.sendLog("auth", "info", "TOTP verification required");
authLogger.info("TOTP verification prompt sent to client", {
operation: "terminal_totp_prompt",
userId: this.context.userId,
hostId: this.context.hostId,
});
this.context.ws.send(
JSON.stringify({
type: "totp_required",
prompt: prompts[promptIndex].prompt,
}),
);
}
private handleInput(
prompts: KeyboardInteractivePrompt[],
promptIndex: number,
isPush: boolean,
finish: (responses: string[]) => void,
password: string | undefined,
): void {
if (this.context.keyboardInteractiveResponded) return;
this.context.keyboardInteractiveResponded = true;
this.context.keyboardInteractiveFinish = (answers: string[]) => {
finish(
responsesWithAnswer(
prompts,
promptIndex,
(answers[0] || "").trim(),
password,
),
);
};
this.armTimeout(isPush ? PUSH_TIMEOUT_MS : TOTP_TIMEOUT_MS, "password");
this.sendLog("auth", "info", "Password authentication required");
this.context.ws.send(
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
echo: prompts[promptIndex].echo,
}),
);
}
private armTimeout(ms: number, kind: "totp" | "password"): void {
if (this.context.totpTimeout) clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = setTimeout(() => {
if (!this.context.keyboardInteractiveFinish) return;
this.context.keyboardInteractiveFinish = null;
if (kind === "totp") {
this.context.totpPromptSent = false;
} else {
this.context.keyboardInteractiveResponded = false;
}
sshLogger.warn(
`${kind === "totp" ? "TOTP" : "Password"} prompt timeout`,
{
operation: `${kind}_timeout`,
hostId: this.context.hostId,
},
);
this.context.ws.send(
JSON.stringify({
type: "error",
message:
kind === "totp"
? "TOTP verification timeout. Please reconnect."
: "Password verification timeout. Please reconnect.",
}),
);
}, ms);
}
sendLog(
stage: string,
level: string,
message: string,
details?: Record<string, unknown>,
): void {
this.context.ws.send(
JSON.stringify({
type: "connection_log",
data: { stage, level, message, details },
}),
);
}
cleanup(): void {
if (this.context.totpTimeout) {
clearTimeout(this.context.totpTimeout);
this.context.totpTimeout = null;
}
if (this.context.warpgateAuthTimeout) {
clearTimeout(this.context.warpgateAuthTimeout);
this.context.warpgateAuthTimeout = null;
}
this.context.keyboardInteractiveFinish = null;
this.context.totpPromptSent = false;
this.context.warpgateAuthPromptSent = false;
this.context.keyboardInteractiveResponded = false;
}
}
+196
View File
@@ -0,0 +1,196 @@
/**
* How the bytes get to a host: port knocking, a Cloudflare Access tunnel, a
* jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
* returns the jump client so the caller can close it with the connection.
*/
import type { Client } from "ssh2";
import { getErrorMessage } from "../../utils/error-message.js";
import { logger } from "../../utils/logger.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../utils/socks5-helper.js";
import type { ProxyNode } from "../../../types/index.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { performPortKnocking } from "../terminal-auth-helpers.js";
import type {
MutableConnectConfig,
SshAuthLog,
SshConnectHost,
} from "./types.js";
export function getHostSocks5Config(host: SshConnectHost): SOCKS5Config | null {
const chain = Array.isArray(host.socks5ProxyChain)
? (host.socks5ProxyChain as ProxyNode[])
: [];
if (!host.useSocks5 || (!host.socks5Host && chain.length === 0)) return null;
return {
useSocks5: host.useSocks5,
socks5Host: host.socks5Host ?? undefined,
socks5Port: host.socks5Port ?? undefined,
socks5Username: host.socks5Username ?? undefined,
socks5Password: host.socks5Password ?? undefined,
socks5ProxyChain: chain,
};
}
export class SshTransportError extends Error {
constructor(
message: string,
readonly stage: "cloudflare" | "jump-host" | "jump-forward" | "proxy",
options?: { cause?: unknown },
) {
super(message, options);
this.name = "SshTransportError";
}
}
export interface OpenTransportOptions {
/** Knock before connecting when the host has a sequence. Default true. */
portKnock?: boolean;
/** Use terminalConfig.cfAccessClient* when present. Default true. */
cloudflare?: boolean;
/** Resolve DNS up front for a direct connection. Default true. */
resolveDns?: boolean;
log?: SshAuthLog;
}
export interface OpenedTransport {
jumpClient: Client | null;
via: "direct" | "proxy" | "jump" | "cloudflare";
}
function forwardThrough(
jumpClient: Client,
host: SshConnectHost,
): Promise<NonNullable<MutableConnectConfig["sock"]>> {
return new Promise((resolve, reject) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port || 22,
(err, stream) => {
if (err) {
reject(
new SshTransportError(
"Failed to forward through jump host: " + err.message,
"jump-forward",
{ cause: err },
),
);
return;
}
resolve(stream);
},
);
});
}
export async function openSshTransport(
host: SshConnectHost,
config: MutableConnectConfig,
options: OpenTransportOptions = {},
): Promise<OpenedTransport> {
const log = options.log ?? (() => {});
if (
options.portKnock !== false &&
Array.isArray(host.portKnockSequence) &&
host.portKnockSequence.length > 0
) {
try {
await performPortKnocking(host.ip, host.portKnockSequence);
} catch {
logger.warn("Port knocking failed, attempting connection anyway", {
operation: "port_knock",
hostId: host.id,
});
}
}
let via: OpenedTransport["via"] = "direct";
const terminalConfig = (host.terminalConfig ?? {}) as Record<string, unknown>;
if (
options.cloudflare !== false &&
terminalConfig.cfAccessClientId &&
terminalConfig.cfAccessClientSecret
) {
try {
const WebSocket = (await import("ws")).default;
const { createWebSocketDuplex, waitForWebSocketOpen } =
await import("../cloudflare-websocket.js");
const cfHostname = (terminalConfig.cfTunnelHostname as string) || host.ip;
const cfWs = new WebSocket(
`wss://${cfHostname}/cdn-cgi/access/ssh-connect`,
{
headers: {
"CF-Access-Client-Id": terminalConfig.cfAccessClientId as string,
"CF-Access-Client-Secret":
terminalConfig.cfAccessClientSecret as string,
},
},
);
await waitForWebSocketOpen(cfWs, 30000);
config.sock = createWebSocketDuplex(
cfWs,
) as unknown as typeof config.sock;
via = "cloudflare";
log("info", "Connected via Cloudflare Tunnel");
} catch (error) {
throw new SshTransportError(
"Cloudflare tunnel connection failed: " + getErrorMessage(error),
"cloudflare",
{ cause: error },
);
}
}
const jumpUserId = host.userId || "";
if (host.jumpHosts && host.jumpHosts.length > 0 && jumpUserId) {
const jumpClient = await createJumpHostChain(host.jumpHosts, jumpUserId);
if (!jumpClient) {
throw new SshTransportError(
"Failed to connect through jump hosts",
"jump-host",
);
}
try {
config.sock = await forwardThrough(jumpClient, host);
} catch (error) {
jumpClient.end();
throw error;
}
return { jumpClient, via: "jump" };
}
const proxyConfig = getHostSocks5Config(host);
if (proxyConfig && via === "direct") {
try {
const proxySocket = await createSocks5Connection(
host.ip,
host.port || 22,
proxyConfig,
);
if (proxySocket) {
config.sock = proxySocket;
via = "proxy";
}
} catch (error) {
throw new SshTransportError(
"Proxy connection failed: " + getErrorMessage(error),
"proxy",
{ cause: error },
);
}
}
if (via === "direct" && options.resolveDns !== false) {
await resolveSshConnectConfigHost(config);
}
return { jumpClient: null, via };
}
+255
View File
@@ -0,0 +1,255 @@
import type { Client, ConnectConfig } from "ssh2";
import type { WebSocket } from "ws";
/**
* What a connection is for. Picks keepalive and timeout defaults so every
* transport keeps the numbers it had before the pipeline existed.
*/
export type SshConnectPurpose =
| "terminal"
| "file-manager"
| "file-transfer"
| "tmux"
| "metrics"
| "fleet"
| "tunnel"
| "docker"
| "docker-console"
| "proxmox"
| "snippet"
| "credential-deploy"
| "jump-host"
| "remote-desktop"
| "plugin";
/** The resolved host fields the pipeline reads. */
export interface SshConnectHost {
id: number;
ip: string;
port: number;
username: string;
userId?: string | null;
authType?: string | null;
password?: string | null;
key?: string | Buffer | null;
keyPassword?: string | null;
keyType?: string | null;
certPublicKey?: string | null;
useWarpgate?: boolean | null;
forceKeyboardInteractive?: boolean | null;
vaultProfile?: { id?: number | null } | null;
terminalConfig?: Record<string, unknown> | null;
jumpHosts?: Array<{ hostId: number }> | null;
useSocks5?: boolean | null;
socks5Host?: string | null;
socks5Port?: number | null;
socks5Username?: string | null;
socks5Password?: string | null;
socks5ProxyChain?: unknown;
portKnockSequence?: Array<{
port: number;
protocol?: string;
delay?: number;
}> | null;
[key: string]: unknown;
}
export type MutableConnectConfig = ConnectConfig & Record<string, unknown>;
export type SshAuthLog = (
level: "info" | "warning" | "error",
message: string,
) => void;
export interface SshAuthEnv {
/** The client about to connect; certificate auth patches it. */
client: Client;
/** The acting user. Certificates and tokens are cached per user. */
userId: string;
/** Server-side host id, used for per-host caches. */
hostId: number;
purpose: SshConnectPurpose;
/** True when a person can answer prompts or finish a browser sign-in. */
interactive: boolean;
log: SshAuthLog;
}
/**
* What preparing auth produced.
*
* `interaction-required` means a browser step has to happen first; the
* transport turns `interaction` into its own message, for example
* `opkssh_auth_required` over the terminal socket.
*/
export type SshAuthOutcome =
| { status: "ready" }
| {
status: "interaction-required";
interaction: string;
message: string;
/** Extra boolean the HTTP transports set in their 401 body. */
flag?: string;
}
| {
status: "error";
code:
| "missing-secret"
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
message: string;
}
| {
/** Try once more with these config changes. Used by Tailscale. */
status: "retry";
patch: Partial<MutableConnectConfig>;
message: string;
};
export interface SshAuthFailureContext {
error: Error;
/** How many retries this connection already made. */
retries: number;
/** A tunnelled socket cannot be reused for a retry. */
canRetry: boolean;
/** The server refused keyboard-interactive or none. */
methodNotAvailable: boolean;
}
export interface SshInteractionRequest {
userId: string;
hostId: number;
/** Host row as stored, not resolved. */
host: { name?: string | null; ip: string; username: string };
socket: WebSocket;
requestOrigin: string;
/** Anything the client sent with the start message. */
payload: Record<string, unknown>;
}
export interface SshAuthField {
key: string;
type:
| "boolean"
| "string"
| "number"
| "select"
| "secret"
| "textarea"
| "custom";
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: Array<{ value: string; labelKey: string }>;
component?: string;
}
export interface SshAuthProvider {
/** Stored in ssh_data.auth_type. */
type: string;
/** "core" for built-ins and the legacy providers A8 keeps in core. */
pluginId: string;
labelKey: string;
descriptionKey?: string;
/** Editor fields, rendered by core unless the frontend registered an editor. */
fields?: SshAuthField[];
/** Also offered as a stored credential type. */
credentialType?: boolean;
/** Needs a browser sign-in or a person at the keyboard. */
needsUserInteraction?: boolean;
/** Carries a secret, so a shared host needs one resolved for the recipient. */
requiresSecret?: boolean;
/**
* Can connect with nobody watching, for polling and batch work. Defaults to
* true; false for types that need a person or a fresh browser sign-in.
*/
supportsBackground?: boolean;
/** Overrides applied to the base config before prepare runs. */
connectOptions?: (
host: SshConnectHost,
purpose: SshConnectPurpose,
) => Partial<MutableConnectConfig>;
/** The interaction name this provider's outcomes use, e.g. "opkssh". */
interaction?: string;
/**
* Starts the browser step behind an `interaction-required` outcome, for
* transports that can show one (the terminal today).
*/
startInteraction?: (request: SshInteractionRequest) => Promise<void>;
prepare: (
config: MutableConnectConfig,
host: SshConnectHost,
env: SshAuthEnv,
) => Promise<SshAuthOutcome>;
/**
* Called when the server rejected auth. Synchronous on purpose: transports
* decide on a retry before the socket's close event arrives. Cache cleanup
* inside runs in the background.
*/
onAuthFailed?: (
host: SshConnectHost,
env: SshAuthEnv,
context: SshAuthFailureContext,
) => SshAuthOutcome | undefined;
}
export interface KeyboardInteractivePrompt {
prompt: string;
echo?: boolean;
}
/** What to do with one keyboard-interactive round. */
export type KeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean };
/**
* Interceptors run before the built-in classifier. A provider that owns a
* prompt style (Warpgate today) returns a decision; everyone else returns null.
*/
export interface KeyboardInteractiveInterceptor {
id: string;
pluginId: string;
detect: (
round: {
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
},
host: SshConnectHost,
) => KeyboardInteractiveDecision | null;
/** Answer password prompts silently instead of asking the user. */
autoAnswerPasswords?: (host: SshConnectHost) => boolean;
}
/**
* How a transport asks a person something mid-connect.
*
* `ask` resolves with the typed answer, or null when the user gave up or the
* transport has nobody to ask. The terminal answers over its WebSocket, the
* file manager parks the connection and answers over HTTP.
*/
export interface SshPromptChannel {
ask: (request: SshPromptRequest) => Promise<string | null>;
/** Terminal socket used for the host key prompt, when there is one. */
hostKeySocket?: WebSocket | null;
}
export type SshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
};
@@ -1,51 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Client as SSHClient, ConnectConfig } from "ssh2";
import { fileLogger } from "../../utils/logger.js";
/**
* Attaches a user-managed CA-signed certificate to an SFTP connection, if the
* host carries one.
*
* The terminal has always done this. The file manager never did: it imports
* and calls `setupOPKSSHCertAuth`, but `setupCACertAuth` — the sibling helper
* for user-managed `-cert.pub` files — had no call site here at all. A host
* whose key is paired with a CA-signed certificate therefore authenticated in
* a terminal and failed in the file manager, while OPKSSH certificates worked
* in both. That asymmetry is the whole bug.
*
* `cert_public_key` is a column on `ssh_data` but is not part of the shared
* `Host` type, so callers pass whichever record they hold and it is read off
* structurally — the same way the terminal reads it.
*
* A certificate that cannot be applied is logged and skipped rather than
* failing the connection: the private key alone may still be accepted, which
* is exactly what happened before any of this was wired up.
*/
export async function applyCACertIfPresent(
config: Record<string, unknown>,
client: SSHClient,
privateKey: Buffer | string,
source: { certPublicKey?: string | null },
username: string,
passphrase?: string,
): Promise<void> {
const certPublicKey = source.certPublicKey;
if (!certPublicKey || !certPublicKey.trim()) return;
try {
const { setupCACertAuth } = await import("../opkssh-cert-auth.js");
await setupCACertAuth(
config as ConnectConfig,
client,
privateKey,
certPublicKey,
username,
passphrase,
);
} catch (certError) {
fileLogger.warn("CA certificate setup failed, continuing with key only", {
operation: "sftp_ca_cert_auth_failed",
error: getErrorMessage(certError),
});
}
}
File diff suppressed because it is too large. Load diff
@@ -1,12 +0,0 @@
/**
* Auth types whose SSH certificate is issued on demand through a browser
* sign-in and cached per user and host (opkssh_tokens). They share the
* whole connect path; only the issuing flow differs.
*/
export const ISSUED_CERTIFICATE_AUTH_TYPES = ["opkssh", "stepca"] as const;
export function usesIssuedCertificate(authType: string | null | undefined) {
return (ISSUED_CERTIFICATE_AUTH_TYPES as readonly string[]).includes(
authType ?? "",
);
}
+20 -102
View File
@@ -1,12 +1,11 @@
import { Client as SSHClient } from "ssh2";
import { fileLogger } from "../utils/logger.js";
import { createSocks5Connection } from "../utils/socks5-helper.js";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import { getErrorMessage } from "../utils/error-message.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
import { buildConnectConfig } from "./connect/build-connect-config.js";
import { createAutoKeyboardInteractiveHandler } from "./connect/keyboard-interactive.js";
import type { SshConnectHost } from "./connect/types.js";
import { resolveHostById } from "./host-resolver.js";
type JumpHostConfig = {
@@ -114,15 +113,6 @@ export async function createJumpHostChain(
const jumpClient = new SSHClient();
clients.push(jumpClient);
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
jumpHostConfig.id,
jumpHostConfig.ip,
jumpHostConfig.port || 22,
null,
userId,
true,
);
let lastError: Error | null = null;
// eslint-disable-next-line no-async-promise-executor
@@ -167,100 +157,28 @@ export async function createJumpHostChain(
resolve(false);
});
const connectConfig: Record<string, unknown> = {
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: jumpHostConfig.authType !== "none",
readyTimeout: readyTimeoutMs,
hostVerifier: jumpHostVerifier,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group18-sha512",
"diffie-hellman-group17-sha512",
"diffie-hellman-group16-sha512",
"diffie-hellman-group15-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
connectConfig.password = jumpHostConfig.password;
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
try {
connectConfig.privateKey = preparePrivateKeyForSSH2(
jumpHostConfig.key,
jumpHostConfig.keyPassword,
);
} catch (keyError) {
clearTimeout(timeout);
lastError = new Error(
`Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`,
);
resolve(false);
return;
}
if (jumpHostConfig.keyPassword) {
connectConfig.passphrase = jumpHostConfig.keyPassword;
}
} else if (jumpHostConfig.authType === "agent") {
const result = await applyAgentAuth(
connectConfig,
jumpHostConfig.terminalConfig as
Record<string, unknown> | undefined,
// Each hop goes through the same pipeline as the target, so hops get
// every auth type, not just password, key and agent.
const built = await buildConnectConfig(
jumpHostConfig as unknown as SshConnectHost,
{ userId, purpose: "jump-host", client: jumpClient },
);
if (built.outcome.status !== "ready") {
clearTimeout(timeout);
lastError = new Error(
`Jump host ${i + 1}/${totalHops}: ${built.outcome.message}`,
);
if ("error" in result) {
throw new Error(result.error);
}
resolve(false);
return;
}
const connectConfig = built.config;
connectConfig.readyTimeout = readyTimeoutMs;
jumpClient.on(
"keyboard-interactive",
(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
return jumpHostConfig.password as string;
}
return "";
});
finish(responses);
},
createAutoKeyboardInteractiveHandler(
jumpHostConfig as unknown as SshConnectHost,
),
);
if (currentClient) {
+26 -205
View File
@@ -1,224 +1,45 @@
import { getErrorMessage } from "../utils/error-message.js";
import { Client, type ConnectConfig } from "ssh2";
import type { Client } from "ssh2";
import type { SSHHost } from "../../types/index.js";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { createJumpHostChain } from "./jump-host-chain.js";
import { resolveSshConnectConfigHost } from "./ssh-dns.js";
connectHost,
getConnectionPoolKey,
SshConnectError,
} from "./connect/connect-host.js";
import { buildConnectConfig } from "./connect/build-connect-config.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
/**
* Non-interactive SSH connection helpers shared by fleet execution.
*
* Mirrors buildSshConfig/createSshFactory/getPoolKey in
* plugins/host-metrics/src/backend/routes.ts,
* minus the keyboard-interactive TOTP prompt handling (metrics/terminal-only)
* and OPKSSH/Vault cert setup (not supported as fleet auth for v1 - those flows
* need an interactive browser step that has no place in a batch fleet run).
* Non-interactive SSH connections for background work (fleets, automations,
* AI tools). A thin wrapper over the connect pipeline, kept so the pool keys
* and call sites stay the same.
*/
export function getFleetPoolKey(host: SSHHost): string {
const socks5Key = host.useSocks5
? `:socks5:${host.socks5Host}:${host.socks5Port}`
: "";
return `fleet:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
return getConnectionPoolKey("fleet", host as unknown as SshConnectHost);
}
export async function buildFleetSshConfig(
host: SSHHost,
): Promise<ConnectConfig> {
const base: ConnectConfig = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port,
username: host.username,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 30000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
host.id,
host.ip,
host.port,
null,
host.userId || "",
false,
),
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group-exchange-sha1",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: SSH_ALGORITHMS.cipher,
hmac: [
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
compress: ["none", "zlib@openssh.com", "zlib"],
},
} as ConnectConfig;
const authType = host.authType;
if (authType === "password" || authType === "credential") {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
base.password = host.password;
} else if (authType === "key") {
if (!host.key) {
throw new Error(`No SSH key available for host ${host.ip}`);
}
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
host.key,
host.keyPassword,
);
if (host.keyPassword) {
(base as Record<string, unknown>).passphrase = host.keyPassword;
}
} else if (authType === "none" || authType === "tailscale") {
// no credentials needed
} else {
throw new Error(
`Unsupported authentication type '${authType}' for fleet execution on host ${host.ip}`,
);
client?: Client,
): Promise<MutableConnectConfig> {
const { Client: SshClient } = await import("ssh2");
const built = await buildConnectConfig(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "fleet",
client: client ?? new SshClient(),
});
if (built.outcome.status !== "ready") {
throw new SshConnectError(built.outcome);
}
return base;
return built.config;
}
export function createFleetSshFactory(host: SSHHost): () => Promise<Client> {
return async () => {
const config = await buildFleetSshConfig(host);
const client = new Client();
const proxyConfig: SOCKS5Config | null =
host.useSocks5 &&
(host.socks5Host ||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
? {
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
}
: null;
const hasJumpHosts =
host.jumpHosts && host.jumpHosts.length > 0 && host.userId;
let jumpClient: Client | null = null;
if (hasJumpHosts) {
jumpClient = await createJumpHostChain(host.jumpHosts!, host.userId!);
if (!jumpClient) {
throw new Error("Failed to establish jump host chain");
}
} else if (proxyConfig) {
try {
const proxySocket = await createSocks5Connection(
host.ip,
host.port,
proxyConfig,
);
if (proxySocket) {
config.sock = proxySocket;
}
} catch (proxyError) {
throw new Error(
"Proxy connection failed: " + getErrorMessage(proxyError),
{ cause: proxyError },
);
}
}
return new Promise<Client>((resolve, reject) => {
const timeout = setTimeout(() => {
client.end();
jumpClient?.end();
reject(new Error("SSH connection timeout"));
}, 30000);
client.on("ready", () => {
clearTimeout(timeout);
resolve(client);
});
client.on("close", () => {
jumpClient?.end();
});
client.on("error", (err) => {
clearTimeout(timeout);
jumpClient?.end();
reject(err);
});
if (jumpClient) {
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port,
(err, stream) => {
if (err) {
clearTimeout(timeout);
jumpClient!.end();
reject(
new Error(
"Failed to forward through jump host: " + err.message,
),
);
return;
}
config.sock = stream;
client.connect(config);
},
);
} else if (config.sock) {
client.connect(config);
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
}
const connection = await connectHost(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "fleet",
});
return connection.client;
};
}
File diff suppressed because it is too large. Load diff
-11
View File
@@ -1,11 +0,0 @@
import type { SSHHost } from "../../../types/index.js";
export function getTmuxAuthBehavior(authType: SSHHost["authType"]): {
credentialless: boolean;
tryKeyboard: boolean;
} {
return {
credentialless: authType === "none" || authType === "tailscale",
tryKeyboard: authType !== "tailscale",
};
}
+12 -177
View File
@@ -1,7 +1,9 @@
import { getErrorMessage } from "../../utils/error-message.js";
import express from "express";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import type { Client } from "ssh2";
import { connectHost, getConnectionPoolKey } from "../connect/connect-host.js";
import type { SshConnectHost } from "../connect/types.js";
import { createCorsMiddleware } from "../../utils/cors-config.js";
import { createCompressionMiddleware } from "../../utils/compression-config.js";
import { AuthManager } from "../../utils/auth-manager.js";
@@ -12,19 +14,9 @@ import {
} from "../../database/repositories/factory.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { sshLogger } from "../../utils/logger.js";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { resolveHostById, checkHostAccess } from "../host-resolver.js";
import type { HostAction } from "../../utils/permission-manager.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { applyAgentAuth } from "../terminal-auth-helpers.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../utils/socks5-helper.js";
import { withConnection } from "../ssh-connection-pool.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { execCommand, tmuxCommand } from "./helper.js";
import {
SEP,
@@ -42,7 +34,6 @@ import {
type PaneMetrics,
} from "./monitor-helpers.js";
import type { SSHHost, AuthenticatedRequest } from "../../../types/index.js";
import { getTmuxAuthBehavior } from "./auth-utils.js";
import { listenOnServicePort } from "../../utils/service-listen.js";
const PANE_ID_RE = /^%\d+$/;
@@ -59,179 +50,23 @@ interface TmuxSessionOverview extends TmuxSessionSummary {
tags: string[];
}
// SSH connection (lean variant of the per-module pattern used by server-stats
// and docker; jump hosts and SOCKS5 reuse the shared helpers)
async function buildSshConfig(host: SSHHost): Promise<ConnectConfig> {
const authBehavior = getTmuxAuthBehavior(host.authType);
const base: ConnectConfig = {
host: (host.ip || "").replace(/^\[|\]$/g, ""),
port: host.port,
username: host.username,
tryKeyboard: authBehavior.tryKeyboard,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
host.id,
host.ip,
host.port,
null,
host.userId || "",
false,
),
algorithms: SSH_ALGORITHMS,
} as ConnectConfig;
if (host.authType === "password") {
if (!host.password) {
throw new Error(`No password available for host ${host.ip}`);
}
base.password = host.password;
} else if (host.authType === "key") {
if (!host.key) {
throw new Error(`No valid SSH key available for host ${host.ip}`);
}
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
host.key,
host.keyPassword,
);
if (host.keyPassword) {
(base as Record<string, unknown>).passphrase = host.keyPassword;
}
} else if (authBehavior.credentialless) {
// no credentials needed
} else if (host.authType === "vault") {
// cert auth setup happens in connectToHost (needs client instance)
} else if (host.authType === "agent") {
const result = await applyAgentAuth(
base as Record<string, unknown>,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
} else {
// opkssh and other interactive flows are not supported by this module
throw new Error(
`Authentication type '${host.authType}' is not supported by the tmux monitor. Open a terminal connection instead.`,
);
}
return base;
}
// SSH connection
export function connectToHost(host: SSHHost): () => Promise<Client> {
return async () => {
const config = await buildSshConfig(host);
const client = new Client();
if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
const proxyConfig: SOCKS5Config | null =
host.useSocks5 &&
(host.socks5Host ||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
? {
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
}
: null;
let jumpClient: Client | null = null;
if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
jumpClient = await createJumpHostChain(host.jumpHosts, host.userId);
if (!jumpClient) {
throw new Error("Failed to establish jump host chain");
}
} else if (proxyConfig) {
const proxySocket = await createSocks5Connection(
host.ip,
host.port,
proxyConfig,
);
if (proxySocket) {
config.sock = proxySocket;
}
}
return new Promise<Client>((resolve, reject) => {
const timeout = setTimeout(() => {
client.end();
jumpClient?.end();
reject(new Error("SSH connection timeout"));
}, 30000);
client.on("ready", () => {
clearTimeout(timeout);
resolve(client);
});
client.on("error", (err) => {
clearTimeout(timeout);
jumpClient?.end();
reject(err);
});
client.on(
"keyboard-interactive",
(_name, _instructions, _lang, prompts, finish) => {
finish(
prompts.map((p) =>
/password/i.test(p.prompt) ? host.password || "" : "",
),
);
},
);
if (jumpClient) {
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port,
(err, stream) => {
if (err) {
clearTimeout(timeout);
jumpClient!.end();
reject(
new Error(
"Failed to forward through jump host: " + err.message,
),
);
return;
}
config.sock = stream;
client.connect(config);
},
);
} else if (config.sock) {
client.connect(config);
} else {
resolveSshConnectConfigHost(config)
.then(() => {
client.connect(config);
})
.catch((error) => {
clearTimeout(timeout);
reject(error);
});
}
const connection = await connectHost(host as unknown as SshConnectHost, {
userId: host.userId || "",
purpose: "tmux",
});
return connection.client;
};
}
function getPoolKey(host: SSHHost): string {
const socks5Key = host.useSocks5
? `:socks5:${host.socks5Host}:${host.socks5Port}`
: "";
return `tmux-monitor:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
return getConnectionPoolKey(
"tmux-monitor",
host as unknown as SshConnectHost,
);
}
async function withHostConnection<T>(
+24 -9
View File
@@ -5,7 +5,7 @@ import { createSocks5Connection } from "../../utils/socks5-helper.js";
import { tunnelLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import {
applyAuthOptions,
applyTunnelAuth,
bindForwardIn,
connectClient,
forwardOut,
@@ -163,13 +163,28 @@ async function connectC2SSourceClient(
algorithms: getManagedTunnelAlgorithms(),
};
applyAuthOptions(connOptions, {
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod,
});
const client = new Client();
const authOutcome = await applyTunnelAuth(
connOptions,
{
password: tunnelConfig.sourcePassword,
sshKey: tunnelConfig.sourceSSHKey,
keyPassword: tunnelConfig.sourceKeyPassword,
keyType: tunnelConfig.sourceKeyType,
authMethod: tunnelConfig.sourceAuthMethod,
},
{
client,
userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId,
hostId: tunnelConfig.sourceHostId,
username: tunnelConfig.sourceUsername,
ip: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
},
);
if (authOutcome.status !== "ready") {
throw new Error(authOutcome.message);
}
if (
tunnelConfig.useSocks5 &&
@@ -194,7 +209,7 @@ async function connectC2SSourceClient(
}
}
return connectClient(connOptions, tunnelConfig.name, "source");
return connectClient(connOptions, tunnelConfig.name, "source", client);
}
async function handleC2SRemoteRelayOpen(
+55 -86
View File
@@ -23,9 +23,8 @@ import { SystemCrypto } from "../../utils/system-crypto.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { createSocks5Connection } from "../../utils/socks5-helper.js";
import { withConnection } from "../ssh-connection-pool.js";
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
import {
applyAuthOptions,
applyTunnelAuth,
bindForwardIn,
connectClient,
forwardOut,
@@ -659,8 +658,29 @@ export async function connectEndpointThroughSource(
algorithms: getManagedTunnelAlgorithms(),
};
applyAuthOptions(endpointOptions, endpointCredentials);
return connectClient(endpointOptions, tunnelConfig.name, "endpoint");
const endpointClient = new Client();
const authOutcome = await applyTunnelAuth(
endpointOptions,
endpointCredentials,
{
client: endpointClient,
userId: tunnelConfig.endpointUserId,
hostId: null,
username: tunnelConfig.endpointUsername,
ip: tunnelConfig.endpointIP,
port: tunnelConfig.endpointSSHPort,
},
);
if (authOutcome.status !== "ready") {
endpointSock.destroy();
throw new Error(authOutcome.message);
}
return connectClient(
endpointOptions,
tunnelConfig.name,
"endpoint",
endpointClient,
);
}
export function resolveS2SLocalTargetHost(tunnelConfig: TunnelConfig): string {
@@ -1500,51 +1520,24 @@ export async function connectSSHTunnel(
},
};
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
try {
connOptions.privateKey = preparePrivateKeyForSSH2(
resolvedSourceCredentials.sshKey,
resolvedSourceCredentials.keyPassword,
);
} catch (error) {
const message = getErrorMessage(error, "Invalid SSH key format");
tunnelLogger.error(
`Invalid SSH key format for tunnel '${tunnelName}': ${message}`,
undefined,
{
operation: "tunnel_invalid_ssh_key_format",
tunnelName,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
keyType: resolvedSourceCredentials.keyType,
},
);
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: message,
});
tunnelConnecting.delete(tunnelName);
return;
}
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
if (
resolvedSourceCredentials.keyType &&
resolvedSourceCredentials.keyType !== "auto"
) {
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
}
} else if (resolvedSourceCredentials.authMethod === "key") {
const sourceAuth = await applyTunnelAuth(
connOptions,
resolvedSourceCredentials,
{
client: conn,
userId: effectiveUserId,
hostId: tunnelConfig.sourceHostId,
username: tunnelConfig.sourceUsername,
ip: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
},
);
if (sourceAuth.status !== "ready") {
tunnelLogger.error(
`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`,
`SSH auth could not be prepared for tunnel '${tunnelName}': ${sourceAuth.message}`,
undefined,
{
operation: "tunnel_ssh_key_missing",
operation: "tunnel_auth_prepare_failed",
tunnelName,
sourceHost: `${tunnelConfig.sourceUsername}@${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}`,
authMethod: resolvedSourceCredentials.authMethod,
@@ -1553,12 +1546,10 @@ export async function connectSSHTunnel(
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: "SSH key authentication requested but no key provided",
reason: sourceAuth.message,
});
tunnelConnecting.delete(tunnelName);
return;
} else {
connOptions.password = resolvedSourceCredentials.password;
}
const finalStatus = connectionStatus.get(tunnelName);
@@ -1694,23 +1685,6 @@ export async function killRemoteTunnelByMarker(
}
}
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
try {
preparePrivateKeyForSSH2(
resolvedSourceCredentials.sshKey,
resolvedSourceCredentials.keyPassword,
);
} catch (error) {
callback(
error instanceof Error ? error : new Error("Invalid SSH key format"),
);
return;
}
}
const poolKey = `tunnel:${tunnelConfig.sourceUserId}:${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}:${tunnelConfig.sourceUsername}`;
const factory = async (): Promise<Client> => {
@@ -1758,25 +1732,21 @@ export async function killRemoteTunnelByMarker(
},
};
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
) {
connOptions.privateKey = preparePrivateKeyForSSH2(
resolvedSourceCredentials.sshKey,
resolvedSourceCredentials.keyPassword,
);
if (resolvedSourceCredentials.keyPassword) {
connOptions.passphrase = resolvedSourceCredentials.keyPassword;
}
if (
resolvedSourceCredentials.keyType &&
resolvedSourceCredentials.keyType !== "auto"
) {
connOptions.privateKeyType = resolvedSourceCredentials.keyType;
}
} else {
connOptions.password = resolvedSourceCredentials.password;
const conn = new Client();
const killAuth = await applyTunnelAuth(
connOptions,
resolvedSourceCredentials,
{
client: conn,
userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId,
hostId: tunnelConfig.sourceHostId,
username: tunnelConfig.sourceUsername,
ip: tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
},
);
if (killAuth.status !== "ready") {
throw new Error(killAuth.message);
}
if (
@@ -1827,7 +1797,6 @@ export async function killRemoteTunnelByMarker(
}
return new Promise<Client>((resolve, reject) => {
const conn = new Client();
conn.on("ready", () => resolve(conn));
conn.on("error", (err) => reject(err));
conn.connect(connOptions);
+76 -23
View File
@@ -2,6 +2,9 @@ import { Client, type ClientChannel } from "ssh2";
import type { Duplex } from "stream";
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
import { tunnelLogger } from "../../utils/logger.js";
import { getSshAuthProvider } from "../connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../connect/core-providers.js";
import type { MutableConnectConfig, SshAuthOutcome } from "../connect/types.js";
export function getManagedTunnelAlgorithms() {
return {
@@ -40,40 +43,90 @@ export function getManagedTunnelAlgorithms() {
};
}
export function applyAuthOptions(
export interface TunnelCredentials {
password?: string;
sshKey?: string;
keyPassword?: string;
keyType?: string;
authMethod?: string;
certPublicKey?: string | null;
terminalConfig?: Record<string, unknown> | null;
vaultProfile?: { id?: number | null } | null;
}
/**
* Puts a tunnel side's auth into its connect options through the same
* providers every other transport uses, so tunnels accept every auth type.
*/
export async function applyTunnelAuth(
connOptions: Record<string, unknown>,
credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
keyType?: string;
authMethod?: string;
credentials: TunnelCredentials,
env: {
client: Client;
userId?: string | null;
hostId?: number | null;
username: string;
ip: string;
port: number;
},
): void {
if (credentials.authMethod === "key" && credentials.sshKey) {
const cleanKey = credentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
if (credentials.keyPassword) {
connOptions.passphrase = credentials.keyPassword;
}
if (credentials.keyType && credentials.keyType !== "auto") {
connOptions.privateKeyType = credentials.keyType;
}
} else {
connOptions.password = credentials.password;
): Promise<SshAuthOutcome> {
ensureCoreSshAuthProviders();
const authType =
credentials.authMethod || (credentials.sshKey ? "key" : "password");
const provider = getSshAuthProvider(authType);
if (!provider) {
return {
status: "error",
code: "provider-missing",
message: `Unsupported authentication type '${authType}' for tunnels`,
};
}
const outcome = await provider.prepare(
connOptions as MutableConnectConfig,
{
id: env.hostId ?? 0,
ip: env.ip,
port: env.port,
username: env.username,
userId: env.userId,
authType,
password: credentials.password,
key: credentials.sshKey,
keyPassword: credentials.keyPassword,
keyType: credentials.keyType,
certPublicKey: credentials.certPublicKey,
terminalConfig: credentials.terminalConfig ?? null,
vaultProfile: credentials.vaultProfile ?? null,
},
{
client: env.client,
userId: env.userId ?? "",
hostId: env.hostId ?? 0,
purpose: "tunnel",
interactive: false,
log: () => {},
},
);
if (
outcome.status === "ready" &&
connOptions.privateKey &&
credentials.keyType &&
credentials.keyType !== "auto"
) {
connOptions.privateKeyType = credentials.keyType;
}
return outcome;
}
export function connectClient(
connOptions: Record<string, unknown>,
tunnelName: string,
role: "source" | "endpoint",
client: Client = new Client(),
): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client();
let settled = false;
client.once("ready", () => {
settled = true;
-39
View File
@@ -1,39 +0,0 @@
import type { Client, ConnectConfig } from "ssh2";
type VaultAuthHost = {
id: number;
username: string;
userId?: string | null;
vaultProfile?: { id?: number | null } | null;
};
export async function setupVaultSshSignerAuth(
config: ConnectConfig,
client: Client,
host: VaultAuthHost,
): Promise<void> {
if (!host.userId) {
throw new Error("Vault SSH signer authentication requires a user session");
}
const vaultProfileId = host.vaultProfile?.id;
if (!vaultProfileId) {
throw new Error("Host has no Vault signer profile configured");
}
const { getVaultCert } = await import("./vault-signer-auth.js");
const cert = await getVaultCert(host.userId, vaultProfileId);
if (!cert) {
throw new Error(
"Vault SSH signer authentication required. Please open a Terminal connection first.",
);
}
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config,
client,
{ privateKey: cert.privateKey, sshCert: cert.sshCert },
host.username,
);
}