import type { AuthenticatedRequest } from "../../../types/index.js"; import type { Request, RequestHandler, Response, Router } from "express"; import { PermissionManager } from "../../utils/permission-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { sshLogger } from "../../utils/logger.js"; import { createCurrentSettingsRepository } from "../repositories/factory.js"; import { DEFAULT_STATUS_INTERVAL, GLOBAL_STATUS_INTERVAL_KEY, hostStatusService, type HostStatusEntry, } from "../../hosts/status/host-status-service.js"; interface HostStatusRoutesDeps { authenticateJWT: RequestHandler; requireAdmin: RequestHandler; } /** "1,2,3" to a set of ids; undefined means the caller asked for all. */ export function parseStatusHostIds(value: unknown): Set | null { if (value === undefined) return null; if (typeof value !== "string") return new Set(); return new Set( value .split(",") .map(Number) .filter((id) => Number.isSafeInteger(id) && id > 0), ); } function sessionExpired(res: Response): Response { return res.status(401).json({ error: "Session expired - please log in again", code: "SESSION_EXPIRED", }); } export function registerHostStatusRoutes( router: Router, { authenticateJWT, requireAdmin }: HostStatusRoutesDeps, ): void { const permissionManager = PermissionManager.getInstance(); /** * @openapi * /host/status: * get: * summary: Get host statuses * description: Returns the online or offline status of every host the user can see. Starts status checks for the user's own hosts on first call and waits briefly for their first results. * tags: * - SSH * parameters: * - in: query * name: hostIds * required: false * schema: * type: string * description: Comma separated ids. Only these of the user's own hosts are checked. * responses: * 200: * description: A map of host ids to status entries. * 401: * description: Session expired - please log in again. */ router.get( "/status", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (DataCrypto.getUserDataKey(userId) === null) return sessionExpired(res); const requested = parseStatusHostIds(req.query.hostIds); const statuses = await hostStatusService.statusesFor(userId, requested); // One batched permission check: this is polled every few seconds, and a // per-host check made it linear in host count against the database. const entries = [...statuses.entries()]; const allowed = await permissionManager.filterAccessibleHostIds( userId, entries.map(([id]) => id), ); const result: Record = {}; for (const [id, entry] of entries) { if (allowed.has(id) && (requested === null || requested.has(id))) { result[id] = entry; } } res.json(result); }, ); /** * @openapi * /host/status/settings: * get: * summary: Get status check settings * description: Returns the default number of seconds between status checks. * tags: * - SSH * responses: * 200: * description: The status check settings. * 403: * description: Admin access required. */ router.get( "/status/settings", authenticateJWT, requireAdmin, async (_req: Request, res: Response) => { const value = Number( await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY), ); res.json({ statusCheckInterval: Number.isInteger(value) && value >= 5 ? value : DEFAULT_STATUS_INTERVAL, }); }, ); /** * @openapi * /host/status/settings: * put: * summary: Update status check settings * description: Sets the default number of seconds between status checks and re-times every running check. * tags: * - SSH * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * statusCheckInterval: * type: integer * minimum: 5 * maximum: 3600 * responses: * 200: * description: Settings saved. * 400: * description: Invalid interval. * 403: * description: Admin access required. */ router.put( "/status/settings", authenticateJWT, requireAdmin, async (req: Request, res: Response) => { const seconds = Number(req.body?.statusCheckInterval); if (!Number.isInteger(seconds) || seconds < 5 || seconds > 3600) { return res .status(400) .json({ error: "Status check interval must be 5 to 3600 seconds" }); } try { await createCurrentSettingsRepository().set( GLOBAL_STATUS_INTERVAL_KEY, String(seconds), ); hostStatusService.retimeAll(); res.json({ statusCheckInterval: seconds }); } catch (error) { sshLogger.error("Failed to save status check settings", error, { operation: "host_status_settings", }); res.status(500).json({ error: "Failed to save settings" }); } }, ); /** * @openapi * /host/status/refresh: * post: * summary: Restart status checks * description: Re-reads the user's own hosts and restarts their status checks. * tags: * - SSH * responses: * 200: * description: Status checks restarted. * 401: * description: Session expired - please log in again. */ router.post( "/status/refresh", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; if (DataCrypto.getUserDataKey(userId) === null) { return sessionExpired(res); } await hostStatusService.refresh(userId); res.json({ message: "Status checks restarted" }); }, ); /** * @openapi * /host/status/{id}: * get: * summary: Get one host's status * description: Returns the status of one host the user can connect to. * tags: * - SSH * parameters: * - in: path * name: id * required: true * schema: * type: integer * responses: * 200: * description: The host's status entry. * 400: * description: Invalid host id. * 404: * description: Status not available. */ router.get( "/status/:id", authenticateJWT, async (req: Request, res: Response) => { const id = Number(req.params.id); if (!Number.isInteger(id) || id <= 0) { return res.status(400).json({ error: "Invalid host ID" }); } const userId = (req as AuthenticatedRequest).userId; if (DataCrypto.getUserDataKey(userId) === null) { return sessionExpired(res); } const access = await permissionManager.canAccessHost( userId, id, "connect", ); if (!access.hasAccess) { return res.status(404).json({ error: "Status not available" }); } await hostStatusService.statusesFor(userId, null); const entry = hostStatusService.get(id) ?? (await hostStatusService.check(id)); if (!entry) { return res.status(404).json({ error: "Status not available" }); } res.json(entry); }, ); }