mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
feat: add multiplayer/shared sessions for terminal and guacd
This commit is contained in:
@@ -467,6 +467,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/session-sharing(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -456,6 +456,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/session-sharing(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||
}
|
||||
|
||||
location /host/tunnel/ {
|
||||
proxy_pass http://127.0.0.1:30003;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -17,14 +17,27 @@ const cryptPath = path.join(
|
||||
"lib",
|
||||
"Crypt.js",
|
||||
);
|
||||
const clientConnectionPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
"guacamole-lite",
|
||||
"lib",
|
||||
"ClientConnection.js",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
||||
if (
|
||||
!fs.existsSync(guacdClientPath) ||
|
||||
!fs.existsSync(cryptPath) ||
|
||||
!fs.existsSync(clientConnectionPath)
|
||||
) {
|
||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||
|
||||
// Patch 1: protocol version negotiation.
|
||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||
@@ -268,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) {
|
||||
patched = true;
|
||||
}
|
||||
|
||||
// Patch 7: drop client-to-guacd input instructions from read-only session-share
|
||||
// joins. guacd has no native read-only enforcement in the versions this project
|
||||
// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
|
||||
// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
|
||||
// clipboard streams) than a new input vector, so failing open is the safer
|
||||
// default for a client we already control.
|
||||
const oldSendMessageToGuacd =
|
||||
" sendMessageToGuacd(message) {\n" +
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||
"\n" +
|
||||
" if (this.guacdClient) {\n" +
|
||||
" this.guacdClient.send(message, true);\n" +
|
||||
" }\n" +
|
||||
" }";
|
||||
const newSendMessageToGuacd =
|
||||
" sendMessageToGuacd(message) {\n" +
|
||||
" this.lastActivity = Date.now();\n" +
|
||||
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||
"\n" +
|
||||
" if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
|
||||
" return;\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" if (this.guacdClient) {\n" +
|
||||
" this.guacdClient.send(message, true);\n" +
|
||||
" }\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" isReadOnlyJoin() {\n" +
|
||||
" const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
|
||||
" return !!(connection && connection.join && connection.readOnly === true);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
|
||||
" // the leading opcode from a raw '<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) {
|
||||
console.log("[patch-guacamole-lite] Already patched");
|
||||
process.exit(0);
|
||||
@@ -275,6 +376,7 @@ if (!patched) {
|
||||
|
||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||
fs.writeFileSync(cryptPath, cryptContent);
|
||||
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
|
||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
@@ -1737,6 +1738,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
|
||||
@@ -495,6 +495,38 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1456,6 +1488,7 @@ const migrateSchema = () => {
|
||||
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
||||
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
||||
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
||||
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
@@ -2290,6 +2323,76 @@ const migrateSchema = () => {
|
||||
}
|
||||
// --- homepage end ---
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM session_shares LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
host_id INTEGER NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
tab_instance_id TEXT,
|
||||
share_type TEXT NOT NULL,
|
||||
target_user_id TEXT,
|
||||
link_token TEXT UNIQUE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
last_joined_at TEXT,
|
||||
join_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_shares_link_token ON session_shares(link_token)",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_shares_target_user ON session_shares(target_user_id)",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_shares_host ON session_shares(host_id)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create session_shares table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM session_share_participants LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
share_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
guest_label TEXT,
|
||||
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
left_at TEXT,
|
||||
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_share_participants_share ON session_share_participants(share_id)",
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create session_share_participants table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
@@ -676,6 +679,62 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
terminationReason: text("termination_reason"),
|
||||
});
|
||||
|
||||
export const sessionShares = sqliteTable("session_shares", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
protocol: text("protocol").notNull(),
|
||||
|
||||
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||
// row (process-local, in-memory) so this intentionally has no FK.
|
||||
sessionId: text("session_id").notNull(),
|
||||
tabInstanceId: text("tab_instance_id"),
|
||||
|
||||
shareType: text("share_type").notNull(), // "link" | "user"
|
||||
targetUserId: text("target_user_id").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
linkToken: text("link_token").unique(),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
|
||||
lastJoinedAt: text("last_joined_at"),
|
||||
joinCount: integer("join_count").notNull().default(0),
|
||||
});
|
||||
|
||||
export const sessionShareParticipants = sqliteTable(
|
||||
"session_share_participants",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
shareId: text("share_id")
|
||||
.notNull()
|
||||
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
guestLabel: text("guest_label"),
|
||||
|
||||
joinedAt: text("joined_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
leftAt: text("left_at"),
|
||||
},
|
||||
);
|
||||
|
||||
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
|
||||
@@ -27,6 +27,7 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
|
||||
import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SessionShareRepository } from "./session-share-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SnippetRepository } from "./snippet-repository.js";
|
||||
@@ -253,6 +254,13 @@ export function createCurrentSessionRepository(): SessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||
return new SessionShareRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||
return new SettingsRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -58,7 +58,12 @@ export class SessionRecordingRepository {
|
||||
|
||||
async updateEnded(
|
||||
id: number,
|
||||
input: { endedAt: string; duration: number | null },
|
||||
input: {
|
||||
endedAt: string;
|
||||
duration: number | null;
|
||||
terminatedByOwner?: boolean;
|
||||
terminationReason?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.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?.();
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ router.post(
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -288,6 +289,7 @@ router.post(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -815,6 +817,7 @@ router.put(
|
||||
enableDocker,
|
||||
enableProxmox,
|
||||
enableTmuxMonitor,
|
||||
allowSessionSharing,
|
||||
showTerminalInSidebar,
|
||||
showFileManagerInSidebar,
|
||||
showTunnelInSidebar,
|
||||
@@ -925,6 +928,7 @@ router.put(
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
enableProxmox: enableProxmox ? 1 : 0,
|
||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||
@@ -1500,9 +1504,13 @@ router.get(
|
||||
const field = (req.query.field as string) || "password";
|
||||
|
||||
if (
|
||||
!["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes(
|
||||
field,
|
||||
)
|
||||
![
|
||||
"password",
|
||||
"sudoPassword",
|
||||
"vncPassword",
|
||||
"key",
|
||||
"keyPassword",
|
||||
].includes(field)
|
||||
) {
|
||||
return res.status(400).json({ error: "Invalid field" });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
||||
import {
|
||||
getCurrentSettingValue,
|
||||
createCurrentOpenTabRepository,
|
||||
createCurrentSessionShareRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* /open-tabs/active-sessions:
|
||||
* get:
|
||||
* summary: Get all active backend sessions for the current user
|
||||
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
|
||||
* description: >
|
||||
* Returns live terminal sessions from the session manager, both sessions the
|
||||
* caller owns and SSH sessions shared to the caller by another user (via
|
||||
* an in-app session share). Used by the Active Connections panel and tab restore logic.
|
||||
* tags:
|
||||
* - Open Tabs
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of active sessions.
|
||||
* description: List of active sessions (own and shared-with-me).
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* type: boolean
|
||||
* createdAt:
|
||||
* type: number
|
||||
* isOwnSession:
|
||||
* type: boolean
|
||||
* sharedByUsername:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* permissionLevel:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* shareId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
*/
|
||||
router.get(
|
||||
"/active-sessions",
|
||||
@@ -309,17 +324,46 @@ router.get(
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const sessions = sessionManager.getUserSessions(userId);
|
||||
return res.json(
|
||||
sessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
})),
|
||||
);
|
||||
const ownSessions = sessionManager.getUserSessions(userId);
|
||||
const result = ownSessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
hostId: s.hostId,
|
||||
hostName: s.hostName,
|
||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||
isConnected: s.isConnected,
|
||||
createdAt: s.createdAt,
|
||||
isOwnSession: true,
|
||||
sharedByUsername: null as string | null,
|
||||
permissionLevel: null as string | null,
|
||||
shareId: null as string | null,
|
||||
}));
|
||||
|
||||
const sharedWithMe =
|
||||
await createCurrentSessionShareRepository().findSharesTargetingUser(
|
||||
userId,
|
||||
);
|
||||
for (const share of sharedWithMe) {
|
||||
if (share.protocol !== "ssh") continue;
|
||||
const sharedSession = sessionManager.getSession(share.sessionId);
|
||||
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||
result.push({
|
||||
sessionId: sharedSession.id,
|
||||
hostId: sharedSession.hostId,
|
||||
hostName: sharedSession.hostName,
|
||||
tabInstanceId:
|
||||
sharedSession.attachedTabInstanceId ??
|
||||
sharedSession.tabInstanceId ??
|
||||
null,
|
||||
isConnected: sharedSession.isConnected,
|
||||
createdAt: sharedSession.createdAt,
|
||||
isOwnSession: false,
|
||||
sharedByUsername: share.ownerUsername,
|
||||
permissionLevel: share.permissionLevel,
|
||||
shareId: share.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get active sessions", e, {
|
||||
operation: "get_active_sessions",
|
||||
|
||||
@@ -616,6 +616,110 @@ export function registerUserSettingsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* get:
|
||||
* summary: Get session sharing globally enabled setting
|
||||
* description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session sharing enabled status.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
*/
|
||||
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get session sharing enabled setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get session sharing enabled setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-sharing-enabled:
|
||||
* patch:
|
||||
* summary: Update session sharing globally enabled setting (admin only)
|
||||
* description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update setting.
|
||||
*/
|
||||
router.patch(
|
||||
"/session-sharing-enabled",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
await createCurrentSettingsRepository().set(
|
||||
"session_sharing_globally_enabled",
|
||||
enabled ? "true" : "false",
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_session_sharing_enabled",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ enabled }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to update session sharing enabled setting",
|
||||
err,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to update session sharing enabled setting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
|
||||
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
|
||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||
|
||||
type GuacamoleClientConnection = {
|
||||
guacamoleConnectionId?: string;
|
||||
connectionSettings?: {
|
||||
connection?: { type?: string };
|
||||
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export interface GuacSessionInfo {
|
||||
guacamoleConnectionId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||
// primary connection's guacd handshake completes.
|
||||
const guacSessionByConnectId = new Map<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(
|
||||
clientConnection: GuacamoleClientConnection,
|
||||
): Promise<void> {
|
||||
@@ -149,6 +201,25 @@ function createGuacServer(): GuacamoleLite {
|
||||
operation: "guac_connection_open",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
const info: GuacSessionInfo = {
|
||||
guacamoleConnectionId,
|
||||
hostId: termixMeta.hostId,
|
||||
ownerUserId: termixMeta.ownerUserId,
|
||||
protocol: termixMeta.protocol,
|
||||
openedAt: Date.now(),
|
||||
};
|
||||
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||
|
||||
const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId);
|
||||
if (resolver) resolver(info);
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||
@@ -156,6 +227,15 @@ function createGuacServer(): GuacamoleLite {
|
||||
operation: "guac_connection_close",
|
||||
type: clientConnection.connectionSettings?.connection?.type,
|
||||
});
|
||||
|
||||
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||
}
|
||||
|
||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||
operation: "guac_recording_persist_error",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import type { SOCKS5Config } from "../../utils/socks5-helper.js";
|
||||
import { waitForGuacdOpen } from "./guacamole-server.js";
|
||||
|
||||
const router = express.Router();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
@@ -183,6 +184,10 @@ router.post("/token", async (req, res) => {
|
||||
* token:
|
||||
* type: string
|
||||
* description: Encrypted connection token
|
||||
* guacamoleConnectionId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: guacd's own connection id for this session, once the handshake completes. Used to mint session-share join tokens.
|
||||
* 400:
|
||||
* description: Invalid request or unsupported connection type
|
||||
* 403:
|
||||
@@ -607,6 +612,14 @@ router.post(
|
||||
guacConfig["recording-include-keys"] = true;
|
||||
}
|
||||
|
||||
const termixConnectId = crypto.randomUUID();
|
||||
const termixMeta = {
|
||||
termixConnectId,
|
||||
hostId,
|
||||
ownerUserId: userId,
|
||||
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||
};
|
||||
|
||||
switch (connectionType) {
|
||||
case "rdp":
|
||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||
@@ -634,6 +647,7 @@ router.post(
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
case "vnc":
|
||||
@@ -648,6 +662,7 @@ router.post(
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
case "telnet":
|
||||
@@ -661,13 +676,19 @@ router.post(
|
||||
...guacdOverrides,
|
||||
},
|
||||
recordingMetadata,
|
||||
termixMeta,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: "Invalid connection type" });
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||
operation: "guac_host_token_error",
|
||||
|
||||
@@ -2,11 +2,13 @@ import crypto from "crypto";
|
||||
import { guacLogger } from "../../utils/logger.js";
|
||||
|
||||
export interface GuacamoleConnectionSettings {
|
||||
type: "rdp" | "vnc" | "telnet";
|
||||
type?: "rdp" | "vnc" | "telnet";
|
||||
join?: string;
|
||||
readOnly?: boolean;
|
||||
guacdHost?: string;
|
||||
guacdPort?: number;
|
||||
settings: {
|
||||
hostname: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -28,9 +30,17 @@ export interface GuacamoleConnectionSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TermixGuacMeta {
|
||||
termixConnectId: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
|
||||
export interface GuacamoleToken {
|
||||
connection: GuacamoleConnectionSettings;
|
||||
recording?: GuacamoleRecordingMetadata;
|
||||
termixMeta?: TermixGuacMeta;
|
||||
}
|
||||
|
||||
export interface GuacamoleRecordingMetadata {
|
||||
@@ -137,6 +147,7 @@ export class GuacamoleTokenService {
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -155,6 +166,7 @@ export class GuacamoleTokenService {
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -168,6 +180,7 @@ export class GuacamoleTokenService {
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -184,6 +197,7 @@ export class GuacamoleTokenService {
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
@@ -197,6 +211,7 @@ export class GuacamoleTokenService {
|
||||
guacdPort?: number;
|
||||
} = {},
|
||||
recording?: GuacamoleRecordingMetadata,
|
||||
termixMeta?: TermixGuacMeta,
|
||||
): string {
|
||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||
const token: GuacamoleToken = {
|
||||
@@ -213,6 +228,20 @@ export class GuacamoleTokenService {
|
||||
},
|
||||
},
|
||||
recording,
|
||||
termixMeta,
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
// join tokens never carry recording params - only the primary connection's
|
||||
// token should write recording-path/recording-name to guacd.
|
||||
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
|
||||
const token: GuacamoleToken = {
|
||||
connection: {
|
||||
join: guacamoleConnectionId,
|
||||
readOnly,
|
||||
settings: {},
|
||||
},
|
||||
};
|
||||
return this.encryptToken(token);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -20,7 +20,14 @@ import { SSHAuthManager } from "../auth-manager.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { sessionManager } from "./session-manager.js";
|
||||
import {
|
||||
sessionManager,
|
||||
isMessageAllowedForParticipant,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
createCurrentSessionShareRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
@@ -105,10 +112,159 @@ const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth path for anonymous share-link guests (?shareToken=<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) => {
|
||||
let userId: string | undefined;
|
||||
let sessionId: string | undefined;
|
||||
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const shareToken = urlObj.searchParams.get("shareToken");
|
||||
|
||||
if (shareToken) {
|
||||
await handleShareTokenConnection(ws, req, shareToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let token: string | undefined;
|
||||
|
||||
@@ -126,7 +282,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
const urlObj = new URL(req.url || "", "http://localhost");
|
||||
const qp = urlObj.searchParams.get("token");
|
||||
if (qp) token = qp;
|
||||
}
|
||||
@@ -242,11 +397,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (currentSessionId) {
|
||||
const session = sessionManager.getSession(currentSessionId);
|
||||
if (session?.isConnected) {
|
||||
// Only detach if this WS is still the one attached to the session.
|
||||
// If a refresh reconnected and reattached a new WS before this close
|
||||
// event fired, we must not clobber that new attachment.
|
||||
if (session.attachedWs === ws || session.attachedWs === null) {
|
||||
sessionManager.detachWs(currentSessionId);
|
||||
const participant = sessionManager.getParticipantForWs(session, ws);
|
||||
if (participant && !participant.isOwner) {
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
} else {
|
||||
// Only detach if this WS is still the owner's attached socket, or
|
||||
// no owner is currently attached. If a refresh reconnected and
|
||||
// reattached a new WS before this close event fired, we must not
|
||||
// clobber that new attachment.
|
||||
const ownerStillAttached = Array.from(
|
||||
session.participants.values(),
|
||||
).some((p) => p.isOwner && p.ws !== ws);
|
||||
if (!ownerStillAttached) {
|
||||
sessionManager.detachWs(currentSessionId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
@@ -295,6 +459,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
// Server-side gate: non-owner participants (read-only or read-write
|
||||
// guests/joiners) may only send input/ping/disconnect - everything else
|
||||
// (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
|
||||
if (type !== "joinSharedSession") {
|
||||
const gateSession = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
const gateParticipant = gateSession
|
||||
? sessionManager.getParticipantForWs(gateSession, ws)
|
||||
: null;
|
||||
if (!isMessageAllowedForParticipant(gateParticipant, type)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost": {
|
||||
const connectData = data as ConnectToHostData;
|
||||
@@ -445,7 +624,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect":
|
||||
case "disconnect": {
|
||||
const disconnectSession = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
const disconnectParticipant = disconnectSession
|
||||
? sessionManager.getParticipantForWs(disconnectSession, ws)
|
||||
: null;
|
||||
if (disconnectParticipant && !disconnectParticipant.isOwner) {
|
||||
if (currentSessionId) {
|
||||
sessionManager.removeParticipant(currentSessionId, ws);
|
||||
currentSessionId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (currentSessionId) {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
currentSessionId = null;
|
||||
@@ -454,6 +646,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn = null;
|
||||
sshStream = null;
|
||||
break;
|
||||
}
|
||||
|
||||
case "get_cwd": {
|
||||
const activeConn =
|
||||
@@ -474,10 +667,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
execStream.stderr.on("data", () => {});
|
||||
execStream.on("close", () => {
|
||||
const cwd = stdout.trim() || "/";
|
||||
const attachedWs =
|
||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -517,10 +708,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
execStream.stderr.on("data", () => {});
|
||||
execStream.on("close", () => {
|
||||
const resolvedPath = stdout.trim() || requestedPath;
|
||||
const attachedWs =
|
||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "open_file_in_editor",
|
||||
path: resolvedPath,
|
||||
@@ -1001,6 +1190,105 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "joinSharedSession": {
|
||||
const joinData = data as { shareId: string; tabInstanceId?: string };
|
||||
try {
|
||||
const shareRepo = createCurrentSessionShareRepository();
|
||||
const share = await shareRepo.findActiveById(joinData.shareId);
|
||||
if (
|
||||
!share ||
|
||||
share.shareType !== "user" ||
|
||||
share.targetUserId !== userId ||
|
||||
share.protocol !== "ssh"
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Share not found or not accessible",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
share.hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Share not found or not accessible",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const joinedSession = sessionManager.joinAsParticipant(
|
||||
share.sessionId,
|
||||
ws,
|
||||
{
|
||||
userId,
|
||||
permissionLevel: share.permissionLevel as
|
||||
| "read-write"
|
||||
| "read-only",
|
||||
tabInstanceId: joinData.tabInstanceId,
|
||||
shareId: share.id,
|
||||
},
|
||||
);
|
||||
if (!joinedSession) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Shared session is no longer active",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
currentSessionId = share.sessionId;
|
||||
sshStream = joinedSession.sshStream;
|
||||
sshConn = joinedSession.sshConn;
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
|
||||
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||
shareRepo
|
||||
.recordParticipantJoin(share.id, userId, null)
|
||||
.catch(() => {});
|
||||
|
||||
const buffered = sessionManager.getBuffer(joinedSession);
|
||||
if (buffered) {
|
||||
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionAttached",
|
||||
sessionId: share.sessionId,
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "Joined session" }),
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to join shared session", error, {
|
||||
operation: "terminal_join_shared_session_error",
|
||||
userId,
|
||||
shareId: joinData.shareId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Failed to join shared session",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message_unknown_type",
|
||||
@@ -1636,12 +1924,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
||||
|
||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({ type: "data", data: utf8String }),
|
||||
);
|
||||
}
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "data",
|
||||
data: utf8String,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error encoding terminal data", error, {
|
||||
@@ -1653,34 +1939,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
sessionManager.bufferOutput(boundSessionId!, fallback);
|
||||
|
||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({ type: "data", data: fallback }),
|
||||
);
|
||||
}
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "data",
|
||||
data: fallback,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code: number | null) => {
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
if (session) {
|
||||
if (code != null) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "session_ended",
|
||||
code,
|
||||
}),
|
||||
);
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "session_ended",
|
||||
code,
|
||||
});
|
||||
} else {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
graceful: true,
|
||||
}),
|
||||
);
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "disconnected",
|
||||
message: "Connection lost",
|
||||
graceful: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (boundSessionId) {
|
||||
@@ -1700,13 +1980,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
username,
|
||||
});
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
||||
session.attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
}),
|
||||
);
|
||||
if (session) {
|
||||
sessionManager.broadcast(boundSessionId!, {
|
||||
type: "error",
|
||||
message: "SSH stream error: " + err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||
const MAX_SESSIONS_PER_USER = 10;
|
||||
|
||||
export interface SessionParticipant {
|
||||
ws: WebSocket;
|
||||
userId: string | null; // null for anonymous link guests
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
isOwner: boolean;
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
joinedViaShareId?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -32,7 +42,7 @@ export interface TerminalSession {
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
|
||||
attachedWs: WebSocket | null;
|
||||
participants: Map<string, SessionParticipant>;
|
||||
lastDetachedAt: number | null;
|
||||
detachTimeout: NodeJS.Timeout | null;
|
||||
|
||||
@@ -48,6 +58,33 @@ export interface TerminalSession {
|
||||
sessionLoggingEnabled: boolean;
|
||||
sessionStartedAt: number;
|
||||
lastPersistedBytes: number;
|
||||
terminatedByOwner: boolean;
|
||||
terminationReason: string | null;
|
||||
}
|
||||
|
||||
/** Message types a non-owner participant may legally send. */
|
||||
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
|
||||
"input",
|
||||
"ping",
|
||||
"disconnect",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Server-side gate for whether a participant may send a given WS message
|
||||
* type. The owner may send anything; non-owners are limited to input (if
|
||||
* read-write), ping, and disconnect. Pure function so read-only enforcement
|
||||
* is unit-testable without a real WebSocketServer.
|
||||
*/
|
||||
export function isMessageAllowedForParticipant(
|
||||
participant: Pick<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 {
|
||||
@@ -81,7 +118,7 @@ class TerminalSessionManager {
|
||||
const userSessions = this.getUserSessions(userId);
|
||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||
const detached = userSessions
|
||||
.filter((s) => s.attachedWs === null)
|
||||
.filter((s) => this.getOwnerParticipant(s) === null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.lastDetachedAt ?? a.createdAt) -
|
||||
@@ -109,7 +146,7 @@ class TerminalSessionManager {
|
||||
operation: "session_tab_duplicate_skip",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
hasAttachedWs: existing.attachedWs !== null,
|
||||
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
|
||||
},
|
||||
);
|
||||
return existing.id;
|
||||
@@ -151,7 +188,7 @@ class TerminalSessionManager {
|
||||
rows,
|
||||
isConnected: false,
|
||||
createdAt: now,
|
||||
attachedWs: null,
|
||||
participants: new Map(),
|
||||
lastDetachedAt: null,
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
@@ -166,6 +203,8 @@ class TerminalSessionManager {
|
||||
sessionLoggingEnabled,
|
||||
sessionStartedAt: now,
|
||||
lastPersistedBytes: 0,
|
||||
terminatedByOwner: false,
|
||||
terminationReason: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
@@ -199,6 +238,25 @@ class TerminalSessionManager {
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
/** Finds the owner's participant entry, if currently attached. */
|
||||
private getOwnerParticipant(
|
||||
session: TerminalSession,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private getOwnerEntry(
|
||||
session: TerminalSession,
|
||||
): [string, SessionParticipant] | null {
|
||||
for (const entry of session.participants.entries()) {
|
||||
if (entry[1].isOwner) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
attachWs(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
@@ -234,8 +292,9 @@ class TerminalSessionManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerParticipant = this.getOwnerParticipant(session);
|
||||
const isDetached =
|
||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
||||
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab =
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
tabInstanceId;
|
||||
@@ -282,9 +341,10 @@ class TerminalSessionManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (session.attachedWs && session.attachedWs !== ws) {
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry && ownerEntry[1].ws !== ws) {
|
||||
try {
|
||||
session.attachedWs.send(
|
||||
ownerEntry[1].ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionTakenOver",
|
||||
sessionId,
|
||||
@@ -294,7 +354,7 @@ class TerminalSessionManager {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
session.attachedWs = null;
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
|
||||
if (session.detachTimeout) {
|
||||
@@ -302,7 +362,14 @@ class TerminalSessionManager {
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
session.attachedWs = ws;
|
||||
const participantId = crypto.randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId,
|
||||
permissionLevel: "read-write",
|
||||
isOwner: true,
|
||||
tabInstanceId,
|
||||
});
|
||||
session.attachedTabInstanceId = tabInstanceId;
|
||||
session.lastDetachedAt = null;
|
||||
|
||||
@@ -316,6 +383,110 @@ class TerminalSessionManager {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a non-owner participant (in-app share join or anonymous link guest).
|
||||
* Purely additive - never evicts the owner or any other participant.
|
||||
*/
|
||||
joinAsParticipant(
|
||||
sessionId: string,
|
||||
ws: WebSocket,
|
||||
opts: {
|
||||
userId: string | null;
|
||||
permissionLevel: "read-write" | "read-only";
|
||||
guestLabel?: string;
|
||||
tabInstanceId?: string;
|
||||
shareId?: string;
|
||||
},
|
||||
): TerminalSession | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !session.isConnected) return null;
|
||||
|
||||
const participantId = crypto.randomUUID();
|
||||
session.participants.set(participantId, {
|
||||
ws,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
isOwner: false,
|
||||
guestLabel: opts.guestLabel,
|
||||
tabInstanceId: opts.tabInstanceId,
|
||||
joinedViaShareId: opts.shareId,
|
||||
});
|
||||
|
||||
sshLogger.info("Participant joined shared session", {
|
||||
operation: "session_join_participant",
|
||||
sessionId,
|
||||
userId: opts.userId,
|
||||
permissionLevel: opts.permissionLevel,
|
||||
shareId: opts.shareId,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||
broadcast(sessionId: string, message: object): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(payload);
|
||||
} catch {
|
||||
/* ignore individual send failures, keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the participant entry (owner or not) for a given socket. */
|
||||
getParticipantForWs(
|
||||
session: TerminalSession,
|
||||
ws: WebSocket,
|
||||
): SessionParticipant | null {
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.ws === ws) return participant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a non-owner participant's socket. No detach timeout or session
|
||||
* destruction side effects - a guest leaving must never end the session.
|
||||
*/
|
||||
removeParticipant(sessionId: string, ws: WebSocket): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const [id, participant] of session.participants.entries()) {
|
||||
if (participant.ws === ws && !participant.isOwner) {
|
||||
session.participants.delete(id);
|
||||
sshLogger.info("Participant left shared session", {
|
||||
operation: "session_leave_participant",
|
||||
sessionId,
|
||||
userId: participant.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcasts termination to all guests, then destroys the session. */
|
||||
ownerEndSession(sessionId: string, reason: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
|
||||
session.terminatedByOwner = true;
|
||||
session.terminationReason = reason;
|
||||
|
||||
sshLogger.info("Owner ended shared session", {
|
||||
operation: "session_owner_end",
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
this.destroySession(sessionId);
|
||||
}
|
||||
|
||||
detachWs(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -325,7 +496,10 @@ class TerminalSessionManager {
|
||||
session.detachTimeout = null;
|
||||
}
|
||||
|
||||
session.attachedWs = null;
|
||||
const ownerEntry = this.getOwnerEntry(session);
|
||||
if (ownerEntry) {
|
||||
session.participants.delete(ownerEntry[0]);
|
||||
}
|
||||
session.lastDetachedAt = Date.now();
|
||||
|
||||
// Persist log immediately when the user detaches so it appears right away,
|
||||
@@ -365,6 +539,23 @@ class TerminalSessionManager {
|
||||
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||
}
|
||||
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner) continue;
|
||||
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||
try {
|
||||
participant.ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionExpired",
|
||||
sessionId,
|
||||
message: "Session has ended",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
session.participants.clear();
|
||||
|
||||
if (session.sshStream) {
|
||||
try {
|
||||
session.sshStream.end();
|
||||
@@ -440,12 +631,16 @@ class TerminalSessionManager {
|
||||
recordingPath: session.recordingPath,
|
||||
protocol: "ssh",
|
||||
format: "asciicast",
|
||||
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||
terminationReason: session.terminationReason ?? undefined,
|
||||
});
|
||||
session.recordingId = created.id;
|
||||
} else {
|
||||
await repo.updateEnded(session.recordingId, {
|
||||
endedAt: new Date(endedAt).toISOString(),
|
||||
duration,
|
||||
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||
terminationReason: session.terminationReason ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -569,10 +764,10 @@ class TerminalSessionManager {
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (!session.isConnected) continue;
|
||||
|
||||
if (
|
||||
session.attachedWs &&
|
||||
session.attachedWs.readyState === WebSocket.OPEN
|
||||
) {
|
||||
const hasOpenParticipant = Array.from(session.participants.values()).some(
|
||||
(p) => p.ws.readyState === WebSocket.OPEN,
|
||||
);
|
||||
if (hasOpenParticipant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
|
||||
@@ -66,6 +66,7 @@ describe("HostFolderRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => {
|
||||
vault_profile_id INTEGER,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
|
||||
@@ -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,
|
||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
|
||||
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
|
||||
|
||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||
});
|
||||
|
||||
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
"Administrator",
|
||||
"secret",
|
||||
{},
|
||||
undefined,
|
||||
termixMeta,
|
||||
);
|
||||
|
||||
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||
});
|
||||
|
||||
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||
expect(decrypted?.connection.type).toBeUndefined();
|
||||
expect(decrypted?.connection.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||
const decrypted = tokenService.decryptToken(token);
|
||||
|
||||
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||
expect(decrypted?.connection.readOnly).toBe(false);
|
||||
expect(decrypted?.recording).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||
return {
|
||||
readyState,
|
||||
send: vi.fn(),
|
||||
} as unknown as import("ws").WebSocket;
|
||||
}
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
|
||||
describe("TerminalSessionManager - session logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMkdir.mockResolvedValue(undefined);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCreate.mockResolvedValue({ id: 1 });
|
||||
mockUpdateEnded.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function createConnectedSession(): string {
|
||||
const id = sessionManager.createSession(
|
||||
"owner-1",
|
||||
1,
|
||||
"host",
|
||||
80,
|
||||
24,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||
// by attachWs/joinAsParticipant.
|
||||
const session = sessionManager.getSession(id)!;
|
||||
session.isConnected = true;
|
||||
return id;
|
||||
}
|
||||
|
||||
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const guestWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
guestLabel: "Guest",
|
||||
});
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.participants.size).toBe(2);
|
||||
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||
session!,
|
||||
ownerWs,
|
||||
);
|
||||
expect(ownerParticipant?.isOwner).toBe(true);
|
||||
expect(ownerWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||
expect(
|
||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs(WS_OPEN);
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
|
||||
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||
userId: null,
|
||||
permissionLevel: "read-only",
|
||||
});
|
||||
|
||||
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||
|
||||
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "data", data: "hello" }),
|
||||
);
|
||||
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("broadcast does not throw if a socket's send throws", () => {
|
||||
const id = createConnectedSession();
|
||||
const throwingWs = makeFakeWs(WS_OPEN);
|
||||
(throwingWs.send as ReturnType<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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
| "verifying"
|
||||
| "idle-auth"
|
||||
@@ -322,6 +328,16 @@ function RootApp() {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const isFullscreen = searchParams.has("view");
|
||||
|
||||
// Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's
|
||||
// auth check entirely rather than waiting on a getUserInfo() call that would always fail.
|
||||
if (searchParams.get("view") === "shared") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SharedSessionView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -151,6 +151,7 @@ export interface Host {
|
||||
enableDocker: boolean;
|
||||
enableProxmox: boolean;
|
||||
enableTmuxMonitor: boolean;
|
||||
allowSessionSharing?: boolean;
|
||||
proxmoxConfig?: ProxmoxConfig | null;
|
||||
showTerminalInSidebar: boolean;
|
||||
showFileManagerInSidebar: boolean;
|
||||
@@ -272,6 +273,7 @@ export interface HostData {
|
||||
enableDocker?: boolean;
|
||||
enableProxmox?: boolean;
|
||||
enableTmuxMonitor?: boolean;
|
||||
allowSessionSharing?: boolean;
|
||||
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
|
||||
showTerminalInSidebar?: boolean;
|
||||
showFileManagerInSidebar?: boolean;
|
||||
|
||||
@@ -281,6 +281,9 @@ export type Tab = {
|
||||
host?: Host;
|
||||
openedAt: number;
|
||||
restoredSessionId?: string | null;
|
||||
/** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
initialFilePath?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
terminalRef?: import("react").RefObject<{
|
||||
@@ -291,6 +294,8 @@ export type Tab = {
|
||||
fit?: () => void;
|
||||
notifyResize?: () => void;
|
||||
getApplicationCursorKeysMode?: () => boolean;
|
||||
openShareModal?: () => void;
|
||||
canShare?: () => boolean;
|
||||
} | null>;
|
||||
};
|
||||
|
||||
|
||||
+71
-4
@@ -272,10 +272,7 @@ export function AppShell({
|
||||
if (id == null) return null;
|
||||
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
||||
});
|
||||
localStorage.setItem(
|
||||
"termix_paneInstanceIds",
|
||||
JSON.stringify(instanceIds),
|
||||
);
|
||||
localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds));
|
||||
}, [paneTabIds, tabs]);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -1056,6 +1053,8 @@ export function AppShell({
|
||||
savedLabel?: string;
|
||||
initialFilePath?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
},
|
||||
) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
@@ -1072,6 +1071,8 @@ export function AppShell({
|
||||
const savedLabel = restore?.savedLabel;
|
||||
const initialFilePath = restore?.initialFilePath;
|
||||
const serialConfig = restore?.serialConfig;
|
||||
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
|
||||
const joinShareId = restore?.joinShareId ?? null;
|
||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||
const isCustomLabel =
|
||||
savedLabel != null &&
|
||||
@@ -1093,6 +1094,8 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
joinSharedSessionId,
|
||||
joinShareId,
|
||||
initialFilePath,
|
||||
serialConfig,
|
||||
},
|
||||
@@ -1125,6 +1128,8 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
joinSharedSessionId,
|
||||
joinShareId,
|
||||
initialFilePath,
|
||||
serialConfig,
|
||||
},
|
||||
@@ -1380,6 +1385,17 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
function openShareForTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
if (!tab) return;
|
||||
const ref = tab.terminalRef?.current;
|
||||
if (ref?.canShare?.()) {
|
||||
ref.openShareModal?.();
|
||||
} else {
|
||||
toast.error(t("sessionSharing.notReadyToShare"));
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(id: string) {
|
||||
const tab = tabs.find((t) => t.id === id);
|
||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||
@@ -1725,6 +1741,56 @@ export function AppShell({
|
||||
}}
|
||||
onRenameTab={renameTab}
|
||||
onReorderTabs={setTabs}
|
||||
onJoinSharedSession={(session) => {
|
||||
if (!session.shareId) return;
|
||||
const existingHost = allHosts.find(
|
||||
(h) => h.id === String(session.hostId),
|
||||
);
|
||||
const host: Host = existingHost ?? {
|
||||
id: String(session.hostId),
|
||||
name: session.hostName,
|
||||
username: "",
|
||||
ip: "",
|
||||
port: 0,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
authType: "none",
|
||||
enableTerminal: false,
|
||||
enableCommandHistory: false,
|
||||
enableTunnel: false,
|
||||
enableFileManager: false,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
};
|
||||
const instanceId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
openTab(host, "terminal", {
|
||||
instanceId,
|
||||
restoredSessionId: null,
|
||||
joinSharedSessionId: session.sessionId,
|
||||
joinShareId: session.shareId,
|
||||
savedLabel: t("connections.sharedSessionLabel", {
|
||||
hostName: session.hostName,
|
||||
}),
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1885,6 +1951,7 @@ export function AppShell({
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||
}}
|
||||
onOpenShare={openShareForTab}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
|
||||
|
||||
export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
guacamoleConnectionId?: string | null;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface ActiveSessionInfo {
|
||||
tabInstanceId: string | null;
|
||||
isConnected: boolean;
|
||||
createdAt: number;
|
||||
isOwnSession: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||
|
||||
@@ -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,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
@@ -41,6 +42,8 @@ interface GuacamoleAppProps {
|
||||
export interface GuacamoleAppHandle {
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
openShareModal: () => void;
|
||||
canShare: () => boolean;
|
||||
}
|
||||
|
||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||
@@ -124,6 +127,10 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
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 [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
@@ -152,6 +159,8 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => displayRef.current?.disconnect(),
|
||||
isConnected: () => displayRef.current?.isConnected() === true,
|
||||
openShareModal: () => setShareModalOpen(true),
|
||||
canShare: () => guacamoleConnectionId !== null,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -161,6 +170,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
}
|
||||
|
||||
setToken(null);
|
||||
setGuacamoleConnectionId(null);
|
||||
setError(null);
|
||||
getGuacdStatus()
|
||||
.then((status) => {
|
||||
@@ -177,6 +187,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||
() => {},
|
||||
);
|
||||
@@ -380,6 +391,16 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
touchMode={touchMode}
|
||||
onTouchModeChange={setTouchMode}
|
||||
/>
|
||||
{shareModalOpen && guacamoleConnectionId && (
|
||||
<ShareSessionModal
|
||||
open={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
hostId={hostId}
|
||||
sessionId={guacamoleConnectionId}
|
||||
protocol={resolvedProtocol}
|
||||
tabInstanceId={tabId}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Save } from "lucide-react";
|
||||
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
||||
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
@@ -164,6 +165,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const pongReceivedRef = useRef(true);
|
||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
|
||||
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
@@ -849,8 +851,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
onOpenFileManager?.("/");
|
||||
}
|
||||
},
|
||||
openShareModal: () => setShareModalOpen(true),
|
||||
canShare: () =>
|
||||
isConnected &&
|
||||
!isQuickConnect &&
|
||||
!hostConfig.joinShareId &&
|
||||
typeof hostConfig.id === "number",
|
||||
}),
|
||||
[isConnected, terminal],
|
||||
[
|
||||
isConnected,
|
||||
terminal,
|
||||
isQuickConnect,
|
||||
hostConfig.joinShareId,
|
||||
hostConfig.id,
|
||||
],
|
||||
);
|
||||
|
||||
function getUseRightClickCopyPaste() {
|
||||
@@ -1079,7 +1093,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const restoredSessionId = pendingRestoredSessionIdRef.current;
|
||||
pendingRestoredSessionIdRef.current = null;
|
||||
|
||||
if (restoredSessionId) {
|
||||
if (hostConfig.joinShareId) {
|
||||
isAttachingSessionRef.current = true;
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "joinSharedSession",
|
||||
data: {
|
||||
shareId: hostConfig.joinShareId,
|
||||
tabInstanceId: hostConfig.instanceId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (restoredSessionId) {
|
||||
sessionIdRef.current = restoredSessionId;
|
||||
isAttachingSessionRef.current = true;
|
||||
|
||||
@@ -3189,6 +3215,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
</div>,
|
||||
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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@ export interface TerminalHostConfig {
|
||||
id?: number;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
@@ -28,4 +31,6 @@ export interface TerminalHandle {
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
getApplicationCursorKeysMode: () => boolean;
|
||||
openShareModal: () => void;
|
||||
canShare: () => boolean;
|
||||
}
|
||||
|
||||
+59
-1
@@ -800,6 +800,8 @@
|
||||
"enableAutoTmuxDesc": "Automatically launch or attach to tmux session",
|
||||
"enableSessionLogging": "Session Logging",
|
||||
"enableSessionLoggingDesc": "Record terminal session output for later review",
|
||||
"allowSessionSharing": "Allow Session Sharing",
|
||||
"allowSessionSharingDesc": "Let live sessions on this host be shared via link or with other users",
|
||||
"enableCommandHistory": "Command History",
|
||||
"enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete",
|
||||
"linkClickBehaviorLabel": "Link Click Behavior",
|
||||
@@ -1447,7 +1449,60 @@
|
||||
"expiresIn": "Expires in {{duration}}",
|
||||
"search": "Search connections...",
|
||||
"noSearchResults": "No connections match your search",
|
||||
"rename": "Rename session"
|
||||
"rename": "Rename session",
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
},
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||
"rateLimited": "Too many attempts, please try again shortly",
|
||||
"sessionEnded": "This session has ended",
|
||||
"readOnlyBadge": "View only"
|
||||
},
|
||||
"modalTitle": "Share session",
|
||||
"shareButton": "Share",
|
||||
"notReadyToShare": "Session is not ready to share yet",
|
||||
"modeTab": {
|
||||
"link": "Link",
|
||||
"user": "User"
|
||||
},
|
||||
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||
"permissionLevel": {
|
||||
"label": "Permission level",
|
||||
"readOnly": "Read-only",
|
||||
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||
"readWrite": "Read-write",
|
||||
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||
},
|
||||
"expiryLabel": "Link expiry",
|
||||
"createLinkButton": "Create link",
|
||||
"createShareButton": "Share with user",
|
||||
"searchUsersPlaceholder": "Search users...",
|
||||
"noUsersFound": "No users found",
|
||||
"linkCreated": "Share link created",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"copyLink": "Copy link",
|
||||
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||
"shareFailed": "Failed to create share",
|
||||
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||
"activeShares": "Active shares",
|
||||
"noActiveShares": "No active shares for this session",
|
||||
"revoke": "Revoke",
|
||||
"revokeConfirmTitle": "Revoke this share?",
|
||||
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||
"revoked": "Share revoked",
|
||||
"revokeFailed": "Failed to revoke share",
|
||||
"joinCount": "{{count}} join",
|
||||
"joinCount_other": "{{count}} joins",
|
||||
"expiresAt": "Expires {{date}}",
|
||||
"linkShareBadge": "Link",
|
||||
"userShareBadge": "User: {{username}}",
|
||||
"loadSharesFailed": "Failed to load active shares"
|
||||
},
|
||||
"guacamole": {
|
||||
"connecting": "Connecting to {{type}} session...",
|
||||
@@ -2714,6 +2769,9 @@
|
||||
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
||||
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
|
||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||
"sessionSharingGloballyEnabled": "Allow Session Sharing",
|
||||
"sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.",
|
||||
"updateSessionSharingFailed": "Failed to update session sharing setting",
|
||||
"sessionTimeout": "Session Timeout",
|
||||
"hours": "hours",
|
||||
"sessionTimeoutRange": "Min 1h · Max 720h",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
FolderOpen,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
@@ -42,6 +43,7 @@ export function TabBar({
|
||||
onRemoveFromSplit,
|
||||
onRenameTab,
|
||||
onOpenFileManager,
|
||||
onOpenShare,
|
||||
isAppFullscreen,
|
||||
onToggleAppFullscreen,
|
||||
}: {
|
||||
@@ -59,6 +61,7 @@ export function TabBar({
|
||||
onRemoveFromSplit: (tabId: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onOpenFileManager?: (tabId: string) => void;
|
||||
onOpenShare?: (tabId: string) => void;
|
||||
isAppFullscreen: boolean;
|
||||
onToggleAppFullscreen: () => void;
|
||||
}) {
|
||||
@@ -352,6 +355,19 @@ export function TabBar({
|
||||
<RefreshCw className="size-3" />
|
||||
</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
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -234,6 +234,8 @@ function TerminalTabContent({
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
joinSharedSessionId: tab.joinSharedSessionId ?? null,
|
||||
joinShareId: tab.joinShareId ?? null,
|
||||
} as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
updateAnalyticsEnabled,
|
||||
type HostDefaults,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getSessionSharingGloballyEnabled,
|
||||
updateSessionSharingGloballyEnabled,
|
||||
} from "@/api/session-sharing-api";
|
||||
import {
|
||||
getAcmeSslSettings,
|
||||
updateAcmeSslSettings,
|
||||
@@ -130,6 +134,8 @@ export function AdminSettingsPanel({
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
|
||||
useState(true);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
|
||||
// SSO / auto-provision state
|
||||
@@ -289,6 +295,7 @@ export function AdminSettingsPanel({
|
||||
tailscale,
|
||||
cmdHistory,
|
||||
analytics,
|
||||
sessionSharingEnabled,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
getPasswordLoginAllowed(),
|
||||
@@ -302,6 +309,7 @@ export function AdminSettingsPanel({
|
||||
getTailscaleSettings(),
|
||||
getCommandHistoryEnabled(),
|
||||
getAnalyticsEnabled(),
|
||||
getSessionSharingGloballyEnabled(),
|
||||
]);
|
||||
|
||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||
@@ -336,6 +344,9 @@ export function AdminSettingsPanel({
|
||||
if (analytics.status === "fulfilled") {
|
||||
setAnalyticsEnabled(analytics.value.enabled);
|
||||
}
|
||||
if (sessionSharingEnabled.status === "fulfilled") {
|
||||
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
|
||||
}
|
||||
} catch {
|
||||
// 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() {
|
||||
const hours = parseInt(sessionTimeout, 10);
|
||||
if (isNaN(hours) || hours < 1 || hours > 720) {
|
||||
@@ -908,6 +930,10 @@ export function AdminSettingsPanel({
|
||||
onToggle={() => toggle("general")}
|
||||
analyticsEnabled={analyticsEnabled}
|
||||
handleToggleAnalytics={handleToggleAnalytics}
|
||||
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
|
||||
handleToggleSessionSharingGloballyEnabled={
|
||||
handleToggleSessionSharingGloballyEnabled
|
||||
}
|
||||
allowRegistration={allowRegistration}
|
||||
handleToggleRegistration={handleToggleRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
|
||||
@@ -32,6 +32,8 @@ type GeneralSettingsSectionProps = {
|
||||
onToggle: () => void;
|
||||
analyticsEnabled: boolean;
|
||||
handleToggleAnalytics: () => void;
|
||||
sessionSharingGloballyEnabled: boolean;
|
||||
handleToggleSessionSharingGloballyEnabled: () => void;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
allowPasswordLogin: boolean;
|
||||
@@ -71,6 +73,8 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle,
|
||||
analyticsEnabled,
|
||||
handleToggleAnalytics,
|
||||
sessionSharingGloballyEnabled,
|
||||
handleToggleSessionSharingGloballyEnabled,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
allowPasswordLogin,
|
||||
@@ -120,6 +124,15 @@ export function AdminGeneralSettingsSection({
|
||||
>
|
||||
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.sessionSharingGloballyEnabled")}
|
||||
description={t("admin.sessionSharingGloballyEnabledDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={sessionSharingGloballyEnabled}
|
||||
onToggle={handleToggleSessionSharingGloballyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { Badge } from "@/components/badge";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -64,7 +65,11 @@ function sessionsUnchanged(
|
||||
a.hostName !== b.hostName ||
|
||||
a.tabInstanceId !== b.tabInstanceId ||
|
||||
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;
|
||||
}
|
||||
@@ -289,6 +294,7 @@ export function ConnectionsPanel({
|
||||
onForgetBackground,
|
||||
onRenameTab,
|
||||
onReorderTabs,
|
||||
onJoinSharedSession,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
@@ -303,6 +309,7 @@ export function ConnectionsPanel({
|
||||
onForgetBackground: (recordId: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onReorderTabs?: (tabs: Tab[]) => void;
|
||||
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
@@ -344,6 +351,22 @@ export function ConnectionsPanel({
|
||||
})
|
||||
: 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.
|
||||
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
|
||||
|
||||
@@ -432,9 +455,12 @@ export function ConnectionsPanel({
|
||||
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 =
|
||||
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
|
||||
filteredOpenTabs.length > 0 ||
|
||||
filteredBackgroundTabs.length > 0 ||
|
||||
filteredSharedWithMe.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return (
|
||||
@@ -593,6 +619,78 @@ export function ConnectionsPanel({
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,8 +140,7 @@ export function HostEditor({
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] =
|
||||
useState(false);
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
useState(false);
|
||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||
@@ -1254,9 +1253,7 @@ export function HostEditor({
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{theme.name}
|
||||
</span>
|
||||
<span className="truncate">{theme.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1602,6 +1599,15 @@ export function HostEditor({
|
||||
onChange={(v) => setField("enableSessionLogging", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.allowSessionSharing")}
|
||||
description={t("hosts.allowSessionSharingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.allowSessionSharing}
|
||||
onChange={(v) => setField("allowSessionSharing", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.enableCommandHistory")}
|
||||
description={
|
||||
|
||||
@@ -107,6 +107,7 @@ export function createHostEditorForm(
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||
allowSessionSharing: host?.allowSessionSharing ?? true,
|
||||
enableProxmox: host?.enableProxmox ?? false,
|
||||
proxmoxConfig: host?.proxmoxConfig ?? {
|
||||
defaultCredentialId: null as number | null,
|
||||
@@ -306,6 +307,7 @@ export function buildHostEditorPayload(
|
||||
enableDocker: form.enableDocker,
|
||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||
allowSessionSharing: form.allowSessionSharing,
|
||||
enableProxmox: form.enableProxmox,
|
||||
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user