From f44d09eef749ae00e1bf3d2be7204c9077278b02 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 20 Jul 2026 22:18:54 -0500 Subject: [PATCH] feat: add multiplayer/shared sessions for terminal and guacd --- docker/nginx-https.conf | 9 + docker/nginx.conf | 9 + scripts/patch-guacamole-lite.cjs | 106 +++- src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 103 ++++ src/backend/database/db/schema.ts | 59 ++ src/backend/database/repositories/factory.ts | 8 + .../session-recording-repository.ts | 7 +- .../repositories/session-share-repository.ts | 247 ++++++++ src/backend/database/routes/host.ts | 14 +- src/backend/database/routes/open-tabs.ts | 70 ++- .../database/routes/user-settings-routes.ts | 104 ++++ .../hosts/guacamole/guacamole-server.ts | 82 ++- src/backend/hosts/guacamole/routes.ts | 23 +- src/backend/hosts/guacamole/token-service.ts | 33 +- src/backend/hosts/session-sharing/routes.ts | 539 ++++++++++++++++++ src/backend/hosts/terminal/index.ts | 376 ++++++++++-- src/backend/hosts/terminal/session-manager.ts | 223 +++++++- .../host-credential-repositories.test.ts | 1 + .../host-folder-repository.test.ts | 1 + .../host-resolution-repository.test.ts | 1 + .../session-share-repository.test.ts | 393 +++++++++++++ .../user-data-export-repository.test.ts | 1 + .../hosts/guacamole/token-service.test.ts | 37 ++ .../hosts/session-sharing/routes.test.ts | 513 +++++++++++++++++ .../hosts/terminal/session-manager.test.ts | 282 ++++++++- src/main.tsx | 16 + src/types/index.ts | 2 + src/types/ui-types.ts | 5 + src/ui/AppShell.tsx | 75 ++- src/ui/api/guacamole-api.ts | 1 + src/ui/api/open-tabs-api.ts | 4 + src/ui/api/session-sharing-api.ts | 191 +++++++ src/ui/features/guacamole/GuacamoleApp.tsx | 21 + .../session-sharing/ShareSessionModal.tsx | 429 ++++++++++++++ .../session-sharing/SharedSessionView.tsx | 329 +++++++++++ src/ui/features/terminal/Terminal.tsx | 41 +- src/ui/features/terminal/terminal-types.ts | 5 + src/ui/locales/en.json | 60 +- src/ui/shell/TabBar.tsx | 16 + src/ui/shell/tabUtils.tsx | 2 + src/ui/sidebar/AdminSettingsPanel.tsx | 26 + src/ui/sidebar/AdminSettingsSections.tsx | 13 + src/ui/sidebar/ConnectionsPanel.tsx | 104 +++- src/ui/sidebar/HostEditor.tsx | 16 +- src/ui/sidebar/HostEditorData.ts | 2 + src/ui/tests/api/session-sharing-api.test.ts | 94 +++ .../ShareSessionModal.test.tsx | 211 +++++++ .../SharedSessionView.test.tsx | 127 +++++ .../tests/sidebar/ConnectionsPanel.test.tsx | 158 +++++ 50 files changed, 5089 insertions(+), 102 deletions(-) create mode 100644 src/backend/database/repositories/session-share-repository.ts create mode 100644 src/backend/hosts/session-sharing/routes.ts create mode 100644 src/backend/tests/database/repositories/session-share-repository.test.ts create mode 100644 src/backend/tests/hosts/session-sharing/routes.test.ts create mode 100644 src/ui/api/session-sharing-api.ts create mode 100644 src/ui/features/session-sharing/ShareSessionModal.tsx create mode 100644 src/ui/features/session-sharing/SharedSessionView.tsx create mode 100644 src/ui/tests/api/session-sharing-api.test.ts create mode 100644 src/ui/tests/features/session-sharing/ShareSessionModal.test.tsx create mode 100644 src/ui/tests/features/session-sharing/SharedSessionView.test.tsx create mode 100644 src/ui/tests/sidebar/ConnectionsPanel.test.tsx diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 9449028a..2825d86c 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -467,6 +467,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/session-sharing(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location /host/tunnel/ { proxy_pass http://127.0.0.1:30003; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index 68cff5a6..77d6374d 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -456,6 +456,15 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } + location ~ ^/session-sharing(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + } + location /host/tunnel/ { proxy_pass http://127.0.0.1:30003; proxy_http_version 1.1; diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs index 97d8da94..b9a76efe 100644 --- a/scripts/patch-guacamole-lite.cjs +++ b/scripts/patch-guacamole-lite.cjs @@ -17,14 +17,27 @@ const cryptPath = path.join( "lib", "Crypt.js", ); +const clientConnectionPath = path.join( + __dirname, + "..", + "node_modules", + "guacamole-lite", + "lib", + "ClientConnection.js", +); -if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) { +if ( + !fs.existsSync(guacdClientPath) || + !fs.existsSync(cryptPath) || + !fs.existsSync(clientConnectionPath) +) { console.log("[patch-guacamole-lite] File not found, skipping"); process.exit(0); } let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8"); let cryptContent = fs.readFileSync(cryptPath, "utf8"); +let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8"); // Patch 1: protocol version negotiation. // guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol @@ -268,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) { patched = true; } +// Patch 7: drop client-to-guacd input instructions from read-only session-share +// joins. guacd has no native read-only enforcement in the versions this project +// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an +// unrecognized opcode is far more likely to be protocol plumbing (sync, blob, +// clipboard streams) than a new input vector, so failing open is the safer +// default for a client we already control. +const oldSendMessageToGuacd = + " sendMessageToGuacd(message) {\n" + + " this.lastActivity = Date.now();\n" + + " this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" + + "\n" + + " if (this.guacdClient) {\n" + + " this.guacdClient.send(message, true);\n" + + " }\n" + + " }"; +const newSendMessageToGuacd = + " sendMessageToGuacd(message) {\n" + + " this.lastActivity = Date.now();\n" + + " this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" + + "\n" + + " if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" + + " return;\n" + + " }\n" + + "\n" + + " if (this.guacdClient) {\n" + + " this.guacdClient.send(message, true);\n" + + " }\n" + + " }\n" + + "\n" + + " isReadOnlyJoin() {\n" + + " const connection = this.connectionSettings && this.connectionSettings.connection;\n" + + " return !!(connection && connection.join && connection.readOnly === true);\n" + + " }\n" + + "\n" + + " // Termix-only read-only gate, not part of the vendored library: extracts just\n" + + " // the leading opcode from a raw '.,...;' instruction without the\n" + + " // overhead of a full stateful parse.\n" + + " isInputInstruction(message) {\n" + + " const dot = message.indexOf('.');\n" + + " if (dot === -1) return false;\n" + + " const len = parseInt(message.substring(0, dot), 10);\n" + + " if (isNaN(len)) return false;\n" + + " const opcode = message.substring(dot + 1, dot + 1 + len);\n" + + " return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" + + " }"; + +if (!clientConnectionContent.includes("isReadOnlyJoin()")) { + if (!clientConnectionContent.includes(oldSendMessageToGuacd)) { + console.log( + "[patch-guacamole-lite] sendMessageToGuacd target not found, skipping read-only patch", + ); + process.exit(0); + } + clientConnectionContent = clientConnectionContent.replace( + oldSendMessageToGuacd, + newSendMessageToGuacd, + ); + patched = true; +} + +// Patch 8: mergeConnectionOptions only preserves `join` across the settings +// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it. +const oldPreserveJoin = + " // For join connections, preserve the join property\n" + + " if (this.connectionSettings.connection.join) {\n" + + " compiledSettings.join = this.connectionSettings.connection.join;\n" + + " }"; +const newPreserveJoin = + " // For join connections, preserve the join property\n" + + " if (this.connectionSettings.connection.join) {\n" + + " compiledSettings.join = this.connectionSettings.connection.join;\n" + + " compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" + + " }"; + +if (!clientConnectionContent.includes("compiledSettings.readOnly")) { + if (!clientConnectionContent.includes(oldPreserveJoin)) { + console.log( + "[patch-guacamole-lite] join-preserve target not found, skipping readOnly propagation patch", + ); + process.exit(0); + } + clientConnectionContent = clientConnectionContent.replace( + oldPreserveJoin, + newPreserveJoin, + ); + patched = true; +} + if (!patched) { console.log("[patch-guacamole-lite] Already patched"); process.exit(0); @@ -275,6 +376,7 @@ if (!patched) { fs.writeFileSync(guacdClientPath, guacdClientContent); fs.writeFileSync(cryptPath, cryptContent); +fs.writeFileSync(clientConnectionPath, clientConnectionContent); console.log( - "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt", + "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering", ); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 66914a15..c60b204d 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js"; import terminalRoutes from "./routes/terminal.js"; import sessionLogRoutes from "./routes/session-log-routes.js"; import guacamoleRoutes from "../hosts/guacamole/routes.js"; +import sessionSharingRoutes from "../hosts/session-sharing/routes.js"; import networkTopologyRoutes from "./routes/network-topology.js"; import rbacRoutes from "./routes/rbac.js"; import openTabsRoutes from "./routes/open-tabs.js"; @@ -1737,6 +1738,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes); app.use("/terminal", terminalRoutes); app.use("/session_logs", sessionLogRoutes); app.use("/guacamole", guacamoleRoutes); +app.use("/session-sharing", sessionSharingRoutes); app.use("/network-topology", networkTopologyRoutes); app.use("/rbac", rbacRoutes); app.use("/open-tabs", openTabsRoutes); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 804f22f4..979c05dc 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -495,6 +495,38 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL ); + CREATE TABLE IF NOT EXISTS session_shares ( + id TEXT PRIMARY KEY, + host_id INTEGER NOT NULL, + owner_user_id TEXT NOT NULL, + protocol TEXT NOT NULL, + session_id TEXT NOT NULL, + tab_instance_id TEXT, + share_type TEXT NOT NULL, + target_user_id TEXT, + link_token TEXT UNIQUE, + permission_level TEXT NOT NULL DEFAULT 'read-only', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + revoked_at TEXT, + last_joined_at TEXT, + join_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS session_share_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_id TEXT NOT NULL, + user_id TEXT, + guest_label TEXT, + joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + left_at TEXT, + FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -1456,6 +1488,7 @@ const migrateSchema = () => { { column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" }, { column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" }, { column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" }, + { column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" }, ]; for (const migration of sshDataMigrations) { @@ -2290,6 +2323,76 @@ const migrateSchema = () => { } // --- homepage end --- + try { + sqlite.prepare("SELECT id FROM session_shares LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS session_shares ( + id TEXT PRIMARY KEY, + host_id INTEGER NOT NULL, + owner_user_id TEXT NOT NULL, + protocol TEXT NOT NULL, + session_id TEXT NOT NULL, + tab_instance_id TEXT, + share_type TEXT NOT NULL, + target_user_id TEXT, + link_token TEXT UNIQUE, + permission_level TEXT NOT NULL DEFAULT 'read-only', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + revoked_at TEXT, + last_joined_at TEXT, + join_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + sqlite.exec( + "CREATE INDEX IF NOT EXISTS idx_session_shares_link_token ON session_shares(link_token)", + ); + sqlite.exec( + "CREATE INDEX IF NOT EXISTS idx_session_shares_target_user ON session_shares(target_user_id)", + ); + sqlite.exec( + "CREATE INDEX IF NOT EXISTS idx_session_shares_host ON session_shares(host_id)", + ); + } catch (createError) { + databaseLogger.warn("Failed to create session_shares table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { + sqlite.prepare("SELECT id FROM session_share_participants LIMIT 1").get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS session_share_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_id TEXT NOT NULL, + user_id TEXT, + guest_label TEXT, + joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + left_at TEXT, + FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + sqlite.exec( + "CREATE INDEX IF NOT EXISTS idx_session_share_participants_share ON session_share_participants(share_id)", + ); + } catch (createError) { + databaseLogger.warn("Failed to create session_share_participants table", { + operation: "schema_migration", + error: createError, + }); + } + } + databaseLogger.success("Schema migration completed", { operation: "schema_migration", }); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index d7c46eca..b22e975b 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", { enableSessionLogging: integer("enable_session_logging", { mode: "boolean" }) .notNull() .default(true), + allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" }) + .notNull() + .default(true), enableCommandHistory: integer("enable_command_history", { mode: "boolean" }) .notNull() .default(true), @@ -676,6 +679,62 @@ export const sessionRecordings = sqliteTable("session_recordings", { terminationReason: text("termination_reason"), }); +export const sessionShares = sqliteTable("session_shares", { + id: text("id").primaryKey(), + + hostId: integer("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + ownerUserId: text("owner_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + protocol: text("protocol").notNull(), + + // Live-session binding: TerminalSessionManager's session.id for SSH, or + // guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB + // row (process-local, in-memory) so this intentionally has no FK. + sessionId: text("session_id").notNull(), + tabInstanceId: text("tab_instance_id"), + + shareType: text("share_type").notNull(), // "link" | "user" + targetUserId: text("target_user_id").references(() => users.id, { + onDelete: "cascade", + }), + linkToken: text("link_token").unique(), + + permissionLevel: text("permission_level").notNull().default("read-only"), + + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expiresAt: text("expires_at").notNull(), + revokedAt: text("revoked_at"), + + lastJoinedAt: text("last_joined_at"), + joinCount: integer("join_count").notNull().default(0), +}); + +export const sessionShareParticipants = sqliteTable( + "session_share_participants", + { + id: integer("id").primaryKey({ autoIncrement: true }), + shareId: text("share_id") + .notNull() + .references(() => sessionShares.id, { onDelete: "cascade" }), + + userId: text("user_id").references(() => users.id, { + onDelete: "cascade", + }), + guestLabel: text("guest_label"), + + joinedAt: text("joined_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + leftAt: text("left_at"), + }, +); + export const opksshTokens = sqliteTable("opkssh_tokens", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 0db7fea1..51cd953c 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -27,6 +27,7 @@ import { RecentActivityRepository } from "./recent-activity-repository.js"; import { RoleRepository } from "./role-repository.js"; import { SessionRecordingRepository } from "./session-recording-repository.js"; import { SessionRepository } from "./session-repository.js"; +import { SessionShareRepository } from "./session-share-repository.js"; import { SettingsRepository } from "./settings-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SnippetRepository } from "./snippet-repository.js"; @@ -253,6 +254,13 @@ export function createCurrentSessionRepository(): SessionRepository { ); } +export function createCurrentSessionShareRepository(): SessionShareRepository { + return new SessionShareRepository( + createCurrentRepositoryContext(), + createCurrentRepositoryWriteHook("session_share_repository_write"), + ); +} + export function createCurrentSettingsRepository(): SettingsRepository { return new SettingsRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts index 678a24a1..fa3efc10 100644 --- a/src/backend/database/repositories/session-recording-repository.ts +++ b/src/backend/database/repositories/session-recording-repository.ts @@ -58,7 +58,12 @@ export class SessionRecordingRepository { async updateEnded( id: number, - input: { endedAt: string; duration: number | null }, + input: { + endedAt: string; + duration: number | null; + terminatedByOwner?: boolean; + terminationReason?: string; + }, ): Promise { await this.context.drizzle .update(sessionRecordings) diff --git a/src/backend/database/repositories/session-share-repository.ts b/src/backend/database/repositories/session-share-repository.ts new file mode 100644 index 00000000..016c44f1 --- /dev/null +++ b/src/backend/database/repositories/session-share-repository.ts @@ -0,0 +1,247 @@ +import { and, eq, gt, isNull, lt } from "drizzle-orm"; +import { + hosts, + sessionShareParticipants, + sessionShares, + users, +} from "../db/schema.js"; +import type { DatabaseContext } from "./database-context.js"; + +export type SessionShareRecord = typeof sessionShares.$inferSelect; +export type SessionShareParticipantRecord = + typeof sessionShareParticipants.$inferSelect; + +export type SessionShareType = "link" | "user"; +export type SessionSharePermissionLevel = "read-only" | "read-write"; + +export interface SessionShareCreateInput { + id: string; + hostId: number; + ownerUserId: string; + protocol: string; + sessionId: string; + tabInstanceId?: string | null; + shareType: SessionShareType; + targetUserId?: string | null; + linkToken?: string | null; + permissionLevel: SessionSharePermissionLevel; + expiresAt: string; +} + +export interface SessionShareWithHost extends SessionShareRecord { + hostName: string | null; + ownerUsername: string | null; +} + +export interface SharedWithMeRecord extends SessionShareRecord { + hostName: string | null; + ownerUsername: string | null; +} + +function activeShareFilter(now: string) { + return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now)); +} + +export class SessionShareRepository { + constructor( + private readonly context: DatabaseContext, + private readonly onWrite?: () => void | Promise, + ) {} + + async create(input: SessionShareCreateInput): Promise { + const [created] = await this.context.drizzle + .insert(sessionShares) + .values({ + id: input.id, + hostId: input.hostId, + ownerUserId: input.ownerUserId, + protocol: input.protocol, + sessionId: input.sessionId, + tabInstanceId: input.tabInstanceId ?? null, + shareType: input.shareType, + targetUserId: input.targetUserId ?? null, + linkToken: input.linkToken ?? null, + permissionLevel: input.permissionLevel, + expiresAt: input.expiresAt, + }) + .returning(); + + await this.afterWrite(); + return created; + } + + async findById(id: string): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionShares) + .where(eq(sessionShares.id, id)) + .limit(1); + return rows[0] ?? null; + } + + async findActiveById( + id: string, + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionShares) + .where(and(eq(sessionShares.id, id), activeShareFilter(now))) + .limit(1); + return rows[0] ?? null; + } + + async findByLinkToken( + linkToken: string, + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select() + .from(sessionShares) + .where( + and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)), + ) + .limit(1); + return rows[0] ?? null; + } + + async findActiveSharesForHost( + hostId: number, + ownerUserId: string, + now = new Date().toISOString(), + ): Promise { + return this.context.drizzle + .select() + .from(sessionShares) + .where( + and( + eq(sessionShares.hostId, hostId), + eq(sessionShares.ownerUserId, ownerUserId), + activeShareFilter(now), + ), + ); + } + + async findSharesTargetingUser( + userId: string, + now = new Date().toISOString(), + ): Promise { + const rows = await this.context.drizzle + .select({ + share: sessionShares, + hostName: hosts.name, + ownerUsername: users.username, + }) + .from(sessionShares) + .leftJoin(hosts, eq(sessionShares.hostId, hosts.id)) + .leftJoin(users, eq(sessionShares.ownerUserId, users.id)) + .where( + and( + eq(sessionShares.shareType, "user"), + eq(sessionShares.targetUserId, userId), + activeShareFilter(now), + ), + ); + + return rows.map((row) => ({ + ...row.share, + hostName: row.hostName, + ownerUsername: row.ownerUsername, + })); + } + + async revoke(shareId: string, requestingUserId: string): Promise { + const rows = await this.context.drizzle + .update(sessionShares) + .set({ revokedAt: new Date().toISOString() }) + .where( + and( + eq(sessionShares.id, shareId), + eq(sessionShares.ownerUserId, requestingUserId), + ), + ) + .returning({ id: sessionShares.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + return rows.length > 0; + } + + async revokeAsAdmin(shareId: string): Promise { + const rows = await this.context.drizzle + .update(sessionShares) + .set({ revokedAt: new Date().toISOString() }) + .where(eq(sessionShares.id, shareId)) + .returning({ id: sessionShares.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + return rows.length > 0; + } + + async deleteExpiredShares(now = new Date().toISOString()): Promise { + const rows = await this.context.drizzle + .delete(sessionShares) + .where(lt(sessionShares.expiresAt, now)) + .returning({ id: sessionShares.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + return rows.length; + } + + async touchShareUsage( + shareId: string, + lastJoinedAt = new Date().toISOString(), + ): Promise { + const current = await this.findById(shareId); + await this.context.drizzle + .update(sessionShares) + .set({ + lastJoinedAt, + joinCount: (current?.joinCount ?? 0) + 1, + }) + .where(eq(sessionShares.id, shareId)); + await this.afterWrite(); + } + + async recordParticipantJoin( + shareId: string, + userId: string | null, + guestLabel: string | null, + ): Promise { + const [created] = await this.context.drizzle + .insert(sessionShareParticipants) + .values({ shareId, userId, guestLabel }) + .returning(); + await this.afterWrite(); + return created; + } + + async recordParticipantLeave(participantId: number): Promise { + await this.context.drizzle + .update(sessionShareParticipants) + .set({ leftAt: new Date().toISOString() }) + .where(eq(sessionShareParticipants.id, participantId)); + await this.afterWrite(); + } + + async deleteSharesForHost(hostId: number): Promise { + const rows = await this.context.drizzle + .delete(sessionShares) + .where(eq(sessionShares.hostId, hostId)) + .returning({ id: sessionShares.id }); + + if (rows.length > 0) { + await this.afterWrite(); + } + return rows.length; + } + + private async afterWrite(): Promise { + await this.onWrite?.(); + } +} diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 68c77f15..18848fc1 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -175,6 +175,7 @@ router.post( enableDocker, enableProxmox, enableTmuxMonitor, + allowSessionSharing, showTerminalInSidebar, showFileManagerInSidebar, showTunnelInSidebar, @@ -288,6 +289,7 @@ router.post( enableDocker: enableDocker ? 1 : 0, enableProxmox: enableProxmox ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, + allowSessionSharing: allowSessionSharing === false ? 0 : 1, showTerminalInSidebar: showTerminalInSidebar ? 1 : 0, showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0, showTunnelInSidebar: showTunnelInSidebar ? 1 : 0, @@ -815,6 +817,7 @@ router.put( enableDocker, enableProxmox, enableTmuxMonitor, + allowSessionSharing, showTerminalInSidebar, showFileManagerInSidebar, showTunnelInSidebar, @@ -925,6 +928,7 @@ router.put( enableDocker: enableDocker ? 1 : 0, enableProxmox: enableProxmox ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, + allowSessionSharing: allowSessionSharing === false ? 0 : 1, showTerminalInSidebar: showTerminalInSidebar ? 1 : 0, showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0, showTunnelInSidebar: showTunnelInSidebar ? 1 : 0, @@ -1500,9 +1504,13 @@ router.get( const field = (req.query.field as string) || "password"; if ( - !["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes( - field, - ) + ![ + "password", + "sudoPassword", + "vncPassword", + "key", + "keyPassword", + ].includes(field) ) { return res.status(400).json({ error: "Invalid field" }); } diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts index 6ea7c22e..0247a47a 100644 --- a/src/backend/database/routes/open-tabs.ts +++ b/src/backend/database/routes/open-tabs.ts @@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js"; import { getCurrentSettingValue, createCurrentOpenTabRepository, + createCurrentSessionShareRepository, } from "../repositories/factory.js"; const router = express.Router(); @@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { * /open-tabs/active-sessions: * get: * summary: Get all active backend sessions for the current user - * description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic. + * description: > + * Returns live terminal sessions from the session manager, both sessions the + * caller owns and SSH sessions shared to the caller by another user (via + * an in-app session share). Used by the Active Connections panel and tab restore logic. * tags: * - Open Tabs * responses: * 200: - * description: List of active sessions. + * description: List of active sessions (own and shared-with-me). * content: * application/json: * schema: @@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => { * type: boolean * createdAt: * type: number + * isOwnSession: + * type: boolean + * sharedByUsername: + * type: string + * nullable: true + * permissionLevel: + * type: string + * nullable: true + * shareId: + * type: string + * nullable: true */ router.get( "/active-sessions", @@ -309,17 +324,46 @@ router.get( async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; try { - const sessions = sessionManager.getUserSessions(userId); - return res.json( - sessions.map((s) => ({ - sessionId: s.id, - hostId: s.hostId, - hostName: s.hostName, - tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null, - isConnected: s.isConnected, - createdAt: s.createdAt, - })), - ); + const ownSessions = sessionManager.getUserSessions(userId); + const result = ownSessions.map((s) => ({ + sessionId: s.id, + hostId: s.hostId, + hostName: s.hostName, + tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null, + isConnected: s.isConnected, + createdAt: s.createdAt, + isOwnSession: true, + sharedByUsername: null as string | null, + permissionLevel: null as string | null, + shareId: null as string | null, + })); + + const sharedWithMe = + await createCurrentSessionShareRepository().findSharesTargetingUser( + userId, + ); + for (const share of sharedWithMe) { + if (share.protocol !== "ssh") continue; + const sharedSession = sessionManager.getSession(share.sessionId); + if (!sharedSession || !sharedSession.isConnected) continue; + result.push({ + sessionId: sharedSession.id, + hostId: sharedSession.hostId, + hostName: sharedSession.hostName, + tabInstanceId: + sharedSession.attachedTabInstanceId ?? + sharedSession.tabInstanceId ?? + null, + isConnected: sharedSession.isConnected, + createdAt: sharedSession.createdAt, + isOwnSession: false, + sharedByUsername: share.ownerUsername, + permissionLevel: share.permissionLevel, + shareId: share.id, + }); + } + + return res.json(result); } catch (e) { databaseLogger.error("Failed to get active sessions", e, { operation: "get_active_sessions", diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index bd0894bf..27819bff 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -616,6 +616,110 @@ export function registerUserSettingsRoutes( } }); + /** + * @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/host-defaults: diff --git a/src/backend/hosts/guacamole/guacamole-server.ts b/src/backend/hosts/guacamole/guacamole-server.ts index 1d142e21..e1e53c06 100644 --- a/src/backend/hosts/guacamole/guacamole-server.ts +++ b/src/backend/hosts/guacamole/guacamole-server.ts @@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR = path.join(DATA_DIR, "session_recordings", "guacamole"); type GuacamoleClientConnection = { + guacamoleConnectionId?: string; connectionSettings?: { - connection?: { type?: string }; + connection?: { type?: string; join?: string; readOnly?: boolean }; recording?: GuacamoleRecordingMetadata; + termixMeta?: { + termixConnectId: string; + hostId: number; + ownerUserId: string; + protocol: string; + }; }; }; +export interface GuacSessionInfo { + guacamoleConnectionId: string; + hostId: number; + ownerUserId: string; + protocol: string; + openedAt: number; +} + +// Keyed by termixConnectId (routes.ts's correlation id), populated once the +// primary connection's guacd handshake completes. +const guacSessionByConnectId = new Map(); +// Keyed by guacd's own guacamoleConnectionId, for join-time lookups. +const guacSessionByGuacamoleId = new Map(); +const pendingConnectResolvers = new Map< + string, + (info: GuacSessionInfo | null) => void +>(); + +export function waitForGuacdOpen( + termixConnectId: string, + timeoutMs = 10000, +): Promise { + const existing = guacSessionByConnectId.get(termixConnectId); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve) => { + let settled = false; + const finish = (info: GuacSessionInfo | null) => { + if (settled) return; + settled = true; + pendingConnectResolvers.delete(termixConnectId); + resolve(info); + }; + + pendingConnectResolvers.set(termixConnectId, finish); + setTimeout(() => finish(null), timeoutMs); + }); +} + +export function getGuacSessionInfo( + guacamoleConnectionId: string, +): GuacSessionInfo | null { + return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null; +} + async function persistGuacamoleRecording( clientConnection: GuacamoleClientConnection, ): Promise { @@ -149,6 +201,25 @@ function createGuacServer(): GuacamoleLite { operation: "guac_connection_open", type: clientConnection.connectionSettings?.connection?.type, }); + + const termixMeta = clientConnection.connectionSettings?.termixMeta; + const guacamoleConnectionId = clientConnection.guacamoleConnectionId; + const isJoin = !!clientConnection.connectionSettings?.connection?.join; + + if (!isJoin && termixMeta && guacamoleConnectionId) { + const info: GuacSessionInfo = { + guacamoleConnectionId, + hostId: termixMeta.hostId, + ownerUserId: termixMeta.ownerUserId, + protocol: termixMeta.protocol, + openedAt: Date.now(), + }; + guacSessionByConnectId.set(termixMeta.termixConnectId, info); + guacSessionByGuacamoleId.set(guacamoleConnectionId, info); + + const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId); + if (resolver) resolver(info); + } }); server.on("close", (clientConnection: GuacamoleClientConnection) => { @@ -156,6 +227,15 @@ function createGuacServer(): GuacamoleLite { operation: "guac_connection_close", type: clientConnection.connectionSettings?.connection?.type, }); + + const isJoin = !!clientConnection.connectionSettings?.connection?.join; + const termixMeta = clientConnection.connectionSettings?.termixMeta; + const guacamoleConnectionId = clientConnection.guacamoleConnectionId; + if (!isJoin && termixMeta && guacamoleConnectionId) { + guacSessionByConnectId.delete(termixMeta.termixConnectId); + guacSessionByGuacamoleId.delete(guacamoleConnectionId); + } + persistGuacamoleRecording(clientConnection).catch((error) => { guacLogger.error("Failed to persist Guacamole recording", error, { operation: "guac_recording_persist_error", diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts index b106dab9..3840ee1c 100644 --- a/src/backend/hosts/guacamole/routes.ts +++ b/src/backend/hosts/guacamole/routes.ts @@ -14,6 +14,7 @@ import { import { resolveGuacdOptions } from "../../utils/guacd-config.js"; import { createJumpHostChain } from "../jump-host-chain.js"; import type { SOCKS5Config } from "../../utils/socks5-helper.js"; +import { waitForGuacdOpen } from "./guacamole-server.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); @@ -183,6 +184,10 @@ router.post("/token", async (req, res) => { * token: * type: string * description: Encrypted connection token + * guacamoleConnectionId: + * type: string + * nullable: true + * description: guacd's own connection id for this session, once the handshake completes. Used to mint session-share join tokens. * 400: * description: Invalid request or unsupported connection type * 403: @@ -607,6 +612,14 @@ router.post( guacConfig["recording-include-keys"] = true; } + const termixConnectId = crypto.randomUUID(); + const termixMeta = { + termixConnectId, + hostId, + ownerUserId: userId, + protocol: connectionType as "rdp" | "vnc" | "telnet", + }; + switch (connectionType) { case "rdp": if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) { @@ -634,6 +647,7 @@ router.post( ...guacdOverrides, }, recordingMetadata, + termixMeta, ); break; case "vnc": @@ -648,6 +662,7 @@ router.post( ...guacdOverrides, }, recordingMetadata, + termixMeta, ); break; case "telnet": @@ -661,13 +676,19 @@ router.post( ...guacdOverrides, }, recordingMetadata, + termixMeta, ); break; default: return res.status(400).json({ error: "Invalid connection type" }); } - res.json({ token }); + const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000); + + res.json({ + token, + guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null, + }); } catch (error) { guacLogger.error("Failed to generate guacamole token for host", error, { operation: "guac_host_token_error", diff --git a/src/backend/hosts/guacamole/token-service.ts b/src/backend/hosts/guacamole/token-service.ts index d2fe23da..a77e6203 100644 --- a/src/backend/hosts/guacamole/token-service.ts +++ b/src/backend/hosts/guacamole/token-service.ts @@ -2,11 +2,13 @@ import crypto from "crypto"; import { guacLogger } from "../../utils/logger.js"; export interface GuacamoleConnectionSettings { - type: "rdp" | "vnc" | "telnet"; + type?: "rdp" | "vnc" | "telnet"; + join?: string; + readOnly?: boolean; guacdHost?: string; guacdPort?: number; settings: { - hostname: string; + hostname?: string; port?: number; username?: string; password?: string; @@ -28,9 +30,17 @@ export interface GuacamoleConnectionSettings { }; } +export interface TermixGuacMeta { + termixConnectId: string; + hostId: number; + ownerUserId: string; + protocol: "rdp" | "vnc" | "telnet"; +} + export interface GuacamoleToken { connection: GuacamoleConnectionSettings; recording?: GuacamoleRecordingMetadata; + termixMeta?: TermixGuacMeta; } export interface GuacamoleRecordingMetadata { @@ -137,6 +147,7 @@ export class GuacamoleTokenService { guacdPort?: number; } = {}, recording?: GuacamoleRecordingMetadata, + termixMeta?: TermixGuacMeta, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -155,6 +166,7 @@ export class GuacamoleTokenService { }, }, recording, + termixMeta, }; return this.encryptToken(token); } @@ -168,6 +180,7 @@ export class GuacamoleTokenService { guacdPort?: number; } = {}, recording?: GuacamoleRecordingMetadata, + termixMeta?: TermixGuacMeta, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -184,6 +197,7 @@ export class GuacamoleTokenService { }, }, recording, + termixMeta, }; return this.encryptToken(token); } @@ -197,6 +211,7 @@ export class GuacamoleTokenService { guacdPort?: number; } = {}, recording?: GuacamoleRecordingMetadata, + termixMeta?: TermixGuacMeta, ): string { const { guacdHost, guacdPort, ...settingsOptions } = options; const token: GuacamoleToken = { @@ -213,6 +228,20 @@ export class GuacamoleTokenService { }, }, recording, + termixMeta, + }; + return this.encryptToken(token); + } + + // join tokens never carry recording params - only the primary connection's + // token should write recording-path/recording-name to guacd. + createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string { + const token: GuacamoleToken = { + connection: { + join: guacamoleConnectionId, + readOnly, + settings: {}, + }, }; return this.encryptToken(token); } diff --git a/src/backend/hosts/session-sharing/routes.ts b/src/backend/hosts/session-sharing/routes.ts new file mode 100644 index 00000000..4e37452d --- /dev/null +++ b/src/backend/hosts/session-sharing/routes.ts @@ -0,0 +1,539 @@ +import crypto from "crypto"; +import express from "express"; +import type { Request, 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 { getGuacSessionInfo } from "../guacamole/guacamole-server.js"; +import { GuacamoleTokenService } from "../guacamole/token-service.js"; +import { + createCurrentSessionShareRepository, + createCurrentSettingsRepository, + createCurrentHostResolutionRepository, +} 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(); +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, +); + +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, + }; +} + +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(); +} + +function isLiveSessionOwnedBy( + protocol: Protocol, + sessionId: string, + userId: string, +): boolean { + if (protocol === "ssh") { + const session = sessionManager.getSession(sessionId); + return !!session && session.isConnected && session.userId === userId; + } + const info = getGuacSessionInfo(sessionId); + return !!info && info.ownerUserId === userId; +} + +function isLiveSession(protocol: Protocol, sessionId: string): boolean { + if (protocol === "ssh") { + const session = sessionManager.getSession(sessionId); + return !!session && session.isConnected; + } + return !!getGuacSessionInfo(sessionId); +} + +/** + * @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; diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index edb4383a..2121177af 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -20,7 +20,14 @@ import { SSHAuthManager } from "../auth-manager.js"; import type { ProxyNode } from "../../../types/index.js"; import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { createJumpHostChain } from "../jump-host-chain.js"; -import { sessionManager } from "./session-manager.js"; +import { + sessionManager, + isMessageAllowedForParticipant, +} from "./session-manager.js"; +import { + createCurrentSessionShareRepository, + createCurrentSettingsRepository, +} from "../../database/repositories/factory.js"; import { detectTmux, attachOrCreateTmuxSession, @@ -105,10 +112,159 @@ const wss = new WebSocketServer({ port: 30002, }); +/** + * Auth path for anonymous share-link guests (?shareToken=). + * Never touches DataCrypto/user credentials - guests join an already-live + * stream and never decrypt stored secrets. + */ +async function handleShareTokenConnection( + ws: WebSocket, + req: import("http").IncomingMessage, + shareToken: string, +): Promise { + const shareRepo = createCurrentSessionShareRepository(); + const share = await shareRepo.findByLinkToken(shareToken); + if (!share) { + ws.close(1008, "Invalid or expired share link"); + return; + } + if (share.protocol !== "ssh") { + ws.close(1008, "Unsupported share protocol"); + return; + } + + const globallyEnabled = await createCurrentSettingsRepository().getBoolean( + "session_sharing_globally_enabled", + true, + ); + if (!globallyEnabled) { + ws.close(1008, "Session sharing is disabled"); + return; + } + + const host = await createCurrentHostResolutionRepository().findHostById( + share.hostId, + share.ownerUserId, + ); + if (!host || host.allowSessionSharing === false) { + ws.close(1008, "Session sharing is disabled for this host"); + return; + } + + const session = sessionManager.getSession(share.sessionId); + if (!session || !session.isConnected) { + ws.close(1008, "Session has ended"); + return; + } + + const permissionLevel = share.permissionLevel as "read-write" | "read-only"; + const joined = sessionManager.joinAsParticipant(share.sessionId, ws, { + userId: null, + permissionLevel, + guestLabel: "Guest", + shareId: share.id, + }); + if (!joined) { + ws.close(1008, "Session is no longer active"); + return; + } + + shareRepo.touchShareUsage(share.id).catch(() => {}); + shareRepo.recordParticipantJoin(share.id, null, "Guest").catch(() => {}); + + const buffered = sessionManager.getBuffer(joined); + if (buffered) { + ws.send(JSON.stringify({ type: "data", data: buffered })); + } + ws.send( + JSON.stringify({ type: "sessionAttached", sessionId: share.sessionId }), + ); + ws.send(JSON.stringify({ type: "connected", message: "Joined session" })); + + const currentSessionId: string = share.sessionId; + + let wsAlive = true; + ws.on("pong", () => { + wsAlive = true; + }); + const wsPingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + if (!wsAlive) { + ws.terminate(); + return; + } + wsAlive = false; + ws.ping(); + } else { + clearInterval(wsPingInterval); + } + }, 30000); + + ws.on("close", () => { + clearInterval(wsPingInterval); + sessionManager.removeParticipant(currentSessionId, ws); + sshLogger.info("Guest left shared terminal session", { + operation: "terminal_guest_disconnect", + sessionId: currentSessionId, + shareId: share.id, + }); + }); + + ws.on("message", (msg: RawData) => { + let parsed: WebSocketMessage; + try { + parsed = JSON.parse(msg.toString()) as WebSocketMessage; + } catch { + return; + } + const { type, data } = parsed; + + const liveSession = sessionManager.getSession(currentSessionId); + const participant = liveSession + ? sessionManager.getParticipantForWs(liveSession, ws) + : null; + if (!isMessageAllowedForParticipant(participant, type)) { + return; + } + + switch (type) { + case "input": { + const inputData = data as string; + sessionManager.bufferInput(currentSessionId, inputData); + const inputStream = liveSession?.sshStream; + if (inputStream) { + try { + inputStream.write(Buffer.from(inputData, "utf8")); + } catch { + inputStream.write(Buffer.from(inputData, "latin1")); + } + } + break; + } + case "ping": + ws.send(JSON.stringify({ type: "pong" })); + break; + case "disconnect": + sessionManager.removeParticipant(currentSessionId, ws); + break; + default: + break; + } + }); +} + wss.on("connection", async (ws: WebSocket, req) => { let userId: string | undefined; let sessionId: string | undefined; + const urlObj = new URL(req.url || "", "http://localhost"); + const shareToken = urlObj.searchParams.get("shareToken"); + + if (shareToken) { + await handleShareTokenConnection(ws, req, shareToken); + return; + } + try { let token: string | undefined; @@ -126,7 +282,6 @@ wss.on("connection", async (ws: WebSocket, req) => { } if (!token) { - const urlObj = new URL(req.url || "", "http://localhost"); const qp = urlObj.searchParams.get("token"); if (qp) token = qp; } @@ -242,11 +397,20 @@ wss.on("connection", async (ws: WebSocket, req) => { if (currentSessionId) { const session = sessionManager.getSession(currentSessionId); if (session?.isConnected) { - // Only detach if this WS is still the one attached to the session. - // If a refresh reconnected and reattached a new WS before this close - // event fired, we must not clobber that new attachment. - if (session.attachedWs === ws || session.attachedWs === null) { - sessionManager.detachWs(currentSessionId); + const participant = sessionManager.getParticipantForWs(session, ws); + if (participant && !participant.isOwner) { + sessionManager.removeParticipant(currentSessionId, ws); + } else { + // Only detach if this WS is still the owner's attached socket, or + // no owner is currently attached. If a refresh reconnected and + // reattached a new WS before this close event fired, we must not + // clobber that new attachment. + const ownerStillAttached = Array.from( + session.participants.values(), + ).some((p) => p.isOwner && p.ws !== ws); + if (!ownerStillAttached) { + sessionManager.detachWs(currentSessionId); + } } } else { sessionManager.destroySession(currentSessionId); @@ -295,6 +459,21 @@ wss.on("connection", async (ws: WebSocket, req) => { const { type, data } = parsed; + // Server-side gate: non-owner participants (read-only or read-write + // guests/joiners) may only send input/ping/disconnect - everything else + // (auth flows, tmux, resize, etc.) is owner-only and silently ignored. + if (type !== "joinSharedSession") { + const gateSession = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + const gateParticipant = gateSession + ? sessionManager.getParticipantForWs(gateSession, ws) + : null; + if (!isMessageAllowedForParticipant(gateParticipant, type)) { + return; + } + } + switch (type) { case "connectToHost": { const connectData = data as ConnectToHostData; @@ -445,7 +624,20 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } - case "disconnect": + case "disconnect": { + const disconnectSession = currentSessionId + ? sessionManager.getSession(currentSessionId) + : null; + const disconnectParticipant = disconnectSession + ? sessionManager.getParticipantForWs(disconnectSession, ws) + : null; + if (disconnectParticipant && !disconnectParticipant.isOwner) { + if (currentSessionId) { + sessionManager.removeParticipant(currentSessionId, ws); + currentSessionId = null; + } + break; + } if (currentSessionId) { sessionManager.destroySession(currentSessionId); currentSessionId = null; @@ -454,6 +646,7 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn = null; sshStream = null; break; + } case "get_cwd": { const activeConn = @@ -474,10 +667,8 @@ wss.on("connection", async (ws: WebSocket, req) => { execStream.stderr.on("data", () => {}); execStream.on("close", () => { const cwd = stdout.trim() || "/"; - const attachedWs = - sessionManager.getSession(currentSessionId)?.attachedWs ?? ws; - if (attachedWs.readyState === WebSocket.OPEN) { - attachedWs.send(JSON.stringify({ type: "cwd", path: cwd })); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "cwd", path: cwd })); } }); }); @@ -517,10 +708,8 @@ wss.on("connection", async (ws: WebSocket, req) => { execStream.stderr.on("data", () => {}); execStream.on("close", () => { const resolvedPath = stdout.trim() || requestedPath; - const attachedWs = - sessionManager.getSession(currentSessionId)?.attachedWs ?? ws; - if (attachedWs.readyState === WebSocket.OPEN) { - attachedWs.send( + if (ws.readyState === WebSocket.OPEN) { + ws.send( JSON.stringify({ type: "open_file_in_editor", path: resolvedPath, @@ -1001,6 +1190,105 @@ wss.on("connection", async (ws: WebSocket, req) => { break; } + case "joinSharedSession": { + const joinData = data as { shareId: string; tabInstanceId?: string }; + try { + const shareRepo = createCurrentSessionShareRepository(); + const share = await shareRepo.findActiveById(joinData.shareId); + if ( + !share || + share.shareType !== "user" || + share.targetUserId !== userId || + share.protocol !== "ssh" + ) { + ws.send( + JSON.stringify({ + type: "error", + message: "Share not found or not accessible", + }), + ); + break; + } + + const { PermissionManager } = + await import("../../utils/permission-manager.js"); + const access = await PermissionManager.getInstance().canAccessHost( + userId, + share.hostId, + "connect", + ); + if (!access.hasAccess) { + ws.send( + JSON.stringify({ + type: "error", + message: "Share not found or not accessible", + }), + ); + break; + } + + const joinedSession = sessionManager.joinAsParticipant( + share.sessionId, + ws, + { + userId, + permissionLevel: share.permissionLevel as + | "read-write" + | "read-only", + tabInstanceId: joinData.tabInstanceId, + shareId: share.id, + }, + ); + if (!joinedSession) { + ws.send( + JSON.stringify({ + type: "error", + message: "Shared session is no longer active", + }), + ); + break; + } + + currentSessionId = share.sessionId; + sshStream = joinedSession.sshStream; + sshConn = joinedSession.sshConn; + isConnecting = false; + isConnected = true; + + shareRepo.touchShareUsage(share.id).catch(() => {}); + shareRepo + .recordParticipantJoin(share.id, userId, null) + .catch(() => {}); + + const buffered = sessionManager.getBuffer(joinedSession); + if (buffered) { + ws.send(JSON.stringify({ type: "data", data: buffered })); + } + ws.send( + JSON.stringify({ + type: "sessionAttached", + sessionId: share.sessionId, + }), + ); + ws.send( + JSON.stringify({ type: "connected", message: "Joined session" }), + ); + } catch (error) { + sshLogger.error("Failed to join shared session", error, { + operation: "terminal_join_shared_session_error", + userId, + shareId: joinData.shareId, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to join shared session", + }), + ); + } + break; + } + default: sshLogger.warn("Unknown message type received", { operation: "websocket_message_unknown_type", @@ -1636,12 +1924,10 @@ wss.on("connection", async (ws: WebSocket, req) => { const session = sessionManager.getSession(boundSessionId); if (session) { sessionManager.bufferOutput(boundSessionId!, utf8String); - - if (session.attachedWs?.readyState === WebSocket.OPEN) { - session.attachedWs.send( - JSON.stringify({ type: "data", data: utf8String }), - ); - } + sessionManager.broadcast(boundSessionId!, { + type: "data", + data: utf8String, + }); } } catch (error) { sshLogger.error("Error encoding terminal data", error, { @@ -1653,34 +1939,28 @@ wss.on("connection", async (ws: WebSocket, req) => { const session = sessionManager.getSession(boundSessionId); if (session) { sessionManager.bufferOutput(boundSessionId!, fallback); - - if (session.attachedWs?.readyState === WebSocket.OPEN) { - session.attachedWs.send( - JSON.stringify({ type: "data", data: fallback }), - ); - } + sessionManager.broadcast(boundSessionId!, { + type: "data", + data: fallback, + }); } } }); stream.on("close", (code: number | null) => { const session = sessionManager.getSession(boundSessionId); - if (session?.attachedWs?.readyState === WebSocket.OPEN) { + if (session) { if (code != null) { - session.attachedWs.send( - JSON.stringify({ - type: "session_ended", - code, - }), - ); + sessionManager.broadcast(boundSessionId!, { + type: "session_ended", + code, + }); } else { - session.attachedWs.send( - JSON.stringify({ - type: "disconnected", - message: "Connection lost", - graceful: true, - }), - ); + sessionManager.broadcast(boundSessionId!, { + type: "disconnected", + message: "Connection lost", + graceful: true, + }); } } if (boundSessionId) { @@ -1700,13 +1980,11 @@ wss.on("connection", async (ws: WebSocket, req) => { username, }); const session = sessionManager.getSession(boundSessionId); - if (session?.attachedWs?.readyState === WebSocket.OPEN) { - session.attachedWs.send( - JSON.stringify({ - type: "error", - message: "SSH stream error: " + err.message, - }), - ); + if (session) { + sessionManager.broadcast(boundSessionId!, { + type: "error", + message: "SSH stream error: " + err.message, + }); } }); diff --git a/src/backend/hosts/terminal/session-manager.ts b/src/backend/hosts/terminal/session-manager.ts index 1e1d3845..542e8c95 100644 --- a/src/backend/hosts/terminal/session-manager.ts +++ b/src/backend/hosts/terminal/session-manager.ts @@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30; const HEALTH_CHECK_INTERVAL_MS = 60_000; const MAX_SESSIONS_PER_USER = 10; +export interface SessionParticipant { + ws: WebSocket; + userId: string | null; // null for anonymous link guests + permissionLevel: "read-write" | "read-only"; + isOwner: boolean; + guestLabel?: string; + tabInstanceId?: string; + joinedViaShareId?: string; +} + export interface TerminalSession { id: string; userId: string; @@ -32,7 +42,7 @@ export interface TerminalSession { isConnected: boolean; createdAt: number; - attachedWs: WebSocket | null; + participants: Map; lastDetachedAt: number | null; detachTimeout: NodeJS.Timeout | null; @@ -48,6 +58,33 @@ export interface TerminalSession { sessionLoggingEnabled: boolean; sessionStartedAt: number; lastPersistedBytes: number; + terminatedByOwner: boolean; + terminationReason: string | null; +} + +/** Message types a non-owner participant may legally send. */ +const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([ + "input", + "ping", + "disconnect", +]); + +/** + * Server-side gate for whether a participant may send a given WS message + * type. The owner may send anything; non-owners are limited to input (if + * read-write), ping, and disconnect. Pure function so read-only enforcement + * is unit-testable without a real WebSocketServer. + */ +export function isMessageAllowedForParticipant( + participant: Pick | null, + messageType: string, +): boolean { + if (!participant || participant.isOwner) return true; + if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false; + if (messageType === "input" && participant.permissionLevel === "read-only") { + return false; + } + return true; } class TerminalSessionManager { @@ -81,7 +118,7 @@ class TerminalSessionManager { const userSessions = this.getUserSessions(userId); if (userSessions.length >= MAX_SESSIONS_PER_USER) { const detached = userSessions - .filter((s) => s.attachedWs === null) + .filter((s) => this.getOwnerParticipant(s) === null) .sort( (a, b) => (a.lastDetachedAt ?? a.createdAt) - @@ -109,7 +146,7 @@ class TerminalSessionManager { operation: "session_tab_duplicate_skip", existingSessionId: existing.id, tabInstanceId, - hasAttachedWs: existing.attachedWs !== null, + hasAttachedWs: this.getOwnerParticipant(existing) !== null, }, ); return existing.id; @@ -151,7 +188,7 @@ class TerminalSessionManager { rows, isConnected: false, createdAt: now, - attachedWs: null, + participants: new Map(), lastDetachedAt: null, detachTimeout: null, outputBuffer: [], @@ -166,6 +203,8 @@ class TerminalSessionManager { sessionLoggingEnabled, sessionStartedAt: now, lastPersistedBytes: 0, + terminatedByOwner: false, + terminationReason: null, }; this.sessions.set(id, session); @@ -199,6 +238,25 @@ class TerminalSessionManager { session.isConnected = true; } + /** Finds the owner's participant entry, if currently attached. */ + private getOwnerParticipant( + session: TerminalSession, + ): SessionParticipant | null { + for (const participant of session.participants.values()) { + if (participant.isOwner) return participant; + } + return null; + } + + private getOwnerEntry( + session: TerminalSession, + ): [string, SessionParticipant] | null { + for (const entry of session.participants.entries()) { + if (entry[1].isOwner) return entry; + } + return null; + } + attachWs( sessionId: string, userId: string, @@ -234,8 +292,9 @@ class TerminalSessionManager { return null; } + const ownerParticipant = this.getOwnerParticipant(session); const isDetached = - !session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN; + !ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN; const isOriginalTab = (session.attachedTabInstanceId ?? session.tabInstanceId) === tabInstanceId; @@ -282,9 +341,10 @@ class TerminalSessionManager { ); } - if (session.attachedWs && session.attachedWs !== ws) { + const ownerEntry = this.getOwnerEntry(session); + if (ownerEntry && ownerEntry[1].ws !== ws) { try { - session.attachedWs.send( + ownerEntry[1].ws.send( JSON.stringify({ type: "sessionTakenOver", sessionId, @@ -294,7 +354,7 @@ class TerminalSessionManager { } catch { /* ignore */ } - session.attachedWs = null; + session.participants.delete(ownerEntry[0]); } if (session.detachTimeout) { @@ -302,7 +362,14 @@ class TerminalSessionManager { session.detachTimeout = null; } - session.attachedWs = ws; + const participantId = crypto.randomUUID(); + session.participants.set(participantId, { + ws, + userId, + permissionLevel: "read-write", + isOwner: true, + tabInstanceId, + }); session.attachedTabInstanceId = tabInstanceId; session.lastDetachedAt = null; @@ -316,6 +383,110 @@ class TerminalSessionManager { return session; } + /** + * Adds a non-owner participant (in-app share join or anonymous link guest). + * Purely additive - never evicts the owner or any other participant. + */ + joinAsParticipant( + sessionId: string, + ws: WebSocket, + opts: { + userId: string | null; + permissionLevel: "read-write" | "read-only"; + guestLabel?: string; + tabInstanceId?: string; + shareId?: string; + }, + ): TerminalSession | null { + const session = this.sessions.get(sessionId); + if (!session || !session.isConnected) return null; + + const participantId = crypto.randomUUID(); + session.participants.set(participantId, { + ws, + userId: opts.userId, + permissionLevel: opts.permissionLevel, + isOwner: false, + guestLabel: opts.guestLabel, + tabInstanceId: opts.tabInstanceId, + joinedViaShareId: opts.shareId, + }); + + sshLogger.info("Participant joined shared session", { + operation: "session_join_participant", + sessionId, + userId: opts.userId, + permissionLevel: opts.permissionLevel, + shareId: opts.shareId, + }); + + return session; + } + + /** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */ + broadcast(sessionId: string, message: object): void { + const session = this.sessions.get(sessionId); + if (!session) return; + const payload = JSON.stringify(message); + for (const participant of session.participants.values()) { + if (participant.ws.readyState !== WebSocket.OPEN) continue; + try { + participant.ws.send(payload); + } catch { + /* ignore individual send failures, keep broadcasting to the rest */ + } + } + } + + /** Finds the participant entry (owner or not) for a given socket. */ + getParticipantForWs( + session: TerminalSession, + ws: WebSocket, + ): SessionParticipant | null { + for (const participant of session.participants.values()) { + if (participant.ws === ws) return participant; + } + return null; + } + + /** + * Removes a non-owner participant's socket. No detach timeout or session + * destruction side effects - a guest leaving must never end the session. + */ + removeParticipant(sessionId: string, ws: WebSocket): void { + const session = this.sessions.get(sessionId); + if (!session) return; + for (const [id, participant] of session.participants.entries()) { + if (participant.ws === ws && !participant.isOwner) { + session.participants.delete(id); + sshLogger.info("Participant left shared session", { + operation: "session_leave_participant", + sessionId, + userId: participant.userId, + }); + return; + } + } + } + + /** Broadcasts termination to all guests, then destroys the session. */ + ownerEndSession(sessionId: string, reason: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason }); + session.terminatedByOwner = true; + session.terminationReason = reason; + + sshLogger.info("Owner ended shared session", { + operation: "session_owner_end", + sessionId, + reason, + }); + + this.destroySession(sessionId); + } + detachWs(sessionId: string): void { const session = this.sessions.get(sessionId); if (!session) return; @@ -325,7 +496,10 @@ class TerminalSessionManager { session.detachTimeout = null; } - session.attachedWs = null; + const ownerEntry = this.getOwnerEntry(session); + if (ownerEntry) { + session.participants.delete(ownerEntry[0]); + } session.lastDetachedAt = Date.now(); // Persist log immediately when the user detaches so it appears right away, @@ -365,6 +539,23 @@ class TerminalSessionManager { fs.promises.unlink(session.recordingPath).catch(() => {}); } + for (const participant of session.participants.values()) { + if (participant.isOwner) continue; + if (participant.ws.readyState !== WebSocket.OPEN) continue; + try { + participant.ws.send( + JSON.stringify({ + type: "sessionExpired", + sessionId, + message: "Session has ended", + }), + ); + } catch { + /* ignore */ + } + } + session.participants.clear(); + if (session.sshStream) { try { session.sshStream.end(); @@ -440,12 +631,16 @@ class TerminalSessionManager { recordingPath: session.recordingPath, protocol: "ssh", format: "asciicast", + terminatedByOwner: session.terminatedByOwner || undefined, + terminationReason: session.terminationReason ?? undefined, }); session.recordingId = created.id; } else { await repo.updateEnded(session.recordingId, { endedAt: new Date(endedAt).toISOString(), duration, + terminatedByOwner: session.terminatedByOwner || undefined, + terminationReason: session.terminationReason ?? undefined, }); } } catch (err) { @@ -569,10 +764,10 @@ class TerminalSessionManager { for (const [id, session] of this.sessions) { if (!session.isConnected) continue; - if ( - session.attachedWs && - session.attachedWs.readyState === WebSocket.OPEN - ) { + const hasOpenParticipant = Array.from(session.participants.values()).some( + (p) => p.ws.readyState === WebSocket.OPEN, + ); + if (hasOpenParticipant) { continue; } diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts index 3973618f..a1901746 100644 --- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -87,6 +87,7 @@ describe("HostRepository and CredentialRepository", () => { vault_profile_id INTEGER, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_session_logging INTEGER NOT NULL DEFAULT 1, + allow_session_sharing INTEGER NOT NULL DEFAULT 1, enable_command_history INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts index c1a03344..e34130cd 100644 --- a/src/backend/tests/database/repositories/host-folder-repository.test.ts +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -66,6 +66,7 @@ describe("HostFolderRepository", () => { vault_profile_id INTEGER, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_session_logging INTEGER NOT NULL DEFAULT 1, + allow_session_sharing INTEGER NOT NULL DEFAULT 1, enable_command_history INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index 806fee12..2c30162f 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => { vault_profile_id INTEGER, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_session_logging INTEGER NOT NULL DEFAULT 1, + allow_session_sharing INTEGER NOT NULL DEFAULT 1, enable_command_history INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, diff --git a/src/backend/tests/database/repositories/session-share-repository.test.ts b/src/backend/tests/database/repositories/session-share-repository.test.ts new file mode 100644 index 00000000..a7cc0318 --- /dev/null +++ b/src/backend/tests/database/repositories/session-share-repository.test.ts @@ -0,0 +1,393 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { TestSqliteDatabase } from "./test-support.js"; +import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js"; + +describe("SessionShareRepository", () => { + let adapter: TestSqliteDatabase | null = null; + + afterEach(async () => { + if (adapter) { + await adapter.close(); + adapter = null; + } + }); + + async function createRepository( + onWrite?: () => void | Promise, + ): Promise { + adapter = new TestSqliteDatabase(); + const context = await adapter.connect(); + context.sqlite?.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + password_hash TEXT NOT NULL + ); + + CREATE TABLE ssh_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + ip TEXT + ); + + CREATE TABLE session_shares ( + id TEXT PRIMARY KEY, + host_id INTEGER NOT NULL, + owner_user_id TEXT NOT NULL, + protocol TEXT NOT NULL, + session_id TEXT NOT NULL, + tab_instance_id TEXT, + share_type TEXT NOT NULL, + target_user_id TEXT, + link_token TEXT UNIQUE, + permission_level TEXT NOT NULL DEFAULT 'read-only', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + revoked_at TEXT, + last_joined_at TEXT, + join_count INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE session_share_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_id TEXT NOT NULL, + user_id TEXT, + guest_label TEXT, + joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + left_at TEXT + ); + + INSERT INTO users (id, username, password_hash) + VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash'); + INSERT INTO ssh_data (id, user_id, name, ip) + VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2'); + `); + + return new SessionShareRepository(context, onWrite); + } + + const FAR_FUTURE = "2999-01-01T00:00:00.000Z"; + const FAR_PAST = "2000-01-01T00:00:00.000Z"; + + it("creates a share and finds it by id", async () => { + const repo = await createRepository(); + + const created = await repo.create({ + id: "share-1", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-abc", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + expect(created).toMatchObject({ + id: "share-1", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + linkToken: "token-abc", + permissionLevel: "read-only", + }); + + const found = await repo.findById("share-1"); + expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" }); + }); + + it("findByLinkToken excludes revoked shares", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-revoked", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-revoked", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + expect(await repo.findByLinkToken("token-revoked")).not.toBeNull(); + + await repo.revoke("share-revoked", "owner-1"); + + expect(await repo.findByLinkToken("token-revoked")).toBeNull(); + }); + + it("findByLinkToken excludes expired shares", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-expired", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-expired", + permissionLevel: "read-only", + expiresAt: FAR_PAST, + }); + + expect(await repo.findByLinkToken("token-expired")).toBeNull(); + }); + + it("findByLinkToken returns active, non-expired, non-revoked shares", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-active", + hostId: 1, + ownerUserId: "owner-1", + protocol: "vnc", + sessionId: "guac-session-1", + shareType: "link", + linkToken: "token-active", + permissionLevel: "read-write", + expiresAt: FAR_FUTURE, + }); + + const found = await repo.findByLinkToken("token-active"); + expect(found).toMatchObject({ + id: "share-active", + protocol: "vnc", + permissionLevel: "read-write", + }); + }); + + it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => { + const repo = await createRepository(); + + await repo.create({ + id: "share-user-active", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "user", + targetUserId: "guest-1", + permissionLevel: "read-write", + expiresAt: FAR_FUTURE, + }); + + // Expired user share for the same target - must be excluded + await repo.create({ + id: "share-user-expired", + hostId: 2, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-2", + shareType: "user", + targetUserId: "guest-1", + permissionLevel: "read-only", + expiresAt: FAR_PAST, + }); + + // Link share, not targeting a user - must be excluded even though it's active + await repo.create({ + id: "share-link-active", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-3", + shareType: "link", + linkToken: "token-unrelated", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + const shares = await repo.findSharesTargetingUser("guest-1"); + expect(shares).toHaveLength(1); + expect(shares[0]).toMatchObject({ + id: "share-user-active", + hostName: "host-one", + ownerUsername: "alice", + }); + }); + + it("revoke only affects the requesting owner's own share", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-owned", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-owned", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + expect(await repo.revoke("share-owned", "guest-1")).toBe(false); + expect(await repo.revoke("share-owned", "owner-1")).toBe(true); + }); + + it("revokeAsAdmin revokes regardless of owner", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-admin-target", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-admin", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true); + expect(await repo.findByLinkToken("token-admin")).toBeNull(); + }); + + it("deleteExpiredShares removes only expired rows", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-old", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-old", + permissionLevel: "read-only", + expiresAt: FAR_PAST, + }); + await repo.create({ + id: "share-current", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-2", + shareType: "link", + linkToken: "token-current", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + const deletedCount = await repo.deleteExpiredShares(); + expect(deletedCount).toBe(1); + expect(await repo.findById("share-old")).toBeNull(); + expect(await repo.findById("share-current")).not.toBeNull(); + }); + + it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-touch", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-touch", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z"); + let row = await repo.findById("share-touch"); + expect(row?.joinCount).toBe(1); + expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z"); + + await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z"); + row = await repo.findById("share-touch"); + expect(row?.joinCount).toBe(2); + }); + + it("records and closes participant joins", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-participants", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-participants", + permissionLevel: "read-write", + expiresAt: FAR_FUTURE, + }); + + const participant = await repo.recordParticipantJoin( + "share-participants", + null, + "Guest", + ); + expect(participant).toMatchObject({ + shareId: "share-participants", + userId: null, + guestLabel: "Guest", + }); + expect(participant.leftAt).toBeNull(); + + await repo.recordParticipantLeave(participant.id); + }); + + it("write hook fires on mutating operations", async () => { + let writeCount = 0; + const repo = await createRepository(() => { + writeCount += 1; + }); + + await repo.create({ + id: "share-write-hook", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-write-hook", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + expect(writeCount).toBe(1); + + await repo.revoke("share-write-hook", "owner-1"); + expect(writeCount).toBe(2); + }); + + it("deleteSharesForHost removes all shares for a host", async () => { + const repo = await createRepository(); + await repo.create({ + id: "share-host-1a", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "token-h1a", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + await repo.create({ + id: "share-host-1b", + hostId: 1, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-2", + shareType: "link", + linkToken: "token-h1b", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + await repo.create({ + id: "share-host-2", + hostId: 2, + ownerUserId: "owner-1", + protocol: "ssh", + sessionId: "session-3", + shareType: "link", + linkToken: "token-h2", + permissionLevel: "read-only", + expiresAt: FAR_FUTURE, + }); + + expect(await repo.deleteSharesForHost(1)).toBe(2); + expect(await repo.findById("share-host-2")).not.toBeNull(); + }); +}); diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts index d52c2c35..58cba98b 100644 --- a/src/backend/tests/database/repositories/user-data-export-repository.test.ts +++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts @@ -49,6 +49,7 @@ describe("UserDataExportRepository", () => { vault_profile_id INTEGER, enable_terminal INTEGER NOT NULL DEFAULT 1, enable_session_logging INTEGER NOT NULL DEFAULT 1, + allow_session_sharing INTEGER NOT NULL DEFAULT 1, enable_command_history INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, diff --git a/src/backend/tests/hosts/guacamole/token-service.test.ts b/src/backend/tests/hosts/guacamole/token-service.test.ts index de3069a5..d6cfc994 100644 --- a/src/backend/tests/hosts/guacamole/token-service.test.ts +++ b/src/backend/tests/hosts/guacamole/token-service.test.ts @@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => { expect(tokenService.decryptToken(token)?.recording).toEqual(recording); }); + + it("preserves termixMeta through the encrypt/decrypt round trip", () => { + const termixMeta = { + termixConnectId: "connect-1", + hostId: 7, + ownerUserId: "user-1", + protocol: "rdp" as const, + }; + const token = tokenService.createRdpToken( + "windows.example.test", + "Administrator", + "secret", + {}, + undefined, + termixMeta, + ); + + expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta); + }); + + it("createJoinToken sets connection.join, not connection.type", () => { + const token = tokenService.createJoinToken("guacd-conn-123", true); + const decrypted = tokenService.decryptToken(token); + + expect(decrypted?.connection.join).toBe("guacd-conn-123"); + expect(decrypted?.connection.type).toBeUndefined(); + expect(decrypted?.connection.readOnly).toBe(true); + }); + + it("createJoinToken round-trips a read-write join through decryptToken", () => { + const token = tokenService.createJoinToken("guacd-conn-456", false); + const decrypted = tokenService.decryptToken(token); + + expect(decrypted?.connection.join).toBe("guacd-conn-456"); + expect(decrypted?.connection.readOnly).toBe(false); + expect(decrypted?.recording).toBeUndefined(); + }); }); diff --git a/src/backend/tests/hosts/session-sharing/routes.test.ts b/src/backend/tests/hosts/session-sharing/routes.test.ts new file mode 100644 index 00000000..052d1701 --- /dev/null +++ b/src/backend/tests/hosts/session-sharing/routes.test.ts @@ -0,0 +1,513 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Request, Response } from "express"; + +const state = vi.hoisted(() => ({ + currentUserId: "user-1", + globalSharingEnabled: true, + hosts: new Map(), + hostOwnerAccess: new Map(), // `${userId}:${hostId}` -> hasAccess + sshSessions: new Map(), + guacSessions: new Map< + string, + { ownerUserId: string; hostId: number; protocol: string } + >(), + shares: new Map>(), + admins: new Set(), +})); + +vi.mock("../../../utils/logger.js", () => ({ + sshLogger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + success: vi.fn(), + }, +})); + +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => + (req: Record, _res: unknown, next: () => void) => { + req.userId = state.currentUserId; + next(); + }, + }), + }, +})); + +vi.mock("../../../utils/permission-manager.js", () => ({ + PermissionManager: { + getInstance: () => ({ + canAccessHost: async ( + userId: string, + hostId: number, + _action: string, + ) => ({ + hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false, + }), + isAdmin: async (userId: string) => state.admins.has(userId), + }), + }, +})); + +vi.mock("../../../hosts/terminal/session-manager.js", () => ({ + sessionManager: { + getSession: (sessionId: string) => { + const session = state.sshSessions.get(sessionId); + if (!session) return null; + return { ...session }; + }, + ownerEndSession: vi.fn(), + }, +})); + +vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({ + getGuacSessionInfo: (guacamoleConnectionId: string) => + state.guacSessions.get(guacamoleConnectionId) ?? null, +})); + +vi.mock("../../../hosts/guacamole/token-service.js", () => ({ + GuacamoleTokenService: { + getInstance: () => ({ + createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) => + `join-token:${guacamoleConnectionId}:${readOnly}`, + }), + }, +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentSessionShareRepository: () => ({ + create: async (input: Record) => { + const row = { + ...input, + createdAt: "2026-07-20T00:00:00.000Z", + revokedAt: null, + lastJoinedAt: null, + joinCount: 0, + }; + state.shares.set(input.id as string, row); + return row; + }, + findById: async (id: string) => state.shares.get(id) ?? null, + findByLinkToken: async (linkToken: string) => { + for (const share of state.shares.values()) { + if ( + share.linkToken === linkToken && + !share.revokedAt && + (share.expiresAt as string) > new Date().toISOString() + ) { + return share; + } + } + return null; + }, + findActiveSharesForHost: async (hostId: number, ownerUserId: string) => { + return [...state.shares.values()].filter( + (s) => + s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt, + ); + }, + revoke: async (shareId: string, requestingUserId: string) => { + const share = state.shares.get(shareId); + if (!share || share.ownerUserId !== requestingUserId) return false; + share.revokedAt = "2026-07-20T01:00:00.000Z"; + return true; + }, + revokeAsAdmin: async (shareId: string) => { + const share = state.shares.get(shareId); + if (!share) return false; + share.revokedAt = "2026-07-20T01:00:00.000Z"; + return true; + }, + touchShareUsage: async () => {}, + recordParticipantJoin: async () => ({ id: 1 }), + }), + createCurrentSettingsRepository: () => ({ + getBoolean: async () => state.globalSharingEnabled, + }), + createCurrentHostResolutionRepository: () => ({ + findHostOwnerId: async (hostId: number) => + state.hosts.get(hostId)?.userId ?? null, + findHostById: async (hostId: number) => { + const host = state.hosts.get(hostId); + if (!host) return null; + return { allowSessionSharing: host.allowSessionSharing }; + }, + }), +})); + +const { default: router } = + await import("../../../hosts/session-sharing/routes.js"); + +type RouteLayer = { + route?: { + path: string; + methods: Record; + stack: { + handle: (req: Request, res: Response, next: () => void) => unknown; + }[]; + }; +}; + +function findHandlers(method: string, path: string) { + const layers = (router as unknown as { stack: RouteLayer[] }).stack; + const layer = layers.find( + (l) => l.route?.path === path && l.route.methods[method], + ); + if (!layer?.route) throw new Error(`No route for ${method} ${path}`); + return layer.route.stack.map((s) => s.handle); +} + +function makeReqRes(overrides: { + body?: Record; + params?: Record; + ip?: string; +}) { + const req = { + body: overrides.body ?? {}, + params: overrides.params ?? {}, + headers: {}, + ip: overrides.ip ?? "127.0.0.1", + socket: { remoteAddress: overrides.ip ?? "127.0.0.1" }, + } as unknown as Request; + + const res = { + statusCode: 200, + jsonBody: null as unknown, + status(code: number) { + (this as unknown as { statusCode: number }).statusCode = code; + return this; + }, + json(payload: unknown) { + (this as unknown as { jsonBody: unknown }).jsonBody = payload; + return this; + }, + } as unknown as Response & { statusCode: number; jsonBody: unknown }; + + return { req, res }; +} + +async function invoke( + method: string, + path: string, + overrides: { + body?: Record; + params?: Record; + ip?: string; + } = {}, +) { + const handlers = findHandlers(method, path); + const { req, res } = makeReqRes(overrides); + + for (const handler of handlers) { + let calledNext = false; + await handler(req, res, () => { + calledNext = true; + }); + if (!calledNext) break; + } + + return res as unknown as { + statusCode: number; + jsonBody: Record | null; + }; +} + +beforeEach(() => { + state.currentUserId = "user-1"; + state.globalSharingEnabled = true; + state.hosts = new Map([ + [1, { userId: "user-1", allowSessionSharing: true }], + [2, { userId: "user-1", allowSessionSharing: false }], + ]); + state.hostOwnerAccess = new Map([["user-2:1", true]]); + state.sshSessions = new Map([ + ["session-1", { userId: "user-1", isConnected: true }], + ]); + state.guacSessions = new Map([ + ["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }], + ]); + state.shares = new Map(); + state.admins = new Set(); +}); + +describe("POST /session-sharing/create", () => { + it("rejects a caller who does not own the live session", async () => { + state.currentUserId = "user-2"; + const res = await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.jsonBody).toMatchObject({ + error: "You do not own this live session", + }); + }); + + it("creates a link share for the session owner", async () => { + const res = await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) }); + expect((res.jsonBody as Record).linkToken).toBeTruthy(); + }); + + it("rejects a user share when the target lacks host access", async () => { + const res = await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "user", + targetUserId: "no-access-user", + permissionLevel: "read-write", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.jsonBody).toMatchObject({ + error: "Target user does not have access to this host", + }); + }); + + it("global kill switch overrides an enabled per-host toggle", async () => { + state.globalSharingEnabled = false; + const res = await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.jsonBody).toMatchObject({ + error: "Session sharing is disabled for this host", + }); + }); + + it("rejects when the per-host toggle is off even though global is on", async () => { + const res = await invoke("post", "/create", { + body: { + hostId: 2, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + + expect(res.statusCode).toBe(403); + }); +}); + +describe("GET /session-sharing/resolve/:linkToken", () => { + async function createActiveLinkShare( + overrides: Partial> = {}, + ) { + await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + ...overrides, + }, + }); + const [share] = [...state.shares.values()]; + return share as { linkToken: string; id: string }; + } + + it("never includes hostname, ip, username, or hostId in the response body", async () => { + const share = await createActiveLinkShare(); + + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: share.linkToken }, + }); + + expect(res.statusCode).toBe(200); + const body = res.jsonBody as Record; + const serialized = JSON.stringify(body).toLowerCase(); + + expect(body).not.toHaveProperty("hostname"); + expect(body).not.toHaveProperty("ip"); + expect(body).not.toHaveProperty("username"); + expect(body).not.toHaveProperty("hostId"); + expect(body).not.toHaveProperty("hostName"); + expect(serialized).not.toContain("10.0.0"); + expect(serialized).not.toContain("hostname"); + expect(serialized).not.toContain('"ip"'); + expect(serialized).not.toContain("username"); + }); + + it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => { + const share = await createActiveLinkShare(); + + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: share.linkToken }, + }); + + expect(res.jsonBody).toEqual({ + protocol: "ssh", + permissionLevel: "read-only", + wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`, + }); + }); + + it("mints a fresh join token for guac protocols", async () => { + await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "guac-conn-1", + protocol: "vnc", + shareType: "link", + permissionLevel: "read-only", + }, + }); + const [share] = [...state.shares.values()] as { + linkToken: string; + }[]; + + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: share.linkToken }, + }); + + expect(res.statusCode).toBe(200); + expect((res.jsonBody as Record).connectParams).toEqual({ + token: "join-token:guac-conn-1:true", + }); + }); + + it("rejects an unknown link token", async () => { + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: "does-not-exist" }, + }); + + expect(res.statusCode).toBe(404); + }); + + it("rejects a revoked link token", async () => { + const share = await createActiveLinkShare(); + await invoke("delete", "/:shareId", { params: { shareId: share.id } }); + + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: share.linkToken }, + }); + + expect(res.statusCode).toBe(404); + }); + + it("rejects an expired link token", async () => { + state.shares.set("share-expired", { + id: "share-expired", + hostId: 1, + ownerUserId: "user-1", + protocol: "ssh", + sessionId: "session-1", + shareType: "link", + linkToken: "expired-token", + permissionLevel: "read-only", + expiresAt: "2000-01-01T00:00:00.000Z", + revokedAt: null, + }); + + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: "expired-token" }, + }); + + expect(res.statusCode).toBe(404); + }); + + it("re-checks the global kill switch at resolve time, not just at creation time", async () => { + const share = await createActiveLinkShare(); + + state.globalSharingEnabled = false; + + const res = await invoke("get", "/resolve/:linkToken", { + params: { linkToken: share.linkToken }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe("DELETE /session-sharing/:shareId", () => { + it("allows the owner to revoke their own share", async () => { + await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + const [share] = [...state.shares.values()] as { id: string }[]; + + const res = await invoke("delete", "/:shareId", { + params: { shareId: share.id }, + }); + + expect(res.statusCode).toBe(200); + }); + + it("rejects a non-owner, non-admin caller", async () => { + await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + const [share] = [...state.shares.values()] as { id: string }[]; + + state.currentUserId = "user-2"; + const res = await invoke("delete", "/:shareId", { + params: { shareId: share.id }, + }); + + expect(res.statusCode).toBe(403); + }); + + it("allows an admin to revoke someone else's share", async () => { + await invoke("post", "/create", { + body: { + hostId: 1, + sessionId: "session-1", + protocol: "ssh", + shareType: "link", + permissionLevel: "read-only", + }, + }); + const [share] = [...state.shares.values()] as { id: string }[]; + + state.currentUserId = "admin-1"; + state.admins.add("admin-1"); + const res = await invoke("delete", "/:shareId", { + params: { shareId: share.id }, + }); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/src/backend/tests/hosts/terminal/session-manager.test.ts b/src/backend/tests/hosts/terminal/session-manager.test.ts index 9a369716..5b94ed1e 100644 --- a/src/backend/tests/hosts/terminal/session-manager.test.ts +++ b/src/backend/tests/hosts/terminal/session-manager.test.ts @@ -49,9 +49,19 @@ vi.mock("fs", () => ({ }, })); -const { sessionManager } = +const { sessionManager, isMessageAllowedForParticipant } = await import("../../../hosts/terminal/session-manager.js"); +// Minimal fake WebSocket - only the surface session-manager touches. +function makeFakeWs(readyState = 1 /* OPEN */) { + return { + readyState, + send: vi.fn(), + } as unknown as import("ws").WebSocket; +} +const WS_OPEN = 1; +const WS_CLOSED = 3; + describe("TerminalSessionManager - session logging", () => { beforeEach(() => { vi.clearAllMocks(); @@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => { sessionManager.destroySession(id); }); }); + +describe("TerminalSessionManager - multiplayer participants", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + mockCreate.mockResolvedValue({ id: 1 }); + mockUpdateEnded.mockResolvedValue(undefined); + }); + + function createConnectedSession(): string { + const id = sessionManager.createSession( + "owner-1", + 1, + "host", + 80, + 24, + undefined, + false, + ); + // Mark connected without a real ssh2 stream - only isConnected is read + // by attachWs/joinAsParticipant. + const session = sessionManager.getSession(id)!; + session.isConnected = true; + return id; + } + + it("joinAsParticipant adds a participant without evicting the owner", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + + const guestWs = makeFakeWs(); + const session = sessionManager.joinAsParticipant(id, guestWs, { + userId: null, + permissionLevel: "read-only", + guestLabel: "Guest", + }); + + expect(session).not.toBeNull(); + expect(session!.participants.size).toBe(2); + const ownerParticipant = sessionManager.getParticipantForWs( + session!, + ownerWs, + ); + expect(ownerParticipant?.isOwner).toBe(true); + expect(ownerWs.send).not.toHaveBeenCalled(); + + sessionManager.destroySession(id); + }); + + it("joinAsParticipant returns null for a nonexistent or unconnected session", () => { + expect( + sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), { + userId: null, + permissionLevel: "read-only", + }), + ).toBeNull(); + }); + + it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(WS_OPEN); + sessionManager.attachWs(id, "owner-1", ownerWs); + + const openGuestWs = makeFakeWs(WS_OPEN); + const closedGuestWs = makeFakeWs(WS_CLOSED); + sessionManager.joinAsParticipant(id, openGuestWs, { + userId: null, + permissionLevel: "read-only", + }); + sessionManager.joinAsParticipant(id, closedGuestWs, { + userId: null, + permissionLevel: "read-only", + }); + + sessionManager.broadcast(id, { type: "data", data: "hello" }); + + expect(ownerWs.send).toHaveBeenCalledWith( + JSON.stringify({ type: "data", data: "hello" }), + ); + expect(openGuestWs.send).toHaveBeenCalledWith( + JSON.stringify({ type: "data", data: "hello" }), + ); + expect(closedGuestWs.send).not.toHaveBeenCalled(); + + sessionManager.destroySession(id); + }); + + it("broadcast does not throw if a socket's send throws", () => { + const id = createConnectedSession(); + const throwingWs = makeFakeWs(WS_OPEN); + (throwingWs.send as ReturnType).mockImplementation(() => { + throw new Error("send failed"); + }); + sessionManager.attachWs(id, "owner-1", throwingWs); + + expect(() => + sessionManager.broadcast(id, { type: "data", data: "x" }), + ).not.toThrow(); + + sessionManager.destroySession(id); + }); + + it("broadcast is a no-op for a nonexistent session", () => { + expect(() => + sessionManager.broadcast("does-not-exist", { type: "data" }), + ).not.toThrow(); + }); + + it("owner detach arms the idle timeout (existing behavior)", () => { + vi.useFakeTimers(); + try { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + + sessionManager.detachWs(id); + const session = sessionManager.getSession(id); + expect(session?.detachTimeout).not.toBeNull(); + expect(session?.lastDetachedAt).not.toBeNull(); + + sessionManager.destroySession(id); + } finally { + vi.useRealTimers(); + } + }); + + it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + + const guestWs = makeFakeWs(); + sessionManager.joinAsParticipant(id, guestWs, { + userId: null, + permissionLevel: "read-write", + }); + + sessionManager.removeParticipant(id, guestWs); + + const session = sessionManager.getSession(id); + expect(session).not.toBeNull(); + expect(session?.detachTimeout).toBeNull(); + expect(session?.participants.size).toBe(1); + expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull(); + + sessionManager.destroySession(id); + }); + + it("removeParticipant is a no-op when the ws belongs to the owner", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + + sessionManager.removeParticipant(id, ownerWs); + + const session = sessionManager.getSession(id); + expect(session?.participants.size).toBe(1); + expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe( + true, + ); + + sessionManager.destroySession(id); + }); + + it("destroySession cleans up all participants, not just the owner", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + + const guestWs = makeFakeWs(); + sessionManager.joinAsParticipant(id, guestWs, { + userId: null, + permissionLevel: "read-only", + }); + + sessionManager.destroySession(id); + + expect(guestWs.send).toHaveBeenCalled(); + expect(sessionManager.getSession(id)).toBeNull(); + }); + + it("ownerEndSession notifies non-owner participants and destroys the session", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + + const guestWs = makeFakeWs(); + sessionManager.joinAsParticipant(id, guestWs, { + userId: null, + permissionLevel: "read-write", + }); + + sessionManager.ownerEndSession(id, "owner ended the session"); + + expect(guestWs.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "sessionTerminatedByOwner", + reason: "owner ended the session", + }), + ); + expect(sessionManager.getSession(id)).toBeNull(); + }); +}); + +describe("isMessageAllowedForParticipant", () => { + it("allows any message type for the owner or when there is no participant", () => { + expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true); + expect( + isMessageAllowedForParticipant( + { isOwner: true, permissionLevel: "read-write" }, + "resize", + ), + ).toBe(true); + }); + + it("drops input from a read-only participant", () => { + expect( + isMessageAllowedForParticipant( + { isOwner: false, permissionLevel: "read-only" }, + "input", + ), + ).toBe(false); + }); + + it("allows input from a read-write non-owner participant", () => { + expect( + isMessageAllowedForParticipant( + { isOwner: false, permissionLevel: "read-write" }, + "input", + ), + ).toBe(true); + }); + + it("allows ping and disconnect for any non-owner participant", () => { + expect( + isMessageAllowedForParticipant( + { isOwner: false, permissionLevel: "read-only" }, + "ping", + ), + ).toBe(true); + expect( + isMessageAllowedForParticipant( + { isOwner: false, permissionLevel: "read-only" }, + "disconnect", + ), + ).toBe(true); + }); + + it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => { + for (const type of [ + "resize", + "totp_response", + "password_response", + "tmux_attach", + "tmux_detach", + "get_cwd", + "vault_start_auth", + "opkssh_start_auth", + ]) { + expect( + isMessageAllowedForParticipant( + { isOwner: false, permissionLevel: "read-write" }, + type, + ), + ).toBe(false); + } + }); +}); diff --git a/src/main.tsx b/src/main.tsx index 1e4c8894..7978d95c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -65,6 +65,12 @@ const ElectronVersionCheck = lazy(() => })), ); +// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=). +// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify. +const SharedSessionView = lazy( + () => import("@/features/session-sharing/SharedSessionView"), +); + type Phase = | "verifying" | "idle-auth" @@ -322,6 +328,16 @@ function RootApp() { const searchParams = new URLSearchParams(window.location.search); const isFullscreen = searchParams.has("view"); + // Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's + // auth check entirely rather than waiting on a getUserInfo() call that would always fail. + if (searchParams.get("view") === "shared") { + return ( + + + + ); + } + if (isFullscreen) { return ( diff --git a/src/types/index.ts b/src/types/index.ts index 13455b45..0c1b8c80 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -151,6 +151,7 @@ export interface Host { enableDocker: boolean; enableProxmox: boolean; enableTmuxMonitor: boolean; + allowSessionSharing?: boolean; proxmoxConfig?: ProxmoxConfig | null; showTerminalInSidebar: boolean; showFileManagerInSidebar: boolean; @@ -272,6 +273,7 @@ export interface HostData { enableDocker?: boolean; enableProxmox?: boolean; enableTmuxMonitor?: boolean; + allowSessionSharing?: boolean; proxmoxConfig?: ProxmoxConfig | Record | null; showTerminalInSidebar?: boolean; showFileManagerInSidebar?: boolean; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 67e6c45a..5bce442d 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -281,6 +281,9 @@ export type Tab = { host?: Host; openedAt: number; restoredSessionId?: string | null; + /** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */ + joinSharedSessionId?: string | null; + joinShareId?: string | null; initialFilePath?: string; serialConfig?: SerialConfig; terminalRef?: import("react").RefObject<{ @@ -291,6 +294,8 @@ export type Tab = { fit?: () => void; notifyResize?: () => void; getApplicationCursorKeysMode?: () => boolean; + openShareModal?: () => void; + canShare?: () => boolean; } | null>; }; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 029060ec..bf31da3d 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -272,10 +272,7 @@ export function AppShell({ if (id == null) return null; return tabs.find((t) => t.id === id)?.instanceId ?? null; }); - localStorage.setItem( - "termix_paneInstanceIds", - JSON.stringify(instanceIds), - ); + localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds)); }, [paneTabIds, tabs]); const isMobile = useIsMobile(); @@ -1056,6 +1053,8 @@ export function AppShell({ savedLabel?: string; initialFilePath?: string; serialConfig?: SerialConfig; + joinSharedSessionId?: string | null; + joinShareId?: string | null; }, ) { const tabId = `${host.name}-${type}-${Date.now()}`; @@ -1072,6 +1071,8 @@ export function AppShell({ const savedLabel = restore?.savedLabel; const initialFilePath = restore?.initialFilePath; const serialConfig = restore?.serialConfig; + const joinSharedSessionId = restore?.joinSharedSessionId ?? null; + const joinShareId = restore?.joinShareId ?? null; // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label const isCustomLabel = savedLabel != null && @@ -1093,6 +1094,8 @@ export function AppShell({ openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, + joinSharedSessionId, + joinShareId, initialFilePath, serialConfig, }, @@ -1125,6 +1128,8 @@ export function AppShell({ openedAt, terminalRef: ref, restoredSessionId: restore?.restoredSessionId ?? null, + joinSharedSessionId, + joinShareId, initialFilePath, serialConfig, }, @@ -1380,6 +1385,17 @@ export function AppShell({ } } + function openShareForTab(id: string) { + const tab = tabs.find((t) => t.id === id); + if (!tab) return; + const ref = tab.terminalRef?.current; + if (ref?.canShare?.()) { + ref.openShareModal?.(); + } else { + toast.error(t("sessionSharing.notReadyToShare")); + } + } + function closeTab(id: string) { const tab = tabs.find((t) => t.id === id); const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; @@ -1725,6 +1741,56 @@ export function AppShell({ }} onRenameTab={renameTab} onReorderTabs={setTabs} + onJoinSharedSession={(session) => { + if (!session.shareId) return; + const existingHost = allHosts.find( + (h) => h.id === String(session.hostId), + ); + const host: Host = existingHost ?? { + id: String(session.hostId), + name: session.hostName, + username: "", + ip: "", + port: 0, + folder: "", + online: false, + cpu: null, + ram: null, + lastAccess: new Date().toISOString(), + authType: "none", + enableTerminal: false, + enableCommandHistory: false, + enableTunnel: false, + enableFileManager: false, + enableDocker: false, + enableProxmox: false, + enableTmuxMonitor: false, + enableSsh: false, + enableRdp: false, + enableVnc: false, + enableTelnet: false, + sshPort: 22, + rdpPort: 3389, + vncPort: 5900, + telnetPort: 23, + serverTunnels: [], + quickActions: [], + }; + const instanceId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + openTab(host, "terminal", { + instanceId, + restoredSessionId: null, + joinSharedSessionId: session.sessionId, + joinShareId: session.shareId, + savedLabel: t("connections.sharedSessionLabel", { + hostName: session.hostName, + }), + }); + if (isMobile) setSidebarOpen(false); + }} /> )} @@ -1885,6 +1951,7 @@ export function AppShell({ const targetTab = tabs.find((t) => t.id === tabId); if (targetTab?.host) openTab(targetTab.host, "files"); }} + onOpenShare={openShareForTab} isAppFullscreen={isAppFullscreen} onToggleAppFullscreen={toggleAppFullscreen} /> diff --git a/src/ui/api/guacamole-api.ts b/src/ui/api/guacamole-api.ts index 368501d1..f47dd455 100644 --- a/src/ui/api/guacamole-api.ts +++ b/src/ui/api/guacamole-api.ts @@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest { export interface GuacamoleTokenResponse { token: string; + guacamoleConnectionId?: string | null; } type GuacamoleConfigSource = { diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index dbcf7429..827654f4 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -42,6 +42,10 @@ export interface ActiveSessionInfo { tabInstanceId: string | null; isConnected: boolean; createdAt: number; + isOwnSession: boolean; + sharedByUsername: string | null; + permissionLevel: string | null; + shareId: string | null; } const activeSessionsCache = createTtlRequestCache(2_000); diff --git a/src/ui/api/session-sharing-api.ts b/src/ui/api/session-sharing-api.ts new file mode 100644 index 00000000..1c4a00e7 --- /dev/null +++ b/src/ui/api/session-sharing-api.ts @@ -0,0 +1,191 @@ +import axios from "axios"; +import { getBasePath } from "@/lib/base-path"; +import { isElectron } from "@/lib/electron"; +import { authApi, getServerConfig, handleApiError } from "@/main-axios"; + +export interface ResolvedShareLink { + protocol: "ssh" | "rdp" | "vnc" | "telnet"; + permissionLevel: "read-only" | "read-write"; + wsPath: string; + connectParams?: { token: string }; +} + +export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown"; + +export class ShareLinkError extends Error { + constructor( + message: string, + public readonly kind: ShareLinkErrorKind, + ) { + super(message); + this.name = "ShareLinkError"; + } +} + +const isDev = (): boolean => + !isElectron() && + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === ""); + +// Guests have no session/JWT, so this deliberately builds a bare base URL +// rather than going through main-axios's authenticated instances. +async function resolveApiBaseUrl(): Promise { + if (isDev()) { + const protocol = window.location.protocol === "https:" ? "https" : "http"; + return `${protocol}://localhost:30001`; + } + if (isElectron()) { + const serverConfig = await getServerConfig(); + const configuredUrl = serverConfig?.serverUrl; + if (configuredUrl) return configuredUrl.replace(/\/$/, ""); + return "http://localhost:30001"; + } + return getBasePath(); +} + +export async function resolveShareLink( + linkToken: string, +): Promise { + const baseUrl = await resolveApiBaseUrl(); + try { + const response = await axios.get( + `${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`, + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + throw new ShareLinkError( + "Share link is invalid, expired, or revoked", + "not-found", + ); + } + if (error.response?.status === 429) { + throw new ShareLinkError( + "Too many attempts, please try again shortly", + "rate-limited", + ); + } + } + throw new ShareLinkError("Failed to resolve share link", "unknown"); + } +} + +// ============================================================================ +// SESSION SHARING (authenticated owner-side API) +// ============================================================================ + +export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet"; +export type SessionShareType = "link" | "user"; +export type SessionSharePermissionLevel = "read-only" | "read-write"; + +export interface SessionShareRecord { + id: string; + hostId: number; + ownerUserId: string; + protocol: SessionShareProtocol; + sessionId: string; + tabInstanceId: string | null; + shareType: SessionShareType; + targetUserId: string | null; + linkToken: string | null; + permissionLevel: SessionSharePermissionLevel; + createdAt: string; + expiresAt: string; + revokedAt: string | null; + lastJoinedAt: string | null; + joinCount: number; +} + +export interface CreateSessionShareRequest { + hostId: number; + sessionId: string; + tabInstanceId?: string; + protocol: SessionShareProtocol; + shareType: SessionShareType; + targetUserId?: string; + permissionLevel: SessionSharePermissionLevel; + expiryHours?: number; +} + +export interface CreateSessionShareResponse { + shareId: string; + linkToken: string | null; + expiresAt: string; +} + +export async function createSessionShare( + request: CreateSessionShareRequest, +): Promise { + try { + const response = await authApi.post("/session-sharing/create", request); + return response.data; + } catch (error) { + throw handleApiError(error, "create session share"); + } +} + +export async function getActiveSessionShares( + hostId: number, +): Promise<{ shares: SessionShareRecord[] }> { + try { + const response = await authApi.get( + `/session-sharing/host/${hostId}/active`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch active session shares"); + } +} + +export async function revokeSessionShare( + shareId: string, +): Promise<{ success: true }> { + try { + const response = await authApi.delete(`/session-sharing/${shareId}`); + return response.data; + } catch (error) { + throw handleApiError(error, "revoke session share"); + } +} + +export async function endSessionShareSession( + shareId: string, +): Promise<{ success: true }> { + try { + const response = await authApi.post(`/session-sharing/${shareId}/end`); + return response.data; + } catch (error) { + throw handleApiError(error, "end shared session"); + } +} + +// ============================================================================ +// GLOBAL ADMIN TOGGLE +// ============================================================================ + +export async function getSessionSharingGloballyEnabled(): Promise<{ + enabled: boolean; +}> { + try { + const response = await authApi.get("/users/session-sharing-enabled"); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch session sharing enabled setting"); + } +} + +export async function updateSessionSharingGloballyEnabled( + enabled: boolean, +): Promise<{ enabled: boolean }> { + try { + const response = await authApi.patch("/users/session-sharing-enabled", { + enabled, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "update session sharing enabled setting"); + } +} diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx index 47659b07..3555c272 100644 --- a/src/ui/features/guacamole/GuacamoleApp.tsx +++ b/src/ui/features/guacamole/GuacamoleApp.tsx @@ -30,6 +30,7 @@ import { DialogTitle, } from "@/components/dialog.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; +import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx"; import type { SSHHost } from "@/types"; interface GuacamoleAppProps { @@ -41,6 +42,8 @@ interface GuacamoleAppProps { export interface GuacamoleAppHandle { disconnect: () => void; isConnected: () => boolean; + openShareModal: () => void; + canShare: () => boolean; } const GuacamoleApp = React.forwardRef( @@ -124,6 +127,10 @@ const GuacamoleAppInner = React.forwardRef< ) { const { t } = useTranslation(); const [token, setToken] = useState(null); + const [guacamoleConnectionId, setGuacamoleConnectionId] = useState< + string | null + >(null); + const [shareModalOpen, setShareModalOpen] = useState(false); const [error, setError] = useState(null); const [connectionError, setConnectionError] = useState(null); const [retryCount, setRetryCount] = useState(0); @@ -152,6 +159,8 @@ const GuacamoleAppInner = React.forwardRef< useImperativeHandle(ref, () => ({ disconnect: () => displayRef.current?.disconnect(), isConnected: () => displayRef.current?.isConnected() === true, + openShareModal: () => setShareModalOpen(true), + canShare: () => guacamoleConnectionId !== null, })); useEffect(() => { @@ -161,6 +170,7 @@ const GuacamoleAppInner = React.forwardRef< } setToken(null); + setGuacamoleConnectionId(null); setError(null); getGuacdStatus() .then((status) => { @@ -177,6 +187,7 @@ const GuacamoleAppInner = React.forwardRef< .then((result) => { if (result) { setToken(result.token); + setGuacamoleConnectionId(result.guacamoleConnectionId ?? null); logActivity(resolvedProtocolForConnect, hostId, hostName).catch( () => {}, ); @@ -380,6 +391,16 @@ const GuacamoleAppInner = React.forwardRef< touchMode={touchMode} onTouchModeChange={setTouchMode} /> + {shareModalOpen && guacamoleConnectionId && ( + setShareModalOpen(false)} + hostId={hostId} + sessionId={guacamoleConnectionId} + protocol={resolvedProtocol} + tabInstanceId={tabId} + /> + )} ); }); diff --git a/src/ui/features/session-sharing/ShareSessionModal.tsx b/src/ui/features/session-sharing/ShareSessionModal.tsx new file mode 100644 index 00000000..082c301c --- /dev/null +++ b/src/ui/features/session-sharing/ShareSessionModal.tsx @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/dropdown-menu"; +import { getUserList } from "@/main-axios"; +import { + createSessionShare, + getActiveSessionShares, + revokeSessionShare, + type SessionShareProtocol, + type SessionSharePermissionLevel, + type SessionShareRecord, +} from "@/api/session-sharing-api"; + +const EXPIRY_PRESETS = [ + { key: "oneHour", hours: 1 }, + { key: "oneDay", hours: 24 }, + { key: "sevenDays", hours: 24 * 7 }, + { key: "thirtyDays", hours: 24 * 30 }, + { key: "custom", hours: undefined }, +] as const; + +type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"]; + +export function ShareSessionModal({ + open, + onClose, + hostId, + sessionId, + protocol, + tabInstanceId, +}: { + open: boolean; + onClose: () => void; + hostId: number; + sessionId: string | null; + protocol: SessionShareProtocol; + tabInstanceId?: string; +}) { + const { t } = useTranslation(); + const [mode, setMode] = useState<"link" | "user">("link"); + const [permissionLevel, setPermissionLevel] = + useState("read-only"); + const [expiryPreset, setExpiryPreset] = useState("oneDay"); + const [customHours, setCustomHours] = useState(""); + const [search, setSearch] = useState(""); + const [users, setUsers] = useState<{ id: string; username: string }[]>([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [createdLink, setCreatedLink] = useState(null); + const [shares, setShares] = useState([]); + const [sharesLoaded, setSharesLoaded] = useState(false); + + useEffect(() => { + if (!open) return; + setMode("link"); + setPermissionLevel("read-only"); + setExpiryPreset("oneDay"); + setCustomHours(""); + setSearch(""); + setSelectedUserId(null); + setCreatedLink(null); + setSharesLoaded(false); + setShares([]); + }, [open, sessionId]); + + useEffect(() => { + if (!open || sharesLoaded) return; + setSharesLoaded(true); + Promise.all([ + getUserList().catch(() => ({ users: [] })), + getActiveSessionShares(hostId).catch(() => ({ shares: [] })), + ]).then(([usersRes, sharesRes]) => { + setUsers( + (usersRes.users ?? []).map((u) => ({ + id: String(u.userId), + username: u.username, + })), + ); + setShares(sharesRes.shares ?? []); + }); + }, [open, hostId, sharesLoaded]); + + const filteredUsers = useMemo(() => { + const q = search.trim().toLowerCase(); + return q + ? users.filter((u) => u.username.toLowerCase().includes(q)) + : users; + }, [users, search]); + + const expiryHours = (() => { + if (expiryPreset === "custom") { + const hours = Number(customHours); + return Number.isFinite(hours) && hours > 0 ? hours : undefined; + } + return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours; + })(); + + async function refreshShares() { + try { + const res = await getActiveSessionShares(hostId); + setShares(res.shares ?? []); + } catch { + // silently ignore + } + } + + async function handleCreate() { + if (!sessionId) return; + if (mode === "user" && !selectedUserId) return; + if (expiryPreset === "custom" && !expiryHours) return; + + setSubmitting(true); + try { + const result = await createSessionShare({ + hostId, + sessionId, + tabInstanceId, + protocol, + shareType: mode, + targetUserId: + mode === "user" ? (selectedUserId ?? undefined) : undefined, + permissionLevel, + expiryHours, + }); + + if (mode === "link" && result.linkToken) { + const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`; + setCreatedLink(url); + toast.success(t("sessionSharing.linkCreated")); + } else { + toast.success(t("sessionSharing.shareCreated")); + setSelectedUserId(null); + } + await refreshShares(); + } catch (error) { + const status = (error as { status?: number })?.status; + if (mode === "user" && status === 403) { + toast.error(t("sessionSharing.userLacksHostAccess")); + } else { + toast.error(t("sessionSharing.shareFailed")); + } + } finally { + setSubmitting(false); + } + } + + async function handleCopyLink() { + if (!createdLink) return; + try { + await navigator.clipboard.writeText(createdLink); + toast.success(t("sessionSharing.linkCopied")); + } catch { + // clipboard API unavailable, ignore + } + } + + async function handleRevoke(shareId: string) { + try { + await revokeSessionShare(shareId); + setShares((prev) => prev.filter((s) => s.id !== shareId)); + toast.success(t("sessionSharing.revoked")); + } catch { + toast.error(t("sessionSharing.revokeFailed")); + } + } + + return ( + !next && onClose()}> + + + {t("sessionSharing.modalTitle")} + + {mode === "link" + ? t("sessionSharing.linkModeDescription") + : t("sessionSharing.userModeDescription")} + + + +
+
+ {(["link", "user"] as const).map((m) => ( + + ))} +
+ + {mode === "user" && ( + <> +
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+
+ {filteredUsers.length === 0 ? ( +
+ {t("sessionSharing.noUsersFound")} +
+ ) : ( + filteredUsers.map((user) => { + const isSelected = selectedUserId === user.id; + return ( + + ); + }) + )} +
+ + )} + +
+
+ + {t("sessionSharing.permissionLevel.label")} + + +
+ + + + + + + {EXPIRY_PRESETS.map((preset) => ( + setExpiryPreset(preset.key)} + > + {expiryPreset === preset.key ? ( + + ) : ( + + )} + {t(`hosts.sharing.expiry.${preset.key}`)} + + ))} + + +
+ + {expiryPreset === "custom" && ( + setCustomHours(e.target.value)} + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + )} + +

