fix: Several bug fixes for terminals, server stats, and general feature improvements

This commit is contained in:
LukeGus
2025-11-08 15:23:14 -06:00
parent c69d31062e
commit b43e98073f
16 changed files with 445 additions and 209 deletions

View File

@@ -456,6 +456,11 @@ const migrateSchema = () => {
"credential_id",
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists(
"ssh_data",
"override_credential_username",
"INTEGER",
);
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");

View File

@@ -72,6 +72,9 @@ export const sshData = sqliteTable("ssh_data", {
autostartKeyPassword: text("autostart_key_password"),
credentialId: integer("credential_id").references(() => sshCredentials.id),
overrideCredentialUsername: integer("override_credential_username", {
mode: "boolean",
}),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),

View File

@@ -8,6 +8,7 @@ import {
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
recentActivity,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
import type { Request, Response } from "express";
@@ -225,6 +226,7 @@ router.post(
authMethod,
authType,
credentialId,
overrideCredentialUsername,
key,
keyPassword,
keyType,
@@ -264,6 +266,7 @@ router.post(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -323,6 +326,7 @@ router.post(
: []
: [],
pin: !!createdHost.pin,
overrideCredentialUsername: !!createdHost.overrideCredentialUsername,
enableTerminal: !!createdHost.enableTerminal,
enableTunnel: !!createdHost.enableTunnel,
tunnelConnections: createdHost.tunnelConnections
@@ -349,6 +353,27 @@ router.post(
},
);
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_create",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost);
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -415,6 +440,7 @@ router.put(
authMethod,
authType,
credentialId,
overrideCredentialUsername,
key,
keyPassword,
keyType,
@@ -455,6 +481,7 @@ router.put(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -532,6 +559,7 @@ router.put(
: []
: [],
pin: !!updatedHost.pin,
overrideCredentialUsername: !!updatedHost.overrideCredentialUsername,
enableTerminal: !!updatedHost.enableTerminal,
enableTunnel: !!updatedHost.enableTunnel,
tunnelConnections: updatedHost.tunnelConnections
@@ -558,6 +586,27 @@ router.put(
},
);
try {
const fetch = (await import("node-fetch")).default;
const token =
req.cookies?.jwt || req.headers.authorization?.replace("Bearer ", "");
await fetch("http://localhost:30005/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token && { Cookie: `jwt=${token}` }),
},
});
} catch (refreshError) {
sshLogger.warn("Failed to refresh server stats polling", {
operation: "stats_refresh_after_update",
error:
refreshError instanceof Error
? refreshError.message
: "Unknown error",
});
}
res.json(resolvedHost);
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
@@ -585,6 +634,18 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
});
return res.status(400).json({ error: "Invalid userId" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch", {
operation: "host_fetch",
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const data = await SimpleDBOps.select(
db.select().from(sshData).where(eq(sshData.userId, userId)),
@@ -603,6 +664,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
: []
: [],
pin: !!row.pin,
overrideCredentialUsername: !!row.overrideCredentialUsername,
enableTerminal: !!row.enableTerminal,
enableTunnel: !!row.enableTunnel,
tunnelConnections: row.tunnelConnections
@@ -649,6 +711,19 @@ router.get(
});
return res.status(400).json({ error: "Invalid userId or hostId" });
}
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
sshLogger.warn("User data not unlocked for SSH host fetch by ID", {
operation: "host_fetch_by_id",
hostId: parseInt(hostId),
userId,
});
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
const data = await db
.select()
@@ -674,6 +749,7 @@ router.get(
: []
: [],
pin: !!host.pin,
overrideCredentialUsername: !!host.overrideCredentialUsername,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
@@ -848,6 +924,15 @@ router.delete(
),
);
await db
.delete(recentActivity)
.where(
and(
eq(recentActivity.userId, userId),
eq(recentActivity.hostId, numericHostId),
),
);
await db
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
@@ -1267,7 +1352,9 @@ async function resolveHostCredentials(
const credential = credentials[0];
return {
...host,
username: credential.username,
username: host.overrideCredentialUsername
? host.username
: credential.username,
authType: credential.auth_type || credential.authType,
password: credential.password,
key: credential.key,
@@ -1446,8 +1533,10 @@ router.post(
username: hostData.username,
password: hostData.authType === "password" ? hostData.password : null,
authType: hostData.authType,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
credentialId: hostData.credentialId || null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
hostData.authType === "key"

View File

@@ -226,6 +226,16 @@ router.post("/create", async (req, res) => {
});
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist user to disk", saveError, {
operation: "user_create_save_failed",
userId: id,
});
}
authLogger.success(
`Traditional user created: ${username} (is_admin: ${isFirstUser})`,
{
@@ -785,6 +795,16 @@ router.get("/oidc/callback", async (req, res) => {
});
}
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist OIDC user to disk", saveError, {
operation: "oidc_user_create_save_failed",
userId: id,
});
}
user = await db.select().from(users).where(eq(users.id, id));
} else {
await db
@@ -836,6 +856,9 @@ router.get("/oidc/callback", async (req, res) => {
? 30 * 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000;
// Clear any existing JWT cookie first to prevent conflicts
res.clearCookie("jwt", authManager.getSecureCookieOptions(req));
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
.redirect(redirectUrl.toString());
@@ -1653,6 +1676,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
.set({ is_admin: true })
.where(eq(users.username, username));
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist admin promotion to disk", saveError, {
operation: "make_admin_save_failed",
username,
});
}
authLogger.success(
`User ${username} made admin by ${adminUser[0].username}`,
);
@@ -1702,6 +1735,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
.set({ is_admin: false })
.where(eq(users.username, username));
try {
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
} catch (saveError) {
authLogger.error("Failed to persist admin removal to disk", saveError, {
operation: "remove_admin_save_failed",
username,
});
}
authLogger.success(
`Admin status removed from ${username} by ${adminUser[0].username}`,
);

View File

@@ -845,7 +845,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
sshConn.lastActive = Date.now();
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });

