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
+6 -3
View File
@@ -10,6 +10,7 @@ interface ResolvedCredentials {
key?: Buffer;
keyPassword?: string;
authType?: string;
certPublicKey?: string;
}
interface HostConfig {
@@ -76,11 +77,13 @@ export class SSHAuthManager {
resolvedCredentials = {
username: (cred.username as string) || hostConfig.username,
password: (cred.password as string) || undefined,
key: cred.privateKey
? Buffer.from(cred.privateKey as string)
: undefined,
key:
cred.key || cred.privateKey
? Buffer.from((cred.key || cred.privateKey) as string)
: undefined,
keyPassword: (cred.keyPassword as string) || undefined,
authType: (cred.authType as string) || "none",
certPublicKey: (cred.certPublicKey as string) || undefined,
};
}
} else {
+9 -34
View File
@@ -24,39 +24,6 @@ const activeSessions = new Map<string, SSHSession>();
const wss = new WebSocketServer({
host: "0.0.0.0",
port: 30009,
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 authManager = AuthManager.getInstance();
const decoded = await authManager.verifyJWTToken(token);
if (!decoded || !decoded.userId) {
return false;
}
return true;
} catch {
return false;
}
},
});
async function detectShell(
@@ -173,7 +140,9 @@ async function createJumpHostChain(
const credential = credentials[0];
resolvedCredentials = {
password: credential.password as string | undefined,
sshKey: credential.privateKey as string | undefined,
sshKey: (credential.key || credential.privateKey) as
| string
| undefined,
keyPassword: credential.keyPassword as string | undefined,
authType: credential.authType as string | undefined,
};
@@ -257,6 +226,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;
+37 -9
View File
@@ -139,10 +139,7 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> {
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,
);
@@ -152,8 +149,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()
@@ -161,11 +187,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) {
@@ -173,7 +199,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,
@@ -714,7 +740,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password as string | undefined,
sshKey: credential.privateKey as string | undefined,
sshKey: (credential.key || credential.privateKey) as
| string
| undefined,
keyPassword: credential.keyPassword as string | undefined,
authType: credential.authType as string | undefined,
};
@@ -2970,7 +2998,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
session.activeOperations++;
try {
let command = `docker logs ${containerId}`;
let command = `docker logs ${containerId} 2>&1`;
if (tail && tail > 0) {
command += ` --tail ${Math.floor(tail)}`;
+199 -13
View File
@@ -151,10 +151,7 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> {
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,
);
@@ -164,8 +161,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()
@@ -173,11 +199,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) {
@@ -185,7 +211,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,
@@ -428,21 +454,33 @@ function execWithSudo(
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
stdout: result.stdout.toString("utf8"),
stderr: result.stderr,
code: result.code,
}));
}
function execWithSudoBuffer(
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: Buffer; stderr: string; code: number }> {
return new Promise((resolve) => {
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
resolve({ stdout: "", stderr: err.message, code: 1 });
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
let stdout = "";
const stdoutChunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
@@ -450,12 +488,22 @@ function execWithSudo(
});
stream.on("close", (code: number) => {
stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, "");
let stdout = Buffer.concat(stdoutChunks);
const sudoPromptMatch = stdout
.toString("utf8", 0, Math.min(stdout.length, 256))
.match(/^\[sudo\] password for .+?:\s*/);
if (sudoPromptMatch) {
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
}
resolve({ stdout, stderr, code: code || 0 });
});
stream.on("error", (streamErr: Error) => {
resolve({ stdout, stderr: streamErr.message, code: 1 });
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr: streamErr.message,
code: 1,
});
});
});
});
@@ -862,7 +910,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
);
// Resolve credentials server-side when frontend doesn't provide them
let resolvedCredentials = { password, sshKey, keyPassword, authType };
let resolvedCredentials = {
password,
sshKey,
keyPassword,
authType,
sudoPassword: undefined as string | undefined,
};
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById } = await import("./host-resolver.js");
@@ -873,6 +927,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
sshKey: resolvedHost.key,
keyPassword: resolvedHost.keyPassword,
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
connectionLogs.push(
createConnectionLog(
@@ -900,6 +955,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
sshKey: resolvedHost.key,
keyPassword: resolvedHost.keyPassword,
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
connectionLogs.push(
createConnectionLog(
@@ -1194,6 +1250,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
sudoPassword: resolvedCredentials.sudoPassword,
};
scheduleSessionCleanup(sessionId);
res.json({
@@ -3058,6 +3115,42 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied = errorData
.toLowerCase()
.includes("permission denied");
if (isPermissionDenied && sshConn.sudoPassword) {
execWithSudoBuffer(
sshConn,
`cat '${escapedPath}'`,
sshConn.sudoPassword,
)
.then((result) => {
if (result.code !== 0) {
return res.status(403).json({
error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
needsSudo: true,
});
}
const sudoData = result.stdout;
const isBinary = detectBinary(sudoData);
res.json({
content: isBinary
? sudoData.toString("base64")
: sudoData.toString("utf8"),
isBinary,
size: sudoData.length,
});
})
.catch(() => {
res
.status(403)
.json({ error: "Permission denied", needsSudo: true });
});
return;
}
fileLogger.error(
`SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
@@ -3490,12 +3583,47 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
}
});
} else {
const isPermDenied = errorData
.toLowerCase()
.includes("permission denied");
if (isPermDenied && sshConn.sudoPassword) {
execWithSudo(
sshConn,
`bash -c "echo '${base64Content}' | base64 -d > '${escapedPath}' && echo SUCCESS"`,
sshConn.sudoPassword,
)
.then(({ stdout, code: sudoCode }) => {
if (sudoCode === 0 && stdout.includes("SUCCESS")) {
restoreOriginalMode(null, () => {
if (!res.headersSent) {
res.json({
message: "File written successfully",
path: filePath,
});
}
});
} else if (!res.headersSent) {
res
.status(403)
.json({ error: "Permission denied", needsSudo: true });
}
})
.catch(() => {
if (!res.headersSent) {
res
.status(403)
.json({ error: "Permission denied", needsSudo: true });
}
});
return;
}
fileLogger.error(
`Fallback write failed with code ${code}: ${errorData}`,
);
if (!res.headersSent) {
res.status(500).json({
error: `Write failed: ${errorData}`,
needsSudo: isPermDenied,
toast: { type: "error", message: `Write failed: ${errorData}` },
});
}
@@ -4870,6 +4998,64 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
});
});
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !filePath) {
return res.status(400).json({ error: "Missing download parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
try {
const sftp = await getSessionSftp(sshConn);
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
(resolve, reject) => {
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
},
);
if (!stats.isFile()) {
return res.status(400).json({ error: "Cannot download directories" });
}
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
res.setHeader("Content-Length", String(stats.size));
const readStream = sftp.createReadStream(filePath);
readStream.on("error", (err) => {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
} else {
res.destroy();
}
});
readStream.pipe(res);
} catch (err) {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${(err as Error).message}` });
}
}
});
/**
* @openapi
* /ssh/file_manager/ssh/copyItem:
+54 -3
View File
@@ -75,8 +75,55 @@ export async function resolveHostById(
if (host.credentialId) {
const ownerId = (host.userId || userId) as string;
try {
// Try shared credential first for non-owner users
// Try user's own override credential first
if (userId !== ownerId) {
try {
const { hostAccess } = await import("../database/db/schema.js");
const accessRecords = await db
.select()
.from(hostAccess)
.where(
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
)
.limit(1);
const overrideCredId = accessRecords[0]?.overrideCredentialId as
| number
| null;
if (overrideCredId) {
const userCreds = await SimpleDBOps.select(
db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, overrideCredId),
eq(sshCredentials.userId, userId),
),
),
"ssh_credentials",
userId,
);
if (userCreds.length > 0) {
const cred = userCreds[0] as Record<string, unknown>;
host.password = cred.password;
host.key = cred.key;
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
if (!host.overrideCredentialUsername) {
host.username = cred.username;
}
host.authType = cred.key
? "key"
: cred.password
? "password"
: "none";
return host as unknown as SSHHost;
}
}
} catch {
// fall through to shared credential
}
try {
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
@@ -126,13 +173,17 @@ export async function resolveHostById(
if (credentials.length > 0) {
const cred = credentials[0] as Record<string, unknown>;
host.password = cred.password;
host.key = cred.key;
// Prefer the normalised private key; fall back to raw key field
host.key = (cred.privateKey || cred.key) as string | null;
host.keyPassword = cred.keyPassword;
host.keyType = cred.keyType;
// CA-signed certificate for cert-based auth
(host as Record<string, unknown>).certPublicKey =
cred.certPublicKey || null;
if (!host.overrideCredentialUsername) {
host.username = cred.username;
}
host.authType = cred.key ? "key" : cred.password ? "password" : "none";
host.authType = host.key ? "key" : host.password ? "password" : "none";
}
} catch (e) {
sshLogger.warn("Failed to resolve credential for host", {
+94 -24
View File
@@ -1,8 +1,11 @@
// OPKSSH certificate authentication workarounds for ssh2.
// SSH certificate authentication workarounds for ssh2.
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
// the certificate onto the parsed key, wraps ECDSA signing to convert
// DER → SSH wire format, and patches Protocol.authPK to use the base
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
//
// setupOPKSSHCertAuth: for OPKSSH-issued ephemeral certificates (no passphrase)
// setupCACertAuth: for user-managed CA-signed -cert.pub files (passphrase supported)
import type {
AnyAuthMethod,
@@ -62,44 +65,39 @@ type OPKSSHNextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
export async function setupOPKSSHCertAuth(
// ── Internal implementation ──────────────────────────────────────────────────
// Grafts an OpenSSH certificate onto an already-parsed private key object and
// patches the ssh2 client so that certificate-based publickey auth succeeds.
async function _applyCertToConnection(
config: ConnectConfig,
client: Client,
token: OPKSSHToken,
username: string,
privKey: ParsedPrivateKey,
certStr: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
const parsed = parseKey(Buffer.from(token.privateKey));
if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key");
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
// Extract cert type and blob from the stored certificate
const certParts = token.sshCert.trim().split(/\s+/);
const certParts = certStr.trim().split(/\s+/);
if (certParts.length < 2) {
throw new Error(
"Invalid certificate format: expected '<type> <base64>' string",
);
}
const certType = certParts[0];
privKey.type = certType;
const certBlob = Buffer.from(certParts[1], "base64");
// Replace the internal public SSH blob with the full certificate
// Graft cert type and blob onto the parsed private key
privKey.type = certType;
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
(s) => String(s) === "Symbol(Public key SSH)",
);
if (!pubSSHSym) {
throw new Error(
"Cannot find public SSH symbol on parsed key, ssh2 internals may have changed",
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
);
}
privKey[pubSSHSym] = certBlob;
// Wrap sign() for ECDSA cert keys
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
if (privKey.type.startsWith("ecdsa-")) {
const origSign = privKey.sign.bind(privKey);
privKey.sign = (data: Buffer, algo?: string) => {
@@ -145,7 +143,7 @@ export async function setupOPKSSHCertAuth(
certAuthAttempted = true;
next({
type: "publickey",
username,
username: (config as Record<string, unknown>).username as string,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else {
@@ -296,3 +294,75 @@ export async function setupOPKSSHCertAuth(
return connectedClient;
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Set up OPKSSH certificate authentication on an ssh2 Client.
* The OPKSSH private key is assumed to be unencrypted (no passphrase).
*/
export async function setupOPKSSHCertAuth(
config: ConnectConfig,
client: Client,
token: OPKSSHToken,
username: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const parsed = parseKey(Buffer.from(token.privateKey));
if (parsed instanceof Error || !parsed) {
throw new Error("Failed to parse OPKSSH private key");
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, token.sshCert);
}
/**
* Set up CA-signed certificate authentication on an ssh2 Client.
* Supports passphrase-protected private keys.
* The cert content is the full text of the -cert.pub file
* (e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...").
*/
export async function setupCACertAuth(
config: ConnectConfig,
client: Client,
privateKey: Buffer | string,
certPublicKey: string,
username: string,
passphrase?: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
// Store username in config so the authHandler can access it
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(privateKey)
? privateKey
: Buffer.from(privateKey, "utf8");
const parsed = passphrase ? parseKey(keyBuf, passphrase) : parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const errMsg = parsed instanceof Error ? parsed.message : "unknown error";
throw new Error(`Failed to parse private key for CA cert auth: ${errMsg}`);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await _applyCertToConnection(config, client, privKey, certPublicKey);
}
+33 -7
View File
@@ -75,10 +75,7 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> {
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,
);
@@ -88,8 +85,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()
@@ -97,11 +123,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) {
@@ -109,7 +135,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,
+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(
+30 -1
View File
@@ -103,6 +103,7 @@ const TMUX_OPTS =
`set -gq mouse on` +
` \\; set -gq history-limit 50000` +
` \\; set -gq set-clipboard on` +
` \\; set -gq aggressive-resize on` +
` \\; set -gq mode-keys vi` +
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
@@ -110,6 +111,32 @@ const TMUX_OPTS =
` 'if -F "#{pane_in_mode}"` +
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
/**
* Wait for a tmux session to appear by polling via exec channel.
* Returns the session name once found, or null on timeout.
*/
export async function waitForTmuxSession(
conn: Client,
sessionName: string,
timeoutMs = 5000,
intervalMs = 100,
): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await execCommand(
conn,
`tmux has-session -t ${shellEscape(sessionName)} 2>/dev/null`,
);
return sessionName;
} catch {
// session not ready yet
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
return null;
}
/**
* Write tmux attach or new-session command to the interactive shell stream.
* Uses && exit so the shell only closes if tmux started successfully.
@@ -117,12 +144,14 @@ const TMUX_OPTS =
export function attachOrCreateTmuxSession(
stream: ClientChannel,
existingSessionName?: string,
newSessionName?: string,
): void {
let command: string;
if (existingSessionName) {
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
} else {
command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`;
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
command = `tmux ${TMUX_OPTS} \\; new-session${nameFlag} && exit\r`;
}
sshLogger.info("Writing tmux command to shell", {
+19 -4
View File
@@ -1674,7 +1674,9 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password as string | undefined,
sshKey: credential.privateKey as string | undefined,
sshKey: (credential.key || credential.privateKey) as
| string
| undefined,
keyPassword: credential.keyPassword as string | undefined,
keyType: credential.keyType as string | undefined,
authMethod: credential.authType as string,
@@ -2695,9 +2697,22 @@ app.post(
res.json({ message: "Connection request received", tunnelName });
operation.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
operation
.catch((err) => {
tunnelLogger.error("Tunnel operation failed", err, {
operation: "tunnel_operation_failed",
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: err instanceof Error ? err.message : "Unknown error",
});
tunnelConnecting.delete(tunnelName);
})
.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
} catch (error) {
tunnelLogger.error("Failed to process tunnel connect", error, {
operation: "tunnel_connect",