import type { AuthenticatedRequest } from "../../../types/index.js"; import type { RequestHandler, Router } from "express"; import { restartGuacServer } from "../../hosts/guacamole/guacamole-server.js"; import { authLogger, getGlobalLogLevel, setGlobalLogLevel, } from "../../utils/logger.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js"; import { createCurrentSettingsRepository, createCurrentUserRepository, } from "../repositories/factory.js"; import type { UserRecord } from "../repositories/user-repository.js"; function getDefaultGuacUrl(): string { return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`; } export type HostDefaults = { useSocks5?: boolean; socks5Host?: string; socks5Port?: number; socks5Username?: string; socks5Password?: string; credentialId?: number | null; metricsEnabled?: boolean; statusCheckEnabled?: boolean; fontSize?: number; fontFamily?: string; theme?: string; cursorStyle?: string; cursorBlink?: boolean; enableSessionLogging?: boolean; enableCommandHistory?: boolean; }; async function getAdminActor( userId: string | undefined, ): Promise { if (!userId) return null; const user = await createCurrentUserRepository().findById(userId); return user?.isAdmin ? user : null; } export function registerUserSettingsRoutes( router: Router, authenticateJWT: RequestHandler, ): void { /** * @openapi * /users/guacamole-settings: * get: * summary: Get Guacamole settings * description: Returns current guacd enabled status and host:port URL. No authentication required. * tags: * - Users * responses: * 200: * description: Guacamole settings. * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * url: * type: string * 500: * description: Failed to get guacamole settings. */ router.get("/guacamole-settings", authenticateJWT, async (_req, res) => { try { const settings = createCurrentSettingsRepository(); const enabled = await settings.getBoolean("guac_enabled", true); const url = await settings.get("guac_url"); res.json({ enabled, url: url ?? getDefaultGuacUrl(), }); } catch (err) { authLogger.error("Failed to get guacamole settings", err); res.status(500).json({ error: "Failed to get guacamole settings" }); } }); /** * @openapi * /users/guacamole-settings: * patch: * summary: Update Guacamole settings * description: Admin-only. Updates guacd enabled status and/or host:port URL. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * url: * type: string * responses: * 200: * description: Guacamole settings updated. * 403: * description: Not authorized. * 500: * description: Failed to update guacamole settings. */ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled, url } = req.body; const settings = createCurrentSettingsRepository(); if (typeof enabled === "boolean") { await settings.set("guac_enabled", enabled ? "true" : "false"); } if (typeof url === "string") { await settings.set("guac_url", url); try { await restartGuacServer(); } catch (err) { authLogger.error( "Failed to restart guac server after URL update", err, ); } } const currentEnabled = await settings.getBoolean("guac_enabled", true); const currentUrl = await settings.get("guac_url"); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_guacamole_settings", resourceType: "setting", details: JSON.stringify({ enabled, url }), ipAddress, userAgent, success: true, }); res.json({ enabled: currentEnabled, url: currentUrl ?? getDefaultGuacUrl(), }); } catch (err) { authLogger.error("Failed to update guacamole settings", err); res.status(500).json({ error: "Failed to update guacamole settings" }); } }); /** * @openapi * /users/log-level: * get: * summary: Get log level setting * description: Returns the configured log verbosity level. * tags: * - Users * responses: * 200: * description: Current log level. */ router.get("/log-level", authenticateJWT, async (_req, res) => { try { const level = await createCurrentSettingsRepository().get("log_level"); res.json({ level: level ?? getGlobalLogLevel(), }); } catch (err) { authLogger.error("Failed to get log level", err); res.status(500).json({ error: "Failed to get log level" }); } }); /** * @openapi * /users/log-level: * patch: * summary: Update log level setting (admin only) * description: Sets the log verbosity level. * tags: * - Users * responses: * 200: * description: Log level updated. * 400: * description: Invalid log level. * 403: * description: Not authorized. */ router.patch("/log-level", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { level } = req.body; const validLevels = ["debug", "info", "warn", "error"]; if (typeof level !== "string" || !validLevels.includes(level)) { return res .status(400) .json({ error: "level must be one of: debug, info, warn, error" }); } await createCurrentSettingsRepository().set("log_level", level); setGlobalLogLevel(level); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_log_level", resourceType: "setting", details: JSON.stringify({ level }), ipAddress, userAgent, success: true, }); res.json({ level }); } catch (err) { authLogger.error("Failed to set log level", err); res.status(500).json({ error: "Failed to set log level" }); } }); /** * @openapi * /users/session-timeout: * get: * summary: Get session timeout setting * description: Returns the configured session timeout in hours. * tags: * - Users * responses: * 200: * description: Current session timeout hours. */ router.get("/session-timeout", authenticateJWT, async (_req, res) => { try { const value = await createCurrentSettingsRepository().get( "session_timeout_hours", ); res.json({ timeoutHours: value ? parseInt(value, 10) : 24, }); } catch (err) { authLogger.error("Failed to get session timeout", err); res.status(500).json({ error: "Failed to get session timeout" }); } }); /** * @openapi * /users/session-timeout: * patch: * summary: Update session timeout setting (admin only) * description: Sets the session timeout in hours. * tags: * - Users * responses: * 200: * description: Session timeout updated. * 400: * description: Invalid value. * 403: * description: Not authorized. */ router.patch("/session-timeout", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { timeoutHours } = req.body; if ( typeof timeoutHours !== "number" || timeoutHours < 1 || timeoutHours > 720 ) { return res .status(400) .json({ error: "timeoutHours must be between 1 and 720" }); } await createCurrentSettingsRepository().set( "session_timeout_hours", String(timeoutHours), ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_session_timeout", resourceType: "setting", details: JSON.stringify({ timeoutHours }), ipAddress, userAgent, success: true, }); res.json({ timeoutHours }); } catch (err) { authLogger.error("Failed to set session timeout", err); res.status(500).json({ error: "Failed to set session timeout" }); } }); /** * @openapi * /users/tailscale-settings: * get: * summary: Get Tailscale settings * description: Returns whether a Tailscale API key is configured (value is masked). * tags: * - Users * responses: * 200: * description: Tailscale settings. * content: * application/json: * schema: * type: object * properties: * apiKey: * type: string * description: Masked API key or empty string if not set. * hasApiKey: * type: boolean * apiBaseUrl: * type: string * description: Custom control-plane API base URL (e.g. a Headscale instance), or empty string for the Tailscale default. */ router.get("/tailscale-settings", authenticateJWT, async (_req, res) => { try { const settingsRepo = createCurrentSettingsRepository(); const apiKey = (await settingsRepo.get("tailscale_api_key")) ?? ""; const apiBaseUrl = (await settingsRepo.get("tailscale_api_base_url")) ?? ""; res.json({ apiKey: apiKey ? `${apiKey.slice(0, 6)}${"*".repeat(Math.max(0, apiKey.length - 6))}` : "", hasApiKey: !!apiKey, apiBaseUrl, }); } catch (err) { authLogger.error("Failed to get Tailscale settings", err); res.status(500).json({ error: "Failed to get Tailscale settings" }); } }); /** * @openapi * /users/tailscale-settings: * patch: * summary: Update Tailscale settings (admin only) * description: Saves or clears the Tailscale API key used for device discovery. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * apiKey: * type: string * apiBaseUrl: * type: string * description: Optional custom control-plane API base URL (e.g. a Headscale instance). Leave empty to use the Tailscale default. * responses: * 200: * description: Tailscale settings updated. * 403: * description: Not authorized. * 500: * description: Failed to update Tailscale settings. */ router.patch("/tailscale-settings", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { apiKey, apiBaseUrl } = req.body; if (typeof apiKey !== "string") { return res.status(400).json({ error: "apiKey must be a string" }); } if (apiBaseUrl !== undefined && typeof apiBaseUrl !== "string") { return res.status(400).json({ error: "apiBaseUrl must be a string" }); } const settingsRepo = createCurrentSettingsRepository(); await settingsRepo.set("tailscale_api_key", apiKey); if (apiBaseUrl !== undefined) { await settingsRepo.set( "tailscale_api_base_url", apiBaseUrl.trim().replace(/\/+$/, ""), ); } const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_tailscale_settings", resourceType: "setting", details: JSON.stringify({ hasApiKey: !!apiKey }), ipAddress, userAgent, success: true, }); res.json({ hasApiKey: !!apiKey }); } catch (err) { authLogger.error("Failed to update Tailscale settings", err); res.status(500).json({ error: "Failed to update Tailscale settings" }); } }); /** * @openapi * /users/command-history-enabled: * get: * summary: Get command history enabled setting * description: Returns whether command history recording is globally enabled. * tags: * - Users * responses: * 200: * description: Command history enabled status. * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean */ router.get("/command-history-enabled", authenticateJWT, async (_req, res) => { try { res.json({ enabled: await createCurrentSettingsRepository().getBoolean( "command_history_enabled", true, ), }); } catch (err) { authLogger.error("Failed to get command history enabled setting", err); res .status(500) .json({ error: "Failed to get command history enabled setting" }); } }); /** * @openapi * /users/command-history-enabled: * patch: * summary: Update command history enabled setting (admin only) * description: Globally enables or disables command history recording for all hosts. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * responses: * 200: * description: Setting updated. * 403: * description: Not authorized. * 500: * description: Failed to update setting. */ router.patch( "/command-history-enabled", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); } await createCurrentSettingsRepository().set( "command_history_enabled", enabled ? "true" : "false", ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_command_history_enabled", resourceType: "setting", details: JSON.stringify({ enabled }), ipAddress, userAgent, success: true, }); res.json({ enabled }); } catch (err) { authLogger.error( "Failed to update command history enabled setting", err, ); res .status(500) .json({ error: "Failed to update command history enabled setting" }); } }, ); /** * @openapi * /users/analytics-enabled: * get: * summary: Get analytics enabled setting * description: Returns whether anonymous usage telemetry is enabled, and whether the value is locked by the ENABLE_TELEMETRY environment variable. * tags: * - Users * responses: * 200: * description: Analytics enabled status. * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * locked: * type: boolean */ router.get("/analytics-enabled", authenticateJWT, async (_req, res) => { try { const override = getTelemetryEnvOverride(); if (override !== null) { return res.json({ enabled: override, locked: true }); } res.json({ enabled: await createCurrentSettingsRepository().getBoolean( "analytics_enabled", true, ), locked: false, }); } catch (err) { authLogger.error("Failed to get analytics enabled setting", err); res .status(500) .json({ error: "Failed to get analytics enabled setting" }); } }); /** * @openapi * /users/analytics-enabled: * patch: * summary: Update analytics enabled setting (admin only) * description: Enables or disables the daily anonymous usage telemetry heartbeat. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * responses: * 200: * description: Setting updated. * 403: * description: Not authorized. * 409: * description: Setting is locked by the ENABLE_TELEMETRY environment variable. * 500: * description: Failed to update setting. */ router.patch("/analytics-enabled", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } if (getTelemetryEnvOverride() !== null) { return res.status(409).json({ error: "Telemetry is locked by the ENABLE_TELEMETRY env variable", }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); } await createCurrentSettingsRepository().set( "analytics_enabled", enabled ? "true" : "false", ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_analytics_enabled", resourceType: "setting", details: JSON.stringify({ enabled }), ipAddress, userAgent, success: true, }); res.json({ enabled }); } catch (err) { authLogger.error("Failed to update analytics enabled setting", err); res .status(500) .json({ error: "Failed to update analytics enabled setting" }); } }); /** * @openapi * /users/session-sharing-enabled: * get: * summary: Get session sharing globally enabled setting * description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false. * tags: * - Users * responses: * 200: * description: Session sharing enabled status. * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean */ router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => { try { res.json({ enabled: await createCurrentSettingsRepository().getBoolean( "session_sharing_globally_enabled", true, ), }); } catch (err) { authLogger.error("Failed to get session sharing enabled setting", err); res .status(500) .json({ error: "Failed to get session sharing enabled setting" }); } }); /** * @openapi * /users/session-sharing-enabled: * patch: * summary: Update session sharing globally enabled setting (admin only) * description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * responses: * 200: * description: Setting updated. * 403: * description: Not authorized. * 500: * description: Failed to update setting. */ router.patch( "/session-sharing-enabled", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); } await createCurrentSettingsRepository().set( "session_sharing_globally_enabled", enabled ? "true" : "false", ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_session_sharing_enabled", resourceType: "setting", details: JSON.stringify({ enabled }), ipAddress, userAgent, success: true, }); res.json({ enabled }); } catch (err) { authLogger.error( "Failed to update session sharing enabled setting", err, ); res .status(500) .json({ error: "Failed to update session sharing enabled setting" }); } }, ); /** * @openapi * /users/ai-enabled: * get: * summary: Get whether the AI assistant is enabled instance-wide * tags: * - Users * responses: * 200: * description: AI enabled status. * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean */ router.get("/ai-enabled", authenticateJWT, async (_req, res) => { try { res.json({ // Defaults to false so upgrading an install never turns the assistant // on without an admin deciding to. enabled: await createCurrentSettingsRepository().getBoolean( "ai_globally_enabled", false, ), }); } catch (err) { authLogger.error("Failed to get AI enabled setting", err); res.status(500).json({ error: "Failed to get AI enabled setting" }); } }); /** * @openapi * /users/ai-enabled: * patch: * summary: Update the instance-wide AI assistant setting (admin only) * description: Turning this off hides and blocks the assistant for every user, whatever their own preference says. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * enabled: * type: boolean * responses: * 200: * description: Setting updated. * 403: * description: Not authorized. */ router.patch("/ai-enabled", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { enabled } = req.body; if (typeof enabled !== "boolean") { return res.status(400).json({ error: "enabled must be a boolean" }); } await createCurrentSettingsRepository().set( "ai_globally_enabled", enabled ? "true" : "false", ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_ai_enabled", resourceType: "setting", details: JSON.stringify({ enabled }), ipAddress, userAgent, success: true, }); res.json({ enabled }); } catch (err) { authLogger.error("Failed to update AI enabled setting", err); res.status(500).json({ error: "Failed to update AI enabled setting" }); } }); /** * @openapi * /users/ai-private-endpoints: * get: * summary: Get the allowlist of private AI endpoint hosts * tags: * - Users * responses: * 200: * description: Allowed hosts. */ router.get("/ai-private-endpoints", authenticateJWT, async (_req, res) => { try { const raw = await createCurrentSettingsRepository().get( AI_PRIVATE_ALLOWLIST_KEY, ); res.json({ hosts: parseAllowlist(raw) }); } catch (err) { authLogger.error("Failed to get AI private endpoint allowlist", err); res.status(500).json({ error: "Failed to get the allowlist" }); } }); /** * @openapi * /users/ai-private-endpoints: * patch: * summary: Replace the allowlist of private AI endpoint hosts (admin only) * description: > * Providers on private or loopback addresses, such as a self-hosted * Ollama, are refused unless their host appears here. Without this an * ordinary user could point a provider at an internal service and use * the server as a probe of its own network. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * hosts: * type: array * items: * type: string * responses: * 200: * description: Allowlist updated. * 403: * description: Not authorized. */ router.patch("/ai-private-endpoints", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const { hosts } = req.body; if (!Array.isArray(hosts)) { return res.status(400).json({ error: "hosts must be an array" }); } if (hosts.length > 50) { return res.status(400).json({ error: "At most 50 hosts are allowed" }); } const cleaned: string[] = []; for (const entry of hosts) { if (typeof entry !== "string") { return res.status(400).json({ error: "Each host must be a string" }); } const host = entry.trim().toLowerCase(); if (!host) continue; // A bare host, not a URL: no scheme, path, port or whitespace. if (!/^[a-z0-9._:-]+$/.test(host)) { return res .status(400) .json({ error: `${entry} is not a valid hostname` }); } if (!cleaned.includes(host)) cleaned.push(host); } await createCurrentSettingsRepository().set( AI_PRIVATE_ALLOWLIST_KEY, JSON.stringify(cleaned), ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_ai_private_endpoints", resourceType: "setting", details: JSON.stringify({ hosts: cleaned }), ipAddress, userAgent, success: true, }); res.json({ hosts: cleaned }); } catch (err) { authLogger.error("Failed to update AI private endpoint allowlist", err); res.status(500).json({ error: "Failed to update the allowlist" }); } }); /** * @openapi * /users/host-defaults: * get: * summary: Get host creation defaults * description: Returns the global default settings applied when creating a new host. * tags: * - Users * responses: * 200: * description: Host defaults object. * 500: * description: Failed to get host defaults. */ router.get("/host-defaults", authenticateJWT, async (_req, res) => { try { const value = await createCurrentSettingsRepository().get("host_defaults"); const defaults: HostDefaults = value ? JSON.parse(value) : {}; res.json(defaults); } catch (err) { authLogger.error("Failed to get host defaults", err); res.status(500).json({ error: "Failed to get host defaults" }); } }); /** * @openapi * /users/host-defaults: * patch: * summary: Update host creation defaults (admin only) * description: Sets global default settings applied when a new host is created. * tags: * - Users * requestBody: * required: true * content: * application/json: * schema: * type: object * responses: * 200: * description: Host defaults updated. * 403: * description: Not authorized. * 500: * description: Failed to update host defaults. */ router.patch("/host-defaults", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { const actor = await getAdminActor(userId); if (!actor) { return res.status(403).json({ error: "Not authorized" }); } const defaults: HostDefaults = req.body; await createCurrentSettingsRepository().set( "host_defaults", JSON.stringify(defaults), ); const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: actor.username ?? userId, action: "update_host_defaults", resourceType: "setting", details: JSON.stringify(defaults), ipAddress, userAgent, success: true, }); res.json(defaults); } catch (err) { authLogger.error("Failed to update host defaults", err); res.status(500).json({ error: "Failed to update host defaults" }); } }); }