feat: add multiplayer/shared sessions for terminal and guacd

This commit is contained in:
LukeGus
2026-07-20 22:18:54 -05:00
parent 36ab7e4872
commit f44d09eef7
50 changed files with 5089 additions and 102 deletions
+9
View File
@@ -467,6 +467,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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/ { location /host/tunnel/ {
proxy_pass http://127.0.0.1:30003; proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1; proxy_http_version 1.1;
+9
View File
@@ -456,6 +456,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 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/ { location /host/tunnel/ {
proxy_pass http://127.0.0.1:30003; proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1; proxy_http_version 1.1;
+104 -2
View File
@@ -17,14 +17,27 @@ const cryptPath = path.join(
"lib", "lib",
"Crypt.js", "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"); console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0); process.exit(0);
} }
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8"); let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8"); let cryptContent = fs.readFileSync(cryptPath, "utf8");
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
// Patch 1: protocol version negotiation. // Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol // guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
@@ -268,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) {
patched = true; 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 '<len>.<opcode>,...;' 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) { if (!patched) {
console.log("[patch-guacamole-lite] Already patched"); console.log("[patch-guacamole-lite] Already patched");
process.exit(0); process.exit(0);
@@ -275,6 +376,7 @@ if (!patched) {
fs.writeFileSync(guacdClientPath, guacdClientContent); fs.writeFileSync(guacdClientPath, guacdClientContent);
fs.writeFileSync(cryptPath, cryptContent); fs.writeFileSync(cryptPath, cryptContent);
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
console.log( 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",
); );
+2
View File
@@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js"; import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js"; import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../hosts/guacamole/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 networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js"; import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js"; import openTabsRoutes from "./routes/open-tabs.js";
@@ -1737,6 +1738,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes); app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes); app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes); app.use("/guacamole", guacamoleRoutes);
app.use("/session-sharing", sessionSharingRoutes);
app.use("/network-topology", networkTopologyRoutes); app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes); app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes); app.use("/open-tabs", openTabsRoutes);
+103
View File
@@ -495,6 +495,38 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL 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 ( CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, 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: "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: "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: "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) { for (const migration of sshDataMigrations) {
@@ -2290,6 +2323,76 @@ const migrateSchema = () => {
} }
// --- homepage end --- // --- 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", { databaseLogger.success("Schema migration completed", {
operation: "schema_migration", operation: "schema_migration",
}); });
+59
View File
@@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", {
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" }) enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
.notNull()
.default(true),
enableCommandHistory: integer("enable_command_history", { mode: "boolean" }) enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
@@ -676,6 +679,62 @@ export const sessionRecordings = sqliteTable("session_recordings", {
terminationReason: text("termination_reason"), 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", { export const opksshTokens = sqliteTable("opkssh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id") userId: text("user_id")
@@ -27,6 +27,7 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js"; import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js"; import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js"; import { SessionRepository } from "./session-repository.js";
import { SessionShareRepository } from "./session-share-repository.js";
import { SettingsRepository } from "./settings-repository.js"; import { SettingsRepository } from "./settings-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-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 { export function createCurrentSettingsRepository(): SettingsRepository {
return new SettingsRepository( return new SettingsRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -58,7 +58,12 @@ export class SessionRecordingRepository {
async updateEnded( async updateEnded(
id: number, id: number,
input: { endedAt: string; duration: number | null }, input: {
endedAt: string;
duration: number | null;
terminatedByOwner?: boolean;
terminationReason?: string;
},
): Promise<void> { ): Promise<void> {
await this.context.drizzle await this.context.drizzle
.update(sessionRecordings) .update(sessionRecordings)
@@ -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<void>,
) {}
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
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<SessionShareRecord | null> {
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<SessionShareRecord | null> {
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<SessionShareRecord | null> {
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<SessionShareRecord[]> {
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<SharedWithMeRecord[]> {
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<boolean> {
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<boolean> {
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<number> {
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<void> {
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<SessionShareParticipantRecord> {
const [created] = await this.context.drizzle
.insert(sessionShareParticipants)
.values({ shareId, userId, guestLabel })
.returning();
await this.afterWrite();
return created;
}
async recordParticipantLeave(participantId: number): Promise<void> {
await this.context.drizzle
.update(sessionShareParticipants)
.set({ leftAt: new Date().toISOString() })
.where(eq(sessionShareParticipants.id, participantId));
await this.afterWrite();
}
async deleteSharesForHost(hostId: number): Promise<number> {
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<void> {
await this.onWrite?.();
}
}
+11 -3
View File
@@ -175,6 +175,7 @@ router.post(
enableDocker, enableDocker,
enableProxmox, enableProxmox,
enableTmuxMonitor, enableTmuxMonitor,
allowSessionSharing,
showTerminalInSidebar, showTerminalInSidebar,
showFileManagerInSidebar, showFileManagerInSidebar,
showTunnelInSidebar, showTunnelInSidebar,
@@ -288,6 +289,7 @@ router.post(
enableDocker: enableDocker ? 1 : 0, enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0, enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0, showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0, showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0, showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -815,6 +817,7 @@ router.put(
enableDocker, enableDocker,
enableProxmox, enableProxmox,
enableTmuxMonitor, enableTmuxMonitor,
allowSessionSharing,
showTerminalInSidebar, showTerminalInSidebar,
showFileManagerInSidebar, showFileManagerInSidebar,
showTunnelInSidebar, showTunnelInSidebar,
@@ -925,6 +928,7 @@ router.put(
enableDocker: enableDocker ? 1 : 0, enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0, enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0, showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0, showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0, showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -1500,9 +1504,13 @@ router.get(
const field = (req.query.field as string) || "password"; const field = (req.query.field as string) || "password";
if ( if (
!["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes( ![
field, "password",
) "sudoPassword",
"vncPassword",
"key",
"keyPassword",
].includes(field)
) { ) {
return res.status(400).json({ error: "Invalid field" }); return res.status(400).json({ error: "Invalid field" });
} }
+50 -6
View File
@@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js";
import { import {
getCurrentSettingValue, getCurrentSettingValue,
createCurrentOpenTabRepository, createCurrentOpenTabRepository,
createCurrentSessionShareRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
const router = express.Router(); const router = express.Router();
@@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* /open-tabs/active-sessions: * /open-tabs/active-sessions:
* get: * get:
* summary: Get all active backend sessions for the current user * 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: * tags:
* - Open Tabs * - Open Tabs
* responses: * responses:
* 200: * 200:
* description: List of active sessions. * description: List of active sessions (own and shared-with-me).
* content: * content:
* application/json: * application/json:
* schema: * schema:
@@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean * type: boolean
* createdAt: * createdAt:
* type: number * type: number
* isOwnSession:
* type: boolean
* sharedByUsername:
* type: string
* nullable: true
* permissionLevel:
* type: string
* nullable: true
* shareId:
* type: string
* nullable: true
*/ */
router.get( router.get(
"/active-sessions", "/active-sessions",
@@ -309,17 +324,46 @@ router.get(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
try { try {
const sessions = sessionManager.getUserSessions(userId); const ownSessions = sessionManager.getUserSessions(userId);
return res.json( const result = ownSessions.map((s) => ({
sessions.map((s) => ({
sessionId: s.id, sessionId: s.id,
hostId: s.hostId, hostId: s.hostId,
hostName: s.hostName, hostName: s.hostName,
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null, tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
isConnected: s.isConnected, isConnected: s.isConnected,
createdAt: s.createdAt, 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) { } catch (e) {
databaseLogger.error("Failed to get active sessions", e, { databaseLogger.error("Failed to get active sessions", e, {
operation: "get_active_sessions", operation: "get_active_sessions",
@@ -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 * @openapi
* /users/host-defaults: * /users/host-defaults:
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
path.join(DATA_DIR, "session_recordings", "guacamole"); path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = { type GuacamoleClientConnection = {
guacamoleConnectionId?: string;
connectionSettings?: { connectionSettings?: {
connection?: { type?: string }; connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata; 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<string, GuacSessionInfo>();
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
const pendingConnectResolvers = new Map<
string,
(info: GuacSessionInfo | null) => void
>();
export function waitForGuacdOpen(
termixConnectId: string,
timeoutMs = 10000,
): Promise<GuacSessionInfo | null> {
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( async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection, clientConnection: GuacamoleClientConnection,
): Promise<void> { ): Promise<void> {
@@ -149,6 +201,25 @@ function createGuacServer(): GuacamoleLite {
operation: "guac_connection_open", operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type, 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) => { server.on("close", (clientConnection: GuacamoleClientConnection) => {
@@ -156,6 +227,15 @@ function createGuacServer(): GuacamoleLite {
operation: "guac_connection_close", operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type, 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) => { persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, { guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error", operation: "guac_recording_persist_error",
+22 -1
View File
@@ -14,6 +14,7 @@ import {
import { resolveGuacdOptions } from "../../utils/guacd-config.js"; import { resolveGuacdOptions } from "../../utils/guacd-config.js";
import { createJumpHostChain } from "../jump-host-chain.js"; import { createJumpHostChain } from "../jump-host-chain.js";
import type { SOCKS5Config } from "../../utils/socks5-helper.js"; import type { SOCKS5Config } from "../../utils/socks5-helper.js";
import { waitForGuacdOpen } from "./guacamole-server.js";
const router = express.Router(); const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance(); const tokenService = GuacamoleTokenService.getInstance();
@@ -183,6 +184,10 @@ router.post("/token", async (req, res) => {
* token: * token:
* type: string * type: string
* description: Encrypted connection token * 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: * 400:
* description: Invalid request or unsupported connection type * description: Invalid request or unsupported connection type
* 403: * 403:
@@ -607,6 +612,14 @@ router.post(
guacConfig["recording-include-keys"] = true; guacConfig["recording-include-keys"] = true;
} }
const termixConnectId = crypto.randomUUID();
const termixMeta = {
termixConnectId,
hostId,
ownerUserId: userId,
protocol: connectionType as "rdp" | "vnc" | "telnet",
};
switch (connectionType) { switch (connectionType) {
case "rdp": case "rdp":
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) { if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
@@ -634,6 +647,7 @@ router.post(
...guacdOverrides, ...guacdOverrides,
}, },
recordingMetadata, recordingMetadata,
termixMeta,
); );
break; break;
case "vnc": case "vnc":
@@ -648,6 +662,7 @@ router.post(
...guacdOverrides, ...guacdOverrides,
}, },
recordingMetadata, recordingMetadata,
termixMeta,
); );
break; break;
case "telnet": case "telnet":
@@ -661,13 +676,19 @@ router.post(
...guacdOverrides, ...guacdOverrides,
}, },
recordingMetadata, recordingMetadata,
termixMeta,
); );
break; break;
default: default:
return res.status(400).json({ error: "Invalid connection type" }); 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) { } catch (error) {
guacLogger.error("Failed to generate guacamole token for host", error, { guacLogger.error("Failed to generate guacamole token for host", error, {
operation: "guac_host_token_error", operation: "guac_host_token_error",
+31 -2
View File
@@ -2,11 +2,13 @@ import crypto from "crypto";
import { guacLogger } from "../../utils/logger.js"; import { guacLogger } from "../../utils/logger.js";
export interface GuacamoleConnectionSettings { export interface GuacamoleConnectionSettings {
type: "rdp" | "vnc" | "telnet"; type?: "rdp" | "vnc" | "telnet";
join?: string;
readOnly?: boolean;
guacdHost?: string; guacdHost?: string;
guacdPort?: number; guacdPort?: number;
settings: { settings: {
hostname: string; hostname?: string;
port?: number; port?: number;
username?: string; username?: string;
password?: 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 { export interface GuacamoleToken {
connection: GuacamoleConnectionSettings; connection: GuacamoleConnectionSettings;
recording?: GuacamoleRecordingMetadata; recording?: GuacamoleRecordingMetadata;
termixMeta?: TermixGuacMeta;
} }
export interface GuacamoleRecordingMetadata { export interface GuacamoleRecordingMetadata {
@@ -137,6 +147,7 @@ export class GuacamoleTokenService {
guacdPort?: number; guacdPort?: number;
} = {}, } = {},
recording?: GuacamoleRecordingMetadata, recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string { ): string {
const { guacdHost, guacdPort, ...settingsOptions } = options; const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = { const token: GuacamoleToken = {
@@ -155,6 +166,7 @@ export class GuacamoleTokenService {
}, },
}, },
recording, recording,
termixMeta,
}; };
return this.encryptToken(token); return this.encryptToken(token);
} }
@@ -168,6 +180,7 @@ export class GuacamoleTokenService {
guacdPort?: number; guacdPort?: number;
} = {}, } = {},
recording?: GuacamoleRecordingMetadata, recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string { ): string {
const { guacdHost, guacdPort, ...settingsOptions } = options; const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = { const token: GuacamoleToken = {
@@ -184,6 +197,7 @@ export class GuacamoleTokenService {
}, },
}, },
recording, recording,
termixMeta,
}; };
return this.encryptToken(token); return this.encryptToken(token);
} }
@@ -197,6 +211,7 @@ export class GuacamoleTokenService {
guacdPort?: number; guacdPort?: number;
} = {}, } = {},
recording?: GuacamoleRecordingMetadata, recording?: GuacamoleRecordingMetadata,
termixMeta?: TermixGuacMeta,
): string { ): string {
const { guacdHost, guacdPort, ...settingsOptions } = options; const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = { const token: GuacamoleToken = {
@@ -213,6 +228,20 @@ export class GuacamoleTokenService {
}, },
}, },
recording, 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); return this.encryptToken(token);
} }
+539
View File
@@ -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<string, ResolveRateEntry>();
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<string, string>;
} = {
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;
+319 -41
View File
@@ -20,7 +20,14 @@ import { SSHAuthManager } from "../auth-manager.js";
import type { ProxyNode } from "../../../types/index.js"; import type { ProxyNode } from "../../../types/index.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js"; import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { createJumpHostChain } from "../jump-host-chain.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 { import {
detectTmux, detectTmux,
attachOrCreateTmuxSession, attachOrCreateTmuxSession,
@@ -105,10 +112,159 @@ const wss = new WebSocketServer({
port: 30002, port: 30002,
}); });
/**
* Auth path for anonymous share-link guests (?shareToken=<linkToken>).
* 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<void> {
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) => { wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined; let userId: string | undefined;
let sessionId: 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 { try {
let token: string | undefined; let token: string | undefined;
@@ -126,7 +282,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
} }
if (!token) { if (!token) {
const urlObj = new URL(req.url || "", "http://localhost");
const qp = urlObj.searchParams.get("token"); const qp = urlObj.searchParams.get("token");
if (qp) token = qp; if (qp) token = qp;
} }
@@ -242,12 +397,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (currentSessionId) { if (currentSessionId) {
const session = sessionManager.getSession(currentSessionId); const session = sessionManager.getSession(currentSessionId);
if (session?.isConnected) { if (session?.isConnected) {
// Only detach if this WS is still the one attached to the session. const participant = sessionManager.getParticipantForWs(session, ws);
// If a refresh reconnected and reattached a new WS before this close if (participant && !participant.isOwner) {
// event fired, we must not clobber that new attachment. sessionManager.removeParticipant(currentSessionId, ws);
if (session.attachedWs === ws || session.attachedWs === null) { } 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); sessionManager.detachWs(currentSessionId);
} }
}
} else { } else {
sessionManager.destroySession(currentSessionId); sessionManager.destroySession(currentSessionId);
currentSessionId = null; currentSessionId = null;
@@ -295,6 +459,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
const { type, data } = parsed; 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) { switch (type) {
case "connectToHost": { case "connectToHost": {
const connectData = data as ConnectToHostData; const connectData = data as ConnectToHostData;
@@ -445,7 +624,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
break; 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) { if (currentSessionId) {
sessionManager.destroySession(currentSessionId); sessionManager.destroySession(currentSessionId);
currentSessionId = null; currentSessionId = null;
@@ -454,6 +646,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn = null; sshConn = null;
sshStream = null; sshStream = null;
break; break;
}
case "get_cwd": { case "get_cwd": {
const activeConn = const activeConn =
@@ -474,10 +667,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
execStream.stderr.on("data", () => {}); execStream.stderr.on("data", () => {});
execStream.on("close", () => { execStream.on("close", () => {
const cwd = stdout.trim() || "/"; const cwd = stdout.trim() || "/";
const attachedWs = if (ws.readyState === WebSocket.OPEN) {
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws; ws.send(JSON.stringify({ type: "cwd", path: cwd }));
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
} }
}); });
}); });
@@ -517,10 +708,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
execStream.stderr.on("data", () => {}); execStream.stderr.on("data", () => {});
execStream.on("close", () => { execStream.on("close", () => {
const resolvedPath = stdout.trim() || requestedPath; const resolvedPath = stdout.trim() || requestedPath;
const attachedWs = if (ws.readyState === WebSocket.OPEN) {
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws; ws.send(
if (attachedWs.readyState === WebSocket.OPEN) {
attachedWs.send(
JSON.stringify({ JSON.stringify({
type: "open_file_in_editor", type: "open_file_in_editor",
path: resolvedPath, path: resolvedPath,
@@ -1001,6 +1190,105 @@ wss.on("connection", async (ws: WebSocket, req) => {
break; 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: default:
sshLogger.warn("Unknown message type received", { sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type", operation: "websocket_message_unknown_type",
@@ -1636,12 +1924,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
if (session) { if (session) {
sessionManager.bufferOutput(boundSessionId!, utf8String); sessionManager.bufferOutput(boundSessionId!, utf8String);
sessionManager.broadcast(boundSessionId!, {
if (session.attachedWs?.readyState === WebSocket.OPEN) { type: "data",
session.attachedWs.send( data: utf8String,
JSON.stringify({ type: "data", data: utf8String }), });
);
}
} }
} catch (error) { } catch (error) {
sshLogger.error("Error encoding terminal data", error, { sshLogger.error("Error encoding terminal data", error, {
@@ -1653,34 +1939,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
if (session) { if (session) {
sessionManager.bufferOutput(boundSessionId!, fallback); sessionManager.bufferOutput(boundSessionId!, fallback);
sessionManager.broadcast(boundSessionId!, {
if (session.attachedWs?.readyState === WebSocket.OPEN) { type: "data",
session.attachedWs.send( data: fallback,
JSON.stringify({ type: "data", data: fallback }), });
);
}
} }
} }
}); });
stream.on("close", (code: number | null) => { stream.on("close", (code: number | null) => {
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
if (session?.attachedWs?.readyState === WebSocket.OPEN) { if (session) {
if (code != null) { if (code != null) {
session.attachedWs.send( sessionManager.broadcast(boundSessionId!, {
JSON.stringify({
type: "session_ended", type: "session_ended",
code, code,
}), });
);
} else { } else {
session.attachedWs.send( sessionManager.broadcast(boundSessionId!, {
JSON.stringify({
type: "disconnected", type: "disconnected",
message: "Connection lost", message: "Connection lost",
graceful: true, graceful: true,
}), });
);
} }
} }
if (boundSessionId) { if (boundSessionId) {
@@ -1700,13 +1980,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
username, username,
}); });
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
if (session?.attachedWs?.readyState === WebSocket.OPEN) { if (session) {
session.attachedWs.send( sessionManager.broadcast(boundSessionId!, {
JSON.stringify({
type: "error", type: "error",
message: "SSH stream error: " + err.message, message: "SSH stream error: " + err.message,
}), });
);
} }
}); });
+209 -14
View File
@@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000; const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10; 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 { export interface TerminalSession {
id: string; id: string;
userId: string; userId: string;
@@ -32,7 +42,7 @@ export interface TerminalSession {
isConnected: boolean; isConnected: boolean;
createdAt: number; createdAt: number;
attachedWs: WebSocket | null; participants: Map<string, SessionParticipant>;
lastDetachedAt: number | null; lastDetachedAt: number | null;
detachTimeout: NodeJS.Timeout | null; detachTimeout: NodeJS.Timeout | null;
@@ -48,6 +58,33 @@ export interface TerminalSession {
sessionLoggingEnabled: boolean; sessionLoggingEnabled: boolean;
sessionStartedAt: number; sessionStartedAt: number;
lastPersistedBytes: 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<SessionParticipant, "isOwner" | "permissionLevel"> | 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 { class TerminalSessionManager {
@@ -81,7 +118,7 @@ class TerminalSessionManager {
const userSessions = this.getUserSessions(userId); const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) { if (userSessions.length >= MAX_SESSIONS_PER_USER) {
const detached = userSessions const detached = userSessions
.filter((s) => s.attachedWs === null) .filter((s) => this.getOwnerParticipant(s) === null)
.sort( .sort(
(a, b) => (a, b) =>
(a.lastDetachedAt ?? a.createdAt) - (a.lastDetachedAt ?? a.createdAt) -
@@ -109,7 +146,7 @@ class TerminalSessionManager {
operation: "session_tab_duplicate_skip", operation: "session_tab_duplicate_skip",
existingSessionId: existing.id, existingSessionId: existing.id,
tabInstanceId, tabInstanceId,
hasAttachedWs: existing.attachedWs !== null, hasAttachedWs: this.getOwnerParticipant(existing) !== null,
}, },
); );
return existing.id; return existing.id;
@@ -151,7 +188,7 @@ class TerminalSessionManager {
rows, rows,
isConnected: false, isConnected: false,
createdAt: now, createdAt: now,
attachedWs: null, participants: new Map(),
lastDetachedAt: null, lastDetachedAt: null,
detachTimeout: null, detachTimeout: null,
outputBuffer: [], outputBuffer: [],
@@ -166,6 +203,8 @@ class TerminalSessionManager {
sessionLoggingEnabled, sessionLoggingEnabled,
sessionStartedAt: now, sessionStartedAt: now,
lastPersistedBytes: 0, lastPersistedBytes: 0,
terminatedByOwner: false,
terminationReason: null,
}; };
this.sessions.set(id, session); this.sessions.set(id, session);
@@ -199,6 +238,25 @@ class TerminalSessionManager {
session.isConnected = true; 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( attachWs(
sessionId: string, sessionId: string,
userId: string, userId: string,
@@ -234,8 +292,9 @@ class TerminalSessionManager {
return null; return null;
} }
const ownerParticipant = this.getOwnerParticipant(session);
const isDetached = const isDetached =
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN; !ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
const isOriginalTab = const isOriginalTab =
(session.attachedTabInstanceId ?? session.tabInstanceId) === (session.attachedTabInstanceId ?? session.tabInstanceId) ===
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 { try {
session.attachedWs.send( ownerEntry[1].ws.send(
JSON.stringify({ JSON.stringify({
type: "sessionTakenOver", type: "sessionTakenOver",
sessionId, sessionId,
@@ -294,7 +354,7 @@ class TerminalSessionManager {
} catch { } catch {
/* ignore */ /* ignore */
} }
session.attachedWs = null; session.participants.delete(ownerEntry[0]);
} }
if (session.detachTimeout) { if (session.detachTimeout) {
@@ -302,7 +362,14 @@ class TerminalSessionManager {
session.detachTimeout = null; 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.attachedTabInstanceId = tabInstanceId;
session.lastDetachedAt = null; session.lastDetachedAt = null;
@@ -316,6 +383,110 @@ class TerminalSessionManager {
return session; 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 { detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) return; if (!session) return;
@@ -325,7 +496,10 @@ class TerminalSessionManager {
session.detachTimeout = null; session.detachTimeout = null;
} }
session.attachedWs = null; const ownerEntry = this.getOwnerEntry(session);
if (ownerEntry) {
session.participants.delete(ownerEntry[0]);
}
session.lastDetachedAt = Date.now(); session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away, // Persist log immediately when the user detaches so it appears right away,
@@ -365,6 +539,23 @@ class TerminalSessionManager {
fs.promises.unlink(session.recordingPath).catch(() => {}); 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) { if (session.sshStream) {
try { try {
session.sshStream.end(); session.sshStream.end();
@@ -440,12 +631,16 @@ class TerminalSessionManager {
recordingPath: session.recordingPath, recordingPath: session.recordingPath,
protocol: "ssh", protocol: "ssh",
format: "asciicast", format: "asciicast",
terminatedByOwner: session.terminatedByOwner || undefined,
terminationReason: session.terminationReason ?? undefined,
}); });
session.recordingId = created.id; session.recordingId = created.id;
} else { } else {
await repo.updateEnded(session.recordingId, { await repo.updateEnded(session.recordingId, {
endedAt: new Date(endedAt).toISOString(), endedAt: new Date(endedAt).toISOString(),
duration, duration,
terminatedByOwner: session.terminatedByOwner || undefined,
terminationReason: session.terminationReason ?? undefined,
}); });
} }
} catch (err) { } catch (err) {
@@ -569,10 +764,10 @@ class TerminalSessionManager {
for (const [id, session] of this.sessions) { for (const [id, session] of this.sessions) {
if (!session.isConnected) continue; if (!session.isConnected) continue;
if ( const hasOpenParticipant = Array.from(session.participants.values()).some(
session.attachedWs && (p) => p.ws.readyState === WebSocket.OPEN,
session.attachedWs.readyState === WebSocket.OPEN );
) { if (hasOpenParticipant) {
continue; continue;
} }
@@ -87,6 +87,7 @@ describe("HostRepository and CredentialRepository", () => {
vault_profile_id INTEGER, vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1, enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging 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_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT, tunnel_connections TEXT,
@@ -66,6 +66,7 @@ describe("HostFolderRepository", () => {
vault_profile_id INTEGER, vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1, enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging 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_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT, tunnel_connections TEXT,
@@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => {
vault_profile_id INTEGER, vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1, enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging 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_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT, tunnel_connections TEXT,
@@ -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<void>,
): Promise<SessionShareRepository> {
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();
});
});
@@ -49,6 +49,7 @@ describe("UserDataExportRepository", () => {
vault_profile_id INTEGER, vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1, enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging 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_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT, tunnel_connections TEXT,
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
expect(tokenService.decryptToken(token)?.recording).toEqual(recording); 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();
});
}); });
@@ -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<number, { userId: string; allowSessionSharing: boolean }>(),
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
guacSessions: new Map<
string,
{ ownerUserId: string; hostId: number; protocol: string }
>(),
shares: new Map<string, Record<string, unknown>>(),
admins: new Set<string>(),
}));
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<string, unknown>, _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<string, unknown>) => {
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<string, boolean>;
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<string, unknown>;
params?: Record<string, unknown>;
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<string, unknown>;
params?: Record<string, unknown>;
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<string, unknown> | 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<string, unknown>).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<Record<string, unknown>> = {},
) {
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<string, unknown>;
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<string, unknown>).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);
});
});
@@ -49,9 +49,19 @@ vi.mock("fs", () => ({
}, },
})); }));
const { sessionManager } = const { sessionManager, isMessageAllowedForParticipant } =
await import("../../../hosts/terminal/session-manager.js"); 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", () => { describe("TerminalSessionManager - session logging", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
sessionManager.destroySession(id); 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<typeof vi.fn>).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);
}
});
});
+16
View File
@@ -65,6 +65,12 @@ const ElectronVersionCheck = lazy(() =>
})), })),
); );
// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=<linkToken>).
// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify.
const SharedSessionView = lazy(
() => import("@/features/session-sharing/SharedSessionView"),
);
type Phase = type Phase =
| "verifying" | "verifying"
| "idle-auth" | "idle-auth"
@@ -322,6 +328,16 @@ function RootApp() {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const isFullscreen = searchParams.has("view"); 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 (
<Suspense fallback={null}>
<SharedSessionView />
</Suspense>
);
}
if (isFullscreen) { if (isFullscreen) {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
+2
View File
@@ -151,6 +151,7 @@ export interface Host {
enableDocker: boolean; enableDocker: boolean;
enableProxmox: boolean; enableProxmox: boolean;
enableTmuxMonitor: boolean; enableTmuxMonitor: boolean;
allowSessionSharing?: boolean;
proxmoxConfig?: ProxmoxConfig | null; proxmoxConfig?: ProxmoxConfig | null;
showTerminalInSidebar: boolean; showTerminalInSidebar: boolean;
showFileManagerInSidebar: boolean; showFileManagerInSidebar: boolean;
@@ -272,6 +273,7 @@ export interface HostData {
enableDocker?: boolean; enableDocker?: boolean;
enableProxmox?: boolean; enableProxmox?: boolean;
enableTmuxMonitor?: boolean; enableTmuxMonitor?: boolean;
allowSessionSharing?: boolean;
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null; proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
showTerminalInSidebar?: boolean; showTerminalInSidebar?: boolean;
showFileManagerInSidebar?: boolean; showFileManagerInSidebar?: boolean;
+5
View File
@@ -281,6 +281,9 @@ export type Tab = {
host?: Host; host?: Host;
openedAt: number; openedAt: number;
restoredSessionId?: string | null; 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; initialFilePath?: string;
serialConfig?: SerialConfig; serialConfig?: SerialConfig;
terminalRef?: import("react").RefObject<{ terminalRef?: import("react").RefObject<{
@@ -291,6 +294,8 @@ export type Tab = {
fit?: () => void; fit?: () => void;
notifyResize?: () => void; notifyResize?: () => void;
getApplicationCursorKeysMode?: () => boolean; getApplicationCursorKeysMode?: () => boolean;
openShareModal?: () => void;
canShare?: () => boolean;
} | null>; } | null>;
}; };
+71 -4
View File
@@ -272,10 +272,7 @@ export function AppShell({
if (id == null) return null; if (id == null) return null;
return tabs.find((t) => t.id === id)?.instanceId ?? null; return tabs.find((t) => t.id === id)?.instanceId ?? null;
}); });
localStorage.setItem( localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds));
"termix_paneInstanceIds",
JSON.stringify(instanceIds),
);
}, [paneTabIds, tabs]); }, [paneTabIds, tabs]);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -1056,6 +1053,8 @@ export function AppShell({
savedLabel?: string; savedLabel?: string;
initialFilePath?: string; initialFilePath?: string;
serialConfig?: SerialConfig; serialConfig?: SerialConfig;
joinSharedSessionId?: string | null;
joinShareId?: string | null;
}, },
) { ) {
const tabId = `${host.name}-${type}-${Date.now()}`; const tabId = `${host.name}-${type}-${Date.now()}`;
@@ -1072,6 +1071,8 @@ export function AppShell({
const savedLabel = restore?.savedLabel; const savedLabel = restore?.savedLabel;
const initialFilePath = restore?.initialFilePath; const initialFilePath = restore?.initialFilePath;
const serialConfig = restore?.serialConfig; 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 // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
const isCustomLabel = const isCustomLabel =
savedLabel != null && savedLabel != null &&
@@ -1093,6 +1094,8 @@ export function AppShell({
openedAt, openedAt,
terminalRef: ref, terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null, restoredSessionId: restore?.restoredSessionId ?? null,
joinSharedSessionId,
joinShareId,
initialFilePath, initialFilePath,
serialConfig, serialConfig,
}, },
@@ -1125,6 +1128,8 @@ export function AppShell({
openedAt, openedAt,
terminalRef: ref, terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null, restoredSessionId: restore?.restoredSessionId ?? null,
joinSharedSessionId,
joinShareId,
initialFilePath, initialFilePath,
serialConfig, 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) { function closeTab(id: string) {
const tab = tabs.find((t) => t.id === id); const tab = tabs.find((t) => t.id === id);
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
@@ -1725,6 +1741,56 @@ export function AppShell({
}} }}
onRenameTab={renameTab} onRenameTab={renameTab}
onReorderTabs={setTabs} 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);
}}
/> />
</div> </div>
)} )}
@@ -1885,6 +1951,7 @@ export function AppShell({
const targetTab = tabs.find((t) => t.id === tabId); const targetTab = tabs.find((t) => t.id === tabId);
if (targetTab?.host) openTab(targetTab.host, "files"); if (targetTab?.host) openTab(targetTab.host, "files");
}} }}
onOpenShare={openShareForTab}
isAppFullscreen={isAppFullscreen} isAppFullscreen={isAppFullscreen}
onToggleAppFullscreen={toggleAppFullscreen} onToggleAppFullscreen={toggleAppFullscreen}
/> />
+1
View File
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
export interface GuacamoleTokenResponse { export interface GuacamoleTokenResponse {
token: string; token: string;
guacamoleConnectionId?: string | null;
} }
type GuacamoleConfigSource = { type GuacamoleConfigSource = {
+4
View File
@@ -42,6 +42,10 @@ export interface ActiveSessionInfo {
tabInstanceId: string | null; tabInstanceId: string | null;
isConnected: boolean; isConnected: boolean;
createdAt: number; createdAt: number;
isOwnSession: boolean;
sharedByUsername: string | null;
permissionLevel: string | null;
shareId: string | null;
} }
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000); const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
+191
View File
@@ -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<string> {
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<ResolvedShareLink> {
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<CreateSessionShareResponse> {
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");
}
}
@@ -30,6 +30,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/dialog.tsx"; } from "@/components/dialog.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
interface GuacamoleAppProps { interface GuacamoleAppProps {
@@ -41,6 +42,8 @@ interface GuacamoleAppProps {
export interface GuacamoleAppHandle { export interface GuacamoleAppHandle {
disconnect: () => void; disconnect: () => void;
isConnected: () => boolean; isConnected: () => boolean;
openShareModal: () => void;
canShare: () => boolean;
} }
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>( const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
@@ -124,6 +127,10 @@ const GuacamoleAppInner = React.forwardRef<
) { ) {
const { t } = useTranslation(); const { t } = useTranslation();
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
string | null
>(null);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
@@ -152,6 +159,8 @@ const GuacamoleAppInner = React.forwardRef<
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(), disconnect: () => displayRef.current?.disconnect(),
isConnected: () => displayRef.current?.isConnected() === true, isConnected: () => displayRef.current?.isConnected() === true,
openShareModal: () => setShareModalOpen(true),
canShare: () => guacamoleConnectionId !== null,
})); }));
useEffect(() => { useEffect(() => {
@@ -161,6 +170,7 @@ const GuacamoleAppInner = React.forwardRef<
} }
setToken(null); setToken(null);
setGuacamoleConnectionId(null);
setError(null); setError(null);
getGuacdStatus() getGuacdStatus()
.then((status) => { .then((status) => {
@@ -177,6 +187,7 @@ const GuacamoleAppInner = React.forwardRef<
.then((result) => { .then((result) => {
if (result) { if (result) {
setToken(result.token); setToken(result.token);
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
logActivity(resolvedProtocolForConnect, hostId, hostName).catch( logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
() => {}, () => {},
); );
@@ -380,6 +391,16 @@ const GuacamoleAppInner = React.forwardRef<
touchMode={touchMode} touchMode={touchMode}
onTouchModeChange={setTouchMode} onTouchModeChange={setTouchMode}
/> />
{shareModalOpen && guacamoleConnectionId && (
<ShareSessionModal
open={shareModalOpen}
onClose={() => setShareModalOpen(false)}
hostId={hostId}
sessionId={guacamoleConnectionId}
protocol={resolvedProtocol}
tabInstanceId={tabId}
/>
)}
</div> </div>
); );
}); });
@@ -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<SessionSharePermissionLevel>("read-only");
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
const [customHours, setCustomHours] = useState("");
const [search, setSearch] = useState("");
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [createdLink, setCreatedLink] = useState<string | null>(null);
const [shares, setShares] = useState<SessionShareRecord[]>([]);
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 (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
<DialogDescription>
{mode === "link"
? t("sessionSharing.linkModeDescription")
: t("sessionSharing.userModeDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex gap-1.5">
{(["link", "user"] as const).map((m) => (
<button
key={m}
onClick={() => {
setMode(m);
setCreatedLink(null);
}}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{m === "link" ? (
<Link2 className="size-3 shrink-0" />
) : (
<User className="size-3 shrink-0" />
)}
{m === "link"
? t("sessionSharing.modeTab.link")
: t("sessionSharing.modeTab.user")}
</button>
))}
</div>
{mode === "user" && (
<>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
<Input
placeholder={t("sessionSharing.searchUsersPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex flex-col border border-border h-28 overflow-y-auto">
{filteredUsers.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noUsersFound")}
</div>
) : (
filteredUsers.map((user) => {
const isSelected = selectedUserId === user.id;
return (
<button
key={user.id}
onClick={() => setSelectedUserId(user.id)}
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
>
<div
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{isSelected && (
<Check className="size-2.5 text-background" />
)}
</div>
<User className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">{user.username}</span>
</button>
);
})
)}
</div>
</>
)}
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("sessionSharing.permissionLevel.label")}
</span>
<select
value={permissionLevel}
onChange={(e) =>
setPermissionLevel(
e.target.value as SessionSharePermissionLevel,
)
}
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
>
<option value="read-only">
{t("sessionSharing.permissionLevel.readOnly")}
</option>
<option value="read-write">
{t("sessionSharing.permissionLevel.readWrite")}
</option>
</select>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex flex-col gap-1 shrink-0">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
{t("sessionSharing.expiryLabel")}
</span>
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
{t(`hosts.sharing.expiry.${expiryPreset}`)}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
{EXPIRY_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.key}
onClick={() => setExpiryPreset(preset.key)}
>
{expiryPreset === preset.key ? (
<Check className="size-3 mr-1.5" />
) : (
<span className="size-3 mr-1.5" />
)}
{t(`hosts.sharing.expiry.${preset.key}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{expiryPreset === "custom" && (
<Input
type="number"
autoFocus
placeholder={t("hosts.sharing.customHoursPlaceholder")}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
)}
<p className="text-[11px] text-muted-foreground leading-snug">
{permissionLevel === "read-only"
? t("sessionSharing.permissionLevel.readOnlyDescription")
: t("sessionSharing.permissionLevel.readWriteDescription")}
</p>
<Button
variant="outline"
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={
!sessionId ||
submitting ||
(mode === "user" && !selectedUserId) ||
(expiryPreset === "custom" && !expiryHours)
}
onClick={handleCreate}
>
{mode === "link"
? t("sessionSharing.createLinkButton")
: t("sessionSharing.createShareButton")}
</Button>
{createdLink && (
<div className="flex items-center gap-1.5">
<Input readOnly value={createdLink} className="text-xs" />
<Button
variant="outline"
size="icon-sm"
onClick={handleCopyLink}
title={t("sessionSharing.copyLink")}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="flex flex-col gap-0 border-t border-border pt-2">
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<Users className="size-3.5" />
{t("sessionSharing.activeShares")}
{shares.length > 0 && (
<span className="text-muted-foreground/40">
({shares.length})
</span>
)}
</div>
<div className="flex flex-col max-h-40 overflow-y-auto">
{shares.length === 0 && (
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
{t("sessionSharing.noActiveShares")}
</div>
)}
{shares.map((share) => {
const targetUser = users.find(
(u) => u.id === share.targetUserId,
);
return (
<div
key={share.id}
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
>
<div className="flex items-center gap-1.5 min-w-0">
{share.shareType === "link" ? (
<Link2 className="size-3 text-muted-foreground shrink-0" />
) : (
<Shield className="size-3 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="truncate font-semibold">
{share.shareType === "link"
? t("sessionSharing.linkShareBadge")
: t("sessionSharing.userShareBadge", {
username:
targetUser?.username ??
share.targetUserId ??
"?",
})}
</span>
<span className="text-[10px] text-muted-foreground/60">
{share.permissionLevel === "read-write"
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
{" · "}
{t("sessionSharing.expiresAt", {
date: new Date(share.expiresAt).toLocaleString(),
})}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
onClick={() => handleRevoke(share.id)}
>
{t("sessionSharing.revoke")}
</Button>
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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<string> {
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 (
<div
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
style={{
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
color: "var(--foreground)",
border: "1px solid var(--border-base)",
}}
>
<Eye className="size-3.5" />
{label}
</div>
);
}
function CenteredMessage({
icon,
message,
}: {
icon: React.ReactNode;
message: string;
}) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4 w-full"
style={{ backgroundColor: "var(--bg-base)" }}
>
{icon}
<p
className="text-sm font-semibold text-center max-w-xs"
style={{ color: "var(--foreground)" }}
>
{message}
</p>
</div>
);
}
function GuestTerminalView({
share,
linkToken,
}: {
share: ResolvedShareLink;
linkToken: string;
}) {
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const [ended, setEnded] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{ended && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={ended}
/>
</div>
)}
<div ref={xtermRef} className="w-full h-full" />
</div>
);
}
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
const { t } = useTranslation();
const [connectionError, setConnectionError] = useState<string | null>(null);
if (!share.connectParams?.token) {
return (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={t("sessionSharing.guestView.linkInvalid")}
/>
);
}
return (
<div className="relative w-full h-full">
{share.permissionLevel === "read-only" && (
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
)}
{connectionError && (
<div
className="absolute inset-0 z-30 flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={connectionError}
/>
</div>
)}
<GuacamoleDisplay
connectionConfig={{
token: share.connectParams.token,
protocol: share.protocol as "rdp" | "vnc" | "telnet",
type: share.protocol as "rdp" | "vnc" | "telnet",
}}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
</div>
);
}
export default function SharedSessionView() {
const { t } = useTranslation();
const [share, setShare] = useState<ResolvedShareLink | null>(null);
const [linkToken, setLinkToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div className="relative flex-1 min-h-0">
{loading && (
<SimpleLoader
visible={true}
message={t("sessionSharing.guestView.loading")}
/>
)}
{!loading && error && (
<CenteredMessage
icon={
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
}
message={error}
/>
)}
{!loading &&
!error &&
share &&
linkToken &&
(share.protocol === "ssh" ? (
<GuestTerminalView share={share} linkToken={linkToken} />
) : (
<GuestGuacamoleView share={share} />
))}
</div>
</div>
);
}
+39 -2
View File
@@ -57,6 +57,7 @@ import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { resolveTermixThemeColors } from "./terminal-theme.ts"; import { resolveTermixThemeColors } from "./terminal-theme.ts";
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts"; import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
import { import {
getNextTerminalFontSize, getNextTerminalFontSize,
@@ -164,6 +165,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const pongReceivedRef = useRef(true); const pongReceivedRef = useRef(true);
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null); const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false); const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false); const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
@@ -849,8 +851,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
onOpenFileManager?.("/"); onOpenFileManager?.("/");
} }
}, },
openShareModal: () => setShareModalOpen(true),
canShare: () =>
isConnected &&
!isQuickConnect &&
!hostConfig.joinShareId &&
typeof hostConfig.id === "number",
}), }),
[isConnected, terminal], [
isConnected,
terminal,
isQuickConnect,
hostConfig.joinShareId,
hostConfig.id,
],
); );
function getUseRightClickCopyPaste() { function getUseRightClickCopyPaste() {
@@ -1079,7 +1093,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const restoredSessionId = pendingRestoredSessionIdRef.current; const restoredSessionId = pendingRestoredSessionIdRef.current;
pendingRestoredSessionIdRef.current = null; 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; sessionIdRef.current = restoredSessionId;
isAttachingSessionRef.current = true; isAttachingSessionRef.current = true;
@@ -3189,6 +3215,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
</div>, </div>,
document.body, document.body,
)} )}
{shareModalOpen && typeof hostConfig.id === "number" && (
<ShareSessionModal
open={shareModalOpen}
onClose={() => setShareModalOpen(false)}
hostId={hostConfig.id}
sessionId={sessionIdRef.current}
protocol="ssh"
tabInstanceId={hostConfig.instanceId}
/>
)}
</div> </div>
); );
}, },
@@ -4,6 +4,9 @@ export interface TerminalHostConfig {
id?: number; id?: number;
instanceId?: string; instanceId?: string;
restoredSessionId?: string | null; 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; ip: string;
port: number; port: number;
username: string; username: string;
@@ -28,4 +31,6 @@ export interface TerminalHandle {
notifyResize: () => void; notifyResize: () => void;
refresh: () => void; refresh: () => void;
getApplicationCursorKeysMode: () => boolean; getApplicationCursorKeysMode: () => boolean;
openShareModal: () => void;
canShare: () => boolean;
} }
+59 -1
View File
@@ -800,6 +800,8 @@
"enableAutoTmuxDesc": "Automatically launch or attach to tmux session", "enableAutoTmuxDesc": "Automatically launch or attach to tmux session",
"enableSessionLogging": "Session Logging", "enableSessionLogging": "Session Logging",
"enableSessionLoggingDesc": "Record terminal session output for later review", "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", "enableCommandHistory": "Command History",
"enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete", "enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete",
"linkClickBehaviorLabel": "Link Click Behavior", "linkClickBehaviorLabel": "Link Click Behavior",
@@ -1447,7 +1449,60 @@
"expiresIn": "Expires in {{duration}}", "expiresIn": "Expires in {{duration}}",
"search": "Search connections...", "search": "Search connections...",
"noSearchResults": "No connections match your search", "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": { "guacamole": {
"connecting": "Connecting to {{type}} session...", "connecting": "Connecting to {{type}} session...",
@@ -2714,6 +2769,9 @@
"analyticsEnabled": "Share Anonymous Usage Statistics", "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.", "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", "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", "sessionTimeout": "Session Timeout",
"hours": "hours", "hours": "hours",
"sessionTimeoutRange": "Min 1h · Max 720h", "sessionTimeoutRange": "Min 1h · Max 720h",
+16
View File
@@ -19,6 +19,7 @@ import {
Maximize2, Maximize2,
Minimize2, Minimize2,
FolderOpen, FolderOpen,
Share2,
} from "lucide-react"; } from "lucide-react";
import { tabIcon } from "@/shell/tabUtils"; import { tabIcon } from "@/shell/tabUtils";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
@@ -42,6 +43,7 @@ export function TabBar({
onRemoveFromSplit, onRemoveFromSplit,
onRenameTab, onRenameTab,
onOpenFileManager, onOpenFileManager,
onOpenShare,
isAppFullscreen, isAppFullscreen,
onToggleAppFullscreen, onToggleAppFullscreen,
}: { }: {
@@ -59,6 +61,7 @@ export function TabBar({
onRemoveFromSplit: (tabId: string) => void; onRemoveFromSplit: (tabId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void;
onOpenFileManager?: (tabId: string) => void; onOpenFileManager?: (tabId: string) => void;
onOpenShare?: (tabId: string) => void;
isAppFullscreen: boolean; isAppFullscreen: boolean;
onToggleAppFullscreen: () => void; onToggleAppFullscreen: () => void;
}) { }) {
@@ -352,6 +355,19 @@ export function TabBar({
<RefreshCw className="size-3" /> <RefreshCw className="size-3" />
</button> </button>
)} )}
{CONNECTION_TAB_TYPES.includes(tab.type) && onOpenShare && (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onOpenShare(tab.id);
}}
title={t("sessionSharing.shareButton")}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
<Share2 className="size-3" />
</button>
)}
<button <button
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
+2
View File
@@ -234,6 +234,8 @@ function TerminalTabContent({
sshPort: host.sshPort ?? host.port, sshPort: host.sshPort ?? host.port,
instanceId: tab.instanceId ?? tab.id, instanceId: tab.instanceId ?? tab.id,
restoredSessionId: tab.restoredSessionId ?? null, restoredSessionId: tab.restoredSessionId ?? null,
joinSharedSessionId: tab.joinSharedSessionId ?? null,
joinShareId: tab.joinShareId ?? null,
} as TerminalHostConfig } as TerminalHostConfig
} }
isVisible={isVisible} isVisible={isVisible}
+26
View File
@@ -45,6 +45,10 @@ import {
updateAnalyticsEnabled, updateAnalyticsEnabled,
type HostDefaults, type HostDefaults,
} from "@/api/settings-api"; } from "@/api/settings-api";
import {
getSessionSharingGloballyEnabled,
updateSessionSharingGloballyEnabled,
} from "@/api/session-sharing-api";
import { import {
getAcmeSslSettings, getAcmeSslSettings,
updateAcmeSslSettings, updateAcmeSslSettings,
@@ -130,6 +134,8 @@ export function AdminSettingsPanel({
const [tailscaleApiKey, setTailscaleApiKey] = useState(""); const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true); const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
useState(true);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({}); const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
// SSO / auto-provision state // SSO / auto-provision state
@@ -289,6 +295,7 @@ export function AdminSettingsPanel({
tailscale, tailscale,
cmdHistory, cmdHistory,
analytics, analytics,
sessionSharingEnabled,
] = await Promise.allSettled([ ] = await Promise.allSettled([
getRegistrationAllowed(), getRegistrationAllowed(),
getPasswordLoginAllowed(), getPasswordLoginAllowed(),
@@ -302,6 +309,7 @@ export function AdminSettingsPanel({
getTailscaleSettings(), getTailscaleSettings(),
getCommandHistoryEnabled(), getCommandHistoryEnabled(),
getAnalyticsEnabled(), getAnalyticsEnabled(),
getSessionSharingGloballyEnabled(),
]); ]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed); if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
@@ -336,6 +344,9 @@ export function AdminSettingsPanel({
if (analytics.status === "fulfilled") { if (analytics.status === "fulfilled") {
setAnalyticsEnabled(analytics.value.enabled); setAnalyticsEnabled(analytics.value.enabled);
} }
if (sessionSharingEnabled.status === "fulfilled") {
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
}
} catch { } catch {
// non-fatal // non-fatal
} }
@@ -454,6 +465,17 @@ export function AdminSettingsPanel({
} }
} }
async function handleToggleSessionSharingGloballyEnabled() {
const newVal = !sessionSharingGloballyEnabled;
setSessionSharingGloballyEnabled(newVal);
try {
await updateSessionSharingGloballyEnabled(newVal);
} catch {
setSessionSharingGloballyEnabled(!newVal);
toast.error(t("admin.updateSessionSharingFailed"));
}
}
async function handleSaveSessionTimeout() { async function handleSaveSessionTimeout() {
const hours = parseInt(sessionTimeout, 10); const hours = parseInt(sessionTimeout, 10);
if (isNaN(hours) || hours < 1 || hours > 720) { if (isNaN(hours) || hours < 1 || hours > 720) {
@@ -908,6 +930,10 @@ export function AdminSettingsPanel({
onToggle={() => toggle("general")} onToggle={() => toggle("general")}
analyticsEnabled={analyticsEnabled} analyticsEnabled={analyticsEnabled}
handleToggleAnalytics={handleToggleAnalytics} handleToggleAnalytics={handleToggleAnalytics}
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
handleToggleSessionSharingGloballyEnabled={
handleToggleSessionSharingGloballyEnabled
}
allowRegistration={allowRegistration} allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration} handleToggleRegistration={handleToggleRegistration}
allowPasswordLogin={allowPasswordLogin} allowPasswordLogin={allowPasswordLogin}
+13
View File
@@ -32,6 +32,8 @@ type GeneralSettingsSectionProps = {
onToggle: () => void; onToggle: () => void;
analyticsEnabled: boolean; analyticsEnabled: boolean;
handleToggleAnalytics: () => void; handleToggleAnalytics: () => void;
sessionSharingGloballyEnabled: boolean;
handleToggleSessionSharingGloballyEnabled: () => void;
allowRegistration: boolean; allowRegistration: boolean;
handleToggleRegistration: () => void; handleToggleRegistration: () => void;
allowPasswordLogin: boolean; allowPasswordLogin: boolean;
@@ -71,6 +73,8 @@ export function AdminGeneralSettingsSection({
onToggle, onToggle,
analyticsEnabled, analyticsEnabled,
handleToggleAnalytics, handleToggleAnalytics,
sessionSharingGloballyEnabled,
handleToggleSessionSharingGloballyEnabled,
allowRegistration, allowRegistration,
handleToggleRegistration, handleToggleRegistration,
allowPasswordLogin, allowPasswordLogin,
@@ -120,6 +124,15 @@ export function AdminGeneralSettingsSection({
> >
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} /> <AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
</SettingRow> </SettingRow>
<SettingRow
label={t("admin.sessionSharingGloballyEnabled")}
description={t("admin.sessionSharingGloballyEnabledDesc")}
>
<AdminToggle
on={sessionSharingGloballyEnabled}
onToggle={handleToggleSessionSharingGloballyEnabled}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("admin.allowRegistration")} label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")} description={t("admin.allowRegistrationDesc")}
+101 -3
View File
@@ -10,6 +10,7 @@ import {
import { tabIcon } from "@/shell/tabUtils"; import { tabIcon } from "@/shell/tabUtils";
import type { Tab, TabType } from "@/types/ui-types"; import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge"; import { Badge } from "@/components/badge";
import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { import {
Tooltip, Tooltip,
@@ -64,7 +65,11 @@ function sessionsUnchanged(
a.hostName !== b.hostName || a.hostName !== b.hostName ||
a.tabInstanceId !== b.tabInstanceId || a.tabInstanceId !== b.tabInstanceId ||
a.isConnected !== b.isConnected || a.isConnected !== b.isConnected ||
a.createdAt !== b.createdAt a.createdAt !== b.createdAt ||
a.isOwnSession !== b.isOwnSession ||
a.sharedByUsername !== b.sharedByUsername ||
a.permissionLevel !== b.permissionLevel ||
a.shareId !== b.shareId
) { ) {
return false; return false;
} }
@@ -289,6 +294,7 @@ export function ConnectionsPanel({
onForgetBackground, onForgetBackground,
onRenameTab, onRenameTab,
onReorderTabs, onReorderTabs,
onJoinSharedSession,
}: { }: {
tabs: Tab[]; tabs: Tab[];
activeTabId: string; activeTabId: string;
@@ -303,6 +309,7 @@ export function ConnectionsPanel({
onForgetBackground: (recordId: string) => void; onForgetBackground: (recordId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void; onRenameTab?: (tabId: string, newLabel: string) => void;
onReorderTabs?: (tabs: Tab[]) => void; onReorderTabs?: (tabs: Tab[]) => void;
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
@@ -344,6 +351,22 @@ export function ConnectionsPanel({
}) })
: backgroundTabs; : backgroundTabs;
const joinedInstanceIds = new Set(
tabs
.map((t) => (t.joinSharedSessionId ? t.instanceId : null))
.filter(Boolean),
);
const sharedWithMe = activeSessions.filter(
(s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId),
);
const filteredSharedWithMe = q
? sharedWithMe.filter(
(s) =>
s.hostName.toLowerCase().includes(q) ||
(s.sharedByUsername ?? "").toLowerCase().includes(q),
)
: sharedWithMe;
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel. // Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
usePageVisibleInterval(() => setNow(Date.now()), 15_000); usePageVisibleInterval(() => setNow(Date.now()), 15_000);
@@ -432,9 +455,12 @@ export function ConnectionsPanel({
activeSessions.map((s) => [s.tabInstanceId, s]), activeSessions.map((s) => [s.tabInstanceId, s]),
); );
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0; const hasAnything =
openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0;
const hasResults = const hasResults =
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0; filteredOpenTabs.length > 0 ||
filteredBackgroundTabs.length > 0 ||
filteredSharedWithMe.length > 0;
if (!hasAnything) { if (!hasAnything) {
return ( return (
@@ -593,6 +619,78 @@ export function ConnectionsPanel({
})} })}
</div> </div>
)} )}
{filteredSharedWithMe.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`}
>
<SectionHeader
label={t("connections.sectionSharedWithMe")}
count={filteredSharedWithMe.length}
/>
{filteredSharedWithMe.map((session) => (
<SharedWithMeRow
key={session.sessionId}
session={session}
onJoin={() => onJoinSharedSession?.(session)}
/>
))}
</div>
)}
</div>
);
}
function SharedWithMeRow({
session,
onJoin,
}: {
session: ActiveSessionInfo;
onJoin: () => void;
}) {
const { t } = useTranslation();
const isReadWrite = session.permissionLevel === "read-write";
return (
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0">
<div className="shrink-0 flex items-center justify-center size-7 rounded bg-muted/60 text-muted-foreground">
{tabIcon("terminal")}
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
session.isConnected ? "bg-green-500" : "bg-muted-foreground/30"
}`}
/>
<span className="text-xs font-semibold truncate flex-1 text-foreground">
{session.hostName}
</span>
<Badge
variant="outline"
className={`text-[9px] px-1 py-0 h-4 font-mono shrink-0 border-border/60 ${
isReadWrite ? "text-accent-brand" : "text-muted-foreground/60"
}`}
>
{isReadWrite
? t("sessionSharing.permissionLevel.readWrite")
: t("sessionSharing.permissionLevel.readOnly")}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{t("connections.sharedBy", {
username: session.sharedByUsername ?? "?",
})}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={onJoin}
>
{t("connections.join")}
</Button>
</div> </div>
); );
} }
+11 -5
View File
@@ -140,8 +140,7 @@ export function HostEditor({
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]); const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
const [showVaultManager, setShowVaultManager] = useState(false); const [showVaultManager, setShowVaultManager] = useState(false);
const [quickCredentialName, setQuickCredentialName] = useState(""); const [quickCredentialName, setQuickCredentialName] = useState("");
const [creatingQuickCredential, setCreatingQuickCredential] = const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
useState(false);
const [showQuickCredentialDialog, setShowQuickCredentialDialog] = const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
useState(false); useState(false);
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]); const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
@@ -1254,9 +1253,7 @@ export function HostEditor({
background: theme.colors.background, background: theme.colors.background,
}} }}
/> />
<span className="truncate"> <span className="truncate">{theme.name}</span>
{theme.name}
</span>
</button> </button>
<button <button
type="button" type="button"
@@ -1602,6 +1599,15 @@ export function HostEditor({
onChange={(v) => setField("enableSessionLogging", v)} onChange={(v) => setField("enableSessionLogging", v)}
/> />
</SettingRow> </SettingRow>
<SettingRow
label={t("hosts.allowSessionSharing")}
description={t("hosts.allowSessionSharingDesc")}
>
<FakeSwitch
checked={form.allowSessionSharing}
onChange={(v) => setField("allowSessionSharing", v)}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("hosts.enableCommandHistory")} label={t("hosts.enableCommandHistory")}
description={ description={
+2
View File
@@ -107,6 +107,7 @@ export function createHostEditorForm(
enableDocker: host?.enableDocker ?? false, enableDocker: host?.enableDocker ?? false,
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const }, dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
enableTmuxMonitor: host?.enableTmuxMonitor ?? false, enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
allowSessionSharing: host?.allowSessionSharing ?? true,
enableProxmox: host?.enableProxmox ?? false, enableProxmox: host?.enableProxmox ?? false,
proxmoxConfig: host?.proxmoxConfig ?? { proxmoxConfig: host?.proxmoxConfig ?? {
defaultCredentialId: null as number | null, defaultCredentialId: null as number | null,
@@ -306,6 +307,7 @@ export function buildHostEditorPayload(
enableDocker: form.enableDocker, enableDocker: form.enableDocker,
dockerConfig: form.enableDocker ? form.dockerConfig : null, dockerConfig: form.enableDocker ? form.dockerConfig : null,
enableTmuxMonitor: form.enableTmuxMonitor, enableTmuxMonitor: form.enableTmuxMonitor,
allowSessionSharing: form.allowSessionSharing,
enableProxmox: form.enableProxmox, enableProxmox: form.enableProxmox,
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null, proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
defaultPath: form.defaultPath || "/", defaultPath: form.defaultPath || "/",
@@ -0,0 +1,94 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const axiosGetMock = vi.hoisted(() => vi.fn());
vi.mock("axios", () => ({
default: {
get: axiosGetMock,
isAxiosError: (err: unknown): err is { response?: { status?: number } } =>
typeof err === "object" && err !== null && "response" in err,
},
}));
vi.mock("@/lib/base-path", () => ({
getBasePath: () => "",
}));
vi.mock("@/lib/electron", () => ({
isElectron: () => false,
}));
vi.mock("@/main-axios", () => ({
getServerConfig: vi.fn(async () => null),
}));
import {
resolveShareLink,
ShareLinkError,
} from "../../api/session-sharing-api";
beforeEach(() => {
axiosGetMock.mockReset();
});
describe("resolveShareLink", () => {
it("returns the resolved share data on success", async () => {
axiosGetMock.mockResolvedValueOnce({
data: {
protocol: "ssh",
permissionLevel: "read-write",
wsPath: "/terminal/ws?shareToken=abc123",
},
});
const result = await resolveShareLink("abc123");
expect(result).toEqual({
protocol: "ssh",
permissionLevel: "read-write",
wsPath: "/terminal/ws?shareToken=abc123",
});
expect(axiosGetMock).toHaveBeenCalledWith(
expect.stringContaining("/session-sharing/resolve/abc123"),
);
});
it("throws a not-found ShareLinkError on 404", async () => {
axiosGetMock.mockRejectedValueOnce({ response: { status: 404 } });
await expect(resolveShareLink("bad-token")).rejects.toMatchObject({
kind: "not-found",
});
await expect(resolveShareLink("bad-token")).rejects.toBeInstanceOf(
ShareLinkError,
);
});
it("throws a rate-limited ShareLinkError on 429", async () => {
axiosGetMock.mockRejectedValueOnce({ response: { status: 429 } });
await expect(resolveShareLink("token")).rejects.toMatchObject({
kind: "rate-limited",
});
});
it("throws a generic ShareLinkError on unexpected failures", async () => {
axiosGetMock.mockRejectedValueOnce(new Error("network down"));
await expect(resolveShareLink("token")).rejects.toMatchObject({
kind: "unknown",
});
});
it("URL-encodes the link token", async () => {
axiosGetMock.mockResolvedValueOnce({
data: { protocol: "ssh", permissionLevel: "read-only", wsPath: "" },
});
await resolveShareLink("a/b c");
expect(axiosGetMock).toHaveBeenCalledWith(
expect.stringContaining(encodeURIComponent("a/b c")),
);
});
});
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
const api = vi.hoisted(() => ({
createSessionShare: vi.fn(),
getActiveSessionShares: vi.fn(async () => ({ shares: [] })),
revokeSessionShare: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("@/api/session-sharing-api", () => api);
const mainAxios = vi.hoisted(() => ({
getUserList: vi.fn(async () => ({
users: [
{ userId: "u1", username: "alice" },
{ userId: "u2", username: "bob" },
],
})),
}));
vi.mock("@/main-axios", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
Object.assign(navigator, {
clipboard: { writeText: vi.fn(async () => {}) },
});
import { ShareSessionModal } from "../../../features/session-sharing/ShareSessionModal";
beforeEach(() => {
api.createSessionShare.mockReset();
api.getActiveSessionShares.mockReset();
api.getActiveSessionShares.mockResolvedValue({ shares: [] });
api.revokeSessionShare.mockReset();
mainAxios.getUserList.mockClear();
});
afterEach(() => {
cleanup();
});
describe("ShareSessionModal", () => {
it("creates a link share with the default read-only permission and 24h expiry", async () => {
api.createSessionShare.mockResolvedValue({
shareId: "share-1",
linkToken: "tok-123",
expiresAt: "2026-07-21T00:00:00.000Z",
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={42}
sessionId="sess-1"
protocol="ssh"
tabInstanceId="tab-1"
/>,
);
const createButton = await screen.findByText(
"sessionSharing.createLinkButton",
);
fireEvent.click(createButton);
await waitFor(() => {
expect(api.createSessionShare).toHaveBeenCalledWith({
hostId: 42,
sessionId: "sess-1",
tabInstanceId: "tab-1",
protocol: "ssh",
shareType: "link",
targetUserId: undefined,
permissionLevel: "read-only",
expiryHours: 24,
});
});
});
it("does not submit a user share until a target user is selected", async () => {
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={7}
sessionId="sess-2"
protocol="rdp"
/>,
);
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
const shareButton = await screen.findByText(
"sessionSharing.createShareButton",
);
expect((shareButton as HTMLButtonElement).disabled).toBe(true);
expect(api.createSessionShare).not.toHaveBeenCalled();
});
it("submits a user share with the selected target and read-write permission", async () => {
api.createSessionShare.mockResolvedValue({
shareId: "share-2",
linkToken: null,
expiresAt: "2026-07-21T00:00:00.000Z",
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={7}
sessionId="sess-2"
protocol="vnc"
/>,
);
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
fireEvent.click(await screen.findByText("alice"));
const select = await screen.findByDisplayValue(
"sessionSharing.permissionLevel.readOnly",
);
fireEvent.change(select, { target: { value: "read-write" } });
fireEvent.click(screen.getByText("sessionSharing.createShareButton"));
await waitFor(() => {
expect(api.createSessionShare).toHaveBeenCalledWith({
hostId: 7,
sessionId: "sess-2",
tabInstanceId: undefined,
protocol: "vnc",
shareType: "user",
targetUserId: "u1",
permissionLevel: "read-write",
expiryHours: 24,
});
});
});
it("does not call create when there is no live session id", async () => {
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={1}
sessionId={null}
protocol="ssh"
/>,
);
const createButton = await screen.findByText(
"sessionSharing.createLinkButton",
);
expect((createButton as HTMLButtonElement).disabled).toBe(true);
});
it("loads and renders active shares for the host", async () => {
api.getActiveSessionShares.mockResolvedValue({
shares: [
{
id: "share-x",
hostId: 42,
ownerUserId: "owner-1",
protocol: "ssh",
sessionId: "sess-1",
tabInstanceId: null,
shareType: "link",
targetUserId: null,
linkToken: "tok-abc",
permissionLevel: "read-only",
createdAt: "2026-07-20T00:00:00.000Z",
expiresAt: "2026-07-21T00:00:00.000Z",
revokedAt: null,
lastJoinedAt: null,
joinCount: 0,
},
],
});
render(
<ShareSessionModal
open={true}
onClose={() => {}}
hostId={42}
sessionId="sess-1"
protocol="ssh"
/>,
);
await waitFor(() => {
expect(screen.getByText("sessionSharing.linkShareBadge")).toBeTruthy();
});
});
});
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
const api = vi.hoisted(() => ({
resolveShareLink: vi.fn(async () => {
throw new Error("resolveShareLink not mocked for this test");
}),
ShareLinkError: class ShareLinkError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/api/session-sharing-api", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("react-xtermjs", () => ({
useXTerm: () => ({
instance: {
loadAddon: vi.fn(),
open: vi.fn(),
write: vi.fn(),
onData: vi.fn(),
},
ref: { current: document.createElement("div") },
}),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class FitAddon {
fit() {}
},
}));
vi.mock("@/features/guacamole/GuacamoleDisplay.tsx", () => ({
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
}));
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
function setSearch(search: string) {
window.history.pushState({}, "", `/?${search}`);
}
beforeEach(() => {
api.resolveShareLink.mockReset();
api.resolveShareLink.mockImplementation(async () => {
throw new Error("resolveShareLink not mocked for this test");
});
});
afterEach(() => {
cleanup();
});
describe("SharedSessionView", () => {
it("shows a friendly error when no token is present in the URL", async () => {
setSearch("view=shared");
render(<SharedSessionView />);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.linkInvalid"),
).toBeTruthy();
});
expect(api.resolveShareLink).not.toHaveBeenCalled();
});
it("shows a generic error when the link is invalid or expired", async () => {
setSearch("view=shared&token=bad");
api.resolveShareLink.mockRejectedValue(
new api.ShareLinkError("not found", "not-found"),
);
render(<SharedSessionView />);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.linkInvalid"),
).toBeTruthy();
});
});
it("shows a rate-limit specific message on 429", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockRejectedValue(
new api.ShareLinkError("slow down", "rate-limited"),
);
render(<SharedSessionView />);
await waitFor(() => {
expect(api.resolveShareLink).toHaveBeenCalledWith("abc");
});
await waitFor(() => {
expect(
screen.getByText("sessionSharing.guestView.rateLimited"),
).toBeTruthy();
});
});
it("renders the guacamole display for a resolved rdp share", async () => {
setSearch("view=shared&token=abc");
api.resolveShareLink.mockResolvedValue({
protocol: "rdp",
permissionLevel: "read-only",
wsPath: "/guacamole/websocket/",
connectParams: { token: "guac-join-token" },
});
render(<SharedSessionView />);
await waitFor(() => {
expect(screen.getByTestId("guacamole-display")).toBeTruthy();
});
expect(
screen.getByText("sessionSharing.guestView.readOnlyBadge"),
).toBeTruthy();
});
});
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
import type { ActiveSessionInfo } from "@/api/open-tabs-api";
const mainAxios = vi.hoisted(() => ({
getActiveSessions: vi.fn(async () => [] as ActiveSessionInfo[]),
deleteOpenTab: vi.fn(async () => {}),
}));
vi.mock("@/main-axios", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
import { ConnectionsPanel } from "../../sidebar/ConnectionsPanel";
function sharedSession(
overrides: Partial<ActiveSessionInfo> = {},
): ActiveSessionInfo {
return {
sessionId: "sess-shared-1",
hostId: 5,
hostName: "prod-db",
tabInstanceId: null,
isConnected: true,
createdAt: Date.now(),
isOwnSession: false,
sharedByUsername: "alice",
permissionLevel: "read-only",
shareId: "share-1",
...overrides,
};
}
beforeEach(() => {
mainAxios.getActiveSessions.mockReset();
mainAxios.getActiveSessions.mockResolvedValue([]);
mainAxios.deleteOpenTab.mockReset();
});
afterEach(() => {
cleanup();
});
describe("ConnectionsPanel - shared with me", () => {
it("renders a shared-with-me row for sessions the current user does not own", async () => {
mainAxios.getActiveSessions.mockResolvedValue([sharedSession()]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByText("connections.sectionSharedWithMe")).toBeTruthy();
});
expect(screen.getByText("prod-db")).toBeTruthy();
expect(
screen.getByText('connections.sharedBy:{"username":"alice"}'),
).toBeTruthy();
});
it("does not show own sessions in the shared-with-me section", async () => {
mainAxios.getActiveSessions.mockResolvedValue([
sharedSession({
isOwnSession: true,
sharedByUsername: null,
shareId: null,
}),
]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(mainAxios.getActiveSessions).toHaveBeenCalled();
});
expect(screen.queryByText("connections.sectionSharedWithMe")).toBeNull();
});
it("dispatches onJoinSharedSession with the session when Join is clicked", async () => {
const session = sharedSession();
mainAxios.getActiveSessions.mockResolvedValue([session]);
const onJoinSharedSession = vi.fn();
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
onJoinSharedSession={onJoinSharedSession}
/>,
);
const joinButton = await screen.findByText("connections.join");
fireEvent.click(joinButton);
expect(onJoinSharedSession).toHaveBeenCalledWith(session);
});
it("shows a read-write badge for read-write shared sessions", async () => {
mainAxios.getActiveSessions.mockResolvedValue([
sharedSession({ permissionLevel: "read-write" }),
]);
render(
<ConnectionsPanel
tabs={[]}
activeTabId=""
allHosts={[]}
backgroundTabRecords={[]}
onSwitchToTab={() => {}}
onCloseTab={() => {}}
onReopenTab={() => {}}
onForgetBackground={() => {}}
/>,
);
await waitFor(() => {
expect(
screen.getByText("sessionSharing.permissionLevel.readWrite"),
).toBeTruthy();
});
});
});