feat: move session sharing and collab rooms into a plugin (B12)

This commit is contained in:
LukeGus committed 2026-09-23 23:42:46 -05:00
1 parent 791d8656a1
commit a276f6898c
207 files changed
+35771 -11072

No files matched your search

@@ -1,21 +0,0 @@
const WINDOW_MS = 60_000;
const MAX_ATTEMPTS = 60;
const attempts = new Map<string, { count: number; windowStart: number }>();
export function isCollabGuestRateLimited(ip: string): boolean {
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || now - entry.windowStart > WINDOW_MS) {
attempts.set(ip, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of attempts) {
if (now - entry.windowStart > WINDOW_MS) attempts.delete(ip);
}
}, 5 * WINDOW_MS).unref();
-115
View File
@@ -1,115 +0,0 @@
import type { WebSocket } from "ws";
import { collabRuntimeStore } from "./runtime-store.js";
const PRESENCE_HEARTBEAT_MS = 15_000;
export interface CollabRoomClient {
ws: WebSocket;
userId: string;
username: string;
}
/**
* Local WebSocket fan-out plus optional Redis pub/sub for multi-instance room
* events and presence. The terminal WS server feeds subscribe/unsubscribe.
*/
class CollabRoomHub {
private rooms = new Map<string, Set<CollabRoomClient>>();
constructor() {
collabRuntimeStore.onEvent((roomId, message) => {
if (
"type" in message &&
typeof message.type === "string" &&
message.type.startsWith("collab_internal_")
) {
return;
}
this.broadcastLocal(roomId, message);
});
setInterval(() => {
for (const roomId of this.rooms.keys()) void this.refreshPresence(roomId);
}, PRESENCE_HEARTBEAT_MS).unref();
}
subscribe(roomId: string, client: CollabRoomClient): void {
let clients = this.rooms.get(roomId);
if (!clients) {
clients = new Set();
this.rooms.set(roomId, clients);
}
for (const existing of clients) {
if (existing.ws === client.ws) return;
}
clients.add(client);
void this.refreshPresence(roomId);
}
/** Drops the socket from one room, or from every room when roomId is omitted. */
unsubscribe(ws: WebSocket, roomId?: string): void {
for (const [id, clients] of this.rooms) {
if (roomId && id !== roomId) continue;
let removed = false;
for (const client of clients) {
if (client.ws === ws) {
clients.delete(client);
removed = true;
}
}
if (clients.size === 0) this.rooms.delete(id);
if (removed) void this.refreshPresence(id);
}
}
broadcast(roomId: string, message: object): void {
this.broadcastLocal(roomId, message);
void collabRuntimeStore.publish(roomId, message);
}
async onlineUsers(
roomId: string,
): Promise<Array<{ userId: string; username: string }>> {
return collabRuntimeStore.onlineUsers(
roomId,
this.localOnlineUsers(roomId),
);
}
private broadcastLocal(roomId: string, message: object): void {
const clients = this.rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(message);
for (const client of clients) {
if (client.ws.readyState !== client.ws.OPEN) continue;
try {
client.ws.send(payload);
} catch {
/* keep broadcasting to the rest */
}
}
}
private localOnlineUsers(
roomId: string,
): Array<{ userId: string; username: string }> {
const clients = this.rooms.get(roomId);
if (!clients) return [];
const seen = new Map<string, string>();
for (const client of clients) {
seen.set(client.userId, client.username);
}
return Array.from(seen, ([userId, username]) => ({ userId, username }));
}
private async refreshPresence(roomId: string): Promise<void> {
const localUsers = this.localOnlineUsers(roomId);
await collabRuntimeStore.updatePresence(roomId, localUsers);
this.broadcast(roomId, {
type: "collab_online",
roomId,
users: await this.onlineUsers(roomId),
});
}
}
export const collabRoomHub = new CollabRoomHub();
@@ -1,16 +0,0 @@
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
/**
* Whether a user may join a room-stage share: the share must be the stage of
* a live room they are a member of. Membership is the authorization - room
* stages are read-only and never expose host credentials or config.
*/
export async function canJoinRoomStageShare(
shareId: string,
userId: string,
): Promise<boolean> {
const repository = createCurrentCollabRoomRepository();
const room = await repository.findByStageShareId(shareId);
if (!room) return false;
return !!(await repository.findMember(room.id, userId));
}
File diff suppressed because it is too large. Load diff
-286
View File
@@ -1,286 +0,0 @@
import crypto from "crypto";
import { createClient } from "redis";
import { sshLogger } from "../../utils/logger.js";
export interface CollabControlRequest {
userId: string;
username: string;
requestedAt: string;
}
interface PresenceEntry {
instanceId: string;
userId: string;
username: string;
}
interface EventEnvelope {
source: string;
roomId: string;
message: object;
}
const KEY_PREFIX = process.env.TERMIX_REDIS_PREFIX?.trim() || "termix:collab";
const EVENT_CHANNEL = `${KEY_PREFIX}:events`;
const PRESENCE_TTL_MS = 45_000;
const STATE_TTL_SECONDS = 12 * 60 * 60;
const CONNECT_RETRY_MS = 15_000;
export class CollabRuntimeStore {
private readonly instanceId = crypto.randomUUID();
private publisher: ReturnType<typeof createClient> | null = null;
private subscriber: ReturnType<typeof createClient> | null = null;
private connecting: Promise<boolean> | null = null;
private nextConnectAttempt = 0;
private eventListeners = new Set<(roomId: string, message: object) => void>();
private localControllers = new Map<string, string>();
private localRequests = new Map<string, Map<string, CollabControlRequest>>();
private presenceMembers = new Map<string, Set<string>>();
onEvent(listener: (roomId: string, message: object) => void): void {
this.eventListeners.add(listener);
void this.ensureConnected();
}
async publish(roomId: string, message: object): Promise<void> {
if (!(await this.ensureConnected()) || !this.publisher) return;
await this.publisher
.publish(
EVENT_CHANNEL,
JSON.stringify({ source: this.instanceId, roomId, message }),
)
.catch((error) => this.logRedisFailure("publish", error));
}
async getController(roomId: string): Promise<string | null> {
if (await this.ensureConnected()) {
const value = await this.publisher
?.get(`${KEY_PREFIX}:controller:${roomId}`)
.catch((error) => {
this.logRedisFailure("get_controller", error);
return undefined;
});
if (value !== undefined) return value;
}
return this.localControllers.get(roomId) ?? null;
}
async setController(roomId: string, userId: string | null): Promise<void> {
if (userId) this.localControllers.set(roomId, userId);
else this.localControllers.delete(roomId);
if (!(await this.ensureConnected()) || !this.publisher) return;
const key = `${KEY_PREFIX}:controller:${roomId}`;
const operation = userId
? this.publisher.set(key, userId, { EX: STATE_TTL_SECONDS })
: this.publisher.del(key);
await operation.catch((error) =>
this.logRedisFailure("set_controller", error),
);
}
async listRequests(roomId: string): Promise<CollabControlRequest[]> {
if (await this.ensureConnected()) {
const values = await this.publisher
?.hVals(`${KEY_PREFIX}:requests:${roomId}`)
.catch((error) => {
this.logRedisFailure("list_requests", error);
return null;
});
if (values) {
return values
.flatMap((value) => this.parse<CollabControlRequest>(value) ?? [])
.sort((a, b) => a.requestedAt.localeCompare(b.requestedAt));
}
}
return Array.from(this.localRequests.get(roomId)?.values() ?? []).sort(
(a, b) => a.requestedAt.localeCompare(b.requestedAt),
);
}
async upsertRequest(
roomId: string,
request: CollabControlRequest,
): Promise<void> {
let requests = this.localRequests.get(roomId);
if (!requests) {
requests = new Map();
this.localRequests.set(roomId, requests);
}
requests.set(request.userId, request);
if (!(await this.ensureConnected()) || !this.publisher) return;
const key = `${KEY_PREFIX}:requests:${roomId}`;
await this.publisher
.multi()
.hSet(key, request.userId, JSON.stringify(request))
.expire(key, STATE_TTL_SECONDS)
.exec()
.catch((error) => this.logRedisFailure("upsert_request", error));
}
async removeRequest(roomId: string, userId: string): Promise<void> {
const requests = this.localRequests.get(roomId);
requests?.delete(userId);
if (requests?.size === 0) this.localRequests.delete(roomId);
if (!(await this.ensureConnected()) || !this.publisher) return;
await this.publisher
.hDel(`${KEY_PREFIX}:requests:${roomId}`, userId)
.catch((error) => this.logRedisFailure("remove_request", error));
}
async clearRequests(roomId: string): Promise<void> {
this.localRequests.delete(roomId);
if (!(await this.ensureConnected()) || !this.publisher) return;
await this.publisher
.del(`${KEY_PREFIX}:requests:${roomId}`)
.catch((error) => this.logRedisFailure("clear_requests", error));
}
async updatePresence(
roomId: string,
users: Array<{ userId: string; username: string }>,
): Promise<void> {
if (!(await this.ensureConnected()) || !this.publisher) return;
const key = `${KEY_PREFIX}:presence:${roomId}`;
const previous = this.presenceMembers.get(roomId) ?? new Set<string>();
const expiresAt = Date.now() + PRESENCE_TTL_MS;
const current = new Set(
users.map((user) =>
JSON.stringify({
instanceId: this.instanceId,
...user,
} satisfies PresenceEntry),
),
);
const transaction = this.publisher.multi();
if (previous.size > 0) transaction.zRem(key, Array.from(previous));
for (const member of current) {
transaction.zAdd(key, { score: expiresAt, value: member });
}
transaction.expire(key, Math.ceil(PRESENCE_TTL_MS / 1000) * 2);
await transaction
.exec()
.then(() => {
if (current.size > 0) this.presenceMembers.set(roomId, current);
else this.presenceMembers.delete(roomId);
})
.catch((error) => this.logRedisFailure("update_presence", error));
}
async onlineUsers(
roomId: string,
localUsers: Array<{ userId: string; username: string }>,
): Promise<Array<{ userId: string; username: string }>> {
if (!(await this.ensureConnected()) || !this.publisher) return localUsers;
const key = `${KEY_PREFIX}:presence:${roomId}`;
const now = Date.now();
const transaction = this.publisher
.multi()
.zRemRangeByScore(key, 0, now)
.zRangeByScore(key, now + 1, "+inf");
const result = await transaction.exec().catch((error) => {
this.logRedisFailure("online_users", error);
return null;
});
const members = result?.[1] as string[] | undefined;
if (!members) return localUsers;
const seen = new Map<string, string>();
for (const member of members) {
const entry = this.parse<PresenceEntry>(member);
if (entry) seen.set(entry.userId, entry.username);
}
return Array.from(seen, ([userId, username]) => ({ userId, username }));
}
async close(): Promise<void> {
await Promise.allSettled([
this.publisher?.isOpen ? this.publisher.quit() : Promise.resolve(),
this.subscriber?.isOpen ? this.subscriber.quit() : Promise.resolve(),
]);
this.publisher = null;
this.subscriber = null;
}
private async ensureConnected(): Promise<boolean> {
const url = process.env.REDIS_URL?.trim();
if (!url) return false;
if (this.publisher?.isReady && this.subscriber?.isReady) return true;
if (Date.now() < this.nextConnectAttempt) return false;
if (this.connecting) return this.connecting;
this.connecting = this.connect(url).finally(() => {
this.connecting = null;
});
return this.connecting;
}
private async connect(url: string): Promise<boolean> {
try {
this.publisher = createClient({
url,
socket: { connectTimeout: 1500, reconnectStrategy: false },
});
this.subscriber = this.publisher.duplicate();
this.publisher.on("error", (error) =>
this.logRedisFailure("client", error),
);
this.subscriber.on("error", (error) =>
this.logRedisFailure("subscriber", error),
);
await Promise.all([this.publisher.connect(), this.subscriber.connect()]);
await this.subscriber.subscribe(EVENT_CHANNEL, (raw) => {
const envelope = this.parse<EventEnvelope>(raw);
if (
!envelope ||
typeof envelope.source !== "string" ||
typeof envelope.roomId !== "string" ||
!envelope.message ||
typeof envelope.message !== "object" ||
envelope.source === this.instanceId
) {
return;
}
for (const listener of this.eventListeners) {
try {
listener(envelope.roomId, envelope.message);
} catch (error) {
this.logRedisFailure("event_listener", error);
}
}
});
sshLogger.info("Collaboration Redis runtime connected", {
operation: "collab_redis_connected",
});
this.nextConnectAttempt = 0;
return true;
} catch (error) {
this.nextConnectAttempt = Date.now() + CONNECT_RETRY_MS;
this.logRedisFailure("connect", error);
await Promise.allSettled([
this.publisher?.disconnect(),
this.subscriber?.disconnect(),
]);
this.publisher = null;
this.subscriber = null;
return false;
}
}
private parse<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
private logRedisFailure(operation: string, error: unknown): void {
sshLogger.warn(
"Collaboration Redis runtime unavailable; using local state",
{
operation: `collab_redis_${operation}`,
error: error instanceof Error ? error.message : String(error),
},
);
}
}
export const collabRuntimeStore = new CollabRuntimeStore();
-18
View File
@@ -1,18 +0,0 @@
/**
* Who besides the presenter may drive the current stage, per room.
*
* Redis-backed when REDIS_URL is configured, with a local fallback for
* single-instance deployments. Every stage switch clears it.
*/
import { collabRuntimeStore } from "./runtime-store.js";
export function getStageController(roomId: string): Promise<string | null> {
return collabRuntimeStore.getController(roomId);
}
export function setStageController(
roomId: string,
userId: string | null,
): Promise<void> {
return collabRuntimeStore.setController(roomId, userId);
}
+8 -64
View File
@@ -1,79 +1,23 @@
/**
* What core needs from the Remote Desktop plugin, resolved through the plugin
* registry rather than an import.
* registry rather than an import: restarting its guacd WebSocket server after
* an admin edits the guacd URL setting.
*
* collab, session-sharing and the admin guacd setting all need three things
* from a running Guacamole server: mint a join token, look up a live session,
* restart after a settings change. None of them can import the plugin -- core
* cannot statically import something outside its rootDir -- and the plugin
* cannot hand core a module reference without becoming core again.
*
* So the plugin publishes "remote-desktop.sessions" through ctx.registry on
* activate, the registry revokes it on deactivate, and the helpers below
* degrade the way each caller needs when nothing is published:
*
* - a lookup returns null, because "no such live session" and "the plugin is
* off" mean the same thing to a caller asking whether a session is live;
* - a restart is a no-op, because resurrecting a server the plugin does not
* own would be worse than doing nothing;
* - minting a token throws, because a caller about to hand a viewer a join
* link needs to know it failed rather than get an unusable value.
*
* This replaces the guacamole-dispatch bridge that used to live in
* database/routes/.
* The plugin publishes "remote-desktop.sessions" through ctx.registry on
* activate and the registry revokes it on deactivate. A restart while it is
* off does nothing, because resurrecting a server the plugin does not own
* would be worse than doing nothing.
*/
import { consume } from "../plugins/registry.js";
export const GUACAMOLE_SESSIONS_KEY = "remote-desktop.sessions";
export interface GuacamoleSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
export interface GuacamoleSessionProvider {
restart: () => Promise<void>;
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => string;
getSessionInfo: (
guacamoleConnectionId: string,
) => GuacamoleSessionInfo | null;
}
function provider(): GuacamoleSessionProvider | undefined {
return consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY);
}
/**
* Restarts the plugin's guacd WebSocket server, e.g. after an admin edits the
* guacd URL setting. A no-op while the plugin is disabled.
*/
/** Restarts the plugin's guacd WebSocket server. A no-op while it is off. */
export async function restartGuacamoleService(): Promise<void> {
await provider()?.restart();
}
/**
* Mints a read-only or writable join token for a live Guacamole session, for
* collab and session-sharing to hand an invited viewer. Throws when the plugin
* is not running.
*/
export function createGuacamoleJoinToken(
guacamoleConnectionId: string,
readOnly: boolean,
): string {
const active = provider();
if (!active) {
throw new Error("Remote Desktop plugin is not available");
}
return active.createJoinToken(guacamoleConnectionId, readOnly);
}
/** Looks up a live Guacamole session by guacd's own connection id. */
export function getGuacamoleSessionInfo(
guacamoleConnectionId: string,
): GuacamoleSessionInfo | null {
return provider()?.getSessionInfo(guacamoleConnectionId) ?? null;
await consume<GuacamoleSessionProvider>(GUACAMOLE_SESSIONS_KEY)?.restart();
}
+7 -38
View File
@@ -1,17 +1,17 @@
/**
* What core needs from live SSH terminal sessions, through the plugin
* service "sessions.live" rather than an import.
* service "sessions.live" (the "ssh" provider) rather than an import.
*
* Session sharing, collab and the open-tabs route still live in core and
* reach the sessions the ssh-terminal plugin holds. With no provider (the
* plugin is off) a lookup finds nothing and a control call does nothing,
* because "no such live session" and "the terminal is off" mean the same
* thing to every caller here.
* The open-tabs route lists the caller's sessions and ties saved tabs to the
* terminal's idle timeout. With no provider (the terminal is off) a lookup
* finds nothing, because "no live session" and "the terminal is off" mean the
* same thing there.
*/
import { getServiceImplementation } from "../plugins/service-registry.js";
export const LIVE_SESSIONS_SERVICE = "sessions.live";
const SSH_PROVIDER = "ssh";
export interface LiveSessionInfo {
id: string;
@@ -28,19 +28,7 @@ export interface LiveSessionInfo {
}
interface LiveSessionsV1 {
getSession: (sessionId: string) => LiveSessionInfo | null;
listForUser: (userId: string) => LiveSessionInfo[];
ownerEndSession: (sessionId: string, reason: string) => void;
disconnectParticipants: (
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
) => number;
setRoomShareControl: (
sessionId: string,
shareId: string,
controllerUserId: string | null,
) => void;
idleTimeoutMinutes: () => number;
}
@@ -48,33 +36,14 @@ function provider(): LiveSessionsV1 | undefined {
return getServiceImplementation<LiveSessionsV1>(
LIVE_SESSIONS_SERVICE,
"^1.0.0",
SSH_PROVIDER,
);
}
export const liveTerminalSessions = {
getSession(sessionId: string): LiveSessionInfo | null {
return provider()?.getSession(sessionId) ?? null;
},
listForUser(userId: string): LiveSessionInfo[] {
return provider()?.listForUser(userId) ?? [];
},
ownerEndSession(sessionId: string, reason: string): void {
provider()?.ownerEndSession(sessionId, reason);
},
disconnectShareParticipants(
sessionId: string,
shareId: string,
options: { reason: string; userId?: string | null },
): number {
return provider()?.disconnectParticipants(sessionId, shareId, options) ?? 0;
},
setRoomShareControl(
sessionId: string,
shareId: string,
controllerUserId: string | null,
): void {
provider()?.setRoomShareControl(sessionId, shareId, controllerUserId);
},
/** How long the terminal keeps a detached session, or null while it is off. */
idleTimeoutMinutes(): number | null {
return provider()?.idleTimeoutMinutes() ?? null;
@@ -1,62 +0,0 @@
import { liveTerminalSessions } from "../live-terminal-sessions.js";
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
// through the same in-core bridge its router is mounted behind, since
// plugins/ is compiled separately from src/backend/ and core cannot
// statically import across that boundary. See
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
// README -- this is an accepted, temporary coupling until collab/
// session-sharing themselves become plugins.
import { getGuacamoleSessionInfo as getGuacSessionInfo } from "../guacamole-sessions.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
export type LiveProtocol = "ssh" | "rdp" | "vnc" | "telnet";
export async function isSharingEnabledForHost(hostId: number): Promise<{
enabled: boolean;
hostOwnerId: string | null;
}> {
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
"session_sharing_globally_enabled",
true,
);
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
const hostResolutionRepository = createCurrentHostResolutionRepository();
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
if (!host) return { enabled: false, hostOwnerId: null };
return {
enabled: host.allowSessionSharing !== false,
hostOwnerId,
};
}
export function isLiveSessionOwnedBy(
protocol: LiveProtocol,
sessionId: string,
userId: string,
): boolean {
if (protocol === "ssh") {
const session = liveTerminalSessions.getSession(sessionId);
return !!session && session.isConnected && session.userId === userId;
}
const info = getGuacSessionInfo(sessionId);
return !!info && info.ownerUserId === userId;
}
export function isLiveSession(
protocol: LiveProtocol,
sessionId: string,
): boolean {
if (protocol === "ssh") {
const session = liveTerminalSessions.getSession(sessionId);
return !!session && session.isConnected;
}
return !!getGuacSessionInfo(sessionId);
}
-503
View File
@@ -1,503 +0,0 @@
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 { liveTerminalSessions } from "../live-terminal-sessions.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
isSharingEnabledForHost,
} from "./live-sessions.js";
// Remote Desktop is a first-party plugin (plugins/remote-desktop); this goes
// through the same in-core bridge its router is mounted behind, since
// plugins/ is compiled separately from src/backend/ and core cannot
// statically import across that boundary. See
// src/backend/database/routes/guacamole-dispatch.ts and that plugin's
// README -- this is an accepted, temporary coupling until session-sharing
// becomes a plugin of its own.
import { createGuacamoleJoinToken } from "../guacamole-sessions.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 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<string, ResolveRateEntry>();
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 {
liveTerminalSessions.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<string, string>;
} = {
protocol,
permissionLevel: share.permissionLevel as PermissionLevel,
// Advisory only: the client builds its own socket URL per deployment
// (see pluginWsUrl). Kept accurate rather than left pointing at the
// ports these two moved off in A4.
wsPath:
protocol === "ssh"
? `/plugin-ws/ssh-terminal/terminal?shareToken=${encodeURIComponent(linkToken)}`
: "/plugin-ws/remote-desktop/display",
};
if (protocol !== "ssh") {
const joinToken = createGuacamoleJoinToken(
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") {
liveTerminalSessions.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;