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
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
+246
-719
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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>(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user