mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
v2.3.0
This commit is contained in:
+272
-102
@@ -1,5 +1,14 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import ssh2Pkg, {
|
||||
type Client as SSHClientType,
|
||||
type ClientChannel,
|
||||
type PseudoTtyOptions,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
type IdentityCallback,
|
||||
} from "ssh2";
|
||||
const { Client, BaseAgent, utils: ssh2Utils } = ssh2Pkg;
|
||||
import net from "net";
|
||||
import dgram from "dgram";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
@@ -22,9 +31,44 @@ import { sessionManager } from "./terminal-session-manager.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
queryNewestTmuxSession,
|
||||
waitForTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
|
||||
class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
|
||||
constructor(key: ParsedKey) {
|
||||
super();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback<ParsedKey>): void {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
sign(
|
||||
pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
|
||||
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
|
||||
try {
|
||||
const algo =
|
||||
options.hash === "sha256"
|
||||
? "rsa-sha2-256"
|
||||
: options.hash === "sha512"
|
||||
? "rsa-sha2-512"
|
||||
: undefined;
|
||||
const signature = this.key.sign(data, algo);
|
||||
callback(null, signature);
|
||||
} catch (err) {
|
||||
callback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function performPortKnocking(
|
||||
host: string,
|
||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||
@@ -145,10 +189,7 @@ async function resolveJumpHost(
|
||||
});
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -158,8 +199,37 @@ async function resolveJumpHost(
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -167,11 +237,11 @@ async function resolveJumpHost(
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -179,7 +249,7 @@ async function resolveJumpHost(
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey 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,
|
||||
@@ -202,13 +272,13 @@ async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
socks5Config?: SOCKS5Config | null,
|
||||
): Promise<Client | null> {
|
||||
): Promise<SSHClientType | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: Client | null = null;
|
||||
const clients: Client[] = [];
|
||||
let currentClient: SSHClientType | null = null;
|
||||
const clients: SSHClientType[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs = await Promise.all(
|
||||
@@ -364,52 +434,6 @@ async function createJumpHostChain(
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const authHeader = info.req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.slice("Bearer ".length);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
|
||||
if (existingConnections && existingConnections.size >= 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket authentication error", error, {
|
||||
operation: "websocket_auth_error",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
@@ -432,6 +456,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
@@ -483,9 +513,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
});
|
||||
|
||||
let currentSessionId: string | null = null;
|
||||
let sshConn: Client | null = null;
|
||||
let sshConn: SSHClientType | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let lastJumpClient: Client | null = null;
|
||||
let lastJumpClient: SSHClientType | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
let totpPromptSent = false;
|
||||
let totpTimeout: NodeJS.Timeout | null = null;
|
||||
@@ -806,8 +836,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
: null;
|
||||
if (session?.sshStream) {
|
||||
const existingName = tmuxData.sessionName || undefined;
|
||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||
if (existingName) {
|
||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||
session.tmuxSessionName = existingName;
|
||||
sshLogger.info("User selected tmux session to attach", {
|
||||
operation: "tmux_user_attach",
|
||||
@@ -821,30 +851,51 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// New session from picker -- query name after startup
|
||||
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
|
||||
attachOrCreateTmuxSession(session.sshStream, undefined, newName);
|
||||
const sshConn = session.sshConn;
|
||||
setTimeout(async () => {
|
||||
const sessionName = sshConn
|
||||
? await queryNewestTmuxSession(sshConn)
|
||||
: null;
|
||||
session.tmuxSessionName = sessionName;
|
||||
sshLogger.info("User requested new tmux session", {
|
||||
operation: "tmux_user_create",
|
||||
sessionName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
if (sshConn) {
|
||||
(async () => {
|
||||
const confirmed = await waitForTmuxSession(sshConn, newName);
|
||||
session.tmuxSessionName = confirmed;
|
||||
sshLogger.info("User requested new tmux session", {
|
||||
operation: "tmux_user_create",
|
||||
sessionName: confirmed,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName: confirmed,
|
||||
}),
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tmux_detach": {
|
||||
const session = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
if (session?.sshConn && session.tmuxSessionName) {
|
||||
const tmuxName = session.tmuxSessionName;
|
||||
session.sshStream?.write("\x02d");
|
||||
session.tmuxSessionName = null;
|
||||
sshLogger.info("User detached from tmux session", {
|
||||
operation: "tmux_user_detach",
|
||||
sessionName: tmuxName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "totp_response": {
|
||||
const totpData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && totpData?.code) {
|
||||
@@ -1239,6 +1290,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword,
|
||||
keyType,
|
||||
authType,
|
||||
certPublicKey: undefined as string | undefined,
|
||||
};
|
||||
const authMethodNotAvailable = false;
|
||||
if (id && userId && !password && !key) {
|
||||
@@ -1253,6 +1305,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||
.certPublicKey as string | undefined,
|
||||
};
|
||||
sendLog(
|
||||
"auth",
|
||||
@@ -1280,6 +1334,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||
.certPublicKey as string | undefined,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1580,6 +1636,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
graceful: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1645,31 +1702,27 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
"tmux is not installed on the remote host. Falling back to standard shell.",
|
||||
}),
|
||||
);
|
||||
// tmux unavailable, run commands in plain shell
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 0) {
|
||||
attachOrCreateTmuxSession(stream);
|
||||
// Query the name tmux assigned after a short delay
|
||||
setTimeout(async () => {
|
||||
const sessionName = await queryNewestTmuxSession(conn);
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Created new tmux session", {
|
||||
operation: "tmux_new_session",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
// Wait for tmux to start before running commands inside it
|
||||
runPostShellCommands(500);
|
||||
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
|
||||
attachOrCreateTmuxSession(stream, undefined, newName);
|
||||
const confirmed = await waitForTmuxSession(conn, newName);
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = confirmed;
|
||||
}
|
||||
sshLogger.info("Created new tmux session", {
|
||||
operation: "tmux_new_session",
|
||||
sessionName: confirmed,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName: confirmed,
|
||||
}),
|
||||
);
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 1) {
|
||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
||||
const sessionName = detection.sessions[0].name;
|
||||
@@ -2233,6 +2286,45 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (resolvedCredentials.password) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
|
||||
// Apply CA-signed certificate if one is stored in the credential
|
||||
if (
|
||||
resolvedCredentials.certPublicKey &&
|
||||
resolvedCredentials.certPublicKey.trim()
|
||||
) {
|
||||
try {
|
||||
const { setupCACertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupCACertAuth(
|
||||
connectConfig,
|
||||
sshConn,
|
||||
connectConfig.privateKey as Buffer,
|
||||
resolvedCredentials.certPublicKey,
|
||||
username,
|
||||
resolvedCredentials.keyPassword,
|
||||
);
|
||||
sendLog("auth", "info", "CA certificate authentication configured");
|
||||
sshLogger.info("CA cert auth configured", {
|
||||
operation: "ca_cert_auth_configured",
|
||||
userId,
|
||||
hostId: id,
|
||||
});
|
||||
} catch (certError) {
|
||||
sendLog(
|
||||
"auth",
|
||||
"warning",
|
||||
"CA certificate setup failed – falling back to key-only auth",
|
||||
);
|
||||
sshLogger.warn("CA cert auth setup failed", {
|
||||
operation: "ca_cert_auth_setup_failed",
|
||||
userId,
|
||||
hostId: id,
|
||||
error:
|
||||
certError instanceof Error
|
||||
? certError.message
|
||||
: String(certError),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.error("SSH key format error: " + keyError.message);
|
||||
ws.send(
|
||||
@@ -2312,6 +2404,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.terminalConfig?.agentForwarding &&
|
||||
connectConfig.privateKey
|
||||
) {
|
||||
try {
|
||||
const parsed = ssh2Utils.parseKey(
|
||||
connectConfig.privateKey as Buffer,
|
||||
connectConfig.passphrase as string | undefined,
|
||||
);
|
||||
if (parsed && !(parsed instanceof Error)) {
|
||||
connectConfig.agent = new MemoryAgent(parsed);
|
||||
connectConfig.agentForward = true;
|
||||
sendLog("auth", "info", "SSH agent forwarding enabled");
|
||||
}
|
||||
} catch {
|
||||
sshLogger.warn("Failed to set up agent forwarding", {
|
||||
operation: "agent_forward_setup",
|
||||
hostId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.portKnockSequence &&
|
||||
hostConfig.portKnockSequence.length > 0
|
||||
@@ -2350,6 +2464,62 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
hostConfig.jumpHosts.length > 0 &&
|
||||
hostConfig.userId;
|
||||
|
||||
// Cloudflare Tunnel: connect via WebSocket proxy
|
||||
const cfConfig = hostConfig.terminalConfig as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) {
|
||||
try {
|
||||
const WebSocket = (await import("ws")).default;
|
||||
const cfHostname = (cfConfig.cfTunnelHostname as string) || ip;
|
||||
const wsUrl = `wss://${cfHostname}/cdn-cgi/access/ssh-connect`;
|
||||
const cfWs = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
"CF-Access-Client-Id": cfConfig.cfAccessClientId as string,
|
||||
"CF-Access-Client-Secret": cfConfig.cfAccessClientSecret as string,
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
cfWs.on("open", () => resolve());
|
||||
cfWs.on("error", (err) => reject(err));
|
||||
setTimeout(
|
||||
() => reject(new Error("Cloudflare tunnel timeout")),
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
const { Duplex } = await import("stream");
|
||||
const duplexStream = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
cfWs.send(chunk, callback);
|
||||
},
|
||||
});
|
||||
cfWs.on("message", (data) => duplexStream.push(data));
|
||||
cfWs.on("close", () => duplexStream.push(null));
|
||||
|
||||
connectConfig.sock =
|
||||
duplexStream as unknown as typeof connectConfig.sock;
|
||||
sendLog("handshake", "info", "Connected via Cloudflare Tunnel");
|
||||
} catch (cfError) {
|
||||
sshLogger.error("Cloudflare tunnel connection failed", cfError, {
|
||||
operation: "cf_tunnel_connect",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Cloudflare tunnel connection failed: " +
|
||||
(cfError instanceof Error ? cfError.message : "Unknown error"),
|
||||
}),
|
||||
);
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasJumpHosts) {
|
||||
try {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
|
||||
Reference in New Issue
Block a user