fix: Several bug fixes for terminals, server stats, and general feature improvements
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user