This commit is contained in:
LukeGus
2026-05-28 22:29:20 -05:00
parent 33dcde0827
commit 5777351145
238 changed files with 62303 additions and 98955 deletions
+272 -102
View File
@@ -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(