mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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,8 +1,11 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { startAiService, stopAiService } from "./routes.js";
|
||||
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startAiService(ctx.http.router());
|
||||
|
||||
// Offered by reference, not copied: this hands over a resolver, so the key
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
|
||||
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
@@ -277,9 +273,9 @@ async function runCommandOnHost(
|
||||
command: string,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(host as any),
|
||||
createFleetSshFactory(host as any),
|
||||
const result = await withSshConnection(
|
||||
host as any as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
|
||||
@@ -34,14 +34,10 @@ vi.mock(
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}),
|
||||
);
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-client-factory.js", () => ({
|
||||
createFleetSshFactory: () => () => ({}),
|
||||
getFleetPoolKey: () => "pool",
|
||||
}));
|
||||
vi.mock("../../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
vi.mock("../../../src/backend/ssh.js", () => ({
|
||||
withSshConnection: async (
|
||||
_host: unknown,
|
||||
_options: unknown,
|
||||
run: (client: unknown) => Promise<unknown>,
|
||||
) => run({}),
|
||||
}));
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { resolveSnippetCommand } from "../../../../../src/backend/database/routes/snippets-execution.js";
|
||||
import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
@@ -383,9 +379,9 @@ async function execOnHost(
|
||||
if (timeout <= 0) return { error: "Run deadline exceeded" };
|
||||
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(sshHost),
|
||||
createFleetSshFactory(sshHost),
|
||||
const result = await withSshConnection(
|
||||
sshHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => {
|
||||
if (elevated) {
|
||||
return execElevated(client, command, sshHost.sudoPassword, {
|
||||
|
||||
@@ -3,11 +3,7 @@ import { createCurrentAutomationRepository } from "../../../../src/backend/datab
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { withSshConnection } from "./ssh.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { onDockerEvent } from "./triggers.js";
|
||||
|
||||
@@ -154,9 +150,9 @@ async function pollHost(hostId: number, userId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(host as never),
|
||||
createFleetSshFactory(host as never),
|
||||
const result = await withSshConnection(
|
||||
host as never as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
(client) =>
|
||||
execCommand(
|
||||
client,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||
import {
|
||||
startAutomationScheduler,
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
} from "./scheduler.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startAutomationsService(
|
||||
// The webhook route authenticates on its own per-automation token rather
|
||||
// than a session, which is the point of an inbound webhook.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { startFleetsService, stopFleetsService } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startFleetsService(ctx.http.router());
|
||||
ctx.log.info("Fleets routes mounted at /plugin-api/fleets");
|
||||
}
|
||||
|
||||
@@ -26,11 +26,7 @@ import {
|
||||
createCurrentUserRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import {
|
||||
getFleetPoolKey,
|
||||
createFleetSshFactory,
|
||||
} from "../../../../src/backend/hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { withSshConnection } from "./ssh.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import {
|
||||
@@ -915,9 +911,9 @@ router.post(
|
||||
inputValues && typeof inputValues === "object" ? inputValues : {},
|
||||
);
|
||||
|
||||
const { stdout, stderr, code } = await withConnection(
|
||||
getFleetPoolKey(fullHost),
|
||||
createFleetSshFactory(fullHost),
|
||||
const { stdout, stderr, code } = await withSshConnection(
|
||||
fullHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
(client) => execCommand(client, resolvedCommand, 60000),
|
||||
);
|
||||
|
||||
@@ -1017,9 +1013,9 @@ router.post(
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
await withConnection(
|
||||
getFleetPoolKey(fullHost),
|
||||
createFleetSshFactory(fullHost),
|
||||
await withSshConnection(
|
||||
fullHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => {
|
||||
const sftp = await getSftp(client);
|
||||
await sftpWriteFile(sftp, remotePath, file.buffer);
|
||||
@@ -1112,9 +1108,9 @@ router.post(
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
const data = await withConnection(
|
||||
getFleetPoolKey(fullHost),
|
||||
createFleetSshFactory(fullHost),
|
||||
const data = await withSshConnection(
|
||||
fullHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => {
|
||||
const sftp = await getSftp(client);
|
||||
return sftpReadFile(sftp, remotePath);
|
||||
@@ -1305,9 +1301,9 @@ router.post(
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
const record = await withConnection(
|
||||
getFleetPoolKey(fullHost),
|
||||
createFleetSshFactory(fullHost),
|
||||
const record = await withSshConnection(
|
||||
fullHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const { stdout } = await execCommand(
|
||||
@@ -1418,9 +1414,9 @@ router.post(
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return withConnection(
|
||||
getFleetPoolKey(fullHost),
|
||||
createFleetSshFactory(fullHost),
|
||||
return withSshConnection(
|
||||
fullHost as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
// "remove" has no PackageAction counterpart in buildPackageActionCommand
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -60,15 +60,10 @@ vi.mock("../../../../src/backend/hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/hosts/ssh-client-factory.js", () => ({
|
||||
getFleetPoolKey: () => "pool-key",
|
||||
createFleetSshFactory: () => async () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
vi.mock("../../src/backend/ssh.js", () => ({
|
||||
withSshConnection: async (
|
||||
_host: unknown,
|
||||
_options: unknown,
|
||||
fn: (client: unknown) => unknown,
|
||||
) => fn({}),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
|
||||
import { pluginSsh } from "./ssh.js";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
@@ -14,9 +14,7 @@ export type TcpPingStatsConfig = {
|
||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
if (host.authType === "none" || usesIssuedCertificate(host.authType))
|
||||
return false;
|
||||
return true;
|
||||
return pluginSsh().supportsBackground(host.authType || "none");
|
||||
}
|
||||
|
||||
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import {
|
||||
onHostDeleted,
|
||||
onHostLogin,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
await startHostMetricsService(ctx.http.router());
|
||||
|
||||
// Core used to POST these to this plugin's own port with an internal auth
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
|
||||
import express, { type Router } from "express";
|
||||
import net from "net";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
|
||||
import {
|
||||
pickResolvedPassword,
|
||||
pickResolvedUsername,
|
||||
} from "../../../../src/backend/hosts/credential-username.js";
|
||||
import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentHostMetricsHistoryRepository,
|
||||
@@ -47,16 +41,7 @@ import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
|
||||
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../../../../src/backend/utils/socks5-helper.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
|
||||
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
|
||||
import {
|
||||
connectionPool,
|
||||
withConnection,
|
||||
} from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { connectionPool } from "../../../../src/backend/hosts/ssh-connection-pool.js";
|
||||
import { registerHostMetricsSettingsRoutes } from "./settings-routes.js";
|
||||
import { registerHostMetricsViewerRoutes } from "./viewer-routes.js";
|
||||
import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js";
|
||||
@@ -75,10 +60,10 @@ import {
|
||||
} from "./automation-bridge.js";
|
||||
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
|
||||
import { registerManagerRoutes } from "./managers/index.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
|
||||
import { AccessDeniedError } from "./managers/route-helpers.js";
|
||||
import type { ManagerHost } from "./managers/types.js";
|
||||
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
|
||||
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { pluginSsh, withSshConnection as withPooledSsh } from "./ssh.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import {
|
||||
isTcpPingEnabled,
|
||||
@@ -617,10 +602,15 @@ class PollingManager {
|
||||
|
||||
let isOnline: boolean;
|
||||
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
refreshedHost.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
let jumpClient: Client | null = null;
|
||||
try {
|
||||
({ client: jumpClient } = await pluginSsh().jumpChain<Client>(
|
||||
refreshedHost.jumpHosts,
|
||||
{ forHost: refreshedHost as unknown as PluginSshHost },
|
||||
));
|
||||
} catch {
|
||||
jumpClient = null;
|
||||
}
|
||||
isOnline = jumpClient
|
||||
? await tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
@@ -1139,176 +1129,38 @@ async function fetchHostById(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves through core's host resolver, so shared-host overrides and
|
||||
* external secret references apply here exactly as in the terminal.
|
||||
*/
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Promise<SSHHostWithCredentials | undefined> {
|
||||
try {
|
||||
const baseHost: Record<string, unknown> = {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder || "",
|
||||
const resolved = (await resolveHostById(
|
||||
host.id as number,
|
||||
userId,
|
||||
)) as unknown as Record<string, unknown> | null;
|
||||
if (!resolved) return undefined;
|
||||
const terminalConfig = resolved.terminalConfig as
|
||||
Record<string, unknown> | undefined;
|
||||
return {
|
||||
...resolved,
|
||||
tags:
|
||||
typeof host.tags === "string"
|
||||
? host.tags
|
||||
? host.tags.split(",").filter(Boolean)
|
||||
typeof resolved.tags === "string"
|
||||
? resolved.tags
|
||||
? resolved.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
authType: host.authType,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath || "/",
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
|
||||
statsConfig: host.statsConfig || undefined,
|
||||
sudoPassword: (() => {
|
||||
const top = host.sudoPassword as string | null | undefined;
|
||||
if (top) return top;
|
||||
try {
|
||||
const tc =
|
||||
typeof host.terminalConfig === "string"
|
||||
? JSON.parse(host.terminalConfig as string)
|
||||
: host.terminalConfig;
|
||||
return (tc?.sudoPassword as string) || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})(),
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
userId: host.userId,
|
||||
useSocks5: !!host.useSocks5,
|
||||
socks5Host: host.socks5Host || undefined,
|
||||
socks5Port: host.socks5Port || undefined,
|
||||
socks5Username: host.socks5Username || undefined,
|
||||
socks5Password: host.socks5Password || undefined,
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: undefined,
|
||||
: (resolved.tags ?? []),
|
||||
sudoPassword:
|
||||
(resolved.sudoPassword as string | undefined) ||
|
||||
(terminalConfig?.sudoPassword as string | undefined) ||
|
||||
undefined,
|
||||
connectionType:
|
||||
(host.connectionType as SSHHostWithCredentials["connectionType"]) ||
|
||||
(resolved.connectionType as SSHHostWithCredentials["connectionType"]) ||
|
||||
"ssh",
|
||||
rdpPort: (host.rdpPort as number | undefined) ?? undefined,
|
||||
vncPort: (host.vncPort as number | undefined) ?? undefined,
|
||||
telnetPort: (host.telnetPort as number | undefined) ?? undefined,
|
||||
};
|
||||
|
||||
if (host.credentialId) {
|
||||
try {
|
||||
const ownerId = host.userId;
|
||||
const isSharedHost = userId !== ownerId;
|
||||
|
||||
if (isSharedHost) {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../../../src/backend/utils/shared-host-secrets-manager.js");
|
||||
const sharedCred =
|
||||
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
||||
host.id as number,
|
||||
userId,
|
||||
"ssh",
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
baseHost.credentialId = host.credentialId;
|
||||
baseHost.authType = sharedCred.authType;
|
||||
|
||||
baseHost.username = pickResolvedUsername(
|
||||
host.username,
|
||||
sharedCred.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
|
||||
if (sharedCred.password) {
|
||||
baseHost.password = sharedCred.password;
|
||||
}
|
||||
if (sharedCred.key) {
|
||||
baseHost.key = sharedCred.key;
|
||||
}
|
||||
if (sharedCred.keyPassword) {
|
||||
baseHost.keyPassword = sharedCred.keyPassword;
|
||||
}
|
||||
if (sharedCred.keyType) {
|
||||
baseHost.keyType = sharedCred.keyType;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const credential =
|
||||
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
|
||||
host.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credential) {
|
||||
baseHost.credentialId = credential.id;
|
||||
baseHost.authType =
|
||||
credential.authType ||
|
||||
(credential.password
|
||||
? "password"
|
||||
: credential.key ||
|
||||
(credential as Record<string, unknown>).privateKey
|
||||
? "key"
|
||||
: "none");
|
||||
|
||||
baseHost.username = pickResolvedUsername(
|
||||
host.username,
|
||||
credential.username,
|
||||
host.overrideCredentialUsername,
|
||||
);
|
||||
|
||||
baseHost.password = pickResolvedPassword(
|
||||
host.password,
|
||||
credential.password,
|
||||
);
|
||||
if (
|
||||
credential.key ||
|
||||
(credential as Record<string, unknown>).privateKey
|
||||
) {
|
||||
baseHost.key =
|
||||
credential.key ||
|
||||
((credential as Record<string, unknown>).privateKey as string);
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
}
|
||||
if (credential.keyType) {
|
||||
baseHost.keyType = credential.keyType;
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
if (baseHost.authType === "credential") {
|
||||
baseHost.authType = baseHost.password
|
||||
? "password"
|
||||
: baseHost.key
|
||||
? "key"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.warn(
|
||||
`Failed to resolve credential ${host.credentialId} for host ${host.id}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
addLegacyCredentials(baseHost, host);
|
||||
if (baseHost.authType === "credential") {
|
||||
baseHost.authType = baseHost.password
|
||||
? "password"
|
||||
: baseHost.key
|
||||
? "key"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addLegacyCredentials(baseHost, host);
|
||||
}
|
||||
|
||||
return baseHost as unknown as SSHHostWithCredentials;
|
||||
} as unknown as SSHHostWithCredentials;
|
||||
} catch (error) {
|
||||
statsLogger.error(
|
||||
`Failed to resolve host credentials for host ${host.id}: ${getErrorMessage(error)}`,
|
||||
@@ -1317,340 +1169,21 @@ async function resolveHostCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
function addLegacyCredentials(
|
||||
baseHost: Record<string, unknown>,
|
||||
host: Record<string, unknown>,
|
||||
): void {
|
||||
baseHost.password = host.password || null;
|
||||
baseHost.key = host.key || null;
|
||||
baseHost.keyPassword = host.keyPassword || null;
|
||||
baseHost.keyType = host.keyType;
|
||||
}
|
||||
|
||||
async function buildSshConfig(
|
||||
host: SSHHostWithCredentials,
|
||||
): Promise<ConnectConfig> {
|
||||
const base: ConnectConfig = {
|
||||
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
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",
|
||||
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",
|
||||
},
|
||||
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;
|
||||
|
||||
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 SSH key available for host ${host.ip}`);
|
||||
}
|
||||
|
||||
try {
|
||||
(base as Record<string, unknown>).privateKey = preparePrivateKeyForSSH2(
|
||||
host.key,
|
||||
host.keyPassword,
|
||||
);
|
||||
|
||||
if (host.keyPassword) {
|
||||
(base as Record<string, unknown>).passphrase = host.keyPassword;
|
||||
}
|
||||
} catch (keyError) {
|
||||
statsLogger.error(
|
||||
`SSH key format error for host ${host.ip}: ${getErrorMessage(keyError)}`,
|
||||
);
|
||||
throw new Error(`Invalid SSH key format for host ${host.ip}`, {
|
||||
cause: keyError,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
host.authType === "none" ||
|
||||
host.authType === "tailscale" ||
|
||||
host.authType === "warpgate"
|
||||
) {
|
||||
// no credentials needed
|
||||
} else if (usesIssuedCertificate(host.authType)) {
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
} else if (host.authType === "vault") {
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
} else if (host.authType === "credential") {
|
||||
if (host.password) {
|
||||
base.password = host.password;
|
||||
} else if (host.key) {
|
||||
const cleanKey = host.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
(base as Record<string, unknown>).privateKey = Buffer.from(
|
||||
cleanKey,
|
||||
"utf8",
|
||||
);
|
||||
if (host.keyPassword) {
|
||||
(base as Record<string, unknown>).passphrase = host.keyPassword;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Credential for host ${host.ip} could not be resolved`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported authentication type '${host.authType}' for host ${host.ip}`,
|
||||
);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function getPoolKey(host: SSHHostWithCredentials): string {
|
||||
const socks5Key = host.useSocks5
|
||||
? `:socks5:${host.socks5Host}:${host.socks5Port}`
|
||||
: "";
|
||||
return `stats:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
|
||||
}
|
||||
|
||||
function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
return async () => {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
// Set up OPKSSH cert auth if needed (requires client instance)
|
||||
if (usesIssuedCertificate(host.authType) && host.userId) {
|
||||
const { getOPKSSHToken } =
|
||||
await import("../../../../src/backend/hosts/opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(host.userId, host.id);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"OPKSSH authentication required. Please open a Terminal connection first.",
|
||||
);
|
||||
}
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
} else if (host.authType === "vault") {
|
||||
const { setupVaultSshSignerAuth } =
|
||||
await import("../../../../src/backend/hosts/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;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
pendingTOTPSessions[sessionId] = {
|
||||
client,
|
||||
finish,
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
userId: host.userId!,
|
||||
prompts: prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo ?? false,
|
||||
})),
|
||||
totpPromptIndex,
|
||||
resolvedPassword: host.password,
|
||||
totpAttempts: 0,
|
||||
};
|
||||
|
||||
return;
|
||||
} else if (host.password) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt)) {
|
||||
return host.password || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
} else {
|
||||
finish(prompts.map(() => ""));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
return pluginSsh().poolKey("stats", host as unknown as PluginSshHost);
|
||||
}
|
||||
|
||||
async function withSshConnection<T>(
|
||||
host: SSHHostWithCredentials,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = getPoolKey(host);
|
||||
const factory = createSshFactory(host);
|
||||
return withConnection(key, factory, fn);
|
||||
// Background polling has nobody to answer a TOTP prompt, so the pooled
|
||||
// connection answers password prompts only and fails fast otherwise.
|
||||
return withPooledSsh(
|
||||
host as unknown as PluginSshHost,
|
||||
{ pool: "stats", purpose: "metrics", overrides: { readyTimeout: 60000 } },
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
||||
const proxmoxPollingManager = new ProxmoxPollingManager<SSHHostWithCredentials>(
|
||||
@@ -2347,34 +1880,30 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
return res.json({ success: true, connectionLogs });
|
||||
}
|
||||
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
if (usesIssuedCertificate(host.authType) && host.userId) {
|
||||
const { getOPKSSHToken } =
|
||||
await import("../../../../src/backend/hosts/opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(host.userId, host.id);
|
||||
if (!token) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"auth",
|
||||
"OPKSSH authentication required. Please open a Terminal connection first.",
|
||||
),
|
||||
);
|
||||
const ssh = pluginSsh();
|
||||
const prepared = await ssh.prepare(host as unknown as PluginSshHost, {
|
||||
purpose: "metrics",
|
||||
client,
|
||||
log: (level, message) =>
|
||||
connectionLogs.push(createConnectionLog(level, "auth", message)),
|
||||
});
|
||||
const config = prepared.config as ConnectConfig;
|
||||
config.readyTimeout = 60000;
|
||||
if (prepared.outcome.status !== "ready") {
|
||||
const outcome = prepared.outcome;
|
||||
connectionLogs.push(
|
||||
createConnectionLog("error", "auth", outcome.message),
|
||||
);
|
||||
if (outcome.status === "interaction-required") {
|
||||
return res.status(401).json({
|
||||
error: "OPKSSH authentication required",
|
||||
requiresOPKSSHAuth: true,
|
||||
error: outcome.message,
|
||||
requiresAuthInteraction: outcome.interaction,
|
||||
...(outcome.flag ? { [outcome.flag]: true } : {}),
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
const { setupOPKSSHCertAuth } =
|
||||
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
} else if (host.authType === "vault") {
|
||||
const { setupVaultSshSignerAuth } =
|
||||
await import("../../../../src/backend/hosts/vault-ssh-connect.js");
|
||||
await setupVaultSshSignerAuth(config, client, host);
|
||||
return res.status(400).json({ error: outcome.message, connectionLogs });
|
||||
}
|
||||
|
||||
const connectionPromise = new Promise<{
|
||||
@@ -2396,12 +1925,13 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(name, instructions, instructionsLang, prompts, finish) => {
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
(name, instructions, _instructionsLang, prompts, finish) => {
|
||||
const decision = ssh.classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host as unknown as PluginSshHost,
|
||||
);
|
||||
const totpPromptIndex =
|
||||
decision.kind === "totp" ? decision.promptIndex : -1;
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
@@ -2443,13 +1973,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && host.password) {
|
||||
return host.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
finish(ssh.autoResponses(prompts, host.password));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -2577,10 +2101,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const hasJumpHosts =
|
||||
host.jumpHosts && host.jumpHosts.length > 0 && host.userId;
|
||||
|
||||
if (hasJumpHosts) {
|
||||
if (host.jumpHosts && host.jumpHosts.length > 0) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -2588,92 +2109,33 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
"Connecting via jump host chain",
|
||||
),
|
||||
);
|
||||
createJumpHostChain(host.jumpHosts!, host.userId!)
|
||||
.then((jumpClient) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
host.ip,
|
||||
host.port,
|
||||
(err, stream) => {
|
||||
if (err || !stream) {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(err || new Error("Jump host forward failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
config.sock = stream;
|
||||
delete config.host;
|
||||
delete config.port;
|
||||
client.connect(config);
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"proxy",
|
||||
`Jump host connection failed: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
(host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy"),
|
||||
);
|
||||
createSocks5Connection(host.ip, host.port, {
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
})
|
||||
.then((socks5Socket) => {
|
||||
if (socks5Socket) {
|
||||
config.sock = socks5Socket;
|
||||
}
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"proxy",
|
||||
`SOCKS5 proxy connection failed: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolveSshConnectConfigHost(config)
|
||||
.then(() => {
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
ssh
|
||||
.openTransport(
|
||||
host as unknown as PluginSshHost,
|
||||
config as Record<string, unknown>,
|
||||
)
|
||||
.then((transport) => {
|
||||
if (transport.jumpClient) {
|
||||
const jumpClient = transport.jumpClient as Client;
|
||||
client.on("close", () => jumpClient.end());
|
||||
}
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
clearTimeout(timeout);
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"proxy",
|
||||
`Connection setup failed: ${getErrorMessage(error)}`,
|
||||
),
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const result = await connectionPromise;
|
||||
@@ -3093,5 +2555,7 @@ export function shutdown(): void {
|
||||
}
|
||||
pollingManager.destroy();
|
||||
proxmoxPollingManager.destroy();
|
||||
connectionPool.destroy();
|
||||
// The pool is core's and shared with tmux and the fleet tools; destroying it
|
||||
// here broke them until a restart. ctx.ssh drops this plugin's pooled
|
||||
// connections on deactivate instead.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
@@ -7,8 +7,17 @@ import {
|
||||
tcpPingThroughJumpHost,
|
||||
} from "../../src/backend/helpers.js";
|
||||
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import { setPluginSsh } from "../../src/backend/ssh.js";
|
||||
|
||||
describe("supportsMetrics", () => {
|
||||
beforeAll(() => {
|
||||
setPluginSsh(createFakeContext().ctx.ssh);
|
||||
});
|
||||
afterAll(() => {
|
||||
setPluginSsh(null);
|
||||
});
|
||||
|
||||
it("supports plain ssh hosts", () => {
|
||||
expect(
|
||||
supportsMetrics({ connectionType: "ssh", authType: "password" }),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { startProxmoxService, stopProxmoxService } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startProxmoxService(ctx.http.router());
|
||||
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import express, { type Router } from "express";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
type AuthenticatedRequest,
|
||||
type SSHHost,
|
||||
} from "../../../../src/types/index.js";
|
||||
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
|
||||
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { connectSsh } from "./ssh.js";
|
||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
|
||||
import {
|
||||
parseProxmoxJumpHosts,
|
||||
@@ -309,109 +309,15 @@ async function discoverProxmoxGuestsForHost(
|
||||
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
|
||||
const config = parseProxmoxConfig(proxmoxCfgRaw);
|
||||
|
||||
const resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: host.password,
|
||||
sshKey: host.key,
|
||||
keyPassword: host.keyPassword,
|
||||
authType: host.authType,
|
||||
};
|
||||
|
||||
const hostCredentialId = host.credentialId ?? null;
|
||||
|
||||
const sshConfig: Record<string, unknown> = {
|
||||
host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
tryKeyboard: false,
|
||||
readyTimeout: 30000,
|
||||
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
|
||||
parsedHostId,
|
||||
host.ip,
|
||||
host.port || 22,
|
||||
null,
|
||||
userId,
|
||||
false,
|
||||
),
|
||||
};
|
||||
const { client } = await connectSsh(host as unknown as PluginSshHost, {
|
||||
purpose: "proxmox",
|
||||
timeoutMs: 35000,
|
||||
overrides: { tryKeyboard: false, readyTimeout: 30000 },
|
||||
});
|
||||
|
||||
const authType = resolvedCredentials.authType;
|
||||
if (authType === "key" && resolvedCredentials.sshKey) {
|
||||
sshConfig.privateKey = resolvedCredentials.sshKey;
|
||||
if (resolvedCredentials.keyPassword)
|
||||
sshConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
} else if (authType === "agent") {
|
||||
const { applyAgentAuth } =
|
||||
await import("../../../../src/backend/hosts/terminal-auth-helpers.js");
|
||||
const result = await applyAgentAuth(
|
||||
sshConfig,
|
||||
host.terminalConfig as unknown as Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
const error = new Error(result.error);
|
||||
(error as Error & { status?: number }).status = 400;
|
||||
throw error;
|
||||
}
|
||||
} else if (resolvedCredentials.password) {
|
||||
sshConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", resolve);
|
||||
client.on("error", reject);
|
||||
|
||||
// Reuse the shared jump-host chain (same path terminal/metrics use)
|
||||
// so Proxmox hosts that are only reachable via a jump host work too
|
||||
// (otherwise the direct connect fails with EHOSTUNREACH). jumpHosts is
|
||||
// stored as a JSON string on the decrypted record, so parse it first.
|
||||
let parsedJumpHosts: Array<{ hostId: number }> = [];
|
||||
try {
|
||||
const rawJumpHosts = (host as { jumpHosts?: unknown }).jumpHosts;
|
||||
const parsed =
|
||||
typeof rawJumpHosts === "string"
|
||||
? JSON.parse(rawJumpHosts)
|
||||
: rawJumpHosts;
|
||||
if (Array.isArray(parsed)) parsedJumpHosts = parsed;
|
||||
} catch {
|
||||
parsedJumpHosts = [];
|
||||
}
|
||||
|
||||
if (parsedJumpHosts.length > 0) {
|
||||
createJumpHostChain(parsedJumpHosts, userId)
|
||||
.then((jumpClient) => {
|
||||
if (!jumpClient) {
|
||||
reject(new Error("Jump host chain could not be established"));
|
||||
return;
|
||||
}
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
sshConfig.host as string,
|
||||
sshConfig.port as number,
|
||||
(err, stream) => {
|
||||
if (err || !stream) {
|
||||
reject(err || new Error("Jump host forward failed"));
|
||||
return;
|
||||
}
|
||||
sshConfig.sock = stream;
|
||||
delete sshConfig.host;
|
||||
delete sshConfig.port;
|
||||
client.connect(sshConfig as import("ssh2").ConnectConfig);
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
client.connect(sshConfig as import("ssh2").ConnectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
proxmoxLogger.info("Proxmox discovery SSH connection established", {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useSshAuthTypes, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Server } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
@@ -20,6 +20,7 @@ export function HostProxmoxTab({
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sshAuthTypes = useSshAuthTypes();
|
||||
const [credentials, setCredentials] = useState<
|
||||
{ id: number; name: string; username: string | null }[]
|
||||
>([]);
|
||||
@@ -107,14 +108,11 @@ export function HostProxmoxTab({
|
||||
}
|
||||
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="password">{t("hosts.authTypePassword")}</option>
|
||||
<option value="key">{t("hosts.authTypeKey")}</option>
|
||||
<option value="credential">
|
||||
{t("hosts.authTypeCredential")}
|
||||
</option>
|
||||
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
|
||||
<option value="stepca">{t("hosts.authTypeStepca")}</option>
|
||||
<option value="none">{t("hosts.authTypeNone")}</option>
|
||||
{sshAuthTypes.types.map((option) => (
|
||||
<option key={option.type} value={option.type}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import {
|
||||
getGuacSessionInfo,
|
||||
handleGuacamoleUpgrade,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
import { startRemoteDesktopService } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
await startGuacamoleService();
|
||||
|
||||
startRemoteDesktopService(ctx.http.router());
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
|
||||
import type { Client } from "ssh2";
|
||||
import { pluginSsh } from "./ssh.js";
|
||||
import { getGuacSessionByConnectId } from "./guacamole-server.js";
|
||||
import {
|
||||
logAudit,
|
||||
@@ -545,18 +546,9 @@ router.post(
|
||||
try {
|
||||
// The chain dials the first hop through that hop's own SOCKS5
|
||||
// settings; the target host's proxy config does not apply to it.
|
||||
const jumpClient = await createJumpHostChain(jumpHosts, userId);
|
||||
|
||||
if (!jumpClient) {
|
||||
guacLogger.error(
|
||||
"Failed to establish jump host chain for guacamole",
|
||||
undefined,
|
||||
{ operation: "guac_ssh_tunnel_error", hostId },
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to establish SSH tunnel to remote host",
|
||||
});
|
||||
}
|
||||
const { client: jumpClient } = await pluginSsh().jumpChain<Client>(
|
||||
jumpHosts as Array<{ hostId: number }>,
|
||||
);
|
||||
|
||||
const targetHostname = hostname;
|
||||
const targetPort = port;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { startWebEndpointService, stopWebEndpointService } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startWebEndpointService(ctx.http.router());
|
||||
ctx.log.info("Web Endpoint routes mounted at /plugin-api/web-endpoint");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user