import crypto from "crypto"; import express, { type Request, type Response } from "express"; import type { AuthenticatedRequest } from "../../../types/index.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js"; import { sshLogger } from "../../utils/logger.js"; import { sessionManager } from "../terminal/session-manager.js"; import { isLiveSession, isLiveSessionOwnedBy, isSharingEnabledForHost, } from "./live-sessions.js"; import { GuacamoleTokenService } from "../guacamole/token-service.js"; import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js"; const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const permissionManager = PermissionManager.getInstance(); const tokenService = GuacamoleTokenService.getInstance(); const DEFAULT_EXPIRY_HOURS = 24; const MAX_EXPIRY_HOURS = 24 * 30; type Protocol = "ssh" | "rdp" | "vnc" | "telnet"; type PermissionLevel = "read-only" | "read-write"; interface ResolveRateEntry { count: number; windowStart: number; } const resolveAttempts = new Map(); function computeExpiresAt(expiryHours: number | undefined): string { const hours = Math.min( Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1), MAX_EXPIRY_HOURS, ); return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString(); } const RESOLVE_WINDOW_MS = 60 * 1000; const RESOLVE_MAX_ATTEMPTS = 30; function isResolveRateLimited(ip: string): boolean { const now = Date.now(); const entry = resolveAttempts.get(ip); if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) { resolveAttempts.set(ip, { count: 1, windowStart: now }); return false; } entry.count += 1; return entry.count > RESOLVE_MAX_ATTEMPTS; } setInterval( () => { const now = Date.now(); for (const [ip, entry] of resolveAttempts.entries()) { if (now - entry.windowStart > RESOLVE_WINDOW_MS) resolveAttempts.delete(ip); } }, 5 * 60 * 1000, ).unref(); /** * @openapi * /session-sharing/create: * post: * summary: Create a session share (link or targeted user) * description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session. * tags: * - Session Sharing * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - hostId * - sessionId * - protocol * - shareType * - permissionLevel * properties: * hostId: * type: integer * sessionId: * type: string * tabInstanceId: * type: string * protocol: * type: string * enum: [ssh, rdp, vnc, telnet] * shareType: * type: string * enum: [link, user] * targetUserId: * type: string * permissionLevel: * type: string * enum: [read-only, read-write] * expiryHours: * type: number * responses: * 200: * description: Share created * 400: * description: Invalid request * 403: * description: Sharing disabled, or caller does not own the session * 500: * description: Server error */ router.post("/create", authenticateJWT, async (req: Request, res: Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const { hostId, sessionId, tabInstanceId, protocol, shareType, targetUserId, permissionLevel, expiryHours, } = req.body ?? {}; if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) { return res.status(400).json({ error: "Missing required fields" }); } if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) { return res.status(400).json({ error: "Invalid protocol" }); } if (!["link", "user"].includes(shareType)) { return res.status(400).json({ error: "Invalid shareType" }); } if (!["read-only", "read-write"].includes(permissionLevel)) { return res.status(400).json({ error: "Invalid permissionLevel" }); } if (shareType === "user" && !targetUserId) { return res .status(400) .json({ error: "targetUserId is required for user shares" }); } const numericHostId = Number(hostId); const { enabled: sharingEnabled } = await isSharingEnabledForHost(numericHostId); if (!sharingEnabled) { return res .status(403) .json({ error: "Session sharing is disabled for this host" }); } if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) { return res .status(403) .json({ error: "You do not own this live session" }); } if (shareType === "user") { const accessInfo = await permissionManager.canAccessHost( targetUserId, numericHostId, "connect", ); if (!accessInfo.hasAccess) { return res.status(403).json({ error: "Target user does not have access to this host", }); } } const shareId = crypto.randomUUID(); const linkToken = shareType === "link" ? crypto.randomBytes(24).toString("base64url") : null; const expiresAt = computeExpiresAt(expiryHours); const created = await createCurrentSessionShareRepository().create({ id: shareId, hostId: numericHostId, ownerUserId: userId, protocol, sessionId: String(sessionId), tabInstanceId: tabInstanceId ?? null, shareType, targetUserId: shareType === "user" ? targetUserId : null, linkToken, permissionLevel, expiresAt, }); res.json({ shareId: created.id, linkToken: created.linkToken, expiresAt: created.expiresAt, }); } catch (error) { sshLogger.error("Failed to create session share", error, { operation: "session_share_create_error", }); res.status(500).json({ error: "Failed to create session share" }); } }); /** * @openapi * /session-sharing/host/{hostId}/active: * get: * summary: List active session shares for a host * description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host. * tags: * - Session Sharing * security: * - bearerAuth: [] * parameters: * - in: path * name: hostId * required: true * schema: * type: integer * responses: * 200: * description: List of active shares * 400: * description: Invalid host id * 500: * description: Server error */ router.get( "/host/:hostId/active", authenticateJWT, async (req: Request, res: Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const hostId = Number.parseInt(String(req.params.hostId), 10); if (!hostId || Number.isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } const shares = await createCurrentSessionShareRepository().findActiveSharesForHost( hostId, userId, ); res.json({ shares }); } catch (error) { sshLogger.error("Failed to list session shares", error, { operation: "session_share_list_error", }); res.status(500).json({ error: "Failed to list session shares" }); } }, ); /** * @openapi * /session-sharing/{shareId}: * delete: * summary: Revoke a session share * description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1. * tags: * - Session Sharing * security: * - bearerAuth: [] * parameters: * - in: path * name: shareId * required: true * schema: * type: string * responses: * 200: * description: Share revoked * 403: * description: Not authorized to revoke this share * 404: * description: Share not found * 500: * description: Server error */ router.delete( "/:shareId", authenticateJWT, async (req: Request, res: Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const shareId = String(req.params.shareId); const repository = createCurrentSessionShareRepository(); const share = await repository.findById(shareId); if (!share) { return res.status(404).json({ error: "Share not found" }); } let revoked = await repository.revoke(shareId, userId); if (!revoked) { if (await permissionManager.isAdmin(userId)) { revoked = await repository.revokeAsAdmin(shareId); } } if (!revoked) { return res .status(403) .json({ error: "Not authorized to revoke this share" }); } // Best-effort kick of live participants. SSH sessions support ending // just the guests via ownerEndSession; guac joins aren't force-kickable // from a REST handler (guacamole-lite exposes no kick API), so a revoked // guac link only blocks *future* resolves until the guest's own socket ends. if (share.protocol === "ssh") { try { sessionManager.ownerEndSession( share.sessionId, "Session share revoked by owner", ); } catch { // best-effort only } } res.json({ success: true }); } catch (error) { sshLogger.error("Failed to revoke session share", error, { operation: "session_share_revoke_error", }); res.status(500).json({ error: "Failed to revoke session share" }); } }, ); /** * @openapi * /session-sharing/resolve/{linkToken}: * get: * summary: Resolve a guest share link * description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP. * tags: * - Session Sharing * parameters: * - in: path * name: linkToken * required: true * schema: * type: string * responses: * 200: * description: Resolved share connection info * 404: * description: Link not found, expired, revoked, or sharing disabled * 429: * description: Too many requests * 500: * description: Server error */ router.get("/resolve/:linkToken", async (req: Request, res: Response) => { try { const ip = req.ip || req.socket.remoteAddress || "unknown"; if (isResolveRateLimited(ip)) { return res.status(429).json({ error: "Too many requests" }); } const linkToken = String(req.params.linkToken); const repository = createCurrentSessionShareRepository(); const share = await repository.findByLinkToken(linkToken); if (!share) { return res.status(404).json({ error: "Link not found or expired" }); } const { enabled: sharingEnabled } = await isSharingEnabledForHost( share.hostId, ); if (!sharingEnabled) { return res.status(404).json({ error: "Link not found or expired" }); } const protocol = share.protocol as Protocol; if (!isLiveSession(protocol, share.sessionId)) { return res.status(404).json({ error: "Session is no longer active" }); } // Field-by-field by design - never spread a host row into this response. // Anonymous guests must never see hostname/IP/username/hostId (decision #5). const response: { protocol: Protocol; permissionLevel: PermissionLevel; wsPath: string; connectParams?: Record; } = { protocol, permissionLevel: share.permissionLevel as PermissionLevel, wsPath: protocol === "ssh" ? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}` : "/guacamole/websocket/", }; if (protocol !== "ssh") { const joinToken = tokenService.createJoinToken( share.sessionId, share.permissionLevel === "read-only", ); response.connectParams = { token: joinToken }; } try { await repository.touchShareUsage(share.id); await repository.recordParticipantJoin(share.id, null, "Guest"); } catch { // best-effort, never fail the resolve response over audit bookkeeping } res.json(response); } catch (error) { sshLogger.error("Failed to resolve session share link", error, { operation: "session_share_resolve_error", }); res.status(500).json({ error: "Failed to resolve share link" }); } }); /** * @openapi * /session-sharing/{shareId}/end: * post: * summary: End a shared session for all participants * description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1. * tags: * - Session Sharing * security: * - bearerAuth: [] * parameters: * - in: path * name: shareId * required: true * schema: * type: string * responses: * 200: * description: Session ended * 403: * description: Not the owner of this share * 404: * description: Share not found * 500: * description: Server error */ router.post( "/:shareId/end", authenticateJWT, async (req: Request, res: Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const shareId = String(req.params.shareId); const repository = createCurrentSessionShareRepository(); const share = await repository.findById(shareId); if (!share) { return res.status(404).json({ error: "Share not found" }); } if (share.ownerUserId !== userId) { return res.status(403).json({ error: "Not the owner of this share" }); } if (share.protocol === "ssh") { sessionManager.ownerEndSession( share.sessionId, "Session ended by owner", ); } // Guac protocols: no kick API available from a REST handler in v1 - see // DELETE /:shareId for the same limitation. res.json({ success: true }); } catch (error) { sshLogger.error("Failed to end shared session", error, { operation: "session_share_end_error", }); res.status(500).json({ error: "Failed to end shared session" }); } }, ); export default router;