fix: Squash commit of several fixes and features for many different elements

This commit is contained in:
LukeGus
2025-10-29 18:12:44 -05:00
parent 562d8c96fd
commit ae73f9ca55
32 changed files with 3149 additions and 2057 deletions

View File

@@ -266,31 +266,44 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
keepaliveCountMax: 3,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
@@ -335,6 +348,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
.status(400)
.json({ error: "Password required for password authentication" });
}
config.password = resolvedCredentials.password;
} else if (resolvedCredentials.authType === "none") {
// Don't set password in config - rely on keyboard-interactive
} else {

View File

@@ -406,6 +406,197 @@ type StatusEntry = {
lastChecked: string;
};
interface StatsConfig {
enabledWidgets: string[];
statusCheckEnabled: boolean;
statusCheckInterval: number;
metricsEnabled: boolean;
metricsInterval: number;
}
const DEFAULT_STATS_CONFIG: StatsConfig = {
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
statusCheckEnabled: true,
statusCheckInterval: 30,
metricsEnabled: true,
metricsInterval: 30,
};
interface HostPollingConfig {
host: SSHHostWithCredentials;
statsConfig: StatsConfig;
statusTimer?: NodeJS.Timeout;
metricsTimer?: NodeJS.Timeout;
}
class PollingManager {
private pollingConfigs = new Map<number, HostPollingConfig>();
private statusStore = new Map<number, StatusEntry>();
private metricsStore = new Map<
number,
{
data: Awaited<ReturnType<typeof collectMetrics>>;
timestamp: number;
}
>();
parseStatsConfig(statsConfigStr?: string): StatsConfig {
if (!statsConfigStr) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed = JSON.parse(statsConfigStr);
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
statsLogger.warn(
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return DEFAULT_STATS_CONFIG;
}
}
async startPollingForHost(host: SSHHostWithCredentials): Promise<void> {
const statsConfig = this.parseStatsConfig(host.statsConfig);
const existingConfig = this.pollingConfigs.get(host.id);
// Clear existing timers if they exist
if (existingConfig) {
if (existingConfig.statusTimer) {
clearInterval(existingConfig.statusTimer);
}
if (existingConfig.metricsTimer) {
clearInterval(existingConfig.metricsTimer);
}
}
const config: HostPollingConfig = {
host,
statsConfig,
};
// Start status polling if enabled
if (statsConfig.statusCheckEnabled) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
// Poll immediately (don't await - let it run in background)
this.pollHostStatus(host);
// Then set up interval to poll periodically
config.statusTimer = setInterval(() => {
this.pollHostStatus(host);
}, intervalMs);
} else {
// Remove status if monitoring is disabled
this.statusStore.delete(host.id);
}
// Start metrics polling if enabled
if (statsConfig.metricsEnabled) {
const intervalMs = statsConfig.metricsInterval * 1000;
// Poll immediately (don't await - let it run in background)
this.pollHostMetrics(host);
// Then set up interval to poll periodically
config.metricsTimer = setInterval(() => {
this.pollHostMetrics(host);
}, intervalMs);
} else {
// Remove metrics if monitoring is disabled
this.metricsStore.delete(host.id);
}
this.pollingConfigs.set(host.id, config);
}
private async pollHostStatus(host: SSHHostWithCredentials): Promise<void> {
try {
const isOnline = await tcpPing(host.ip, host.port, 5000);
const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: new Date().toISOString(),
};
this.statusStore.set(host.id, statusEntry);
} catch (error) {
statsLogger.warn(
`Failed to poll status for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
const statusEntry: StatusEntry = {
status: "offline",
lastChecked: new Date().toISOString(),
};
this.statusStore.set(host.id, statusEntry);
}
}
private async pollHostMetrics(host: SSHHostWithCredentials): Promise<void> {
try {
const metrics = await collectMetrics(host);
this.metricsStore.set(host.id, {
data: metrics,
timestamp: Date.now(),
});
} catch (error) {}
}
stopPollingForHost(hostId: number): void {
const config = this.pollingConfigs.get(hostId);
if (config) {
if (config.statusTimer) {
clearInterval(config.statusTimer);
}
if (config.metricsTimer) {
clearInterval(config.metricsTimer);
}
this.pollingConfigs.delete(hostId);
this.statusStore.delete(hostId);
this.metricsStore.delete(hostId);
}
}
getStatus(hostId: number): StatusEntry | undefined {
return this.statusStore.get(hostId);
}
getAllStatuses(): Map<number, StatusEntry> {
return this.statusStore;
}
getMetrics(
hostId: number,
):
| { data: Awaited<ReturnType<typeof collectMetrics>>; timestamp: number }
| undefined {
return this.metricsStore.get(hostId);
}
async initializePolling(userId: string): Promise<void> {
const hosts = await fetchAllHosts(userId);
for (const host of hosts) {
await this.startPollingForHost(host);
}
}
async refreshHostPolling(userId: string): Promise<void> {
// Stop all current polling
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
}
// Reinitialize
await this.initializePolling(userId);
}
destroy(): void {
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
}
}
}
const pollingManager = new PollingManager();
function validateHostId(
req: express.Request,
res: express.Response,
@@ -460,8 +651,6 @@ app.use(express.json({ limit: "1mb" }));
app.use(authManager.createAuthMiddleware());
const hostStatuses: Map<number, StatusEntry> = new Map();
async function fetchAllHosts(
userId: string,
): Promise<SSHHostWithCredentials[]> {
@@ -499,11 +688,6 @@ async function fetchHostById(
): Promise<SSHHostWithCredentials | undefined> {
try {
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
statsLogger.debug("User data locked - cannot fetch host", {
operation: "fetchHostById_data_locked",
userId,
hostId: id,
});
return undefined;
}
@@ -637,31 +821,44 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig {
readyTimeout: 10_000,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
@@ -999,7 +1196,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
);
const netStatOut = await execCommand(
client,
"ip -o link show | awk '{print $2,$9}' | sed 's/:$//'",
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
);
const addrs = ifconfigOut.stdout
@@ -1234,47 +1431,6 @@ function tcpPing(
});
}
async function pollStatusesOnce(userId?: string): Promise<void> {
if (!userId) {
statsLogger.warn("Skipping status poll - no authenticated user", {
operation: "status_poll",
});
return;
}
const hosts = await fetchAllHosts(userId);
if (hosts.length === 0) {
statsLogger.warn("No hosts retrieved for status polling", {
operation: "status_poll",
userId,
});
return;
}
const checks = hosts.map(async (h) => {
const isOnline = await tcpPing(h.ip, h.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: now,
};
hostStatuses.set(h.id, statusEntry);
return isOnline;
});
const results = await Promise.allSettled(checks);
const onlineCount = results.filter(
(r) => r.status === "fulfilled" && r.value === true,
).length;
const offlineCount = hosts.length - onlineCount;
statsLogger.success("Status polling completed", {
operation: "status_poll",
totalHosts: hosts.length,
onlineCount,
offlineCount,
});
}
app.get("/status", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -1285,11 +1441,14 @@ app.get("/status", async (req, res) => {
});
}
if (hostStatuses.size === 0) {
await pollStatusesOnce(userId);
// Initialize polling if no hosts are being polled yet
const statuses = pollingManager.getAllStatuses();
if (statuses.size === 0) {
await pollingManager.initializePolling(userId);
}
const result: Record<number, StatusEntry> = {};
for (const [id, entry] of hostStatuses.entries()) {
for (const [id, entry] of pollingManager.getAllStatuses().entries()) {
result[id] = entry;
}
res.json(result);
@@ -1306,25 +1465,18 @@ app.get("/status/:id", validateHostId, async (req, res) => {
});
}
try {
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const isOnline = await tcpPing(host.ip, host.port, 5000);
const now = new Date().toISOString();
const statusEntry: StatusEntry = {
status: isOnline ? "online" : "offline",
lastChecked: now,
};
hostStatuses.set(id, statusEntry);
res.json(statusEntry);
} catch (err) {
statsLogger.error("Failed to check host status", err);
res.status(500).json({ error: "Failed to check host status" });
// Initialize polling if no hosts are being polled yet
const statuses = pollingManager.getAllStatuses();
if (statuses.size === 0) {
await pollingManager.initializePolling(userId);
}
const statusEntry = pollingManager.getStatus(id);
if (!statusEntry) {
return res.status(404).json({ error: "Status not available" });
}
res.json(statusEntry);
});
app.post("/refresh", async (req, res) => {
@@ -1337,8 +1489,8 @@ app.post("/refresh", async (req, res) => {
});
}
await pollStatusesOnce(userId);
res.json({ message: "Refreshed" });
await pollingManager.refreshHostPolling(userId);
res.json({ message: "Polling refreshed" });
});
app.get("/metrics/:id", validateHostId, async (req, res) => {
@@ -1352,121 +1504,10 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
});
}
try {
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const isOnline = await tcpPing(host.ip, host.port, 5000);
if (!isOnline) {
return res.status(503).json({
error: "Host is offline",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
},
network: { interfaces: [] },
uptime: { seconds: null, formatted: null },
processes: { total: null, running: null, top: [] },
system: { hostname: null, kernel: null, os: null },
lastChecked: new Date().toISOString(),
});
}
const metrics = await collectMetrics(host);
res.json({ ...metrics, lastChecked: new Date().toISOString() });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
// Check if this is a skip due to auth failure tracking
if (
errorMessage.includes("TOTP authentication required") ||
errorMessage.includes("metrics unavailable")
) {
// Don't log as error - this is expected for TOTP hosts
return res.status(403).json({
error: "TOTP_REQUIRED",
message: errorMessage,
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
},
network: { interfaces: [] },
uptime: { seconds: null, formatted: null },
processes: { total: null, running: null, top: [] },
system: { hostname: null, kernel: null, os: null },
lastChecked: new Date().toISOString(),
});
}
// Check if this is a skip due to too many failures or config issues
if (
errorMessage.includes("Too many authentication failures") ||
errorMessage.includes("Retry in") ||
errorMessage.includes("Invalid configuration") ||
errorMessage.includes("Authentication failed")
) {
// Don't log - return error silently to avoid spam
return res.status(429).json({
error: "UNAVAILABLE",
message: errorMessage,
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
},
network: { interfaces: [] },
uptime: { seconds: null, formatted: null },
processes: { total: null, running: null, top: [] },
system: { hostname: null, kernel: null, os: null },
lastChecked: new Date().toISOString(),
});
}
// Only log unexpected errors
if (
!errorMessage.includes("timeout") &&
!errorMessage.includes("offline") &&
!errorMessage.includes("permanently") &&
!errorMessage.includes("none") &&
!errorMessage.includes("No password")
) {
statsLogger.error("Failed to collect metrics", err);
}
if (err instanceof Error && err.message.includes("timeout")) {
return res.status(504).json({
error: "Metrics collection timeout",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
percent: null,
usedHuman: null,
totalHuman: null,
availableHuman: null,
},
network: { interfaces: [] },
uptime: { seconds: null, formatted: null },
processes: { total: null, running: null, top: [] },
system: { hostname: null, kernel: null, os: null },
lastChecked: new Date().toISOString(),
});
}
return res.status(500).json({
error: "Failed to collect metrics",
const metricsData = pollingManager.getMetrics(id);
if (!metricsData) {
return res.status(404).json({
error: "Metrics not available",
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: {
@@ -1482,14 +1523,21 @@ app.get("/metrics/:id", validateHostId, async (req, res) => {
lastChecked: new Date().toISOString(),
});
}
res.json({
...metricsData.data,
lastChecked: new Date(metricsData.timestamp).toISOString(),
});
});
process.on("SIGINT", () => {
pollingManager.destroy();
connectionPool.destroy();
process.exit(0);
});
process.on("SIGTERM", () => {
pollingManager.destroy();
connectionPool.destroy();
process.exit(0);
});

View File

@@ -870,40 +870,44 @@ wss.on("connection", async (ws: WebSocket, req) => {
},
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-rsa",
"rsa-sha2-256",
"rsa-sha2-512",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],
@@ -913,6 +917,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (resolvedCredentials.authType === "none") {
// Don't set password in config - rely on keyboard-interactive
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
"Password authentication requested but no password provided",
);
ws.send(
JSON.stringify({
type: "error",
message:
"Password authentication requested but no password provided",
}),
);
return;
}
connectConfig.password = resolvedCredentials.password;
} else if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.key
@@ -954,20 +973,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
return;
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
"Password authentication requested but no password provided",
);
ws.send(
JSON.stringify({
type: "error",
message:
"Password authentication requested but no password provided",
}),
);
return;
}
} else {
sshLogger.error("No valid authentication method provided");
ws.send(

View File

@@ -914,31 +914,44 @@ async function connectSSHTunnel(
tcpKeepAliveInitialDelay: 15000,
algorithms: {
kex: [
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp521",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp256",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group-exchange-sha1",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group1-sha1",
],
serverHostKey: [
"ssh-ed25519",
"ecdsa-sha2-nistp521",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp256",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ssh-dss",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes192-cbc",
"aes128-cbc",
"3des-cbc",
],
hmac: [
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256",
"hmac-sha2-256-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-md5",
],