feat: add auth extension points and one ssh connect pipeline

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

No files matched your search

+11 -270
View File
@@ -3,20 +3,18 @@
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import type { Client as SSHClient } from "ssh2";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import { systemLogger } from "../../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../../src/types/index.js";
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
hostAddressMismatch,
HOST_ADDRESS_MISMATCH_MESSAGE,
@@ -151,197 +149,6 @@ async function detectShell(
return "sh";
}
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<SSHClient | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: SSHClient | null = null;
const repository = createCurrentHostResolutionRepository();
for (let i = 0; i < jumpHosts.length; i++) {
const jumpHostId = jumpHosts[i].hostId;
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
throw new Error(`Jump host ${jumpHostId} not found`);
}
const jumpHost = resolvedJumpHost as unknown as SSHHost;
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
try {
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
} catch (e) {
sshLogger.error("Failed to parse jump hosts", e, {
hostId: jumpHost.id,
});
jumpHost.jumpHosts = [];
}
}
let resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: jumpHost.password,
sshKey: jumpHost.key,
keyPassword: jumpHost.keyPassword,
authType: jumpHost.authType,
};
if (jumpHost.credentialId) {
const credential = await repository.findCredentialByIdForUser(
jumpHost.credentialId as number,
userId,
);
if (credential) {
resolvedCredentials = {
password: credential.password as string | undefined,
sshKey: (credential.key || credential.privateKey) as
string | undefined,
keyPassword: credential.keyPassword as string | undefined,
authType: credential.authType as string | undefined,
};
}
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: resolvedCredentials.authType !== "none",
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
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 (
resolvedCredentials.authType === "password" &&
resolvedCredentials.password
) {
config.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
const cleanKey = resolvedCredentials.sshKey
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
jumpHost.terminalConfig as unknown as
Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
}
client.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) && resolvedCredentials.password) {
return resolvedCredentials.password as string;
}
return "";
});
finish(responses);
},
);
if (currentClient) {
await new Promise<void>((resolve, reject) => {
currentClient!.forwardOut(
"127.0.0.1",
0,
jumpHost.ip,
jumpHost.port || 22,
(err, stream) => {
if (err) return reject(err);
config.sock = stream;
resolve();
},
);
});
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
currentClient = client;
}
return currentClient;
}
async function onConsoleConnection(
ws: WebSocket,
req: import("http").IncomingMessage,
@@ -514,81 +321,15 @@ async function onConsoleConnection(
return;
}
const client = new SSHClient();
const config: Record<string, unknown> = {
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
port: resolvedHost.port || 22,
username: resolvedHost.username,
tryKeyboard: true,
readyTimeout: 60000,
keepaliveInterval: 30000,
keepaliveCountMax: 120,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
};
if (resolvedHost.authType === "password" && resolvedHost.password) {
config.password = resolvedHost.password;
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
const cleanKey = resolvedHost.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
config.privateKey = Buffer.from(cleanKey, "utf8");
if (resolvedHost.keyPassword) {
config.passphrase = resolvedHost.keyPassword;
}
} else if (resolvedHost.authType === "agent") {
const result = await applyAgentAuth(
config,
resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined,
);
if ("error" in result) {
ws.send(
JSON.stringify({
type: "error",
message: result.error,
}),
);
return;
}
}
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
const jumpClient = await createJumpHostChain(
resolvedHost.jumpHosts,
// The console socket is public and authenticates itself, so the
// acting user is carried on the host rather than the request.
const { client } = await connectSsh(
{
...(resolvedHost as unknown as PluginSshHost),
userId,
);
if (jumpClient) {
const stream = await new Promise<import("ssh2").ClientChannel>(
(resolve, reject) => {
jumpClient.forwardOut(
"127.0.0.1",
0,
resolvedHost.ip,
resolvedHost.port || 22,
(err, stream) => {
if (err) return reject(err);
resolve(stream);
},
);
},
);
config.sock = stream;
}
}
if (!config.sock) {
await resolveSshConnectConfigHost(config);
}
await new Promise<void>((resolve, reject) => {
client.on("ready", () => resolve());
client.on("error", reject);
client.connect(config);
});
} as PluginSshHost,
{ purpose: "docker-console", timeoutMs: 65000 },
);
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
+3
View File
@@ -1,5 +1,6 @@
import express, { type Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
@@ -23,6 +24,8 @@ let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
// An express app rather than the router directly, so the existing
// register*Routes helpers keep the Application they expect. Core mounts it
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
+177 -561
View File
@@ -2,10 +2,11 @@
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { pluginSsh } from "./ssh.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
@@ -13,33 +14,23 @@ import {
getRequestMeta,
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import type {
AuthenticatedRequest,
ProxyNode,
SSHHost,
} from "../../../../src/types/index.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../../../src/backend/utils/socks5-helper.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../../src/types/connection-log.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
import {
containerCommand,
getContainerRuntimeConfig,
getRuntimeLabel,
} from "./container-runtime.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import {
type SSHSession,
sshSessions,
@@ -242,200 +233,69 @@ export function registerDockerSshRoutes(app: express.Express): void {
delete pendingTOTPSessions[sessionId];
}
const resolvedCredentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
} = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
};
const connectTarget = {
...(host as unknown as PluginSshHost),
id: hostId,
port: host.port || 22,
userId,
// The Docker panel sends its own proxy settings with the request.
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
} as PluginSshHost;
if (userProvidedPassword) {
resolvedCredentials.password = userProvidedPassword;
resolvedCredentials.authType = "password";
connectTarget.password = userProvidedPassword;
connectTarget.authType = "password";
}
if (userProvidedSshKey) {
resolvedCredentials.sshKey = userProvidedSshKey;
resolvedCredentials.authType = "key";
connectTarget.key = userProvidedSshKey;
connectTarget.authType = "key";
}
if (userProvidedKeyPassword) {
resolvedCredentials.keyPassword = userProvidedKeyPassword;
connectTarget.keyPassword = userProvidedKeyPassword;
}
const resolvedPassword = connectTarget.password as string | undefined;
const authType = (connectTarget.authType as string) || "none";
const client = new SSHClient();
const config: Record<string, unknown> = {
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: true,
keepaliveInterval:
typeof host.terminalConfig?.keepaliveInterval === "number"
? host.terminalConfig.keepaliveInterval * 1000
: 60000,
keepaliveCountMax:
typeof host.terminalConfig?.keepaliveCountMax === "number"
? host.terminalConfig.keepaliveCountMax
: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId,
host.ip,
host.port || 22,
null,
userId,
false,
),
};
if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale" ||
resolvedCredentials.authType === "warpgate"
) {
// Tailscale SSH, "none", and Warpgate auth: no static credentials
} else if (resolvedCredentials.authType === "password") {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
}
} else if (usesIssuedCertificate(resolvedCredentials.authType)) {
try {
const { getOPKSSHToken } =
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
if (!token) {
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.",
),
);
return res.status(401).json({
error:
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.",
requiresOPKSSHAuth: true,
connectionLogs,
});
}
const { setupOPKSSHCertAuth } =
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config as import("ssh2").ConnectConfig,
client,
token,
host.username,
);
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "docker",
client,
log: (level, message) =>
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Using OPKSSH certificate authentication",
),
);
} catch (opksshError) {
sshLogger.error("OPKSSH authentication error for Docker", {
operation: "docker_connect",
sessionId,
hostId,
error: getErrorMessage(opksshError),
});
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
`OPKSSH authentication failed: ${getErrorMessage(opksshError)}`,
),
);
return res.status(500).json({
error: "OPKSSH authentication failed",
createConnectionLog(level, "docker_auth", message),
),
});
const config = prepared.config;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
connectionLogs.push(
createConnectionLog("error", "docker_auth", outcome.message),
);
if (outcome.status === "interaction-required") {
return res.status(401).json({
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs,
});
}
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey
) {
try {
config.privateKey = preparePrivateKeyForSSH2(
resolvedCredentials.sshKey,
resolvedCredentials.keyPassword,
);
if (resolvedCredentials.keyPassword) {
config.passphrase = resolvedCredentials.keyPassword;
}
} catch (error) {
const message = getErrorMessage(error, "Invalid private key format");
sshLogger.error("SSH key processing error", error, {
operation: "docker_connect",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
`SSH key processing error: ${message}`,
),
);
return res.status(400).json({
error: `SSH key format error: ${message}`,
connectionLogs,
});
}
} else if (resolvedCredentials.authType === "key") {
sshLogger.error(
"SSH key authentication requested but no key provided",
{
operation: "docker_connect",
sessionId,
hostId,
},
);
connectionLogs.push(
createConnectionLog(
"error",
"docker_auth",
"SSH key authentication requested but no key provided",
),
);
return res.status(400).json({
error: "SSH key authentication requested but no key provided",
connectionLogs,
});
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
connectionLogs.push(
createConnectionLog("error", "docker_auth", result.error),
);
return res.status(400).json({ error: result.error, connectionLogs });
}
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Using SSH agent authentication",
),
);
return res.status(400).json({ error: outcome.message, connectionLogs });
}
// Auth types with no stored secret answer a password prompt with
// "auth_required" so the panel can ask for credentials.
const credentialless = !ssh.requiresSecret(authType);
let responseSent = false;
connectionLogs.push(
createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
);
connectionLogs.push(
createConnectionLog(
"info",
@@ -443,36 +303,10 @@ export function registerDockerSshRoutes(app: express.Express): void {
`Connecting to ${host.ip}:${host.port || 22}`,
),
);
connectionLogs.push(
createConnectionLog("info", "handshake", "Initiating SSH handshake"),
);
if (resolvedCredentials.authType === "password") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with password"),
);
} else if (resolvedCredentials.authType === "key") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH key"),
);
} else if (resolvedCredentials.authType === "agent") {
connectionLogs.push(
createConnectionLog("info", "auth", "Authenticating with SSH agent"),
);
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale"
) {
connectionLogs.push(
createConnectionLog(
"info",
"auth",
"Attempting keyboard-interactive authentication",
),
);
}
client.on("ready", () => {
if (responseSent) return;
responseSent = true;
@@ -630,8 +464,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
credentialless &&
(err.message.includes("authentication") ||
err.message.includes(
"All configured authentication methods failed",
@@ -662,388 +495,171 @@ export function registerDockerSshRoutes(app: express.Express): void {
}
});
const parkForAnswer = (
finish: (responses: string[]) => void,
prompts: Array<{ prompt: string; echo: boolean }>,
promptIndex: number,
isWarpgate = false,
) => {
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: promptIndex,
resolvedPassword,
totpAttempts: 0,
containerRuntime,
...(isWarpgate ? { isWarpgate: true } : {}),
};
};
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
_instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const promptTexts = prompts.map((p) => p.prompt);
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
connectTarget,
);
const autoFinish = () =>
finish(ssh.autoResponses(prompts, resolvedPassword));
const warpgatePattern = /warpgate\s+authentication/i;
const isWarpgate =
warpgatePattern.test(name) ||
warpgatePattern.test(instructions) ||
promptTexts.some((p) => warpgatePattern.test(p));
if (isWarpgate) {
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) {
if (responseSent) return;
responseSent = true;
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: -1,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
isWarpgate: true,
containerRuntime,
};
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"Warpgate authentication required",
),
);
res.json({
requires_warpgate: true,
sessionId,
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
connectionLogs,
});
return;
}
if (decision.kind === "auto") {
finish(decision.responses);
return;
}
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
if (decision.kind === "warpgate") {
if (responseSent) return;
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
containerRuntime,
};
parkForAnswer(finish, prompts, -1, true);
connectionLogs.push(
createConnectionLog(
"info",
"docker_auth",
"TOTP verification required",
"Warpgate authentication required",
),
);
res.json({
requires_warpgate: true,
sessionId,
url: decision.url,
securityKey: decision.securityKey,
connectionLogs,
});
return;
}
const promptText = prompts[decision.promptIndex].prompt;
const isPasswordPrompt = /password/i.test(promptText);
if (
decision.kind === "input" &&
!decision.isPush &&
!isPasswordPrompt
) {
autoFinish();
return;
}
if (decision.kind === "input" && isPasswordPrompt && credentialless) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({ status: "auth_required", reason: "no_keyboard" });
return;
}
if (responseSent || pendingTOTPSessions[sessionId]) {
autoFinish();
return;
}
responseSent = true;
parkForAnswer(finish, prompts, decision.promptIndex);
if (decision.kind === "input" && isPasswordPrompt) {
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
connectionLogs,
prompt: promptText,
isPassword: true,
});
} else {
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (resolvedCredentials.authType === "warpgate") {
finish(prompts.map(() => ""));
return;
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({
status: "auth_required",
reason: "no_keyboard",
});
return;
}
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none";
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (
/password/i.test(p.prompt) &&
resolvedCredentials.password
) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip: host.ip,
port: host.port || 22,
username: host.username,
userId,
prompts,
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
containerRuntime,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
});
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
},
);
const proxyConfig: SOCKS5Config | null =
useSocks5 &&
(socks5Host ||
(socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
? {
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain: socks5ProxyChain as ProxyNode[],
}
: null;
const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0;
if (hasJumpHosts) {
try {
if (proxyConfig) {
connectionLogs.push(
createConnectionLog(
"info",
"proxy",
"Connecting via proxy + jump hosts",
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Connecting via ${host.jumpHosts!.length} jump host(s)`,
"docker_auth",
"TOTP verification required",
),
);
const jumpClient = await createJumpHostChain(
host.jumpHosts as Array<{ hostId: number }>,
userId,
);
if (!jumpClient) {
connectionLogs.push(
createConnectionLog(
"error",
"jump",
"Failed to establish jump host chain",
),
);
return res.status(500).json({
error: "Failed to establish jump host chain",
connectionLogs,
});
}
jumpClient.forwardOut(
"127.0.0.1",
0,
host.ip,
host.port || 22,
(err, stream) => {
if (err) {
sshLogger.error("Failed to forward through jump host", err, {
operation: "docker_jump_forward",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
`Failed to forward through jump host: ${err.message}`,
),
);
jumpClient.end();
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error:
"Failed to forward through jump host: " + err.message,
connectionLogs,
});
}
return;
}
config.sock = stream;
client.connect(config);
},
);
} catch (jumpError) {
sshLogger.error("Jump host connection failed", jumpError, {
operation: "docker_jump_connect",
res.json({
requires_totp: true,
sessionId,
hostId,
prompt: promptText,
connectionLogs,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
`Jump host connection failed: ${getErrorMessage(jumpError)}`,
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error:
"Jump host connection failed: " + getErrorMessage(jumpError),
connectionLogs,
});
}
return;
}
} else if (proxyConfig) {
},
);
if (host.jumpHosts && host.jumpHosts.length > 0) {
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Connecting via ${host.jumpHosts.length} jump host(s)`,
),
);
} else if (useSocks5) {
connectionLogs.push(
createConnectionLog("info", "proxy", "Connecting via proxy"),
);
try {
const proxySocket = await createSocks5Connection(
host.ip,
host.port || 22,
proxyConfig,
);
if (proxySocket) {
config.sock = proxySocket;
}
client.connect(config);
} catch (proxyError) {
sshLogger.error("Proxy connection failed", proxyError, {
operation: "docker_proxy_connect",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"proxy",
`Proxy connection failed: ${getErrorMessage(proxyError)}`,
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error: "Proxy connection failed: " + getErrorMessage(proxyError),
connectionLogs,
});
}
return;
}
} else {
await resolveSshConnectConfigHost(config);
client.connect(config);
}
try {
const transport = await ssh.openTransport(connectTarget, config);
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as SSHClient;
client.on("close", () => jumpClient.end());
}
} catch (transportError) {
sshLogger.error("Docker SSH transport failed", transportError, {
operation: "docker_transport",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
host.jumpHosts && host.jumpHosts.length > 0 ? "jump" : "proxy",
getErrorMessage(transportError),
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error: getErrorMessage(transportError),
connectionLogs,
});
}
return;
}
client.connect(config);
} catch (error) {
sshLogger.error("Docker SSH connection error", error, {
operation: "docker_connect",
+36
View File
@@ -0,0 +1,36 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}