diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 2a496ea8..5522b67d 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -15,6 +15,10 @@ const authManager = AuthManager.getInstance(); // Track server start time const serverStartTime = Date.now(); +// In-memory rate limiter for activity logging +const activityRateLimiter = new Map(); +const RATE_LIMIT_MS = 1000; // 1 second window + app.use( cors({ origin: (origin, callback) => { @@ -134,6 +138,32 @@ app.post("/activity/log", async (req, res) => { }); } + // In-memory rate limiting to prevent duplicate requests + const rateLimitKey = `${userId}:${hostId}:${type}`; + const now = Date.now(); + const lastLogged = activityRateLimiter.get(rateLimitKey); + + if (lastLogged && now - lastLogged < RATE_LIMIT_MS) { + // Too soon after last request, reject as duplicate + return res.json({ + message: "Activity already logged recently (rate limited)", + }); + } + + // Update rate limiter + activityRateLimiter.set(rateLimitKey, now); + + // Clean up old entries from rate limiter (keep it from growing indefinitely) + if (activityRateLimiter.size > 10000) { + const entriesToDelete: string[] = []; + for (const [key, timestamp] of activityRateLimiter.entries()) { + if (now - timestamp > RATE_LIMIT_MS * 2) { + entriesToDelete.push(key); + } + } + entriesToDelete.forEach((key) => activityRateLimiter.delete(key)); + } + // Verify the host belongs to the user const hosts = await SimpleDBOps.select( getDb() @@ -148,36 +178,6 @@ app.post("/activity/log", async (req, res) => { return res.status(404).json({ error: "Host not found" }); } - // Check if this activity already exists in recent history (within last 5 minutes) - const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString(); - const recentSimilar = await SimpleDBOps.select( - getDb() - .select() - .from(recentActivity) - .where( - and( - eq(recentActivity.userId, userId), - eq(recentActivity.hostId, hostId), - eq(recentActivity.type, type), - ), - ) - .orderBy(desc(recentActivity.timestamp)) - .limit(1), - "recent_activity", - userId, - ); - - if ( - recentSimilar.length > 0 && - recentSimilar[0].timestamp >= fiveMinutesAgo - ) { - // Activity already logged recently, don't duplicate - return res.json({ - message: "Activity already logged recently", - id: recentSimilar[0].id, - }); - } - // Insert new activity const result = (await SimpleDBOps.insert( recentActivity, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 1ea74cc9..13003bf8 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -415,63 +415,75 @@ router.get("/oidc-config", async (req, res) => { let config = JSON.parse((row as Record).value as string); - if (config.client_secret) { - if (config.client_secret.startsWith("encrypted:")) { - const authHeader = req.headers["authorization"]; - if (authHeader?.startsWith("Bearer ")) { - const token = authHeader.split(" ")[1]; - const authManager = AuthManager.getInstance(); - const payload = await authManager.verifyJWTToken(token); + // Check if user is authenticated admin + let isAuthenticatedAdmin = false; + const authHeader = req.headers["authorization"]; + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.split(" ")[1]; + const authManager = AuthManager.getInstance(); + const payload = await authManager.verifyJWTToken(token); - if (payload) { - const userId = payload.userId; - const user = await db - .select() - .from(users) - .where(eq(users.id, userId)); + if (payload) { + const userId = payload.userId; + const user = await db.select().from(users).where(eq(users.id, userId)); - if (user && user.length > 0 && user[0].is_admin) { - try { - const adminDataKey = DataCrypto.getUserDataKey(userId); - if (adminDataKey) { - config = DataCrypto.decryptRecord( - "settings", - config, - userId, - adminDataKey, - ); - } else { - config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]"; - } - } catch { - authLogger.warn("Failed to decrypt OIDC config for admin", { - operation: "oidc_config_decrypt_failed", + if (user && user.length > 0 && user[0].is_admin) { + isAuthenticatedAdmin = true; + + // Only decrypt for authenticated admins + if (config.client_secret?.startsWith("encrypted:")) { + try { + const adminDataKey = DataCrypto.getUserDataKey(userId); + if (adminDataKey) { + config = DataCrypto.decryptRecord( + "settings", + config, userId, - }); - config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]"; + adminDataKey, + ); + } else { + config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]"; } - } else { - config.client_secret = "[ENCRYPTED - ADMIN ONLY]"; + } catch { + authLogger.warn("Failed to decrypt OIDC config for admin", { + operation: "oidc_config_decrypt_failed", + userId, + }); + config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]"; + } + } else if (config.client_secret?.startsWith("encoded:")) { + // Decode for authenticated admins only + try { + const decoded = Buffer.from( + config.client_secret.substring(8), + "base64", + ).toString("utf8"); + config.client_secret = decoded; + } catch { + config.client_secret = "[ENCODING ERROR]"; } - } else { - config.client_secret = "[ENCRYPTED - AUTH REQUIRED]"; } - } else { - config.client_secret = "[ENCRYPTED - AUTH REQUIRED]"; - } - } else if (config.client_secret.startsWith("encoded:")) { - try { - const decoded = Buffer.from( - config.client_secret.substring(8), - "base64", - ).toString("utf8"); - config.client_secret = decoded; - } catch { - config.client_secret = "[ENCODING ERROR]"; } } } + // For non-admin users, hide sensitive fields + if (!isAuthenticatedAdmin) { + // Remove all sensitive fields for public access + delete config.client_secret; + delete config.id; + + // Only return public fields needed for login page + const publicConfig = { + client_id: config.client_id, + issuer_url: config.issuer_url, + authorization_url: config.authorization_url, + scopes: config.scopes, + }; + + return res.json(publicConfig); + } + res.json(config); } catch (err) { authLogger.error("Failed to get OIDC config", err); @@ -940,7 +952,7 @@ router.post("/login", async (req, res) => { } const token = await authManager.generateJWTToken(userRecord.id, { - expiresIn: "24h", + expiresIn: "7d", }); authLogger.success(`User logged in successfully: ${username}`, { @@ -968,7 +980,7 @@ router.post("/login", async (req, res) => { .cookie( "jwt", token, - authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000), + authManager.getSecureCookieOptions(req, 7 * 24 * 60 * 60 * 1000), ) .json(response); } catch (err) { @@ -1386,9 +1398,27 @@ router.post("/complete-reset", async (req, res) => { .where(eq(users.username, username)); try { + // Delete all encrypted data since we're creating a new DEK + // The old DEK is lost, so old encrypted data becomes unreadable + await db.delete(sshData).where(eq(sshData.userId, userId)); + await db + .delete(fileManagerRecent) + .where(eq(fileManagerRecent.userId, userId)); + await db + .delete(fileManagerPinned) + .where(eq(fileManagerPinned.userId, userId)); + await db + .delete(fileManagerShortcuts) + .where(eq(fileManagerShortcuts.userId, userId)); + await db + .delete(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + // Now setup new encryption with new DEK await authManager.registerUser(userId, newPassword); authManager.logoutUser(userId); + // Clear TOTP settings await db .update(users) .set({ @@ -1399,16 +1429,16 @@ router.post("/complete-reset", async (req, res) => { .where(eq(users.id, userId)); authLogger.warn( - `Password reset completed for user: ${username}. Existing encrypted data is now inaccessible and will need to be re-entered.`, + `Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`, { - operation: "password_reset_data_inaccessible", + operation: "password_reset_data_deleted", userId, username, }, ); } catch (encryptionError) { authLogger.error( - "Failed to re-encrypt user data after password reset", + "Failed to setup user data encryption after password reset", encryptionError, { operation: "password_reset_encryption_failed", @@ -1417,8 +1447,7 @@ router.post("/complete-reset", async (req, res) => { }, ); return res.status(500).json({ - error: - "Password reset completed but user data encryption failed. Please contact administrator.", + error: "Password reset failed. Please contact administrator.", }); } diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 42b80cfb..64f9faa8 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -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 { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 8bedc87e..96b6cb8b 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -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(); + private statusStore = new Map(); + private metricsStore = new Map< + number, + { + data: Awaited>; + 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 { + 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 { + 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 { + 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 { + return this.statusStore; + } + + getMetrics( + hostId: number, + ): + | { data: Awaited>; timestamp: number } + | undefined { + return this.metricsStore.get(hostId); + } + + async initializePolling(userId: string): Promise { + const hosts = await fetchAllHosts(userId); + + for (const host of hosts) { + await this.startPollingForHost(host); + } + } + + async refreshHostPolling(userId: string): Promise { + // 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 = new Map(); - async function fetchAllHosts( userId: string, ): Promise { @@ -499,11 +688,6 @@ async function fetchHostById( ): Promise { 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 { - 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 = {}; - 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); }); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 67a71fac..145c484e 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -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( diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index afb6f095..72629b6d 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -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", ], diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 5a1b3263..e4ffe714 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -142,7 +142,7 @@ class AuthManager { } return jwt.sign(payload, jwtSecret, { - expiresIn: options.expiresIn || "24h", + expiresIn: options.expiresIn || "7d", } as jwt.SignOptions); } @@ -177,7 +177,7 @@ class AuthManager { getSecureCookieOptions( req: RequestWithHeaders, - maxAge: number = 24 * 60 * 60 * 1000, + maxAge: number = 7 * 24 * 60 * 60 * 1000, ) { return { httpOnly: false, diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index cdd414e3..17676b73 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -5,12 +5,13 @@ import LanguageDetector from "i18next-browser-languagedetector"; import enTranslation from "../locales/en/translation.json"; import zhTranslation from "../locales/zh/translation.json"; import deTranslation from "../locales/de/translation.json"; +import ptbrTranslation from "../locales/pt-br/translation.json"; i18n .use(LanguageDetector) .use(initReactI18next) .init({ - supportedLngs: ["en", "zh", "de"], + supportedLngs: ["en", "zh", "de", "ptbr"], fallbackLng: "en", debug: false, @@ -32,6 +33,9 @@ i18n de: { translation: deTranslation, }, + ptbr: { + translation: ptbrTranslation, + }, }, interpolation: { diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index aa742128..722decff 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -529,7 +529,19 @@ "passwordRequired": "Passwort erforderlich", "confirmExport": "Export bestätigen", "exportDescription": "SSH-Hosts und Anmeldedaten als SQLite-Datei exportieren", - "importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)" + "importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)", + "criticalWarning": "Kritische Warnung", + "cannotDisablePasswordLoginWithoutOIDC": "Passwort-Login kann nicht ohne konfiguriertes OIDC deaktiviert werden! Sie müssen die OIDC-Authentifizierung konfigurieren, bevor Sie die Passwort-Anmeldung deaktivieren, sonst verlieren Sie den Zugriff auf Termix.", + "confirmDisablePasswordLogin": "Sind Sie sicher, dass Sie die Passwort-Anmeldung deaktivieren möchten? Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist und funktioniert, bevor Sie fortfahren, sonst verlieren Sie den Zugriff auf Ihre Termix-Instanz.", + "passwordLoginDisabled": "Passwort-Login erfolgreich deaktiviert", + "passwordLoginAndRegistrationDisabled": "Passwort-Login und Registrierung neuer Konten erfolgreich deaktiviert", + "requiresPasswordLogin": "Erfordert aktivierte Passwort-Anmeldung", + "passwordLoginDisabledWarning": "Passwort-Login ist deaktiviert. Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist, sonst können Sie sich nicht bei Termix anmelden.", + "oidcRequiredWarning": "KRITISCH: Passwort-Login ist deaktiviert. Wenn Sie OIDC zurücksetzen oder falsch konfigurieren, verlieren Sie den gesamten Zugriff auf Termix und Ihre Instanz wird unbrauchbar. Fahren Sie nur fort, wenn Sie absolut sicher sind.", + "confirmDisableOIDCWarning": "WARNUNG: Sie sind dabei, OIDC zu deaktivieren, während auch die Passwort-Anmeldung deaktiviert ist. Dies macht Ihre Termix-Instanz unbrauchbar und Sie verlieren den gesamten Zugriff. Sind Sie absolut sicher, dass Sie fortfahren möchten?", + "allowPasswordLogin": "Benutzername/Passwort-Anmeldung zulassen", + "failedToFetchPasswordLoginStatus": "Abrufen des Passwort-Login-Status fehlgeschlagen", + "failedToUpdatePasswordLoginStatus": "Aktualisierung des Passwort-Login-Status fehlgeschlagen" }, "hosts": { "title": "Host-Manager", @@ -623,6 +635,7 @@ "password": "Passwort", "key": "Schlüssel", "credential": "Anmeldedaten", + "none": "Keine", "selectCredential": "Anmeldeinformationen auswählen", "selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...", "credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich", @@ -659,7 +672,32 @@ "folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt", "failedToRenameFolder": "Ordner konnte nicht umbenannt werden", "movedToFolder": "Host \"{{name}}\" wurde erfolgreich nach \"{{folder}}\" verschoben", - "failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden" + "failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden", + "statistics": "Statistiken", + "enabledWidgets": "Aktivierte Widgets", + "enabledWidgetsDesc": "Wählen Sie aus, welche Statistik-Widgets für diesen Host angezeigt werden sollen", + "monitoringConfiguration": "Überwachungskonfiguration", + "monitoringConfigurationDesc": "Konfigurieren Sie, wie oft Serverstatistiken und Status überprüft werden", + "statusCheckEnabled": "Statusüberwachung aktivieren", + "statusCheckEnabledDesc": "Prüfen Sie, ob der Server online oder offline ist", + "statusCheckInterval": "Statusprüfintervall", + "statusCheckIntervalDesc": "Wie oft überprüft werden soll, ob der Host online ist (5s - 1h)", + "metricsEnabled": "Metriküberwachung aktivieren", + "metricsEnabledDesc": "CPU-, RAM-, Festplatten- und andere Systemstatistiken erfassen", + "metricsInterval": "Metriken-Erfassungsintervall", + "metricsIntervalDesc": "Wie oft Serverstatistiken erfasst werden sollen (5s - 1h)", + "intervalSeconds": "Sekunden", + "intervalMinutes": "Minuten", + "intervalValidation": "Überwachungsintervalle müssen zwischen 5 Sekunden und 1 Stunde (3600 Sekunden) liegen", + "monitoringDisabled": "Die Serverüberwachung ist für diesen Host deaktiviert", + "enableMonitoring": "Überwachung aktivieren in Host-Manager → Statistiken-Tab", + "monitoringDisabledBadge": "Überwachung Aus", + "statusMonitoring": "Status", + "metricsMonitoring": "Metriken", + "terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.", + "noneAuthTitle": "Keyboard-Interactive-Authentifizierung", + "noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.", + "noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern." }, "terminal": { "title": "Terminal", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 1aecde15..a15b3d40 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -583,7 +583,16 @@ "passwordRequired": "Password required", "confirmExport": "Confirm Export", "exportDescription": "Export SSH hosts and credentials as SQLite file", - "importDescription": "Import SQLite file with incremental merge (skips duplicates)" + "importDescription": "Import SQLite file with incremental merge (skips duplicates)", + "criticalWarning": "Critical Warning", + "cannotDisablePasswordLoginWithoutOIDC": "Cannot disable password login without OIDC configured! You must configure OIDC authentication before disabling password login, or you will lose access to Termix.", + "confirmDisablePasswordLogin": "Are you sure you want to disable password login? Make sure OIDC is properly configured and working before proceeding, or you will lose access to your Termix instance.", + "passwordLoginDisabled": "Password login disabled successfully", + "passwordLoginAndRegistrationDisabled": "Password login and new account registration disabled successfully", + "requiresPasswordLogin": "Requires password login enabled", + "passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.", + "oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.", + "confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?" }, "hosts": { "title": "Host Manager", @@ -677,6 +686,7 @@ "password": "Password", "key": "Key", "credential": "Credential", + "none": "None", "selectCredential": "Select Credential", "selectCredentialPlaceholder": "Choose a credential...", "credentialRequired": "Credential is required when using credential authentication", @@ -733,7 +743,29 @@ "failedToMoveToFolder": "Failed to move host to folder", "statistics": "Statistics", "enabledWidgets": "Enabled Widgets", - "enabledWidgetsDesc": "Select which statistics widgets to display for this host" + "enabledWidgetsDesc": "Select which statistics widgets to display for this host", + "monitoringConfiguration": "Monitoring Configuration", + "monitoringConfigurationDesc": "Configure how often server statistics and status are checked", + "statusCheckEnabled": "Enable Status Monitoring", + "statusCheckEnabledDesc": "Check if the server is online or offline", + "statusCheckInterval": "Status Check Interval", + "statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)", + "metricsEnabled": "Enable Metrics Monitoring", + "metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics", + "metricsInterval": "Metrics Collection Interval", + "metricsIntervalDesc": "How often to collect server statistics (5s - 1h)", + "intervalSeconds": "seconds", + "intervalMinutes": "minutes", + "intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)", + "monitoringDisabled": "Server monitoring is disabled for this host", + "enableMonitoring": "Enable monitoring in Host Manager → Statistics tab", + "monitoringDisabledBadge": "Monitoring Off", + "statusMonitoring": "Status", + "metricsMonitoring": "Metrics", + "terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.", + "noneAuthTitle": "Keyboard-Interactive Authentication", + "noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.", + "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or dynamic password entry." }, "terminal": { "title": "Terminal", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 9372e89b..adad339e 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -418,16 +418,16 @@ "oidcConfigurationDisabled": "Configuração OIDC desativada com sucesso!", "failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC", "failedToDisableOidcConfig": "Falha ao desativar configuração OIDC", - "enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar admin", - "userIsNowAdmin": "O usuário {{username}} agora é um administrador", - "failedToMakeUserAdmin": "Falha ao tornar o usuário administrador", - "removeAdminStatus": "Remover status de administrador de {{username}}?", - "adminStatusRemoved": "Status de administrador removido de {{username}}", - "failedToRemoveAdminStatus": "Falha ao remover o status de administrador", - "confirmDeleteUser": "Excluir usuário {{username}}? Esta ação não pode ser desfeita.", - "userDeletedSuccessfully": "Usuário {{username}} excluído com sucesso", - "failedToDeleteUser": "Falha ao excluir usuário", - "overrideUserInfoUrl": "Sobrescrever URL de informações do usuário (não obrigatório)", + "enterUsernameToMakeAdmin": "Insira o nome de usuário para tornar admin", + "userIsNowAdmin": "O usuário {{username}} agora é um administrador", + "failedToMakeUserAdmin": "Falha ao tornar o usuário administrador", + "removeAdminStatus": "Remover status de administrador de {{username}}?", + "adminStatusRemoved": "Status de administrador removido de {{username}}", + "failedToRemoveAdminStatus": "Falha ao remover o status de administrador", + "confirmDeleteUser": "Excluir usuário {{username}}? Esta ação não pode ser desfeita.", + "userDeletedSuccessfully": "Usuário {{username}} excluído com sucesso", + "failedToDeleteUser": "Falha ao excluir usuário", + "overrideUserInfoUrl": "Sobrescrever URL de informações do usuário (não obrigatório)", "databaseSecurity": "Segurança do Banco de Dados", "encryptionStatus": "Status da Criptografia", "encryptionEnabled": "Criptografia Ativada", @@ -546,7 +546,19 @@ "passwordRequired": "Senha necessária", "confirmExport": "Confirmar Exportação", "exportDescription": "Exportar hosts SSH e credenciais como arquivo SQLite", - "importDescription": "Importar arquivo SQLite com mesclagem incremental (ignora duplicados)" + "importDescription": "Importar arquivo SQLite com mesclagem incremental (ignora duplicados)", + "criticalWarning": "Aviso Crítico", + "cannotDisablePasswordLoginWithoutOIDC": "Não é possível desativar o login por senha sem OIDC configurado! Você deve configurar a autenticação OIDC antes de desativar o login por senha, ou perderá o acesso ao Termix.", + "confirmDisablePasswordLogin": "Tem certeza que deseja desativar o login por senha? Certifique-se de que o OIDC está configurado corretamente e funcionando antes de continuar, ou você perderá o acesso à sua instância do Termix.", + "passwordLoginDisabled": "Login por senha desativado com sucesso", + "passwordLoginAndRegistrationDisabled": "Login por senha e registro de novas contas desativados com sucesso", + "requiresPasswordLogin": "Requer login por senha ativado", + "passwordLoginDisabledWarning": "Login por senha está desativado. Certifique-se de que o OIDC está configurado corretamente ou você não conseguirá fazer login no Termix.", + "oidcRequiredWarning": "CRÍTICO: Login por senha está desativado. Se você redefinir ou configurar incorretamente o OIDC, você perderá todo o acesso ao Termix e inutilizará sua instância. Prossiga apenas se tiver absoluta certeza.", + "confirmDisableOIDCWarning": "AVISO: Você está prestes a desativar o OIDC enquanto o login por senha também está desativado. Isso inutilizará sua instância do Termix e você perderá todo o acesso. Tem absoluta certeza de que deseja continuar?", + "allowPasswordLogin": "Permitir login com nome de usuário/senha", + "failedToFetchPasswordLoginStatus": "Falha ao buscar status do login por senha", + "failedToUpdatePasswordLoginStatus": "Falha ao atualizar status do login por senha" }, "hosts": { "title": "Gerenciador de Hosts", @@ -598,22 +610,22 @@ "hostAddedSuccessfully": "Host \"{{name}}\" adicionado com sucesso!", "hostDeletedSuccessfully": "Host \"{{name}}\" excluído com sucesso!", "failedToSaveHost": "Falha ao salvar host. Por favor, tente novamente.", - "enableTerminal": "Habilitar Terminal", - "enableTerminalDesc": "Habilitar/desabilitar visibilidade do host na aba Terminal", - "enableTunnel": "Habilitar Túnel", - "enableTunnelDesc": "Habilitar/desabilitar visibilidade do host na aba Túnel", - "enableFileManager": "Habilitar Gerenciador de Arquivos", - "enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciador de Arquivos", - "defaultPath": "Caminho Padrão", - "defaultPathDesc": "Diretório padrão ao abrir o gerenciador de arquivos para este host", + "enableTerminal": "Habilitar Terminal", + "enableTerminalDesc": "Habilitar/desabilitar visibilidade do host na aba Terminal", + "enableTunnel": "Habilitar Túnel", + "enableTunnelDesc": "Habilitar/desabilitar visibilidade do host na aba Túnel", + "enableFileManager": "Habilitar Gerenciador de Arquivos", + "enableFileManagerDesc": "Habilitar/desabilitar visibilidade do host na aba Gerenciador de Arquivos", + "defaultPath": "Caminho Padrão", + "defaultPathDesc": "Diretório padrão ao abrir o gerenciador de arquivos para este host", "tunnelConnections": "Conexões de Túnel", "connection": "Conexão", "remove": "Remover", "sourcePort": "Porta de Origem", - "sourcePortDesc": "(Source refere-se aos Detalhes da Conexão Atual na aba Geral)", + "sourcePortDesc": "(Source refere-se aos Detalhes da Conexão Atual na aba Geral)", "endpointPort": "Porta de Destino", "endpointSshConfig": "Configuração SSH do Endpoint", - "tunnelForwardDescription": "Este túnel encaminhará o tráfego da porta {{sourcePort}} na máquina de origem (detalhes da conexão atual na aba Geral) para a porta {{endpointPort}} na máquina de destino.", + "tunnelForwardDescription": "Este túnel encaminhará o tráfego da porta {{sourcePort}} na máquina de origem (detalhes da conexão atual na aba Geral) para a porta {{endpointPort}} na máquina de destino.", "maxRetries": "Máximo de Tentativas", "maxRetriesDescription": "Número máximo de tentativas de reconexão para a conexão do túnel.", "retryInterval": "Intervalo de Tentativas (segundos)", @@ -621,14 +633,14 @@ "autoStartContainer": "Iniciar Automaticamente ao Lançar Container", "autoStartDesc": "Iniciar automaticamente este túnel quando o container for iniciado", "addConnection": "Adicionar Conexão de Túnel", - "sshpassRequired": "sshpass é necessário para autenticação por senha", - "sshpassRequiredDesc": "Para autenticação por senha em túneis, o sshpass deve estar instalado no sistema.", - "otherInstallMethods": "Outros métodos de instalação:", - "debianUbuntuEquivalent": "(Debian/Ubuntu) ou o equivalente para seu SO.", - "or": "ou", - "centosRhelFedora": "CentOS/RHEL/Fedora", - "macos": "macOS", - "windows": "Windows", + "sshpassRequired": "sshpass é necessário para autenticação por senha", + "sshpassRequiredDesc": "Para autenticação por senha em túneis, o sshpass deve estar instalado no sistema.", + "otherInstallMethods": "Outros métodos de instalação:", + "debianUbuntuEquivalent": "(Debian/Ubuntu) ou o equivalente para seu SO.", + "or": "ou", + "centosRhelFedora": "CentOS/RHEL/Fedora", + "macos": "macOS", + "windows": "Windows", "sshServerConfigRequired": "Configuração do Servidor SSH Necessária", "sshServerConfigDesc": "Para conexões de túnel, o servidor SSH deve ser configurado para permitir o encaminhamento de porta:", "gatewayPortsYes": "para vincular portas remotas a todas as interfaces", @@ -676,7 +688,29 @@ "folderRenamed": "Pasta \"{{oldName}}\" renomeada para \"{{newName}}\" com sucesso", "failedToRenameFolder": "Falha ao renomear pasta", "movedToFolder": "Host \"{{name}}\" movido para \"{{folder}}\" com sucesso", - "failedToMoveToFolder": "Falha ao mover host para a pasta" + "failedToMoveToFolder": "Falha ao mover host para a pasta", + "statistics": "Estatísticas", + "enabledWidgets": "Widgets Habilitados", + "enabledWidgetsDesc": "Selecione quais widgets de estatísticas exibir para este host", + "monitoringConfiguration": "Configuração de Monitoramento", + "monitoringConfigurationDesc": "Configure com que frequência as estatísticas e o status do servidor são verificados", + "statusCheckEnabled": "Habilitar Monitoramento de Status", + "statusCheckEnabledDesc": "Verificar se o servidor está online ou offline", + "statusCheckInterval": "Intervalo de Verificação de Status", + "statusCheckIntervalDesc": "Com que frequência verificar se o host está online (5s - 1h)", + "metricsEnabled": "Habilitar Monitoramento de Métricas", + "metricsEnabledDesc": "Coletar estatísticas de CPU, RAM, disco e outros sistemas", + "metricsInterval": "Intervalo de Coleta de Métricas", + "metricsIntervalDesc": "Com que frequência coletar estatísticas do servidor (5s - 1h)", + "intervalSeconds": "segundos", + "intervalMinutes": "minutos", + "intervalValidation": "Os intervalos de monitoramento devem estar entre 5 segundos e 1 hora (3600 segundos)", + "monitoringDisabled": "O monitoramento do servidor está desabilitado para este host", + "enableMonitoring": "Habilite o monitoramento em Gerenciador de Hosts → aba Estatísticas", + "monitoringDisabledBadge": "Monitoramento Desligado", + "statusMonitoring": "Status", + "metricsMonitoring": "Métricas", + "terminalCustomizationNotice": "Nota: As personalizações do terminal funcionam apenas na versão Desktop Website. Aplicativos Mobile e Electron usam as configurações padrão do terminal do sistema." }, "terminal": { "title": "Terminal", @@ -753,31 +787,31 @@ "createNewFolder": "Criar Nova Pasta", "folderName": "Nome da Pasta", "createFolder": "Criar Pasta", - "warningCannotUndo": "Aviso: Esta ação não pode ser desfeita", - "itemPath": "Caminho do Item", - "thisIsDirectory": "Isto é um diretório (será excluído recursivamente)", - "deleting": "Excluindo...", - "currentPathLabel": "Caminho Atual", - "newName": "Novo Nome", - "thisIsDirectoryRename": "Isto é um diretório", - "renaming": "Renomeando...", - "fileUploadedSuccessfully": "Arquivo \"{{name}}\" enviado com sucesso", - "failedToUploadFile": "Falha ao enviar arquivo", - "fileDownloadedSuccessfully": "Arquivo \"{{name}}\" baixado com sucesso", - "failedToDownloadFile": "Falha ao baixar arquivo", - "noFileContent": "Nenhum conteúdo de arquivo recebido", - "filePath": "Caminho do Arquivo", - "fileCreatedSuccessfully": "Arquivo \"{{name}}\" criado com sucesso", - "failedToCreateFile": "Falha ao criar arquivo", - "folderCreatedSuccessfully": "Pasta \"{{name}}\" criada com sucesso", - "failedToCreateFolder": "Falha ao criar pasta", - "failedToCreateItem": "Falha ao criar item", - "operationFailed": "Operação {{operation}} falhou para {{name}}: {{error}}", - "failedToResolveSymlink": "Falha ao resolver link simbólico", - "itemDeletedSuccessfully": "{{type}} excluído com sucesso", - "itemsDeletedSuccessfully": "{{count}} itens excluídos com sucesso", - "failedToDeleteItems": "Falha ao excluir itens", - "dragFilesToUpload": "Arraste arquivos aqui para enviar", + "warningCannotUndo": "Aviso: Esta ação não pode ser desfeita", + "itemPath": "Caminho do Item", + "thisIsDirectory": "Isto é um diretório (será excluído recursivamente)", + "deleting": "Excluindo...", + "currentPathLabel": "Caminho Atual", + "newName": "Novo Nome", + "thisIsDirectoryRename": "Isto é um diretório", + "renaming": "Renomeando...", + "fileUploadedSuccessfully": "Arquivo \"{{name}}\" enviado com sucesso", + "failedToUploadFile": "Falha ao enviar arquivo", + "fileDownloadedSuccessfully": "Arquivo \"{{name}}\" baixado com sucesso", + "failedToDownloadFile": "Falha ao baixar arquivo", + "noFileContent": "Nenhum conteúdo de arquivo recebido", + "filePath": "Caminho do Arquivo", + "fileCreatedSuccessfully": "Arquivo \"{{name}}\" criado com sucesso", + "failedToCreateFile": "Falha ao criar arquivo", + "folderCreatedSuccessfully": "Pasta \"{{name}}\" criada com sucesso", + "failedToCreateFolder": "Falha ao criar pasta", + "failedToCreateItem": "Falha ao criar item", + "operationFailed": "Operação {{operation}} falhou para {{name}}: {{error}}", + "failedToResolveSymlink": "Falha ao resolver link simbólico", + "itemDeletedSuccessfully": "{{type}} excluído com sucesso", + "itemsDeletedSuccessfully": "{{count}} itens excluídos com sucesso", + "failedToDeleteItems": "Falha ao excluir itens", + "dragFilesToUpload": "Arraste arquivos aqui para enviar", "emptyFolder": "Esta pasta está vazia", "itemCount": "{{count}} itens", "selectedCount": "{{count}} selecionados", @@ -960,7 +994,7 @@ "fileSavedSuccessfully": "Arquivo salvo com sucesso", "autoSaveFailed": "Falha no salvamento automático", "fileAutoSaved": "Arquivo salvo automaticamente", - + "moveFileFailed": "Falha ao mover {{name}}", "moveOperationFailed": "Falha na operação de mover", "canOnlyCompareFiles": "Só é possível comparar dois arquivos", @@ -1378,57 +1412,57 @@ "passwordRequired": "Senha é obrigatória quando usar autenticação por senha", "sshKeyRequired": "Chave Privada SSH é obrigatória quando usar autenticação por chave", "keyTypeRequired": "Tipo de Chave é obrigatório quando usar autenticação por chave", - "validSshConfigRequired": "Deve selecionar uma configuração SSH válida da lista", - "updateHost": "Atualizar Host", - "addHost": "Adicionar Host", - "editHost": "Editar Host", - "pinConnection": "Fixar Conexão", - "authentication": "Autenticação", - "password": "Senha", - "key": "Chave", - "sshPrivateKey": "Chave Privada SSH", - "keyPassword": "Senha da Chave", - "keyType": "Tipo de Chave", - "enableTerminal": "Habilitar Terminal", - "enableTunnel": "Habilitar Túnel", - "enableFileManager": "Habilitar Gerenciador de Arquivos", - "defaultPath": "Caminho Padrão", - "tunnelConnections": "Conexões de Túnel", - "maxRetries": "Máximo de Tentativas", - "upload": "Enviar", - "updateKey": "Atualizar Chave", - "productionFolder": "Produção", - "databaseServer": "Servidor de Banco de Dados", - "developmentServer": "Servidor de Desenvolvimento", - "developmentFolder": "Desenvolvimento", - "webServerProduction": "Servidor Web - Produção", - "unknownError": "Erro desconhecido", - "failedToInitiatePasswordReset": "Falha ao iniciar redefinição de senha", - "failedToVerifyResetCode": "Falha ao verificar código de redefinição", - "failedToCompletePasswordReset": "Falha ao completar redefinição de senha", - "invalidTotpCode": "Código TOTP inválido", - "failedToStartOidcLogin": "Falha ao iniciar login OIDC", - "failedToGetUserInfoAfterOidc": "Falha ao obter informações do usuário após login OIDC", - "loginWithExternalProvider": "Login com provedor externo", - "loginWithExternal": "Login com Provedor Externo", - "sendResetCode": "Enviar Código de Redefinição", - "verifyCode": "Verificar Código", - "resetPassword": "Redefinir Senha", - "login": "Login", - "signUp": "Cadastrar", - "failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC", - "failedToMakeUserAdmin": "Falha ao tornar usuário administrador", - "failedToStartTotpSetup": "Falha ao iniciar configuração TOTP", - "invalidVerificationCode": "Código de verificação inválido", - "failedToDisableTotp": "Falha ao desabilitar TOTP", - "failedToGenerateBackupCodes": "Falha ao gerar códigos de backup" + "validSshConfigRequired": "Deve selecionar uma configuração SSH válida da lista", + "updateHost": "Atualizar Host", + "addHost": "Adicionar Host", + "editHost": "Editar Host", + "pinConnection": "Fixar Conexão", + "authentication": "Autenticação", + "password": "Senha", + "key": "Chave", + "sshPrivateKey": "Chave Privada SSH", + "keyPassword": "Senha da Chave", + "keyType": "Tipo de Chave", + "enableTerminal": "Habilitar Terminal", + "enableTunnel": "Habilitar Túnel", + "enableFileManager": "Habilitar Gerenciador de Arquivos", + "defaultPath": "Caminho Padrão", + "tunnelConnections": "Conexões de Túnel", + "maxRetries": "Máximo de Tentativas", + "upload": "Enviar", + "updateKey": "Atualizar Chave", + "productionFolder": "Produção", + "databaseServer": "Servidor de Banco de Dados", + "developmentServer": "Servidor de Desenvolvimento", + "developmentFolder": "Desenvolvimento", + "webServerProduction": "Servidor Web - Produção", + "unknownError": "Erro desconhecido", + "failedToInitiatePasswordReset": "Falha ao iniciar redefinição de senha", + "failedToVerifyResetCode": "Falha ao verificar código de redefinição", + "failedToCompletePasswordReset": "Falha ao completar redefinição de senha", + "invalidTotpCode": "Código TOTP inválido", + "failedToStartOidcLogin": "Falha ao iniciar login OIDC", + "failedToGetUserInfoAfterOidc": "Falha ao obter informações do usuário após login OIDC", + "loginWithExternalProvider": "Login com provedor externo", + "loginWithExternal": "Login com Provedor Externo", + "sendResetCode": "Enviar Código de Redefinição", + "verifyCode": "Verificar Código", + "resetPassword": "Redefinir Senha", + "login": "Login", + "signUp": "Cadastrar", + "failedToUpdateOidcConfig": "Falha ao atualizar configuração OIDC", + "failedToMakeUserAdmin": "Falha ao tornar usuário administrador", + "failedToStartTotpSetup": "Falha ao iniciar configuração TOTP", + "invalidVerificationCode": "Código de verificação inválido", + "failedToDisableTotp": "Falha ao desabilitar TOTP", + "failedToGenerateBackupCodes": "Falha ao gerar códigos de backup" }, "mobile": { - "selectHostToStart": "Selecione um host para iniciar sua sessão do terminal", - "limitedSupportMessage": "O suporte móvel do site ainda está em desenvolvimento. Use o aplicativo móvel para uma melhor experiência.", - "mobileAppInProgress": "Aplicativo móvel em desenvolvimento", - "mobileAppInProgressDesc": "Estamos trabalhando em um aplicativo móvel dedicado para proporcionar uma melhor experiência em dispositivos móveis.", - "viewMobileAppDocs": "Instalar Aplicativo Móvel", - "mobileAppDocumentation": "Documentação do Aplicativo Móvel" + "selectHostToStart": "Selecione um host para iniciar sua sessão do terminal", + "limitedSupportMessage": "O suporte móvel do site ainda está em desenvolvimento. Use o aplicativo móvel para uma melhor experiência.", + "mobileAppInProgress": "Aplicativo móvel em desenvolvimento", + "mobileAppInProgressDesc": "Estamos trabalhando em um aplicativo móvel dedicado para proporcionar uma melhor experiência em dispositivos móveis.", + "viewMobileAppDocs": "Instalar Aplicativo Móvel", + "mobileAppDocumentation": "Documentação do Aplicativo Móvel" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index a62e7bc0..8da995fb 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -567,7 +567,17 @@ "passwordRequired": "密码为必填项", "confirmExport": "确认导出", "exportDescription": "将SSH主机和凭据导出为SQLite文件", - "importDescription": "导入SQLite文件并进行增量合并(跳过重复项)" + "importDescription": "导入SQLite文件并进行增量合并(跳过重复项)", + "criticalWarning": "严重警告", + "cannotDisablePasswordLoginWithoutOIDC": "无法在未配置 OIDC 的情况下禁用密码登录!您必须先配置 OIDC 认证,然后再禁用密码登录,否则您将失去对 Termix 的访问权限。", + "confirmDisablePasswordLogin": "您确定要禁用密码登录吗?在继续之前,请确保 OIDC 已正确配置并且正常工作,否则您将失去对 Termix 实例的访问权限。", + "passwordLoginDisabled": "密码登录已成功禁用", + "passwordLoginAndRegistrationDisabled": "密码登录和新账户注册已成功禁用", + "requiresPasswordLogin": "需要启用密码登录", + "passwordLoginDisabledWarning": "密码登录已禁用。请确保 OIDC 已正确配置,否则您将无法登录 Termix。", + "oidcRequiredWarning": "严重警告:密码登录已禁用。如果您重置或错误配置 OIDC,您将失去对 Termix 的所有访问权限并使您的实例无法使用。只有在您完全确定的情况下才能继续。", + "confirmDisableOIDCWarning": "警告:您即将在密码登录也已禁用的情况下禁用 OIDC。这将使您的 Termix 实例无法使用,您将失去所有访问权限。您确定要继续吗?", + "failedToUpdatePasswordLoginStatus": "更新密码登录状态失败" }, "hosts": { "title": "主机管理", @@ -755,7 +765,26 @@ "failedToMoveToFolder": "移动主机到文件夹失败", "statistics": "统计", "enabledWidgets": "已启用组件", - "enabledWidgetsDesc": "选择要为此主机显示的统计组件" + "enabledWidgetsDesc": "选择要为此主机显示的统计组件", + "monitoringConfiguration": "监控配置", + "monitoringConfigurationDesc": "配置服务器统计信息和状态的检查频率", + "statusCheckEnabled": "启用状态监控", + "statusCheckEnabledDesc": "检查服务器是在线还是离线", + "statusCheckInterval": "状态检查间隔", + "statusCheckIntervalDesc": "检查主机是否在线的频率 (5秒 - 1小时)", + "metricsEnabled": "启用指标监控", + "metricsEnabledDesc": "收集CPU、内存、磁盘和其他系统统计信息", + "metricsInterval": "指标收集间隔", + "metricsIntervalDesc": "收集服务器统计信息的频率 (5秒 - 1小时)", + "intervalSeconds": "秒", + "intervalMinutes": "分钟", + "intervalValidation": "监控间隔必须在 5 秒到 1 小时(3600 秒)之间", + "monitoringDisabled": "此主机的服务器监控已禁用", + "enableMonitoring": "在主机管理器 → 统计选项卡中启用监控", + "monitoringDisabledBadge": "监控已关闭", + "statusMonitoring": "状态", + "metricsMonitoring": "指标", + "terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。" }, "terminal": { "title": "终端", diff --git a/src/main.tsx b/src/main.tsx index 230db6da..abc315b2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -55,10 +55,18 @@ function useWindowWidth() { function RootApp() { const width = useWindowWidth(); const isMobile = width < 768; + + const userAgent = navigator.userAgent || navigator.vendor || window.opera; + const isTermixMobile = /Termix-Mobile/.test(userAgent); + if (isElectron()) { return ; } + if (isTermixMobile) { + return ; + } + return isMobile ? : ; } diff --git a/src/types/index.ts b/src/types/index.ts index 931dccf4..09446742 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -60,7 +60,7 @@ export interface SSHHostData { enableFileManager?: boolean; defaultPath?: string; tunnelConnections?: TunnelConnection[]; - statsConfig?: string; + statsConfig?: string | Record; // Can be string (from backend) or object (from form) terminalConfig?: TerminalConfig; } @@ -374,6 +374,7 @@ export interface HostManagerProps { onSelectView?: (view: string) => void; isTopbarOpen?: boolean; initialTab?: string; + hostConfig?: SSHHost; } export interface SSHManagerHostEditorProps { diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index 3f083f17..fb3a08eb 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -9,8 +9,18 @@ export type WidgetType = export interface StatsConfig { enabledWidgets: WidgetType[]; + // Status monitoring configuration + statusCheckEnabled: boolean; + statusCheckInterval: number; // seconds (5-3600) + // Metrics monitoring configuration + metricsEnabled: boolean; + metricsInterval: number; // seconds (5-3600) } export const DEFAULT_STATS_CONFIG: StatsConfig = { enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"], + statusCheckEnabled: true, + statusCheckInterval: 30, + metricsEnabled: true, + metricsInterval: 30, }; diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 6d0ff0f8..82a1a84d 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -231,6 +231,51 @@ export function AdminSettings({ }; const handleTogglePasswordLogin = async (checked: boolean) => { + // If disabling password login, warn the user + if (!checked) { + // Check if OIDC is configured + const hasOIDCConfigured = + oidcConfig.client_id && + oidcConfig.client_secret && + oidcConfig.issuer_url && + oidcConfig.authorization_url && + oidcConfig.token_url; + + if (!hasOIDCConfigured) { + toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), { + duration: 5000, + }); + return; + } + + confirmWithToast( + t("admin.confirmDisablePasswordLogin"), + async () => { + setPasswordLoginLoading(true); + try { + await updatePasswordLoginAllowed(checked); + setAllowPasswordLogin(checked); + + // Auto-disable registration when password login is disabled + if (allowRegistration) { + await updateRegistrationAllowed(false); + setAllowRegistration(false); + toast.success(t("admin.passwordLoginAndRegistrationDisabled")); + } else { + toast.success(t("admin.passwordLoginDisabled")); + } + } catch { + toast.error(t("admin.failedToUpdatePasswordLoginStatus")); + } finally { + setPasswordLoginLoading(false); + } + }, + "destructive", + ); + return; + } + + // Enabling password login - proceed normally setPasswordLoginLoading(true); try { await updatePasswordLoginAllowed(checked); @@ -552,9 +597,14 @@ export function AdminSettings({ {t("admin.allowNewAccountRegistration")} + {!allowPasswordLogin && ( + + ({t("admin.requiresPasswordLogin")}) + + )}