+ {permissionLevel === "read-only" + ? t("sessionSharing.permissionLevel.readOnlyDescription") + : t("sessionSharing.permissionLevel.readWriteDescription")} +

+ + + + {createdLink && ( +
+ + +
+ )} + +
+
+ + {t("sessionSharing.activeShares")} + {shares.length > 0 && ( + + ({shares.length}) + + )} +
+
+ {shares.length === 0 && ( +
+ {t("sessionSharing.noActiveShares")} +
+ )} + {shares.map((share) => { + const targetUser = users.find( + (u) => u.id === share.targetUserId, + ); + return ( +
+
+ {share.shareType === "link" ? ( + + ) : ( + + )} +
+ + {share.shareType === "link" + ? t("sessionSharing.linkShareBadge") + : t("sessionSharing.userShareBadge", { + username: + targetUser?.username ?? + share.targetUserId ?? + "?", + })} + + + {share.permissionLevel === "read-write" + ? t("sessionSharing.permissionLevel.readWrite") + : t("sessionSharing.permissionLevel.readOnly")} + {" · "} + {t("sessionSharing.expiresAt", { + date: new Date(share.expiresAt).toLocaleString(), + })} + +
+
+ +
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx new file mode 100644 index 00000000..8f757dae --- /dev/null +++ b/src/ui/features/session-sharing/SharedSessionView.tsx @@ -0,0 +1,329 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useXTerm } from "react-xtermjs"; +import { FitAddon } from "@xterm/addon-fit"; +import { AlertCircle, Eye } from "lucide-react"; +import { + resolveShareLink, + type ResolvedShareLink, + type ShareLinkErrorKind, +} from "@/api/session-sharing-api"; +import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; +import { getBasePath } from "@/lib/base-path"; +import { isElectron } from "@/lib/electron"; +import { getServerConfig } from "@/main-axios"; +import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; + +const PING_INTERVAL_MS = 30000; + +interface TerminalWsMessage { + type: string; + data?: string; + [key: string]: unknown; +} + +// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod). +// Duplicated rather than extracted from that file to avoid touching it here. +async function resolveTerminalWsBaseUrl(): Promise { + const isDev = + !isElectron() && + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === ""); + + if (isDev) { + return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; + } + if (isElectron()) { + const serverConfig = await getServerConfig(); + const configuredUrl = serverConfig?.serverUrl; + if (configuredUrl) { + const wsProtocol = configuredUrl.startsWith("https://") + ? "wss://" + : "ws://"; + const wsHost = configuredUrl + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + return `${wsProtocol}${wsHost}/ssh/websocket/`; + } + return "ws://127.0.0.1:30002"; + } + const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws"; + return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`; +} + +function ReadOnlyBadge({ label }: { label: string }) { + return ( +
+ + {label} +
+ ); +} + +function CenteredMessage({ + icon, + message, +}: { + icon: React.ReactNode; + message: string; +}) { + return ( +
+ {icon} +

+ {message} +

+
+ ); +} + +function GuestTerminalView({ + share, + linkToken, +}: { + share: ResolvedShareLink; + linkToken: string; +}) { + const { t } = useTranslation(); + const { instance: terminal, ref: xtermRef } = useXTerm(); + const [ended, setEnded] = useState(null); + const wsRef = useRef(null); + const pingIntervalRef = useRef | null>(null); + + useEffect(() => { + if (!terminal || !xtermRef.current) return; + + terminal.options.theme = { background: "#0c0d0b" }; + + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(xtermRef.current); + fitAddon.fit(); + + const resizeObserver = new ResizeObserver(() => fitAddon.fit()); + resizeObserver.observe(xtermRef.current); + + let cancelled = false; + let ws: WebSocket | null = null; + + resolveTerminalWsBaseUrl().then((baseWsUrl) => { + if (cancelled) return; + const separator = baseWsUrl.includes("?") ? "&" : "?"; + ws = new WebSocket( + `${baseWsUrl}${separator}shareToken=${encodeURIComponent(linkToken)}`, + ); + wsRef.current = ws; + + ws.onopen = () => { + pingIntervalRef.current = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, PING_INTERVAL_MS); + }; + + ws.onmessage = (event) => { + let msg: TerminalWsMessage; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + + switch (msg.type) { + case "data": + if (typeof msg.data === "string") terminal.write(msg.data); + break; + case "sessionExpired": + case "sessionTerminatedByOwner": + case "session_ended": + setEnded(t("sessionSharing.guestView.sessionEnded")); + break; + default: + break; + } + }; + + ws.onclose = () => { + setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded")); + }; + + if (share.permissionLevel === "read-write") { + terminal.onData((data) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data })); + } + }); + } + }); + + return () => { + cancelled = true; + resizeObserver.disconnect(); + if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); + ws?.close(); + wsRef.current = null; + }; + // Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [terminal, linkToken]); + + return ( +
+ {share.permissionLevel === "read-only" && ( + + )} + {ended && ( +
+ + } + message={ended} + /> +
+ )} +
+
+ ); +} + +function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) { + const { t } = useTranslation(); + const [connectionError, setConnectionError] = useState(null); + + if (!share.connectParams?.token) { + return ( + + } + message={t("sessionSharing.guestView.linkInvalid")} + /> + ); + } + + return ( +
+ {share.permissionLevel === "read-only" && ( + + )} + {connectionError && ( +
+ + } + message={connectionError} + /> +
+ )} + setConnectionError(err)} + /> +
+ ); +} + +export default function SharedSessionView() { + const { t } = useTranslation(); + const [share, setShare] = useState(null); + const [linkToken, setLinkToken] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) { + setError(t("sessionSharing.guestView.linkInvalid")); + setLoading(false); + return; + } + setLinkToken(token); + + resolveShareLink(token) + .then((resolved) => setShare(resolved)) + .catch((err) => { + const kind = (err as { kind?: ShareLinkErrorKind })?.kind; + if (kind === "rate-limited") { + setError(t("sessionSharing.guestView.rateLimited")); + } else { + setError(t("sessionSharing.guestView.linkInvalid")); + } + }) + .finally(() => setLoading(false)); + }, [t]); + + return ( +
+
+ {loading && ( + + )} + {!loading && error && ( + + } + message={error} + /> + )} + {!loading && + !error && + share && + linkToken && + (share.protocol === "ssh" ? ( + + ) : ( + + ))} +
+
+ ); +} diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 18993961..081ef725 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -57,6 +57,7 @@ import { toast } from "sonner"; import { Button } from "@/components/button"; import { Save } from "lucide-react"; import { resolveTermixThemeColors } from "./terminal-theme.ts"; +import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx"; import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; import { getNextTerminalFontSize, @@ -164,6 +165,7 @@ const TerminalInner = forwardRef( const pongReceivedRef = useRef(true); const pongTimeoutRef = useRef(null); const [isConnected, setIsConnected] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false); const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false); const [isConnecting, setIsConnecting] = useState(false); @@ -849,8 +851,20 @@ const TerminalInner = forwardRef( onOpenFileManager?.("/"); } }, + openShareModal: () => setShareModalOpen(true), + canShare: () => + isConnected && + !isQuickConnect && + !hostConfig.joinShareId && + typeof hostConfig.id === "number", }), - [isConnected, terminal], + [ + isConnected, + terminal, + isQuickConnect, + hostConfig.joinShareId, + hostConfig.id, + ], ); function getUseRightClickCopyPaste() { @@ -1079,7 +1093,19 @@ const TerminalInner = forwardRef( const restoredSessionId = pendingRestoredSessionIdRef.current; pendingRestoredSessionIdRef.current = null; - if (restoredSessionId) { + if (hostConfig.joinShareId) { + isAttachingSessionRef.current = true; + + ws.send( + JSON.stringify({ + type: "joinSharedSession", + data: { + shareId: hostConfig.joinShareId, + tabInstanceId: hostConfig.instanceId, + }, + }), + ); + } else if (restoredSessionId) { sessionIdRef.current = restoredSessionId; isAttachingSessionRef.current = true; @@ -3189,6 +3215,17 @@ const TerminalInner = forwardRef(
, document.body, )} + + {shareModalOpen && typeof hostConfig.id === "number" && ( + setShareModalOpen(false)} + hostId={hostConfig.id} + sessionId={sessionIdRef.current} + protocol="ssh" + tabInstanceId={hostConfig.instanceId} + /> + )} ); }, diff --git a/src/ui/features/terminal/terminal-types.ts b/src/ui/features/terminal/terminal-types.ts index 88fff378..3fe928d8 100644 --- a/src/ui/features/terminal/terminal-types.ts +++ b/src/ui/features/terminal/terminal-types.ts @@ -4,6 +4,9 @@ export interface TerminalHostConfig { id?: number; instanceId?: string; restoredSessionId?: string | null; + /** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */ + joinSharedSessionId?: string | null; + joinShareId?: string | null; ip: string; port: number; username: string; @@ -28,4 +31,6 @@ export interface TerminalHandle { notifyResize: () => void; refresh: () => void; getApplicationCursorKeysMode: () => boolean; + openShareModal: () => void; + canShare: () => boolean; } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 54af7f92..89e3baa5 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -800,6 +800,8 @@ "enableAutoTmuxDesc": "Automatically launch or attach to tmux session", "enableSessionLogging": "Session Logging", "enableSessionLoggingDesc": "Record terminal session output for later review", + "allowSessionSharing": "Allow Session Sharing", + "allowSessionSharingDesc": "Let live sessions on this host be shared via link or with other users", "enableCommandHistory": "Command History", "enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete", "linkClickBehaviorLabel": "Link Click Behavior", @@ -1447,7 +1449,60 @@ "expiresIn": "Expires in {{duration}}", "search": "Search connections...", "noSearchResults": "No connections match your search", - "rename": "Rename session" + "rename": "Rename session", + "sectionSharedWithMe": "Shared with me", + "sharedBy": "Shared by {{username}}", + "join": "Join", + "sharedSessionLabel": "{{hostName}} (shared)" + }, + "sessionSharing": { + "guestView": { + "loading": "Connecting to shared session...", + "linkInvalid": "This share link is invalid, expired, or has been revoked", + "rateLimited": "Too many attempts, please try again shortly", + "sessionEnded": "This session has ended", + "readOnlyBadge": "View only" + }, + "modalTitle": "Share session", + "shareButton": "Share", + "notReadyToShare": "Session is not ready to share yet", + "modeTab": { + "link": "Link", + "user": "User" + }, + "linkModeDescription": "Anyone with this link can join, no account required.", + "userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.", + "permissionLevel": { + "label": "Permission level", + "readOnly": "Read-only", + "readOnlyDescription": "Can watch the session live but cannot type or interact.", + "readWrite": "Read-write", + "readWriteDescription": "Can type and interact with the session just like the owner." + }, + "expiryLabel": "Link expiry", + "createLinkButton": "Create link", + "createShareButton": "Share with user", + "searchUsersPlaceholder": "Search users...", + "noUsersFound": "No users found", + "linkCreated": "Share link created", + "linkCopied": "Link copied to clipboard", + "copyLink": "Copy link", + "shareCreated": "Session shared. It will appear in their Connections tab.", + "shareFailed": "Failed to create share", + "userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.", + "activeShares": "Active shares", + "noActiveShares": "No active shares for this session", + "revoke": "Revoke", + "revokeConfirmTitle": "Revoke this share?", + "revokeConfirmDescription": "Anyone using this share will lose access immediately.", + "revoked": "Share revoked", + "revokeFailed": "Failed to revoke share", + "joinCount": "{{count}} join", + "joinCount_other": "{{count}} joins", + "expiresAt": "Expires {{date}}", + "linkShareBadge": "Link", + "userShareBadge": "User: {{username}}", + "loadSharesFailed": "Failed to load active shares" }, "guacamole": { "connecting": "Connecting to {{type}} session...", @@ -2714,6 +2769,9 @@ "analyticsEnabled": "Share Anonymous Usage Statistics", "analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.", "updateAnalyticsFailed": "Failed to update analytics setting", + "sessionSharingGloballyEnabled": "Allow Session Sharing", + "sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.", + "updateSessionSharingFailed": "Failed to update session sharing setting", "sessionTimeout": "Session Timeout", "hours": "hours", "sessionTimeoutRange": "Min 1h · Max 720h", diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index f9553dc1..f7e2305b 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -19,6 +19,7 @@ import { Maximize2, Minimize2, FolderOpen, + Share2, } from "lucide-react"; import { tabIcon } from "@/shell/tabUtils"; import { isElectron } from "@/lib/electron"; @@ -42,6 +43,7 @@ export function TabBar({ onRemoveFromSplit, onRenameTab, onOpenFileManager, + onOpenShare, isAppFullscreen, onToggleAppFullscreen, }: { @@ -59,6 +61,7 @@ export function TabBar({ onRemoveFromSplit: (tabId: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void; onOpenFileManager?: (tabId: string) => void; + onOpenShare?: (tabId: string) => void; isAppFullscreen: boolean; onToggleAppFullscreen: () => void; }) { @@ -352,6 +355,19 @@ export function TabBar({ )} + {CONNECTION_TAB_TYPES.includes(tab.type) && onOpenShare && ( + + )} ); } diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index 9f27fd6d..a315c29a 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -140,8 +140,7 @@ export function HostEditor({ const [vaultProfiles, setVaultProfiles] = useState([]); const [showVaultManager, setShowVaultManager] = useState(false); const [quickCredentialName, setQuickCredentialName] = useState(""); - const [creatingQuickCredential, setCreatingQuickCredential] = - useState(false); + const [creatingQuickCredential, setCreatingQuickCredential] = useState(false); const [showQuickCredentialDialog, setShowQuickCredentialDialog] = useState(false); const [savedThemes, setSavedThemes] = useState([]); @@ -1254,9 +1253,7 @@ export function HostEditor({ background: theme.colors.background, }} /> - - {theme.name} - + {theme.name}