feat: host manager bug fixes, i18n improvements, general tab fixes

This commit is contained in:
LukeGus
2026-05-20 13:13:56 -05:00
parent a966cc930b
commit d3fa8c4420
18 changed files with 998 additions and 2739 deletions
+10 -2
View File
@@ -671,6 +671,8 @@ router.post(
authType ||
authMethod ||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
const sshDataObj: Record<string, unknown> = {
userId: userId,
connectionType: effectiveConnectionType,
@@ -679,7 +681,7 @@ router.post(
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
ip,
port,
username,
username: effectiveUsername,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
@@ -1203,6 +1205,8 @@ router.put(
}
const effectiveAuthType = authType || authMethod;
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
const sshDataObj: Record<string, unknown> = {
connectionType: connectionType || "ssh",
name,
@@ -1210,7 +1214,7 @@ router.put(
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
ip,
port,
username,
username: effectiveUsername,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
@@ -3817,6 +3821,10 @@ router.post(
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
enableSsh: hostData.enableSsh ?? false,
enableRdp: hostData.enableRdp ?? false,
enableVnc: hostData.enableVnc ?? false,
enableTelnet: hostData.enableTelnet ?? false,
updatedAt: new Date().toISOString(),
};
+48 -52
View File
@@ -1,5 +1,4 @@
import express from "express";
import express from "express";
import { GuacamoleTokenService } from "./token-service.js";
import { guacLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
@@ -197,7 +196,8 @@ router.post(
if (guacConfig.dpi != null) {
const parsed = parseInt(String(guacConfig.dpi), 10);
guacConfig.dpi = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
guacConfig.dpi =
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
let token: string;
@@ -222,61 +222,57 @@ router.post(
if (jumpHosts.length > 0) {
try {
const { resolveHostById } = await import(
"../ssh/host-resolver.js"
);
const jumpHost = await resolveHostById(
jumpHosts[0].hostId,
userId,
);
const { resolveHostById } = await import("../ssh/host-resolver.js");
const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId);
if (jumpHost) {
const tunnelPort = await new Promise<number>(
(resolve, reject) => {
const sshClient = new Client();
sshClient.on("ready", () => {
const server = net.createServer((sock) => {
sshClient.forwardOut(
"127.0.0.1",
0,
hostname,
port,
(err, stream) => {
if (err) {
sock.destroy();
return;
}
sock.pipe(stream).pipe(sock);
},
);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour
setTimeout(() => {
const tunnelPort = await new Promise<number>((resolve, reject) => {
const sshClient = new Client();
sshClient.on("ready", () => {
const server = net.createServer((sock) => {
sshClient.forwardOut(
"127.0.0.1",
0,
hostname,
port,
(err, stream) => {
if (err) {
sock.destroy();
return;
}
sock.pipe(stream).pipe(sock);
},
);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as net.AddressInfo;
// Auto-cleanup after 1 hour
setTimeout(
() => {
server.close();
sshClient.end();
}, 60 * 60 * 1000);
resolve(addr.port);
});
},
60 * 60 * 1000,
);
resolve(addr.port);
});
sshClient.on("error", reject);
});
sshClient.on("error", reject);
const connectOpts: Record<string, unknown> = {
host: jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
readyTimeout: 30000,
};
if (jumpHost.key) {
connectOpts.privateKey = jumpHost.key;
if (jumpHost.keyPassword)
connectOpts.passphrase = jumpHost.keyPassword;
} else if (jumpHost.password) {
connectOpts.password = jumpHost.password;
}
sshClient.connect(connectOpts);
},
);
const connectOpts: Record<string, unknown> = {
host: jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
readyTimeout: 30000,
};
if (jumpHost.key) {
connectOpts.privateKey = jumpHost.key;
if (jumpHost.keyPassword)
connectOpts.passphrase = jumpHost.keyPassword;
} else if (jumpHost.password) {
connectOpts.password = jumpHost.password;
}
sshClient.connect(connectOpts);
});
hostname = "127.0.0.1";
port = tunnelPort;
guacLogger.info("SSH tunnel established for guacamole", {
+70 -59
View File
@@ -151,10 +151,7 @@ async function resolveJumpHost(
): Promise<JumpHostConfig | null> {
try {
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(hosts)
.where(eq(hosts.id, hostId)),
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
@@ -172,8 +169,10 @@ async function resolveJumpHost(
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred =
await sharedCredManager.getSharedCredentialForUser(hostId, userId);
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
hostId,
userId,
);
if (sharedCred) {
return {
...host,
@@ -889,7 +888,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, sudoPassword: undefined as string | undefined };
let resolvedCredentials = {
password,
sshKey,
keyPassword,
authType,
sudoPassword: undefined as string | undefined,
};
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById } = await import("./host-resolver.js");
@@ -3088,47 +3093,36 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied =
errorData.toLowerCase().includes("permission denied");
const isPermissionDenied = errorData
.toLowerCase()
.includes("permission denied");
if (isPermissionDenied && sshConn.sudoPassword) {
execWithSudo(
sshConn,
`cat '${escapedPath}'`,
sshConn.sudoPassword,
(sudoErr, sudoStream) => {
if (sudoErr) {
)
.then(({ stdout, stderr, code: sudoCode }) => {
if (sudoCode !== 0) {
return res.status(403).json({
error: "Permission denied",
error: `Permission denied: ${stderr}`,
needsSudo: true,
});
}
let sudoData = Buffer.alloc(0);
let sudoErrorData = "";
sudoStream.on("data", (chunk: Buffer) => {
sudoData = Buffer.concat([sudoData, chunk]);
const sudoData = Buffer.from(stdout, "utf8");
const isBinary = detectBinary(sudoData);
res.json({
content: isBinary ? sudoData.toString("base64") : stdout,
isBinary,
size: sudoData.length,
});
sudoStream.stderr.on("data", (chunk: Buffer) => {
sudoErrorData += chunk.toString();
});
sudoStream.on("close", (sudoCode) => {
if (sudoCode !== 0) {
return res.status(403).json({
error: `Permission denied: ${sudoErrorData}`,
needsSudo: true,
});
}
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;
}
@@ -3564,30 +3558,38 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
}
});
} else {
const isPermDenied = errorData.toLowerCase().includes("permission denied");
const isPermDenied = errorData
.toLowerCase()
.includes("permission denied");
if (isPermDenied && sshConn.sudoPassword) {
const sudoWriteCmd = `echo '${base64Content}' | base64 -d | sudo tee '${escapedPath}' > /dev/null && echo "SUCCESS"`;
execWithSudo(sshConn, `bash -c "echo '${base64Content}' | base64 -d > '${escapedPath}' && echo SUCCESS"`, sshConn.sudoPassword, (sudoErr, sudoStream) => {
if (sudoErr) {
if (!res.headersSent) {
res.status(403).json({ error: "Permission denied", needsSudo: true });
}
return;
}
let sudoOut = "";
sudoStream.on("data", (chunk: Buffer) => { sudoOut += chunk.toString(); });
sudoStream.on("close", () => {
if (sudoOut.includes("SUCCESS")) {
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 });
res.json({
message: "File written successfully",
path: filePath,
});
}
});
} else if (!res.headersSent) {
res.status(403).json({ error: "Permission denied", needsSudo: true });
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(
@@ -4981,7 +4983,9 @@ app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH session not found or not connected" });
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" });
@@ -4991,9 +4995,11 @@ app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
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)));
});
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" });
@@ -5001,7 +5007,10 @@ app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(fileName)}"`);
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
res.setHeader("Content-Length", String(stats.size));
const readStream = sftp.createReadStream(filePath);
@@ -5015,7 +5024,9 @@ app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
readStream.pipe(res);
} catch (err) {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${(err as Error).message}` });
res
.status(500)
.json({ error: `Download failed: ${(err as Error).message}` });
}
}
});
+35 -28
View File
@@ -1,14 +1,14 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
import {
Client,
type ClientChannel,
type PseudoTtyOptions,
BaseAgent,
utils as ssh2Utils,
type ParsedKey,
type SignCallback,
type SigningRequestOptions,
type IdentityCallback,
import ssh2pkg from "ssh2";
const { Client, BaseAgent, utils: ssh2Utils } = ssh2pkg;
import type {
Client as ClientType,
ClientChannel,
PseudoTtyOptions,
ParsedKey,
SignCallback,
SigningRequestOptions,
IdentityCallback,
} from "ssh2";
import net from "net";
import dgram from "dgram";
@@ -54,8 +54,7 @@ class MemoryAgent extends BaseAgent {
cb?: SignCallback,
): void {
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
const options =
typeof optionsOrCb === "function" ? {} : optionsOrCb;
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
try {
const algo =
options.hash === "sha256"
@@ -191,10 +190,7 @@ async function resolveJumpHost(
});
try {
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(hosts)
.where(eq(hosts.id, hostId)),
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
@@ -212,8 +208,10 @@ async function resolveJumpHost(
const { SharedCredentialManager } =
await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred =
await sharedCredManager.getSharedCredentialForUser(hostId, userId);
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
hostId,
userId,
);
if (sharedCred) {
return {
...host,
@@ -275,13 +273,13 @@ async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
socks5Config?: SOCKS5Config | null,
): Promise<Client | null> {
): Promise<ClientType | null> {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
}
let currentClient: Client | null = null;
const clients: Client[] = [];
let currentClient: ClientType | null = null;
const clients: ClientType[] = [];
try {
const jumpHostConfigs = await Promise.all(
@@ -562,9 +560,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
let currentSessionId: string | null = null;
let sshConn: Client | null = null;
let sshConn: ClientType | null = null;
let sshStream: ClientChannel | null = null;
let lastJumpClient: Client | null = null;
let lastJumpClient: ClientType | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
let totpTimeout: NodeJS.Timeout | null = null;
@@ -938,7 +936,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
sessionName: tmuxName,
hostId: session.hostId,
});
ws.send(JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }));
ws.send(
JSON.stringify({ type: "tmux_detached", sessionName: tmuxName }),
);
}
break;
}
@@ -2468,7 +2468,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
hostConfig.userId;
// Cloudflare Tunnel: connect via WebSocket proxy
const cfConfig = hostConfig.terminalConfig as Record<string, unknown> | undefined;
const cfConfig = hostConfig.terminalConfig as
| Record<string, unknown>
| undefined;
if (cfConfig?.cfAccessClientId && cfConfig?.cfAccessClientSecret) {
try {
const WebSocket = (await import("ws")).default;
@@ -2484,7 +2486,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
await new Promise<void>((resolve, reject) => {
cfWs.on("open", () => resolve());
cfWs.on("error", (err) => reject(err));
setTimeout(() => reject(new Error("Cloudflare tunnel timeout")), 30000);
setTimeout(
() => reject(new Error("Cloudflare tunnel timeout")),
30000,
);
});
const { Duplex } = await import("stream");
@@ -2497,7 +2502,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
cfWs.on("message", (data) => duplexStream.push(data));
cfWs.on("close", () => duplexStream.push(null));
connectConfig.sock = duplexStream as unknown as typeof connectConfig.sock;
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, {
@@ -2507,7 +2513,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({
type: "error",
message: "Cloudflare tunnel connection failed: " +
message:
"Cloudflare tunnel connection failed: " +
(cfError instanceof Error ? cfError.message : "Unknown error"),
}),
);