mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 18:58:52 +00:00
340 lines
9.8 KiB
TypeScript
340 lines
9.8 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
|||
|
|
import ssh2Pkg, { type Client as SSHClientType } from "ssh2";
|
||
|
|
import { getDb } from "../database/db/index.js";
|
||
|
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||
|
|
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||
|
|
import { sshLogger } from "../utils/logger.js";
|
||
|
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||
|
|
import {
|
||
|
|
createSocks5Connection,
|
||
|
|
type SOCKS5Config,
|
||
|
|
} from "../utils/socks5-helper.js";
|
||
|
|
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||
|
|
|
||
|
|
const { Client } = ssh2Pkg;
|
||
|
|
|
||
|
|
interface JumpHostConfig {
|
||
|
|
id: number;
|
||
|
|
ip: string;
|
||
|
|
port: number;
|
||
|
|
username: string;
|
||
|
|
password?: string;
|
||
|
|
key?: string;
|
||
|
|
keyPassword?: string;
|
||
|
|
keyType?: string;
|
||
|
|
authType?: string;
|
||
|
|
credentialId?: number;
|
||
|
|
[key: string]: unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function resolveJumpHost(
|
||
|
|
hostId: number,
|
||
|
|
userId: string,
|
||
|
|
): Promise<JumpHostConfig | null> {
|
||
|
|
sshLogger.info("Resolving jump host", {
|
||
|
|
operation: "terminal_jumphost_resolve",
|
||
|
|
userId,
|
||
|
|
hostId,
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
const hostResults = await SimpleDBOps.select(
|
||
|
|
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||
|
|
"ssh_data",
|
||
|
|
userId,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (hostResults.length === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const host = hostResults[0];
|
||
|
|
const ownerId = (host.userId || userId) as string;
|
||
|
|
|
||
|
|
if (host.credentialId) {
|
||
|
|
if (userId !== ownerId) {
|
||
|
|
try {
|
||
|
|
const { SharedCredentialManager } =
|
||
|
|
await import("../utils/shared-credential-manager.js");
|
||
|
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||
|
|
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||
|
|
hostId,
|
||
|
|
userId,
|
||
|
|
);
|
||
|
|
if (sharedCred) {
|
||
|
|
return {
|
||
|
|
...host,
|
||
|
|
password: sharedCred.password,
|
||
|
|
key: sharedCred.key,
|
||
|
|
keyPassword: sharedCred.keyPassword,
|
||
|
|
keyType: sharedCred.keyType,
|
||
|
|
authType: sharedCred.key
|
||
|
|
? "key"
|
||
|
|
: sharedCred.password
|
||
|
|
? "password"
|
||
|
|
: "none",
|
||
|
|
} as JumpHostConfig;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// fall through to owner credential lookup
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const credentials = await SimpleDBOps.select(
|
||
|
|
getDb()
|
||
|
|
.select()
|
||
|
|
.from(sshCredentials)
|
||
|
|
.where(
|
||
|
|
and(
|
||
|
|
eq(sshCredentials.id, host.credentialId as number),
|
||
|
|
eq(sshCredentials.userId, ownerId),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
"ssh_credentials",
|
||
|
|
ownerId,
|
||
|
|
);
|
||
|
|
|
||
|
|
if (credentials.length > 0) {
|
||
|
|
const credential = credentials[0];
|
||
|
|
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) {
|
||
|
|
sshLogger.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,
|
||
|
|
socks5Config?: SOCKS5Config | null,
|
||
|
|
): Promise<SSHClientType | null> {
|
||
|
|
if (!jumpHosts || jumpHosts.length === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
let currentClient: SSHClientType | null = null;
|
||
|
|
const clients: SSHClientType[] = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
const jumpHostConfigs = await Promise.all(
|
||
|
|
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
|
||
|
|
);
|
||
|
|
|
||
|
|
const totalHops = jumpHostConfigs.length;
|
||
|
|
|
||
|
|
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||
|
|
if (!jumpHostConfigs[i]) {
|
||
|
|
sshLogger.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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let proxySocket: import("net").Socket | null = null;
|
||
|
|
if (socks5Config?.useSocks5) {
|
||
|
|
const firstHop = jumpHostConfigs[0];
|
||
|
|
proxySocket = await createSocks5Connection(
|
||
|
|
firstHop.ip,
|
||
|
|
firstHop.port || 22,
|
||
|
|
socks5Config,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||
|
|
const jumpHostConfig = jumpHostConfigs[i];
|
||
|
|
|
||
|
|
const jumpClient = new Client();
|
||
|
|
clients.push(jumpClient);
|
||
|
|
|
||
|
|
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||
|
|
jumpHostConfig.id,
|
||
|
|
jumpHostConfig.ip,
|
||
|
|
jumpHostConfig.port || 22,
|
||
|
|
null,
|
||
|
|
userId,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
|
||
|
|
const connected = await new Promise<boolean>((resolve) => {
|
||
|
|
const timeout = setTimeout(() => {
|
||
|
|
resolve(false);
|
||
|
|
}, 30000);
|
||
|
|
|
||
|
|
jumpClient.on("ready", () => {
|
||
|
|
clearTimeout(timeout);
|
||
|
|
sshLogger.success("Jump host connection established", {
|
||
|
|
operation: "terminal_jumphost_connected",
|
||
|
|
userId,
|
||
|
|
hostId: jumpHostConfig.id,
|
||
|
|
ip: jumpHostConfig.ip,
|
||
|
|
depth: i,
|
||
|
|
hopIndex: i,
|
||
|
|
totalHops,
|
||
|
|
usedProxySocket: i === 0 && !!proxySocket,
|
||
|
|
});
|
||
|
|
resolve(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
jumpClient.on("error", (err) => {
|
||
|
|
clearTimeout(timeout);
|
||
|
|
sshLogger.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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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) {
|
||
|
|
sshLogger.error("Failed to create jump host chain", error, {
|
||
|
|
operation: "jump_host_chain",
|
||
|
|
});
|
||
|
|
clients.forEach((c) => c.end());
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|