View File

@@ -455,6 +455,13 @@ class PollingManager {
}
}
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id);
this.metricsStore.delete(host.id);
return;
}
const config: HostPollingConfig = {
host,
statsConfig,
@@ -514,7 +521,7 @@ class PollingManager {
} catch (error) {}
}
stopPollingForHost(hostId: number): void {
stopPollingForHost(hostId: number, clearData = true): void {
const config = this.pollingConfigs.get(hostId);
if (config) {
if (config.statusTimer) {
@@ -524,8 +531,10 @@ class PollingManager {
clearInterval(config.metricsTimer);
}
this.pollingConfigs.delete(hostId);
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
if (clearData) {
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
}
}
}
@@ -554,11 +563,23 @@ class PollingManager {
}
async refreshHostPolling(userId: string): Promise<void> {
const hosts = await fetchAllHosts(userId);
const currentHostIds = new Set(hosts.map((h) => h.id));
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
this.stopPollingForHost(hostId, false);
}
await this.initializePolling(userId);
for (const hostId of this.statusStore.keys()) {
if (!currentHostIds.has(hostId)) {
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
}
}
for (const host of hosts) {
await this.startPollingForHost(host);
}
}
destroy(): void {

View File

@@ -152,6 +152,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
let totpPromptSent = false;
let isKeyboardInteractive = false;
let keyboardInteractiveResponded = false;
let isConnecting = false;
let isConnected = false;
let isCleaningUp = false;
ws.on("close", () => {
const userWs = userConnections.get(userId);
@@ -417,10 +420,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
if (isConnecting || isConnected) {
sshLogger.warn("Connection already in progress or established", {
operation: "ssh_connect",
hostId: id,
isConnecting,
isConnected,
});
return;
}
isConnecting = true;
sshConn = new Client();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
if (sshConn && isConnecting && !isConnected) {
sshLogger.error("SSH connection timeout", undefined, {
operation: "ssh_connect",
hostId: id,
@@ -433,7 +447,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
cleanupSSH(connectionTimeout);
}
}, 60000);
}, 120000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
let authMethodNotAvailable = false;
@@ -498,7 +512,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
if (!sshConn) {
const conn = sshConn;
if (!conn || isCleaningUp) {
sshLogger.warn(
"SSH connection was cleaned up before shell could be created",
{
@@ -507,6 +523,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ip,
port,
username,
isCleaningUp,
},
);
ws.send(
@@ -519,7 +536,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
sshConn.shell(
isConnecting = false;
isConnected = true;
conn.shell(
{
rows: data.rows,
cols: data.cols,
@@ -836,9 +856,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 60000,
readyTimeout: 120000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
timeout: 120000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
@@ -982,6 +1003,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (isCleaningUp) {
return;
}
isCleaningUp = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
@@ -1019,6 +1045,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
isKeyboardInteractive = false;
keyboardInteractiveResponded = false;
keyboardInteractiveFinish = null;
isConnecting = false;
isConnected = false;
setTimeout(() => {
isCleaningUp = false;
}, 100);
}
function setupPingInterval() {