mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
327 lines
9.9 KiB
TypeScript
327 lines
9.9 KiB
TypeScript
import { Client as SSHClient } from "ssh2";
|
|
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
|
import { fileLogger } from "../utils/logger.js";
|
|
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
|
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
|
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
|
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
|
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
|
|
|
type JumpHostConfig = {
|
|
id: number;
|
|
ip: string;
|
|
port: number;
|
|
username: string;
|
|
password?: string;
|
|
key?: string;
|
|
keyPassword?: string;
|
|
keyType?: string;
|
|
authType?: string;
|
|
credentialId?: number;
|
|
useSocks5?: boolean | null;
|
|
socks5Host?: string | null;
|
|
socks5Port?: number | null;
|
|
socks5Username?: string | null;
|
|
socks5Password?: string | null;
|
|
socks5ProxyChain?: string | import("../../types/index.js").ProxyNode[] | null;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
async function resolveJumpHost(
|
|
hostId: number,
|
|
userId: string,
|
|
): Promise<JumpHostConfig | null> {
|
|
try {
|
|
const repository = createCurrentHostResolutionRepository();
|
|
const ownerId = (await repository.findHostOwnerId(hostId)) ?? userId;
|
|
const resolvedHost = await repository.findHostById(hostId, ownerId);
|
|
|
|
if (!resolvedHost) {
|
|
return null;
|
|
}
|
|
|
|
const host = resolvedHost as Record<string, unknown>;
|
|
|
|
if (host.credentialId) {
|
|
if (userId !== ownerId) {
|
|
try {
|
|
const { SharedHostSecretsManager } =
|
|
await import("../utils/shared-host-secrets-manager.js");
|
|
const secret =
|
|
await SharedHostSecretsManager.getInstance().getSecretForUser(
|
|
hostId,
|
|
userId,
|
|
"ssh",
|
|
);
|
|
if (secret) {
|
|
return {
|
|
...host,
|
|
password: secret.password,
|
|
key: secret.key,
|
|
keyPassword: secret.keyPassword,
|
|
keyType: secret.keyType,
|
|
authType: secret.key
|
|
? "key"
|
|
: secret.password
|
|
? "password"
|
|
: "none",
|
|
} as JumpHostConfig;
|
|
}
|
|
} catch {
|
|
// fall through to owner credential lookup
|
|
}
|
|
}
|
|
|
|
const credential = (await repository.findCredentialByIdForUser(
|
|
host.credentialId as number,
|
|
ownerId,
|
|
)) as Record<string, unknown> | null;
|
|
|
|
if (credential) {
|
|
return {
|
|
...host,
|
|
password: credential.password as string | undefined,
|
|
key: (credential.key || credential.privateKey) as string | undefined,
|
|
keyPassword: credential.keyPassword as string | undefined,
|
|
keyType: credential.keyType as string | undefined,
|
|
authType: credential.authType as string | undefined,
|
|
} as JumpHostConfig;
|
|
}
|
|
}
|
|
|
|
return host as JumpHostConfig;
|
|
} catch (error) {
|
|
fileLogger.error("Failed to resolve jump host", error, {
|
|
operation: "resolve_jump_host",
|
|
hostId,
|
|
userId,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export 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 clients: SSHClient[] = [];
|
|
|
|
try {
|
|
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
|
|
[];
|
|
for (let i = 0; i < jumpHosts.length; i++) {
|
|
const config = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
|
jumpHostConfigs.push(config);
|
|
}
|
|
|
|
const totalHops = jumpHostConfigs.length;
|
|
|
|
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
|
if (!jumpHostConfigs[i]) {
|
|
fileLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
|
operation: "jump_host_chain",
|
|
hostId: jumpHosts[i].hostId,
|
|
hopIndex: i,
|
|
totalHops,
|
|
});
|
|
clients.forEach((c) => c.end());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const firstHopSocks5Config = getJumpHostSocks5Config(jumpHostConfigs[0]);
|
|
let proxySocket: import("net").Socket | null = null;
|
|
if (firstHopSocks5Config?.useSocks5) {
|
|
const firstHop = jumpHostConfigs[0]!;
|
|
proxySocket = await createSocks5Connection(
|
|
firstHop.ip,
|
|
firstHop.port || 22,
|
|
firstHopSocks5Config,
|
|
);
|
|
}
|
|
|
|
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
|
const jumpHostConfig = jumpHostConfigs[i]!;
|
|
|
|
const jumpClient = new SSHClient();
|
|
clients.push(jumpClient);
|
|
|
|
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
|
jumpHostConfig.id,
|
|
jumpHostConfig.ip,
|
|
jumpHostConfig.port || 22,
|
|
null,
|
|
userId,
|
|
true,
|
|
);
|
|
|
|
// eslint-disable-next-line no-async-promise-executor
|
|
const connected = await new Promise<boolean>(async (resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
resolve(false);
|
|
}, 30000);
|
|
|
|
jumpClient.on("ready", () => {
|
|
clearTimeout(timeout);
|
|
resolve(true);
|
|
});
|
|
|
|
jumpClient.on("error", (err) => {
|
|
clearTimeout(timeout);
|
|
fileLogger.error(
|
|
`Jump host ${i + 1}/${totalHops} connection failed`,
|
|
err,
|
|
{
|
|
operation: "jump_host_connect",
|
|
hostId: jumpHostConfig.id,
|
|
ip: jumpHostConfig.ip,
|
|
hopIndex: i,
|
|
totalHops,
|
|
previousHop:
|
|
i > 0
|
|
? jumpHostConfigs[i - 1]?.ip
|
|
: proxySocket
|
|
? "proxy"
|
|
: "direct",
|
|
usedProxySocket: i === 0 && !!proxySocket,
|
|
},
|
|
);
|
|
resolve(false);
|
|
});
|
|
|
|
const connectConfig: Record<string, unknown> = {
|
|
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
|
port: jumpHostConfig.port || 22,
|
|
username: jumpHostConfig.username,
|
|
tryKeyboard: jumpHostConfig.authType !== "none",
|
|
readyTimeout: 60000,
|
|
hostVerifier: jumpHostVerifier,
|
|
algorithms: {
|
|
kex: [
|
|
"curve25519-sha256",
|
|
"curve25519-sha256@libssh.org",
|
|
"ecdh-sha2-nistp521",
|
|
"ecdh-sha2-nistp384",
|
|
"ecdh-sha2-nistp256",
|
|
"diffie-hellman-group-exchange-sha256",
|
|
"diffie-hellman-group18-sha512",
|
|
"diffie-hellman-group17-sha512",
|
|
"diffie-hellman-group16-sha512",
|
|
"diffie-hellman-group15-sha512",
|
|
"diffie-hellman-group14-sha256",
|
|
"diffie-hellman-group14-sha1",
|
|
"diffie-hellman-group-exchange-sha1",
|
|
"diffie-hellman-group1-sha1",
|
|
],
|
|
serverHostKey: [
|
|
"ssh-ed25519",
|
|
"ecdsa-sha2-nistp521",
|
|
"ecdsa-sha2-nistp384",
|
|
"ecdsa-sha2-nistp256",
|
|
"rsa-sha2-512",
|
|
"rsa-sha2-256",
|
|
"ssh-rsa",
|
|
"ssh-dss",
|
|
],
|
|
cipher: SSH_ALGORITHMS.cipher,
|
|
hmac: [
|
|
"hmac-sha2-512-etm@openssh.com",
|
|
"hmac-sha2-256-etm@openssh.com",
|
|
"hmac-sha2-512",
|
|
"hmac-sha2-256",
|
|
"hmac-sha1",
|
|
"hmac-md5",
|
|
],
|
|
compress: ["none", "zlib@openssh.com", "zlib"],
|
|
},
|
|
};
|
|
|
|
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
|
connectConfig.password = jumpHostConfig.password;
|
|
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
|
const cleanKey = jumpHostConfig.key
|
|
.trim()
|
|
.replace(/\r\n/g, "\n")
|
|
.replace(/\r/g, "\n");
|
|
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
|
if (jumpHostConfig.keyPassword) {
|
|
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
|
}
|
|
} else if (jumpHostConfig.authType === "agent") {
|
|
const result = await applyAgentAuth(
|
|
connectConfig,
|
|
jumpHostConfig.terminalConfig as
|
|
| Record<string, unknown>
|
|
| undefined,
|
|
);
|
|
if ("error" in result) {
|
|
throw new Error(result.error);
|
|
}
|
|
}
|
|
|
|
jumpClient.on(
|
|
"keyboard-interactive",
|
|
(
|
|
_name: string,
|
|
_instructions: string,
|
|
_lang: string,
|
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
|
finish: (responses: string[]) => void,
|
|
) => {
|
|
const responses = prompts.map((p) => {
|
|
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
|
return jumpHostConfig.password as string;
|
|
}
|
|
return "";
|
|
});
|
|
finish(responses);
|
|
},
|
|
);
|
|
|
|
if (currentClient) {
|
|
currentClient.forwardOut(
|
|
"127.0.0.1",
|
|
0,
|
|
jumpHostConfig.ip,
|
|
jumpHostConfig.port || 22,
|
|
(err, stream) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
connectConfig.sock = stream;
|
|
jumpClient.connect(connectConfig);
|
|
},
|
|
);
|
|
} else if (proxySocket) {
|
|
connectConfig.sock = proxySocket;
|
|
jumpClient.connect(connectConfig);
|
|
} else {
|
|
jumpClient.connect(connectConfig);
|
|
}
|
|
});
|
|
|
|
if (!connected) {
|
|
clients.forEach((c) => c.end());
|
|
return null;
|
|
}
|
|
|
|
currentClient = jumpClient;
|
|
}
|
|
|
|
return currentClient;
|
|
} catch (error) {
|
|
fileLogger.error("Failed to create jump host chain", error, {
|
|
operation: "jump_host_chain",
|
|
});
|
|
clients.forEach((c) => c.end());
|
|
return null;
|
|
}
|
|
}
|