mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/session-sharing(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location /host/tunnel/ {
|
location /host/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:30003;
|
proxy_pass http://127.0.0.1:30003;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -456,6 +456,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/session-sharing(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location /host/tunnel/ {
|
location /host/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:30003;
|
proxy_pass http://127.0.0.1:30003;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -17,14 +17,27 @@ const cryptPath = path.join(
|
|||||||
"lib",
|
"lib",
|
||||||
"Crypt.js",
|
"Crypt.js",
|
||||||
);
|
);
|
||||||
|
const clientConnectionPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
"guacamole-lite",
|
||||||
|
"lib",
|
||||||
|
"ClientConnection.js",
|
||||||
|
);
|
||||||
|
|
||||||
if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
|
if (
|
||||||
|
!fs.existsSync(guacdClientPath) ||
|
||||||
|
!fs.existsSync(cryptPath) ||
|
||||||
|
!fs.existsSync(clientConnectionPath)
|
||||||
|
) {
|
||||||
console.log("[patch-guacamole-lite] File not found, skipping");
|
console.log("[patch-guacamole-lite] File not found, skipping");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
|
||||||
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
let cryptContent = fs.readFileSync(cryptPath, "utf8");
|
||||||
|
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
|
||||||
|
|
||||||
// Patch 1: protocol version negotiation.
|
// Patch 1: protocol version negotiation.
|
||||||
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
|
||||||
@@ -268,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) {
|
|||||||
patched = true;
|
patched = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch 7: drop client-to-guacd input instructions from read-only session-share
|
||||||
|
// joins. guacd has no native read-only enforcement in the versions this project
|
||||||
|
// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
|
||||||
|
// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
|
||||||
|
// clipboard streams) than a new input vector, so failing open is the safer
|
||||||
|
// default for a client we already control.
|
||||||
|
const oldSendMessageToGuacd =
|
||||||
|
" sendMessageToGuacd(message) {\n" +
|
||||||
|
" this.lastActivity = Date.now();\n" +
|
||||||
|
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||||
|
"\n" +
|
||||||
|
" if (this.guacdClient) {\n" +
|
||||||
|
" this.guacdClient.send(message, true);\n" +
|
||||||
|
" }\n" +
|
||||||
|
" }";
|
||||||
|
const newSendMessageToGuacd =
|
||||||
|
" sendMessageToGuacd(message) {\n" +
|
||||||
|
" this.lastActivity = Date.now();\n" +
|
||||||
|
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
|
||||||
|
"\n" +
|
||||||
|
" if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
|
||||||
|
" return;\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" if (this.guacdClient) {\n" +
|
||||||
|
" this.guacdClient.send(message, true);\n" +
|
||||||
|
" }\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" isReadOnlyJoin() {\n" +
|
||||||
|
" const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
|
||||||
|
" return !!(connection && connection.join && connection.readOnly === true);\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
|
||||||
|
" // the leading opcode from a raw '<len>.<opcode>,...;' instruction without the\n" +
|
||||||
|
" // overhead of a full stateful parse.\n" +
|
||||||
|
" isInputInstruction(message) {\n" +
|
||||||
|
" const dot = message.indexOf('.');\n" +
|
||||||
|
" if (dot === -1) return false;\n" +
|
||||||
|
" const len = parseInt(message.substring(0, dot), 10);\n" +
|
||||||
|
" if (isNaN(len)) return false;\n" +
|
||||||
|
" const opcode = message.substring(dot + 1, dot + 1 + len);\n" +
|
||||||
|
" return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" +
|
||||||
|
" }";
|
||||||
|
|
||||||
|
if (!clientConnectionContent.includes("isReadOnlyJoin()")) {
|
||||||
|
if (!clientConnectionContent.includes(oldSendMessageToGuacd)) {
|
||||||
|
console.log(
|
||||||
|
"[patch-guacamole-lite] sendMessageToGuacd target not found, skipping read-only patch",
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
clientConnectionContent = clientConnectionContent.replace(
|
||||||
|
oldSendMessageToGuacd,
|
||||||
|
newSendMessageToGuacd,
|
||||||
|
);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch 8: mergeConnectionOptions only preserves `join` across the settings
|
||||||
|
// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it.
|
||||||
|
const oldPreserveJoin =
|
||||||
|
" // For join connections, preserve the join property\n" +
|
||||||
|
" if (this.connectionSettings.connection.join) {\n" +
|
||||||
|
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||||
|
" }";
|
||||||
|
const newPreserveJoin =
|
||||||
|
" // For join connections, preserve the join property\n" +
|
||||||
|
" if (this.connectionSettings.connection.join) {\n" +
|
||||||
|
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
|
||||||
|
" compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" +
|
||||||
|
" }";
|
||||||
|
|
||||||
|
if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
|
||||||
|
if (!clientConnectionContent.includes(oldPreserveJoin)) {
|
||||||
|
console.log(
|
||||||
|
"[patch-guacamole-lite] join-preserve target not found, skipping readOnly propagation patch",
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
clientConnectionContent = clientConnectionContent.replace(
|
||||||
|
oldPreserveJoin,
|
||||||
|
newPreserveJoin,
|
||||||
|
);
|
||||||
|
patched = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!patched) {
|
if (!patched) {
|
||||||
console.log("[patch-guacamole-lite] Already patched");
|
console.log("[patch-guacamole-lite] Already patched");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -275,6 +376,7 @@ if (!patched) {
|
|||||||
|
|
||||||
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
fs.writeFileSync(guacdClientPath, guacdClientContent);
|
||||||
fs.writeFileSync(cryptPath, cryptContent);
|
fs.writeFileSync(cryptPath, cryptContent);
|
||||||
|
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
|
||||||
console.log(
|
console.log(
|
||||||
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
|
"[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
|||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||||
|
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import openTabsRoutes from "./routes/open-tabs.js";
|
import openTabsRoutes from "./routes/open-tabs.js";
|
||||||
@@ -1737,6 +1738,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
|||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
app.use("/session_logs", sessionLogRoutes);
|
app.use("/session_logs", sessionLogRoutes);
|
||||||
app.use("/guacamole", guacamoleRoutes);
|
app.use("/guacamole", guacamoleRoutes);
|
||||||
|
app.use("/session-sharing", sessionSharingRoutes);
|
||||||
app.use("/network-topology", networkTopologyRoutes);
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
|
|||||||
@@ -495,6 +495,38 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
tab_instance_id TEXT,
|
||||||
|
share_type TEXT NOT NULL,
|
||||||
|
target_user_id TEXT,
|
||||||
|
link_token TEXT UNIQUE,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_joined_at TEXT,
|
||||||
|
join_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
guest_label TEXT,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
left_at TEXT,
|
||||||
|
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -1456,6 +1488,7 @@ const migrateSchema = () => {
|
|||||||
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
|
||||||
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
|
||||||
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
|
||||||
|
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const migration of sshDataMigrations) {
|
for (const migration of sshDataMigrations) {
|
||||||
@@ -2290,6 +2323,76 @@ const migrateSchema = () => {
|
|||||||
}
|
}
|
||||||
// --- homepage end ---
|
// --- homepage end ---
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("SELECT id FROM session_shares LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
tab_instance_id TEXT,
|
||||||
|
share_type TEXT NOT NULL,
|
||||||
|
target_user_id TEXT,
|
||||||
|
link_token TEXT UNIQUE,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_joined_at TEXT,
|
||||||
|
join_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_shares_link_token ON session_shares(link_token)",
|
||||||
|
);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_shares_target_user ON session_shares(target_user_id)",
|
||||||
|
);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_shares_host ON session_shares(host_id)",
|
||||||
|
);
|
||||||
|
} catch (createError) {
|
||||||
|
databaseLogger.warn("Failed to create session_shares table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite.prepare("SELECT id FROM session_share_participants LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS session_share_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
guest_label TEXT,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
left_at TEXT,
|
||||||
|
FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
sqlite.exec(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_session_share_participants_share ON session_share_participants(share_id)",
|
||||||
|
);
|
||||||
|
} catch (createError) {
|
||||||
|
databaseLogger.warn("Failed to create session_share_participants table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
databaseLogger.success("Schema migration completed", {
|
databaseLogger.success("Schema migration completed", {
|
||||||
operation: "schema_migration",
|
operation: "schema_migration",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", {
|
|||||||
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
@@ -676,6 +679,62 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
|||||||
terminationReason: text("termination_reason"),
|
terminationReason: text("termination_reason"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sessionShares = sqliteTable("session_shares", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
|
||||||
|
hostId: integer("host_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
|
ownerUserId: text("owner_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
|
|
||||||
|
// Live-session binding: TerminalSessionManager's session.id for SSH, or
|
||||||
|
// guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
|
||||||
|
// row (process-local, in-memory) so this intentionally has no FK.
|
||||||
|
sessionId: text("session_id").notNull(),
|
||||||
|
tabInstanceId: text("tab_instance_id"),
|
||||||
|
|
||||||
|
shareType: text("share_type").notNull(), // "link" | "user"
|
||||||
|
targetUserId: text("target_user_id").references(() => users.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
linkToken: text("link_token").unique(),
|
||||||
|
|
||||||
|
permissionLevel: text("permission_level").notNull().default("read-only"),
|
||||||
|
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
expiresAt: text("expires_at").notNull(),
|
||||||
|
revokedAt: text("revoked_at"),
|
||||||
|
|
||||||
|
lastJoinedAt: text("last_joined_at"),
|
||||||
|
joinCount: integer("join_count").notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionShareParticipants = sqliteTable(
|
||||||
|
"session_share_participants",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
shareId: text("share_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sessionShares.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
|
userId: text("user_id").references(() => users.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
guestLabel: text("guest_label"),
|
||||||
|
|
||||||
|
joinedAt: text("joined_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
leftAt: text("left_at"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
export const opksshTokens = sqliteTable("opkssh_tokens", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
|
|||||||
import { RoleRepository } from "./role-repository.js";
|
import { RoleRepository } from "./role-repository.js";
|
||||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||||
import { SessionRepository } from "./session-repository.js";
|
import { SessionRepository } from "./session-repository.js";
|
||||||
|
import { SessionShareRepository } from "./session-share-repository.js";
|
||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
import { SnippetRepository } from "./snippet-repository.js";
|
import { SnippetRepository } from "./snippet-repository.js";
|
||||||
@@ -253,6 +254,13 @@ export function createCurrentSessionRepository(): SessionRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||||
|
return new SessionShareRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("session_share_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentSettingsRepository(): SettingsRepository {
|
export function createCurrentSettingsRepository(): SettingsRepository {
|
||||||
return new SettingsRepository(
|
return new SettingsRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ export class SessionRecordingRepository {
|
|||||||
|
|
||||||
async updateEnded(
|
async updateEnded(
|
||||||
id: number,
|
id: number,
|
||||||
input: { endedAt: string; duration: number | null },
|
input: {
|
||||||
|
endedAt: string;
|
||||||
|
duration: number | null;
|
||||||
|
terminatedByOwner?: boolean;
|
||||||
|
terminationReason?: string;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.update(sessionRecordings)
|
.update(sessionRecordings)
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { and, eq, gt, isNull, lt } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
hosts,
|
||||||
|
sessionShareParticipants,
|
||||||
|
sessionShares,
|
||||||
|
users,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
|
||||||
|
export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||||
|
export type SessionShareParticipantRecord =
|
||||||
|
typeof sessionShareParticipants.$inferSelect;
|
||||||
|
|
||||||
|
export type SessionShareType = "link" | "user";
|
||||||
|
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||||
|
|
||||||
|
export interface SessionShareCreateInput {
|
||||||
|
id: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
sessionId: string;
|
||||||
|
tabInstanceId?: string | null;
|
||||||
|
shareType: SessionShareType;
|
||||||
|
targetUserId?: string | null;
|
||||||
|
linkToken?: string | null;
|
||||||
|
permissionLevel: SessionSharePermissionLevel;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionShareWithHost extends SessionShareRecord {
|
||||||
|
hostName: string | null;
|
||||||
|
ownerUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedWithMeRecord extends SessionShareRecord {
|
||||||
|
hostName: string | null;
|
||||||
|
ownerUsername: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeShareFilter(now: string) {
|
||||||
|
return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionShareRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(input: SessionShareCreateInput): Promise<SessionShareRecord> {
|
||||||
|
const [created] = await this.context.drizzle
|
||||||
|
.insert(sessionShares)
|
||||||
|
.values({
|
||||||
|
id: input.id,
|
||||||
|
hostId: input.hostId,
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
protocol: input.protocol,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
tabInstanceId: input.tabInstanceId ?? null,
|
||||||
|
shareType: input.shareType,
|
||||||
|
targetUserId: input.targetUserId ?? null,
|
||||||
|
linkToken: input.linkToken ?? null,
|
||||||
|
permissionLevel: input.permissionLevel,
|
||||||
|
expiresAt: input.expiresAt,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SessionShareRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(eq(sessionShares.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveById(
|
||||||
|
id: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SessionShareRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(and(eq(sessionShares.id, id), activeShareFilter(now)))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByLinkToken(
|
||||||
|
linkToken: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SessionShareRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(
|
||||||
|
and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveSharesForHost(
|
||||||
|
hostId: number,
|
||||||
|
ownerUserId: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SessionShareRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(sessionShares)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessionShares.hostId, hostId),
|
||||||
|
eq(sessionShares.ownerUserId, ownerUserId),
|
||||||
|
activeShareFilter(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSharesTargetingUser(
|
||||||
|
userId: string,
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
): Promise<SharedWithMeRecord[]> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
share: sessionShares,
|
||||||
|
hostName: hosts.name,
|
||||||
|
ownerUsername: users.username,
|
||||||
|
})
|
||||||
|
.from(sessionShares)
|
||||||
|
.leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
|
||||||
|
.leftJoin(users, eq(sessionShares.ownerUserId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessionShares.shareType, "user"),
|
||||||
|
eq(sessionShares.targetUserId, userId),
|
||||||
|
activeShareFilter(now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row.share,
|
||||||
|
hostName: row.hostName,
|
||||||
|
ownerUsername: row.ownerUsername,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(shareId: string, requestingUserId: string): Promise<boolean> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.update(sessionShares)
|
||||||
|
.set({ revokedAt: new Date().toISOString() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessionShares.id, shareId),
|
||||||
|
eq(sessionShares.ownerUserId, requestingUserId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAsAdmin(shareId: string): Promise<boolean> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.update(sessionShares)
|
||||||
|
.set({ revokedAt: new Date().toISOString() })
|
||||||
|
.where(eq(sessionShares.id, shareId))
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpiredShares(now = new Date().toISOString()): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sessionShares)
|
||||||
|
.where(lt(sessionShares.expiresAt, now))
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async touchShareUsage(
|
||||||
|
shareId: string,
|
||||||
|
lastJoinedAt = new Date().toISOString(),
|
||||||
|
): Promise<void> {
|
||||||
|
const current = await this.findById(shareId);
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sessionShares)
|
||||||
|
.set({
|
||||||
|
lastJoinedAt,
|
||||||
|
joinCount: (current?.joinCount ?? 0) + 1,
|
||||||
|
})
|
||||||
|
.where(eq(sessionShares.id, shareId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordParticipantJoin(
|
||||||
|
shareId: string,
|
||||||
|
userId: string | null,
|
||||||
|
guestLabel: string | null,
|
||||||
|
): Promise<SessionShareParticipantRecord> {
|
||||||
|
const [created] = await this.context.drizzle
|
||||||
|
.insert(sessionShareParticipants)
|
||||||
|
.values({ shareId, userId, guestLabel })
|
||||||
|
.returning();
|
||||||
|
await this.afterWrite();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordParticipantLeave(participantId: number): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(sessionShareParticipants)
|
||||||
|
.set({ leftAt: new Date().toISOString() })
|
||||||
|
.where(eq(sessionShareParticipants.id, participantId));
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSharesForHost(hostId: number): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.delete(sessionShares)
|
||||||
|
.where(eq(sessionShares.hostId, hostId))
|
||||||
|
.returning({ id: sessionShares.id });
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.afterWrite();
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async afterWrite(): Promise<void> {
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -175,6 +175,7 @@ router.post(
|
|||||||
enableDocker,
|
enableDocker,
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
|
allowSessionSharing,
|
||||||
showTerminalInSidebar,
|
showTerminalInSidebar,
|
||||||
showFileManagerInSidebar,
|
showFileManagerInSidebar,
|
||||||
showTunnelInSidebar,
|
showTunnelInSidebar,
|
||||||
@@ -288,6 +289,7 @@ router.post(
|
|||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableProxmox: enableProxmox ? 1 : 0,
|
enableProxmox: enableProxmox ? 1 : 0,
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
|
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||||
@@ -815,6 +817,7 @@ router.put(
|
|||||||
enableDocker,
|
enableDocker,
|
||||||
enableProxmox,
|
enableProxmox,
|
||||||
enableTmuxMonitor,
|
enableTmuxMonitor,
|
||||||
|
allowSessionSharing,
|
||||||
showTerminalInSidebar,
|
showTerminalInSidebar,
|
||||||
showFileManagerInSidebar,
|
showFileManagerInSidebar,
|
||||||
showTunnelInSidebar,
|
showTunnelInSidebar,
|
||||||
@@ -925,6 +928,7 @@ router.put(
|
|||||||
enableDocker: enableDocker ? 1 : 0,
|
enableDocker: enableDocker ? 1 : 0,
|
||||||
enableProxmox: enableProxmox ? 1 : 0,
|
enableProxmox: enableProxmox ? 1 : 0,
|
||||||
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
|
||||||
|
allowSessionSharing: allowSessionSharing === false ? 0 : 1,
|
||||||
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
|
||||||
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
|
||||||
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
|
||||||
@@ -1500,9 +1504,13 @@ router.get(
|
|||||||
const field = (req.query.field as string) || "password";
|
const field = (req.query.field as string) || "password";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!["password", "sudoPassword", "vncPassword", "key", "keyPassword"].includes(
|
![
|
||||||
field,
|
"password",
|
||||||
)
|
"sudoPassword",
|
||||||
|
"vncPassword",
|
||||||
|
"key",
|
||||||
|
"keyPassword",
|
||||||
|
].includes(field)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({ error: "Invalid field" });
|
return res.status(400).json({ error: "Invalid field" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js";
|
|||||||
import {
|
import {
|
||||||
getCurrentSettingValue,
|
getCurrentSettingValue,
|
||||||
createCurrentOpenTabRepository,
|
createCurrentOpenTabRepository,
|
||||||
|
createCurrentSessionShareRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* /open-tabs/active-sessions:
|
* /open-tabs/active-sessions:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get all active backend sessions for the current user
|
* summary: Get all active backend sessions for the current user
|
||||||
* description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
|
* description: >
|
||||||
|
* Returns live terminal sessions from the session manager, both sessions the
|
||||||
|
* caller owns and SSH sessions shared to the caller by another user (via
|
||||||
|
* an in-app session share). Used by the Active Connections panel and tab restore logic.
|
||||||
* tags:
|
* tags:
|
||||||
* - Open Tabs
|
* - Open Tabs
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: List of active sessions.
|
* description: List of active sessions (own and shared-with-me).
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
@@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* type: boolean
|
* type: boolean
|
||||||
* createdAt:
|
* createdAt:
|
||||||
* type: number
|
* type: number
|
||||||
|
* isOwnSession:
|
||||||
|
* type: boolean
|
||||||
|
* sharedByUsername:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* permissionLevel:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* shareId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/active-sessions",
|
"/active-sessions",
|
||||||
@@ -309,17 +324,46 @@ router.get(
|
|||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
try {
|
try {
|
||||||
const sessions = sessionManager.getUserSessions(userId);
|
const ownSessions = sessionManager.getUserSessions(userId);
|
||||||
return res.json(
|
const result = ownSessions.map((s) => ({
|
||||||
sessions.map((s) => ({
|
sessionId: s.id,
|
||||||
sessionId: s.id,
|
hostId: s.hostId,
|
||||||
hostId: s.hostId,
|
hostName: s.hostName,
|
||||||
hostName: s.hostName,
|
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
||||||
tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
|
isConnected: s.isConnected,
|
||||||
isConnected: s.isConnected,
|
createdAt: s.createdAt,
|
||||||
createdAt: s.createdAt,
|
isOwnSession: true,
|
||||||
})),
|
sharedByUsername: null as string | null,
|
||||||
);
|
permissionLevel: null as string | null,
|
||||||
|
shareId: null as string | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sharedWithMe =
|
||||||
|
await createCurrentSessionShareRepository().findSharesTargetingUser(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
for (const share of sharedWithMe) {
|
||||||
|
if (share.protocol !== "ssh") continue;
|
||||||
|
const sharedSession = sessionManager.getSession(share.sessionId);
|
||||||
|
if (!sharedSession || !sharedSession.isConnected) continue;
|
||||||
|
result.push({
|
||||||
|
sessionId: sharedSession.id,
|
||||||
|
hostId: sharedSession.hostId,
|
||||||
|
hostName: sharedSession.hostName,
|
||||||
|
tabInstanceId:
|
||||||
|
sharedSession.attachedTabInstanceId ??
|
||||||
|
sharedSession.tabInstanceId ??
|
||||||
|
null,
|
||||||
|
isConnected: sharedSession.isConnected,
|
||||||
|
createdAt: sharedSession.createdAt,
|
||||||
|
isOwnSession: false,
|
||||||
|
sharedByUsername: share.ownerUsername,
|
||||||
|
permissionLevel: share.permissionLevel,
|
||||||
|
shareId: share.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.error("Failed to get active sessions", e, {
|
databaseLogger.error("Failed to get active sessions", e, {
|
||||||
operation: "get_active_sessions",
|
operation: "get_active_sessions",
|
||||||
|
|||||||
@@ -616,6 +616,110 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/session-sharing-enabled:
|
||||||
|
* get:
|
||||||
|
* summary: Get session sharing globally enabled setting
|
||||||
|
* description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Session sharing enabled status.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
*/
|
||||||
|
router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
enabled: await createCurrentSettingsRepository().getBoolean(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get session sharing enabled setting", err);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to get session sharing enabled setting" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/session-sharing-enabled:
|
||||||
|
* patch:
|
||||||
|
* summary: Update session sharing globally enabled setting (admin only)
|
||||||
|
* description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Setting updated.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update setting.
|
||||||
|
*/
|
||||||
|
router.patch(
|
||||||
|
"/session-sharing-enabled",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await getAdminActor(userId);
|
||||||
|
if (!actor) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { enabled } = req.body;
|
||||||
|
if (typeof enabled !== "boolean") {
|
||||||
|
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||||
|
}
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
enabled ? "true" : "false",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_session_sharing_enabled",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ enabled }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ enabled });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error(
|
||||||
|
"Failed to update session sharing enabled setting",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "Failed to update session sharing enabled setting" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/host-defaults:
|
* /users/host-defaults:
|
||||||
|
|||||||
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
|
|||||||
path.join(DATA_DIR, "session_recordings", "guacamole");
|
path.join(DATA_DIR, "session_recordings", "guacamole");
|
||||||
|
|
||||||
type GuacamoleClientConnection = {
|
type GuacamoleClientConnection = {
|
||||||
|
guacamoleConnectionId?: string;
|
||||||
connectionSettings?: {
|
connectionSettings?: {
|
||||||
connection?: { type?: string };
|
connection?: { type?: string; join?: string; readOnly?: boolean };
|
||||||
recording?: GuacamoleRecordingMetadata;
|
recording?: GuacamoleRecordingMetadata;
|
||||||
|
termixMeta?: {
|
||||||
|
termixConnectId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface GuacSessionInfo {
|
||||||
|
guacamoleConnectionId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: string;
|
||||||
|
openedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
|
||||||
|
// primary connection's guacd handshake completes.
|
||||||
|
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
|
||||||
|
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
|
||||||
|
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
|
||||||
|
const pendingConnectResolvers = new Map<
|
||||||
|
string,
|
||||||
|
(info: GuacSessionInfo | null) => void
|
||||||
|
>();
|
||||||
|
|
||||||
|
export function waitForGuacdOpen(
|
||||||
|
termixConnectId: string,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<GuacSessionInfo | null> {
|
||||||
|
const existing = guacSessionByConnectId.get(termixConnectId);
|
||||||
|
if (existing) return Promise.resolve(existing);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (info: GuacSessionInfo | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
pendingConnectResolvers.delete(termixConnectId);
|
||||||
|
resolve(info);
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingConnectResolvers.set(termixConnectId, finish);
|
||||||
|
setTimeout(() => finish(null), timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGuacSessionInfo(
|
||||||
|
guacamoleConnectionId: string,
|
||||||
|
): GuacSessionInfo | null {
|
||||||
|
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function persistGuacamoleRecording(
|
async function persistGuacamoleRecording(
|
||||||
clientConnection: GuacamoleClientConnection,
|
clientConnection: GuacamoleClientConnection,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -149,6 +201,25 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
operation: "guac_connection_open",
|
operation: "guac_connection_open",
|
||||||
type: clientConnection.connectionSettings?.connection?.type,
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||||
|
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||||
|
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||||
|
|
||||||
|
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||||
|
const info: GuacSessionInfo = {
|
||||||
|
guacamoleConnectionId,
|
||||||
|
hostId: termixMeta.hostId,
|
||||||
|
ownerUserId: termixMeta.ownerUserId,
|
||||||
|
protocol: termixMeta.protocol,
|
||||||
|
openedAt: Date.now(),
|
||||||
|
};
|
||||||
|
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
|
||||||
|
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
|
||||||
|
|
||||||
|
const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId);
|
||||||
|
if (resolver) resolver(info);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
server.on("close", (clientConnection: GuacamoleClientConnection) => {
|
||||||
@@ -156,6 +227,15 @@ function createGuacServer(): GuacamoleLite {
|
|||||||
operation: "guac_connection_close",
|
operation: "guac_connection_close",
|
||||||
type: clientConnection.connectionSettings?.connection?.type,
|
type: clientConnection.connectionSettings?.connection?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
|
||||||
|
const termixMeta = clientConnection.connectionSettings?.termixMeta;
|
||||||
|
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
|
||||||
|
if (!isJoin && termixMeta && guacamoleConnectionId) {
|
||||||
|
guacSessionByConnectId.delete(termixMeta.termixConnectId);
|
||||||
|
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
persistGuacamoleRecording(clientConnection).catch((error) => {
|
persistGuacamoleRecording(clientConnection).catch((error) => {
|
||||||
guacLogger.error("Failed to persist Guacamole recording", error, {
|
guacLogger.error("Failed to persist Guacamole recording", error, {
|
||||||
operation: "guac_recording_persist_error",
|
operation: "guac_recording_persist_error",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||||
import type { SOCKS5Config } from "../../utils/socks5-helper.js";
|
import type { SOCKS5Config } from "../../utils/socks5-helper.js";
|
||||||
|
import { waitForGuacdOpen } from "./guacamole-server.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
@@ -183,6 +184,10 @@ router.post("/token", async (req, res) => {
|
|||||||
* token:
|
* token:
|
||||||
* type: string
|
* type: string
|
||||||
* description: Encrypted connection token
|
* description: Encrypted connection token
|
||||||
|
* guacamoleConnectionId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: guacd's own connection id for this session, once the handshake completes. Used to mint session-share join tokens.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid request or unsupported connection type
|
* description: Invalid request or unsupported connection type
|
||||||
* 403:
|
* 403:
|
||||||
@@ -607,6 +612,14 @@ router.post(
|
|||||||
guacConfig["recording-include-keys"] = true;
|
guacConfig["recording-include-keys"] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const termixConnectId = crypto.randomUUID();
|
||||||
|
const termixMeta = {
|
||||||
|
termixConnectId,
|
||||||
|
hostId,
|
||||||
|
ownerUserId: userId,
|
||||||
|
protocol: connectionType as "rdp" | "vnc" | "telnet",
|
||||||
|
};
|
||||||
|
|
||||||
switch (connectionType) {
|
switch (connectionType) {
|
||||||
case "rdp":
|
case "rdp":
|
||||||
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
|
||||||
@@ -634,6 +647,7 @@ router.post(
|
|||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
recordingMetadata,
|
||||||
|
termixMeta,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "vnc":
|
case "vnc":
|
||||||
@@ -648,6 +662,7 @@ router.post(
|
|||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
recordingMetadata,
|
||||||
|
termixMeta,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "telnet":
|
case "telnet":
|
||||||
@@ -661,13 +676,19 @@ router.post(
|
|||||||
...guacdOverrides,
|
...guacdOverrides,
|
||||||
},
|
},
|
||||||
recordingMetadata,
|
recordingMetadata,
|
||||||
|
termixMeta,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return res.status(400).json({ error: "Invalid connection type" });
|
return res.status(400).json({ error: "Invalid connection type" });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ token });
|
const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
guacLogger.error("Failed to generate guacamole token for host", error, {
|
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||||
operation: "guac_host_token_error",
|
operation: "guac_host_token_error",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import crypto from "crypto";
|
|||||||
import { guacLogger } from "../../utils/logger.js";
|
import { guacLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
export interface GuacamoleConnectionSettings {
|
export interface GuacamoleConnectionSettings {
|
||||||
type: "rdp" | "vnc" | "telnet";
|
type?: "rdp" | "vnc" | "telnet";
|
||||||
|
join?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
guacdHost?: string;
|
guacdHost?: string;
|
||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
settings: {
|
settings: {
|
||||||
hostname: string;
|
hostname?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -28,9 +30,17 @@ export interface GuacamoleConnectionSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TermixGuacMeta {
|
||||||
|
termixConnectId: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: "rdp" | "vnc" | "telnet";
|
||||||
|
}
|
||||||
|
|
||||||
export interface GuacamoleToken {
|
export interface GuacamoleToken {
|
||||||
connection: GuacamoleConnectionSettings;
|
connection: GuacamoleConnectionSettings;
|
||||||
recording?: GuacamoleRecordingMetadata;
|
recording?: GuacamoleRecordingMetadata;
|
||||||
|
termixMeta?: TermixGuacMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuacamoleRecordingMetadata {
|
export interface GuacamoleRecordingMetadata {
|
||||||
@@ -137,6 +147,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
recording?: GuacamoleRecordingMetadata,
|
recording?: GuacamoleRecordingMetadata,
|
||||||
|
termixMeta?: TermixGuacMeta,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -155,6 +166,7 @@ export class GuacamoleTokenService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
recording,
|
||||||
|
termixMeta,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -168,6 +180,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
recording?: GuacamoleRecordingMetadata,
|
recording?: GuacamoleRecordingMetadata,
|
||||||
|
termixMeta?: TermixGuacMeta,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -184,6 +197,7 @@ export class GuacamoleTokenService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
recording,
|
||||||
|
termixMeta,
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
@@ -197,6 +211,7 @@ export class GuacamoleTokenService {
|
|||||||
guacdPort?: number;
|
guacdPort?: number;
|
||||||
} = {},
|
} = {},
|
||||||
recording?: GuacamoleRecordingMetadata,
|
recording?: GuacamoleRecordingMetadata,
|
||||||
|
termixMeta?: TermixGuacMeta,
|
||||||
): string {
|
): string {
|
||||||
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
const { guacdHost, guacdPort, ...settingsOptions } = options;
|
||||||
const token: GuacamoleToken = {
|
const token: GuacamoleToken = {
|
||||||
@@ -213,6 +228,20 @@ export class GuacamoleTokenService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
recording,
|
recording,
|
||||||
|
termixMeta,
|
||||||
|
};
|
||||||
|
return this.encryptToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// join tokens never carry recording params - only the primary connection's
|
||||||
|
// token should write recording-path/recording-name to guacd.
|
||||||
|
createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
|
||||||
|
const token: GuacamoleToken = {
|
||||||
|
connection: {
|
||||||
|
join: guacamoleConnectionId,
|
||||||
|
readOnly,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return this.encryptToken(token);
|
return this.encryptToken(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { ProxyNode } from "../../../types/index.js";
|
||||||
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
|
||||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||||
import { sessionManager } from "./session-manager.js";
|
import {
|
||||||
|
sessionManager,
|
||||||
|
isMessageAllowedForParticipant,
|
||||||
|
} from "./session-manager.js";
|
||||||
|
import {
|
||||||
|
createCurrentSessionShareRepository,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
detectTmux,
|
detectTmux,
|
||||||
attachOrCreateTmuxSession,
|
attachOrCreateTmuxSession,
|
||||||
@@ -105,10 +112,159 @@ const wss = new WebSocketServer({
|
|||||||
port: 30002,
|
port: 30002,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth path for anonymous share-link guests (?shareToken=<linkToken>).
|
||||||
|
* Never touches DataCrypto/user credentials - guests join an already-live
|
||||||
|
* stream and never decrypt stored secrets.
|
||||||
|
*/
|
||||||
|
async function handleShareTokenConnection(
|
||||||
|
ws: WebSocket,
|
||||||
|
req: import("http").IncomingMessage,
|
||||||
|
shareToken: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const shareRepo = createCurrentSessionShareRepository();
|
||||||
|
const share = await shareRepo.findByLinkToken(shareToken);
|
||||||
|
if (!share) {
|
||||||
|
ws.close(1008, "Invalid or expired share link");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (share.protocol !== "ssh") {
|
||||||
|
ws.close(1008, "Unsupported share protocol");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globallyEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||||
|
"session_sharing_globally_enabled",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!globallyEnabled) {
|
||||||
|
ws.close(1008, "Session sharing is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await createCurrentHostResolutionRepository().findHostById(
|
||||||
|
share.hostId,
|
||||||
|
share.ownerUserId,
|
||||||
|
);
|
||||||
|
if (!host || host.allowSessionSharing === false) {
|
||||||
|
ws.close(1008, "Session sharing is disabled for this host");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionManager.getSession(share.sessionId);
|
||||||
|
if (!session || !session.isConnected) {
|
||||||
|
ws.close(1008, "Session has ended");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionLevel = share.permissionLevel as "read-write" | "read-only";
|
||||||
|
const joined = sessionManager.joinAsParticipant(share.sessionId, ws, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel,
|
||||||
|
guestLabel: "Guest",
|
||||||
|
shareId: share.id,
|
||||||
|
});
|
||||||
|
if (!joined) {
|
||||||
|
ws.close(1008, "Session is no longer active");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||||
|
shareRepo.recordParticipantJoin(share.id, null, "Guest").catch(() => {});
|
||||||
|
|
||||||
|
const buffered = sessionManager.getBuffer(joined);
|
||||||
|
if (buffered) {
|
||||||
|
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "sessionAttached", sessionId: share.sessionId }),
|
||||||
|
);
|
||||||
|
ws.send(JSON.stringify({ type: "connected", message: "Joined session" }));
|
||||||
|
|
||||||
|
const currentSessionId: string = share.sessionId;
|
||||||
|
|
||||||
|
let wsAlive = true;
|
||||||
|
ws.on("pong", () => {
|
||||||
|
wsAlive = true;
|
||||||
|
});
|
||||||
|
const wsPingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
if (!wsAlive) {
|
||||||
|
ws.terminate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsAlive = false;
|
||||||
|
ws.ping();
|
||||||
|
} else {
|
||||||
|
clearInterval(wsPingInterval);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
clearInterval(wsPingInterval);
|
||||||
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
|
sshLogger.info("Guest left shared terminal session", {
|
||||||
|
operation: "terminal_guest_disconnect",
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
shareId: share.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", (msg: RawData) => {
|
||||||
|
let parsed: WebSocketMessage;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { type, data } = parsed;
|
||||||
|
|
||||||
|
const liveSession = sessionManager.getSession(currentSessionId);
|
||||||
|
const participant = liveSession
|
||||||
|
? sessionManager.getParticipantForWs(liveSession, ws)
|
||||||
|
: null;
|
||||||
|
if (!isMessageAllowedForParticipant(participant, type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "input": {
|
||||||
|
const inputData = data as string;
|
||||||
|
sessionManager.bufferInput(currentSessionId, inputData);
|
||||||
|
const inputStream = liveSession?.sshStream;
|
||||||
|
if (inputStream) {
|
||||||
|
try {
|
||||||
|
inputStream.write(Buffer.from(inputData, "utf8"));
|
||||||
|
} catch {
|
||||||
|
inputStream.write(Buffer.from(inputData, "latin1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ping":
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
break;
|
||||||
|
case "disconnect":
|
||||||
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
wss.on("connection", async (ws: WebSocket, req) => {
|
wss.on("connection", async (ws: WebSocket, req) => {
|
||||||
let userId: string | undefined;
|
let userId: string | undefined;
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
const urlObj = new URL(req.url || "", "http://localhost");
|
||||||
|
const shareToken = urlObj.searchParams.get("shareToken");
|
||||||
|
|
||||||
|
if (shareToken) {
|
||||||
|
await handleShareTokenConnection(ws, req, shareToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
|
|
||||||
@@ -126,7 +282,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const urlObj = new URL(req.url || "", "http://localhost");
|
|
||||||
const qp = urlObj.searchParams.get("token");
|
const qp = urlObj.searchParams.get("token");
|
||||||
if (qp) token = qp;
|
if (qp) token = qp;
|
||||||
}
|
}
|
||||||
@@ -242,11 +397,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const session = sessionManager.getSession(currentSessionId);
|
const session = sessionManager.getSession(currentSessionId);
|
||||||
if (session?.isConnected) {
|
if (session?.isConnected) {
|
||||||
// Only detach if this WS is still the one attached to the session.
|
const participant = sessionManager.getParticipantForWs(session, ws);
|
||||||
// If a refresh reconnected and reattached a new WS before this close
|
if (participant && !participant.isOwner) {
|
||||||
// event fired, we must not clobber that new attachment.
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
if (session.attachedWs === ws || session.attachedWs === null) {
|
} else {
|
||||||
sessionManager.detachWs(currentSessionId);
|
// 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 {
|
} else {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
@@ -295,6 +459,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
|
|
||||||
const { type, data } = parsed;
|
const { type, data } = parsed;
|
||||||
|
|
||||||
|
// Server-side gate: non-owner participants (read-only or read-write
|
||||||
|
// guests/joiners) may only send input/ping/disconnect - everything else
|
||||||
|
// (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
|
||||||
|
if (type !== "joinSharedSession") {
|
||||||
|
const gateSession = currentSessionId
|
||||||
|
? sessionManager.getSession(currentSessionId)
|
||||||
|
: null;
|
||||||
|
const gateParticipant = gateSession
|
||||||
|
? sessionManager.getParticipantForWs(gateSession, ws)
|
||||||
|
: null;
|
||||||
|
if (!isMessageAllowedForParticipant(gateParticipant, type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "connectToHost": {
|
case "connectToHost": {
|
||||||
const connectData = data as ConnectToHostData;
|
const connectData = data as ConnectToHostData;
|
||||||
@@ -445,7 +624,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "disconnect":
|
case "disconnect": {
|
||||||
|
const disconnectSession = currentSessionId
|
||||||
|
? sessionManager.getSession(currentSessionId)
|
||||||
|
: null;
|
||||||
|
const disconnectParticipant = disconnectSession
|
||||||
|
? sessionManager.getParticipantForWs(disconnectSession, ws)
|
||||||
|
: null;
|
||||||
|
if (disconnectParticipant && !disconnectParticipant.isOwner) {
|
||||||
|
if (currentSessionId) {
|
||||||
|
sessionManager.removeParticipant(currentSessionId, ws);
|
||||||
|
currentSessionId = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionManager.destroySession(currentSessionId);
|
sessionManager.destroySession(currentSessionId);
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
@@ -454,6 +646,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
sshConn = null;
|
sshConn = null;
|
||||||
sshStream = null;
|
sshStream = null;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "get_cwd": {
|
case "get_cwd": {
|
||||||
const activeConn =
|
const activeConn =
|
||||||
@@ -474,10 +667,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
execStream.stderr.on("data", () => {});
|
execStream.stderr.on("data", () => {});
|
||||||
execStream.on("close", () => {
|
execStream.on("close", () => {
|
||||||
const cwd = stdout.trim() || "/";
|
const cwd = stdout.trim() || "/";
|
||||||
const attachedWs =
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
ws.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
|
||||||
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -517,10 +708,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
execStream.stderr.on("data", () => {});
|
execStream.stderr.on("data", () => {});
|
||||||
execStream.on("close", () => {
|
execStream.on("close", () => {
|
||||||
const resolvedPath = stdout.trim() || requestedPath;
|
const resolvedPath = stdout.trim() || requestedPath;
|
||||||
const attachedWs =
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
ws.send(
|
||||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
|
||||||
attachedWs.send(
|
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "open_file_in_editor",
|
type: "open_file_in_editor",
|
||||||
path: resolvedPath,
|
path: resolvedPath,
|
||||||
@@ -1001,6 +1190,105 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "joinSharedSession": {
|
||||||
|
const joinData = data as { shareId: string; tabInstanceId?: string };
|
||||||
|
try {
|
||||||
|
const shareRepo = createCurrentSessionShareRepository();
|
||||||
|
const share = await shareRepo.findActiveById(joinData.shareId);
|
||||||
|
if (
|
||||||
|
!share ||
|
||||||
|
share.shareType !== "user" ||
|
||||||
|
share.targetUserId !== userId ||
|
||||||
|
share.protocol !== "ssh"
|
||||||
|
) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Share not found or not accessible",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../../utils/permission-manager.js");
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
share.hostId,
|
||||||
|
"connect",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Share not found or not accessible",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinedSession = sessionManager.joinAsParticipant(
|
||||||
|
share.sessionId,
|
||||||
|
ws,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
permissionLevel: share.permissionLevel as
|
||||||
|
| "read-write"
|
||||||
|
| "read-only",
|
||||||
|
tabInstanceId: joinData.tabInstanceId,
|
||||||
|
shareId: share.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!joinedSession) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Shared session is no longer active",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSessionId = share.sessionId;
|
||||||
|
sshStream = joinedSession.sshStream;
|
||||||
|
sshConn = joinedSession.sshConn;
|
||||||
|
isConnecting = false;
|
||||||
|
isConnected = true;
|
||||||
|
|
||||||
|
shareRepo.touchShareUsage(share.id).catch(() => {});
|
||||||
|
shareRepo
|
||||||
|
.recordParticipantJoin(share.id, userId, null)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
const buffered = sessionManager.getBuffer(joinedSession);
|
||||||
|
if (buffered) {
|
||||||
|
ws.send(JSON.stringify({ type: "data", data: buffered }));
|
||||||
|
}
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sessionAttached",
|
||||||
|
sessionId: share.sessionId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "connected", message: "Joined session" }),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to join shared session", error, {
|
||||||
|
operation: "terminal_join_shared_session_error",
|
||||||
|
userId,
|
||||||
|
shareId: joinData.shareId,
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Failed to join shared session",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
sshLogger.warn("Unknown message type received", {
|
sshLogger.warn("Unknown message type received", {
|
||||||
operation: "websocket_message_unknown_type",
|
operation: "websocket_message_unknown_type",
|
||||||
@@ -1636,12 +1924,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
sessionManager.bufferOutput(boundSessionId!, utf8String);
|
||||||
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
type: "data",
|
||||||
session.attachedWs.send(
|
data: utf8String,
|
||||||
JSON.stringify({ type: "data", data: utf8String }),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Error encoding terminal data", error, {
|
sshLogger.error("Error encoding terminal data", error, {
|
||||||
@@ -1653,34 +1939,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
sessionManager.bufferOutput(boundSessionId!, fallback);
|
sessionManager.bufferOutput(boundSessionId!, fallback);
|
||||||
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
if (session.attachedWs?.readyState === WebSocket.OPEN) {
|
type: "data",
|
||||||
session.attachedWs.send(
|
data: fallback,
|
||||||
JSON.stringify({ type: "data", data: fallback }),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.on("close", (code: number | null) => {
|
stream.on("close", (code: number | null) => {
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
if (session) {
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
session.attachedWs.send(
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
JSON.stringify({
|
type: "session_ended",
|
||||||
type: "session_ended",
|
code,
|
||||||
code,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
session.attachedWs.send(
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
JSON.stringify({
|
type: "disconnected",
|
||||||
type: "disconnected",
|
message: "Connection lost",
|
||||||
message: "Connection lost",
|
graceful: true,
|
||||||
graceful: true,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (boundSessionId) {
|
if (boundSessionId) {
|
||||||
@@ -1700,13 +1980,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
if (session?.attachedWs?.readyState === WebSocket.OPEN) {
|
if (session) {
|
||||||
session.attachedWs.send(
|
sessionManager.broadcast(boundSessionId!, {
|
||||||
JSON.stringify({
|
type: "error",
|
||||||
type: "error",
|
message: "SSH stream error: " + err.message,
|
||||||
message: "SSH stream error: " + err.message,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30;
|
|||||||
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
const HEALTH_CHECK_INTERVAL_MS = 60_000;
|
||||||
const MAX_SESSIONS_PER_USER = 10;
|
const MAX_SESSIONS_PER_USER = 10;
|
||||||
|
|
||||||
|
export interface SessionParticipant {
|
||||||
|
ws: WebSocket;
|
||||||
|
userId: string | null; // null for anonymous link guests
|
||||||
|
permissionLevel: "read-write" | "read-only";
|
||||||
|
isOwner: boolean;
|
||||||
|
guestLabel?: string;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
joinedViaShareId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -32,7 +42,7 @@ export interface TerminalSession {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|
||||||
attachedWs: WebSocket | null;
|
participants: Map<string, SessionParticipant>;
|
||||||
lastDetachedAt: number | null;
|
lastDetachedAt: number | null;
|
||||||
detachTimeout: NodeJS.Timeout | null;
|
detachTimeout: NodeJS.Timeout | null;
|
||||||
|
|
||||||
@@ -48,6 +58,33 @@ export interface TerminalSession {
|
|||||||
sessionLoggingEnabled: boolean;
|
sessionLoggingEnabled: boolean;
|
||||||
sessionStartedAt: number;
|
sessionStartedAt: number;
|
||||||
lastPersistedBytes: number;
|
lastPersistedBytes: number;
|
||||||
|
terminatedByOwner: boolean;
|
||||||
|
terminationReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message types a non-owner participant may legally send. */
|
||||||
|
const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
|
||||||
|
"input",
|
||||||
|
"ping",
|
||||||
|
"disconnect",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side gate for whether a participant may send a given WS message
|
||||||
|
* type. The owner may send anything; non-owners are limited to input (if
|
||||||
|
* read-write), ping, and disconnect. Pure function so read-only enforcement
|
||||||
|
* is unit-testable without a real WebSocketServer.
|
||||||
|
*/
|
||||||
|
export function isMessageAllowedForParticipant(
|
||||||
|
participant: Pick<SessionParticipant, "isOwner" | "permissionLevel"> | null,
|
||||||
|
messageType: string,
|
||||||
|
): boolean {
|
||||||
|
if (!participant || participant.isOwner) return true;
|
||||||
|
if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
|
||||||
|
if (messageType === "input" && participant.permissionLevel === "read-only") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TerminalSessionManager {
|
class TerminalSessionManager {
|
||||||
@@ -81,7 +118,7 @@ class TerminalSessionManager {
|
|||||||
const userSessions = this.getUserSessions(userId);
|
const userSessions = this.getUserSessions(userId);
|
||||||
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
|
||||||
const detached = userSessions
|
const detached = userSessions
|
||||||
.filter((s) => s.attachedWs === null)
|
.filter((s) => this.getOwnerParticipant(s) === null)
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(a.lastDetachedAt ?? a.createdAt) -
|
(a.lastDetachedAt ?? a.createdAt) -
|
||||||
@@ -109,7 +146,7 @@ class TerminalSessionManager {
|
|||||||
operation: "session_tab_duplicate_skip",
|
operation: "session_tab_duplicate_skip",
|
||||||
existingSessionId: existing.id,
|
existingSessionId: existing.id,
|
||||||
tabInstanceId,
|
tabInstanceId,
|
||||||
hasAttachedWs: existing.attachedWs !== null,
|
hasAttachedWs: this.getOwnerParticipant(existing) !== null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return existing.id;
|
return existing.id;
|
||||||
@@ -151,7 +188,7 @@ class TerminalSessionManager {
|
|||||||
rows,
|
rows,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
attachedWs: null,
|
participants: new Map(),
|
||||||
lastDetachedAt: null,
|
lastDetachedAt: null,
|
||||||
detachTimeout: null,
|
detachTimeout: null,
|
||||||
outputBuffer: [],
|
outputBuffer: [],
|
||||||
@@ -166,6 +203,8 @@ class TerminalSessionManager {
|
|||||||
sessionLoggingEnabled,
|
sessionLoggingEnabled,
|
||||||
sessionStartedAt: now,
|
sessionStartedAt: now,
|
||||||
lastPersistedBytes: 0,
|
lastPersistedBytes: 0,
|
||||||
|
terminatedByOwner: false,
|
||||||
|
terminationReason: null,
|
||||||
};
|
};
|
||||||
this.sessions.set(id, session);
|
this.sessions.set(id, session);
|
||||||
|
|
||||||
@@ -199,6 +238,25 @@ class TerminalSessionManager {
|
|||||||
session.isConnected = true;
|
session.isConnected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Finds the owner's participant entry, if currently attached. */
|
||||||
|
private getOwnerParticipant(
|
||||||
|
session: TerminalSession,
|
||||||
|
): SessionParticipant | null {
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.isOwner) return participant;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOwnerEntry(
|
||||||
|
session: TerminalSession,
|
||||||
|
): [string, SessionParticipant] | null {
|
||||||
|
for (const entry of session.participants.entries()) {
|
||||||
|
if (entry[1].isOwner) return entry;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
attachWs(
|
attachWs(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -234,8 +292,9 @@ class TerminalSessionManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownerParticipant = this.getOwnerParticipant(session);
|
||||||
const isDetached =
|
const isDetached =
|
||||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
!ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
|
||||||
const isOriginalTab =
|
const isOriginalTab =
|
||||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||||
tabInstanceId;
|
tabInstanceId;
|
||||||
@@ -282,9 +341,10 @@ class TerminalSessionManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.attachedWs && session.attachedWs !== ws) {
|
const ownerEntry = this.getOwnerEntry(session);
|
||||||
|
if (ownerEntry && ownerEntry[1].ws !== ws) {
|
||||||
try {
|
try {
|
||||||
session.attachedWs.send(
|
ownerEntry[1].ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "sessionTakenOver",
|
type: "sessionTakenOver",
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -294,7 +354,7 @@ class TerminalSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
session.attachedWs = null;
|
session.participants.delete(ownerEntry[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.detachTimeout) {
|
if (session.detachTimeout) {
|
||||||
@@ -302,7 +362,14 @@ class TerminalSessionManager {
|
|||||||
session.detachTimeout = null;
|
session.detachTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.attachedWs = ws;
|
const participantId = crypto.randomUUID();
|
||||||
|
session.participants.set(participantId, {
|
||||||
|
ws,
|
||||||
|
userId,
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
isOwner: true,
|
||||||
|
tabInstanceId,
|
||||||
|
});
|
||||||
session.attachedTabInstanceId = tabInstanceId;
|
session.attachedTabInstanceId = tabInstanceId;
|
||||||
session.lastDetachedAt = null;
|
session.lastDetachedAt = null;
|
||||||
|
|
||||||
@@ -316,6 +383,110 @@ class TerminalSessionManager {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a non-owner participant (in-app share join or anonymous link guest).
|
||||||
|
* Purely additive - never evicts the owner or any other participant.
|
||||||
|
*/
|
||||||
|
joinAsParticipant(
|
||||||
|
sessionId: string,
|
||||||
|
ws: WebSocket,
|
||||||
|
opts: {
|
||||||
|
userId: string | null;
|
||||||
|
permissionLevel: "read-write" | "read-only";
|
||||||
|
guestLabel?: string;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
shareId?: string;
|
||||||
|
},
|
||||||
|
): TerminalSession | null {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session || !session.isConnected) return null;
|
||||||
|
|
||||||
|
const participantId = crypto.randomUUID();
|
||||||
|
session.participants.set(participantId, {
|
||||||
|
ws,
|
||||||
|
userId: opts.userId,
|
||||||
|
permissionLevel: opts.permissionLevel,
|
||||||
|
isOwner: false,
|
||||||
|
guestLabel: opts.guestLabel,
|
||||||
|
tabInstanceId: opts.tabInstanceId,
|
||||||
|
joinedViaShareId: opts.shareId,
|
||||||
|
});
|
||||||
|
|
||||||
|
sshLogger.info("Participant joined shared session", {
|
||||||
|
operation: "session_join_participant",
|
||||||
|
sessionId,
|
||||||
|
userId: opts.userId,
|
||||||
|
permissionLevel: opts.permissionLevel,
|
||||||
|
shareId: opts.shareId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||||
|
broadcast(sessionId: string, message: object): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
try {
|
||||||
|
participant.ws.send(payload);
|
||||||
|
} catch {
|
||||||
|
/* ignore individual send failures, keep broadcasting to the rest */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the participant entry (owner or not) for a given socket. */
|
||||||
|
getParticipantForWs(
|
||||||
|
session: TerminalSession,
|
||||||
|
ws: WebSocket,
|
||||||
|
): SessionParticipant | null {
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.ws === ws) return participant;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a non-owner participant's socket. No detach timeout or session
|
||||||
|
* destruction side effects - a guest leaving must never end the session.
|
||||||
|
*/
|
||||||
|
removeParticipant(sessionId: string, ws: WebSocket): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
for (const [id, participant] of session.participants.entries()) {
|
||||||
|
if (participant.ws === ws && !participant.isOwner) {
|
||||||
|
session.participants.delete(id);
|
||||||
|
sshLogger.info("Participant left shared session", {
|
||||||
|
operation: "session_leave_participant",
|
||||||
|
sessionId,
|
||||||
|
userId: participant.userId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcasts termination to all guests, then destroys the session. */
|
||||||
|
ownerEndSession(sessionId: string, reason: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
|
||||||
|
session.terminatedByOwner = true;
|
||||||
|
session.terminationReason = reason;
|
||||||
|
|
||||||
|
sshLogger.info("Owner ended shared session", {
|
||||||
|
operation: "session_owner_end",
|
||||||
|
sessionId,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.destroySession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
detachWs(sessionId: string): void {
|
detachWs(sessionId: string): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -325,7 +496,10 @@ class TerminalSessionManager {
|
|||||||
session.detachTimeout = null;
|
session.detachTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.attachedWs = null;
|
const ownerEntry = this.getOwnerEntry(session);
|
||||||
|
if (ownerEntry) {
|
||||||
|
session.participants.delete(ownerEntry[0]);
|
||||||
|
}
|
||||||
session.lastDetachedAt = Date.now();
|
session.lastDetachedAt = Date.now();
|
||||||
|
|
||||||
// Persist log immediately when the user detaches so it appears right away,
|
// Persist log immediately when the user detaches so it appears right away,
|
||||||
@@ -365,6 +539,23 @@ class TerminalSessionManager {
|
|||||||
fs.promises.unlink(session.recordingPath).catch(() => {});
|
fs.promises.unlink(session.recordingPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const participant of session.participants.values()) {
|
||||||
|
if (participant.isOwner) continue;
|
||||||
|
if (participant.ws.readyState !== WebSocket.OPEN) continue;
|
||||||
|
try {
|
||||||
|
participant.ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sessionExpired",
|
||||||
|
sessionId,
|
||||||
|
message: "Session has ended",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session.participants.clear();
|
||||||
|
|
||||||
if (session.sshStream) {
|
if (session.sshStream) {
|
||||||
try {
|
try {
|
||||||
session.sshStream.end();
|
session.sshStream.end();
|
||||||
@@ -440,12 +631,16 @@ class TerminalSessionManager {
|
|||||||
recordingPath: session.recordingPath,
|
recordingPath: session.recordingPath,
|
||||||
protocol: "ssh",
|
protocol: "ssh",
|
||||||
format: "asciicast",
|
format: "asciicast",
|
||||||
|
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||||
|
terminationReason: session.terminationReason ?? undefined,
|
||||||
});
|
});
|
||||||
session.recordingId = created.id;
|
session.recordingId = created.id;
|
||||||
} else {
|
} else {
|
||||||
await repo.updateEnded(session.recordingId, {
|
await repo.updateEnded(session.recordingId, {
|
||||||
endedAt: new Date(endedAt).toISOString(),
|
endedAt: new Date(endedAt).toISOString(),
|
||||||
duration,
|
duration,
|
||||||
|
terminatedByOwner: session.terminatedByOwner || undefined,
|
||||||
|
terminationReason: session.terminationReason ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -569,10 +764,10 @@ class TerminalSessionManager {
|
|||||||
for (const [id, session] of this.sessions) {
|
for (const [id, session] of this.sessions) {
|
||||||
if (!session.isConnected) continue;
|
if (!session.isConnected) continue;
|
||||||
|
|
||||||
if (
|
const hasOpenParticipant = Array.from(session.participants.values()).some(
|
||||||
session.attachedWs &&
|
(p) => p.ws.readyState === WebSocket.OPEN,
|
||||||
session.attachedWs.readyState === WebSocket.OPEN
|
);
|
||||||
) {
|
if (hasOpenParticipant) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe("HostRepository and CredentialRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ describe("HostFolderRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
|
||||||
|
|
||||||
|
describe("SessionShareRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(
|
||||||
|
onWrite?: () => void | Promise<void>,
|
||||||
|
): Promise<SessionShareRepository> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
context.sqlite?.exec(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ssh_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ip TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session_shares (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_id INTEGER NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
tab_instance_id TEXT,
|
||||||
|
share_type TEXT NOT NULL,
|
||||||
|
target_user_id TEXT,
|
||||||
|
link_token TEXT UNIQUE,
|
||||||
|
permission_level TEXT NOT NULL DEFAULT 'read-only',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_joined_at TEXT,
|
||||||
|
join_count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session_share_participants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
guest_label TEXT,
|
||||||
|
joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
left_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
|
||||||
|
INSERT INTO ssh_data (id, user_id, name, ip)
|
||||||
|
VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2');
|
||||||
|
`);
|
||||||
|
|
||||||
|
return new SessionShareRepository(context, onWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
|
||||||
|
const FAR_PAST = "2000-01-01T00:00:00.000Z";
|
||||||
|
|
||||||
|
it("creates a share and finds it by id", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
const created = await repo.create({
|
||||||
|
id: "share-1",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-abc",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
id: "share-1",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
linkToken: "token-abc",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await repo.findById("share-1");
|
||||||
|
expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findByLinkToken excludes revoked shares", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-revoked",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-revoked",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
|
||||||
|
|
||||||
|
await repo.revoke("share-revoked", "owner-1");
|
||||||
|
|
||||||
|
expect(await repo.findByLinkToken("token-revoked")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findByLinkToken excludes expired shares", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-expired",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-expired",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_PAST,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.findByLinkToken("token-expired")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-active",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "vnc",
|
||||||
|
sessionId: "guac-session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-active",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await repo.findByLinkToken("token-active");
|
||||||
|
expect(found).toMatchObject({
|
||||||
|
id: "share-active",
|
||||||
|
protocol: "vnc",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
|
||||||
|
await repo.create({
|
||||||
|
id: "share-user-active",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "guest-1",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expired user share for the same target - must be excluded
|
||||||
|
await repo.create({
|
||||||
|
id: "share-user-expired",
|
||||||
|
hostId: 2,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-2",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "guest-1",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_PAST,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link share, not targeting a user - must be excluded even though it's active
|
||||||
|
await repo.create({
|
||||||
|
id: "share-link-active",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-3",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-unrelated",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shares = await repo.findSharesTargetingUser("guest-1");
|
||||||
|
expect(shares).toHaveLength(1);
|
||||||
|
expect(shares[0]).toMatchObject({
|
||||||
|
id: "share-user-active",
|
||||||
|
hostName: "host-one",
|
||||||
|
ownerUsername: "alice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revoke only affects the requesting owner's own share", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-owned",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-owned",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
|
||||||
|
expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokeAsAdmin revokes regardless of owner", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-admin-target",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-admin",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
|
||||||
|
expect(await repo.findByLinkToken("token-admin")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteExpiredShares removes only expired rows", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-old",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-old",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_PAST,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
id: "share-current",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-2",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-current",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedCount = await repo.deleteExpiredShares();
|
||||||
|
expect(deletedCount).toBe(1);
|
||||||
|
expect(await repo.findById("share-old")).toBeNull();
|
||||||
|
expect(await repo.findById("share-current")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-touch",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-touch",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
|
||||||
|
let row = await repo.findById("share-touch");
|
||||||
|
expect(row?.joinCount).toBe(1);
|
||||||
|
expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
|
||||||
|
row = await repo.findById("share-touch");
|
||||||
|
expect(row?.joinCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records and closes participant joins", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-participants",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-participants",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const participant = await repo.recordParticipantJoin(
|
||||||
|
"share-participants",
|
||||||
|
null,
|
||||||
|
"Guest",
|
||||||
|
);
|
||||||
|
expect(participant).toMatchObject({
|
||||||
|
shareId: "share-participants",
|
||||||
|
userId: null,
|
||||||
|
guestLabel: "Guest",
|
||||||
|
});
|
||||||
|
expect(participant.leftAt).toBeNull();
|
||||||
|
|
||||||
|
await repo.recordParticipantLeave(participant.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write hook fires on mutating operations", async () => {
|
||||||
|
let writeCount = 0;
|
||||||
|
const repo = await createRepository(() => {
|
||||||
|
writeCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.create({
|
||||||
|
id: "share-write-hook",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-write-hook",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
expect(writeCount).toBe(1);
|
||||||
|
|
||||||
|
await repo.revoke("share-write-hook", "owner-1");
|
||||||
|
expect(writeCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteSharesForHost removes all shares for a host", async () => {
|
||||||
|
const repo = await createRepository();
|
||||||
|
await repo.create({
|
||||||
|
id: "share-host-1a",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-h1a",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
id: "share-host-1b",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-2",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-h1b",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
id: "share-host-2",
|
||||||
|
hostId: 2,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-3",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "token-h2",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: FAR_FUTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await repo.deleteSharesForHost(1)).toBe(2);
|
||||||
|
expect(await repo.findById("share-host-2")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,6 +49,7 @@ describe("UserDataExportRepository", () => {
|
|||||||
vault_profile_id INTEGER,
|
vault_profile_id INTEGER,
|
||||||
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
enable_terminal INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
enable_session_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
allow_session_sharing INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
enable_command_history INTEGER NOT NULL DEFAULT 1,
|
||||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||||
tunnel_connections TEXT,
|
tunnel_connections TEXT,
|
||||||
|
|||||||
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
|
|||||||
|
|
||||||
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves termixMeta through the encrypt/decrypt round trip", () => {
|
||||||
|
const termixMeta = {
|
||||||
|
termixConnectId: "connect-1",
|
||||||
|
hostId: 7,
|
||||||
|
ownerUserId: "user-1",
|
||||||
|
protocol: "rdp" as const,
|
||||||
|
};
|
||||||
|
const token = tokenService.createRdpToken(
|
||||||
|
"windows.example.test",
|
||||||
|
"Administrator",
|
||||||
|
"secret",
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
termixMeta,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createJoinToken sets connection.join, not connection.type", () => {
|
||||||
|
const token = tokenService.createJoinToken("guacd-conn-123", true);
|
||||||
|
const decrypted = tokenService.decryptToken(token);
|
||||||
|
|
||||||
|
expect(decrypted?.connection.join).toBe("guacd-conn-123");
|
||||||
|
expect(decrypted?.connection.type).toBeUndefined();
|
||||||
|
expect(decrypted?.connection.readOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createJoinToken round-trips a read-write join through decryptToken", () => {
|
||||||
|
const token = tokenService.createJoinToken("guacd-conn-456", false);
|
||||||
|
const decrypted = tokenService.decryptToken(token);
|
||||||
|
|
||||||
|
expect(decrypted?.connection.join).toBe("guacd-conn-456");
|
||||||
|
expect(decrypted?.connection.readOnly).toBe(false);
|
||||||
|
expect(decrypted?.recording).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
currentUserId: "user-1",
|
||||||
|
globalSharingEnabled: true,
|
||||||
|
hosts: new Map<number, { userId: string; allowSessionSharing: boolean }>(),
|
||||||
|
hostOwnerAccess: new Map<string, boolean>(), // `${userId}:${hostId}` -> hasAccess
|
||||||
|
sshSessions: new Map<string, { userId: string; isConnected: boolean }>(),
|
||||||
|
guacSessions: new Map<
|
||||||
|
string,
|
||||||
|
{ ownerUserId: string; hostId: number; protocol: string }
|
||||||
|
>(),
|
||||||
|
shares: new Map<string, Record<string, unknown>>(),
|
||||||
|
admins: new Set<string>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
sshLogger: {
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||||
|
AuthManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createAuthMiddleware:
|
||||||
|
() =>
|
||||||
|
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||||
|
req.userId = state.currentUserId;
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
canAccessHost: async (
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
_action: string,
|
||||||
|
) => ({
|
||||||
|
hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
|
||||||
|
}),
|
||||||
|
isAdmin: async (userId: string) => state.admins.has(userId),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||||
|
sessionManager: {
|
||||||
|
getSession: (sessionId: string) => {
|
||||||
|
const session = state.sshSessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
return { ...session };
|
||||||
|
},
|
||||||
|
ownerEndSession: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
|
||||||
|
getGuacSessionInfo: (guacamoleConnectionId: string) =>
|
||||||
|
state.guacSessions.get(guacamoleConnectionId) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
||||||
|
GuacamoleTokenService: {
|
||||||
|
getInstance: () => ({
|
||||||
|
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
|
||||||
|
`join-token:${guacamoleConnectionId}:${readOnly}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSessionShareRepository: () => ({
|
||||||
|
create: async (input: Record<string, unknown>) => {
|
||||||
|
const row = {
|
||||||
|
...input,
|
||||||
|
createdAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
revokedAt: null,
|
||||||
|
lastJoinedAt: null,
|
||||||
|
joinCount: 0,
|
||||||
|
};
|
||||||
|
state.shares.set(input.id as string, row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
findById: async (id: string) => state.shares.get(id) ?? null,
|
||||||
|
findByLinkToken: async (linkToken: string) => {
|
||||||
|
for (const share of state.shares.values()) {
|
||||||
|
if (
|
||||||
|
share.linkToken === linkToken &&
|
||||||
|
!share.revokedAt &&
|
||||||
|
(share.expiresAt as string) > new Date().toISOString()
|
||||||
|
) {
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
|
||||||
|
return [...state.shares.values()].filter(
|
||||||
|
(s) =>
|
||||||
|
s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
revoke: async (shareId: string, requestingUserId: string) => {
|
||||||
|
const share = state.shares.get(shareId);
|
||||||
|
if (!share || share.ownerUserId !== requestingUserId) return false;
|
||||||
|
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
revokeAsAdmin: async (shareId: string) => {
|
||||||
|
const share = state.shares.get(shareId);
|
||||||
|
if (!share) return false;
|
||||||
|
share.revokedAt = "2026-07-20T01:00:00.000Z";
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
touchShareUsage: async () => {},
|
||||||
|
recordParticipantJoin: async () => ({ id: 1 }),
|
||||||
|
}),
|
||||||
|
createCurrentSettingsRepository: () => ({
|
||||||
|
getBoolean: async () => state.globalSharingEnabled,
|
||||||
|
}),
|
||||||
|
createCurrentHostResolutionRepository: () => ({
|
||||||
|
findHostOwnerId: async (hostId: number) =>
|
||||||
|
state.hosts.get(hostId)?.userId ?? null,
|
||||||
|
findHostById: async (hostId: number) => {
|
||||||
|
const host = state.hosts.get(hostId);
|
||||||
|
if (!host) return null;
|
||||||
|
return { allowSessionSharing: host.allowSessionSharing };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { default: router } =
|
||||||
|
await import("../../../hosts/session-sharing/routes.js");
|
||||||
|
|
||||||
|
type RouteLayer = {
|
||||||
|
route?: {
|
||||||
|
path: string;
|
||||||
|
methods: Record<string, boolean>;
|
||||||
|
stack: {
|
||||||
|
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function findHandlers(method: string, path: string) {
|
||||||
|
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||||
|
const layer = layers.find(
|
||||||
|
(l) => l.route?.path === path && l.route.methods[method],
|
||||||
|
);
|
||||||
|
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||||
|
return layer.route.stack.map((s) => s.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReqRes(overrides: {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
ip?: string;
|
||||||
|
}) {
|
||||||
|
const req = {
|
||||||
|
body: overrides.body ?? {},
|
||||||
|
params: overrides.params ?? {},
|
||||||
|
headers: {},
|
||||||
|
ip: overrides.ip ?? "127.0.0.1",
|
||||||
|
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
jsonBody: null as unknown,
|
||||||
|
status(code: number) {
|
||||||
|
(this as unknown as { statusCode: number }).statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload: unknown) {
|
||||||
|
(this as unknown as { jsonBody: unknown }).jsonBody = payload;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as unknown as Response & { statusCode: number; jsonBody: unknown };
|
||||||
|
|
||||||
|
return { req, res };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
overrides: {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
ip?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const handlers = findHandlers(method, path);
|
||||||
|
const { req, res } = makeReqRes(overrides);
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
let calledNext = false;
|
||||||
|
await handler(req, res, () => {
|
||||||
|
calledNext = true;
|
||||||
|
});
|
||||||
|
if (!calledNext) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res as unknown as {
|
||||||
|
statusCode: number;
|
||||||
|
jsonBody: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.currentUserId = "user-1";
|
||||||
|
state.globalSharingEnabled = true;
|
||||||
|
state.hosts = new Map([
|
||||||
|
[1, { userId: "user-1", allowSessionSharing: true }],
|
||||||
|
[2, { userId: "user-1", allowSessionSharing: false }],
|
||||||
|
]);
|
||||||
|
state.hostOwnerAccess = new Map([["user-2:1", true]]);
|
||||||
|
state.sshSessions = new Map([
|
||||||
|
["session-1", { userId: "user-1", isConnected: true }],
|
||||||
|
]);
|
||||||
|
state.guacSessions = new Map([
|
||||||
|
["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
|
||||||
|
]);
|
||||||
|
state.shares = new Map();
|
||||||
|
state.admins = new Set();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /session-sharing/create", () => {
|
||||||
|
it("rejects a caller who does not own the live session", async () => {
|
||||||
|
state.currentUserId = "user-2";
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.jsonBody).toMatchObject({
|
||||||
|
error: "You do not own this live session",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a link share for the session owner", async () => {
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
|
||||||
|
expect((res.jsonBody as Record<string, unknown>).linkToken).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a user share when the target lacks host access", async () => {
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "no-access-user",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.jsonBody).toMatchObject({
|
||||||
|
error: "Target user does not have access to this host",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("global kill switch overrides an enabled per-host toggle", async () => {
|
||||||
|
state.globalSharingEnabled = false;
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
expect(res.jsonBody).toMatchObject({
|
||||||
|
error: "Session sharing is disabled for this host",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the per-host toggle is off even though global is on", async () => {
|
||||||
|
const res = await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 2,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /session-sharing/resolve/:linkToken", () => {
|
||||||
|
async function createActiveLinkShare(
|
||||||
|
overrides: Partial<Record<string, unknown>> = {},
|
||||||
|
) {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()];
|
||||||
|
return share as { linkToken: string; id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("never includes hostname, ip, username, or hostId in the response body", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.jsonBody as Record<string, unknown>;
|
||||||
|
const serialized = JSON.stringify(body).toLowerCase();
|
||||||
|
|
||||||
|
expect(body).not.toHaveProperty("hostname");
|
||||||
|
expect(body).not.toHaveProperty("ip");
|
||||||
|
expect(body).not.toHaveProperty("username");
|
||||||
|
expect(body).not.toHaveProperty("hostId");
|
||||||
|
expect(body).not.toHaveProperty("hostName");
|
||||||
|
expect(serialized).not.toContain("10.0.0");
|
||||||
|
expect(serialized).not.toContain("hostname");
|
||||||
|
expect(serialized).not.toContain('"ip"');
|
||||||
|
expect(serialized).not.toContain("username");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.jsonBody).toEqual({
|
||||||
|
protocol: "ssh",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mints a fresh join token for guac protocols", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "guac-conn-1",
|
||||||
|
protocol: "vnc",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as {
|
||||||
|
linkToken: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect((res.jsonBody as Record<string, unknown>).connectParams).toEqual({
|
||||||
|
token: "join-token:guac-conn-1:true",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown link token", async () => {
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: "does-not-exist" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a revoked link token", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
await invoke("delete", "/:shareId", { params: { shareId: share.id } });
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired link token", async () => {
|
||||||
|
state.shares.set("share-expired", {
|
||||||
|
id: "share-expired",
|
||||||
|
hostId: 1,
|
||||||
|
ownerUserId: "user-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "session-1",
|
||||||
|
shareType: "link",
|
||||||
|
linkToken: "expired-token",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||||
|
revokedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: "expired-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
|
||||||
|
const share = await createActiveLinkShare();
|
||||||
|
|
||||||
|
state.globalSharingEnabled = false;
|
||||||
|
|
||||||
|
const res = await invoke("get", "/resolve/:linkToken", {
|
||||||
|
params: { linkToken: share.linkToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /session-sharing/:shareId", () => {
|
||||||
|
it("allows the owner to revoke their own share", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as { id: string }[];
|
||||||
|
|
||||||
|
const res = await invoke("delete", "/:shareId", {
|
||||||
|
params: { shareId: share.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-owner, non-admin caller", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as { id: string }[];
|
||||||
|
|
||||||
|
state.currentUserId = "user-2";
|
||||||
|
const res = await invoke("delete", "/:shareId", {
|
||||||
|
params: { shareId: share.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an admin to revoke someone else's share", async () => {
|
||||||
|
await invoke("post", "/create", {
|
||||||
|
body: {
|
||||||
|
hostId: 1,
|
||||||
|
sessionId: "session-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [share] = [...state.shares.values()] as { id: string }[];
|
||||||
|
|
||||||
|
state.currentUserId = "admin-1";
|
||||||
|
state.admins.add("admin-1");
|
||||||
|
const res = await invoke("delete", "/:shareId", {
|
||||||
|
params: { shareId: share.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,9 +49,19 @@ vi.mock("fs", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { sessionManager } =
|
const { sessionManager, isMessageAllowedForParticipant } =
|
||||||
await import("../../../hosts/terminal/session-manager.js");
|
await import("../../../hosts/terminal/session-manager.js");
|
||||||
|
|
||||||
|
// Minimal fake WebSocket - only the surface session-manager touches.
|
||||||
|
function makeFakeWs(readyState = 1 /* OPEN */) {
|
||||||
|
return {
|
||||||
|
readyState,
|
||||||
|
send: vi.fn(),
|
||||||
|
} as unknown as import("ws").WebSocket;
|
||||||
|
}
|
||||||
|
const WS_OPEN = 1;
|
||||||
|
const WS_CLOSED = 3;
|
||||||
|
|
||||||
describe("TerminalSessionManager - session logging", () => {
|
describe("TerminalSessionManager - session logging", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
|
|||||||
sessionManager.destroySession(id);
|
sessionManager.destroySession(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TerminalSessionManager - multiplayer participants", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockMkdir.mockResolvedValue(undefined);
|
||||||
|
mockWriteFile.mockResolvedValue(undefined);
|
||||||
|
mockCreate.mockResolvedValue({ id: 1 });
|
||||||
|
mockUpdateEnded.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConnectedSession(): string {
|
||||||
|
const id = sessionManager.createSession(
|
||||||
|
"owner-1",
|
||||||
|
1,
|
||||||
|
"host",
|
||||||
|
80,
|
||||||
|
24,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// Mark connected without a real ssh2 stream - only isConnected is read
|
||||||
|
// by attachWs/joinAsParticipant.
|
||||||
|
const session = sessionManager.getSession(id)!;
|
||||||
|
session.isConnected = true;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("joinAsParticipant adds a participant without evicting the owner", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
const session = sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
guestLabel: "Guest",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session).not.toBeNull();
|
||||||
|
expect(session!.participants.size).toBe(2);
|
||||||
|
const ownerParticipant = sessionManager.getParticipantForWs(
|
||||||
|
session!,
|
||||||
|
ownerWs,
|
||||||
|
);
|
||||||
|
expect(ownerParticipant?.isOwner).toBe(true);
|
||||||
|
expect(ownerWs.send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||||
|
expect(
|
||||||
|
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs(WS_OPEN);
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const openGuestWs = makeFakeWs(WS_OPEN);
|
||||||
|
const closedGuestWs = makeFakeWs(WS_CLOSED);
|
||||||
|
sessionManager.joinAsParticipant(id, openGuestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
sessionManager.joinAsParticipant(id, closedGuestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.broadcast(id, { type: "data", data: "hello" });
|
||||||
|
|
||||||
|
expect(ownerWs.send).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify({ type: "data", data: "hello" }),
|
||||||
|
);
|
||||||
|
expect(openGuestWs.send).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify({ type: "data", data: "hello" }),
|
||||||
|
);
|
||||||
|
expect(closedGuestWs.send).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcast does not throw if a socket's send throws", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const throwingWs = makeFakeWs(WS_OPEN);
|
||||||
|
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||||
|
throw new Error("send failed");
|
||||||
|
});
|
||||||
|
sessionManager.attachWs(id, "owner-1", throwingWs);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
sessionManager.broadcast(id, { type: "data", data: "x" }),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcast is a no-op for a nonexistent session", () => {
|
||||||
|
expect(() =>
|
||||||
|
sessionManager.broadcast("does-not-exist", { type: "data" }),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owner detach arms the idle timeout (existing behavior)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
sessionManager.detachWs(id);
|
||||||
|
const session = sessionManager.getSession(id);
|
||||||
|
expect(session?.detachTimeout).not.toBeNull();
|
||||||
|
expect(session?.lastDetachedAt).not.toBeNull();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.removeParticipant(id, guestWs);
|
||||||
|
|
||||||
|
const session = sessionManager.getSession(id);
|
||||||
|
expect(session).not.toBeNull();
|
||||||
|
expect(session?.detachTimeout).toBeNull();
|
||||||
|
expect(session?.participants.size).toBe(1);
|
||||||
|
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
sessionManager.removeParticipant(id, ownerWs);
|
||||||
|
|
||||||
|
const session = sessionManager.getSession(id);
|
||||||
|
expect(session?.participants.size).toBe(1);
|
||||||
|
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroySession cleans up all participants, not just the owner", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.destroySession(id);
|
||||||
|
|
||||||
|
expect(guestWs.send).toHaveBeenCalled();
|
||||||
|
expect(sessionManager.getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
|
||||||
|
const id = createConnectedSession();
|
||||||
|
const ownerWs = makeFakeWs();
|
||||||
|
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||||
|
|
||||||
|
const guestWs = makeFakeWs();
|
||||||
|
sessionManager.joinAsParticipant(id, guestWs, {
|
||||||
|
userId: null,
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionManager.ownerEndSession(id, "owner ended the session");
|
||||||
|
|
||||||
|
expect(guestWs.send).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "sessionTerminatedByOwner",
|
||||||
|
reason: "owner ended the session",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sessionManager.getSession(id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isMessageAllowedForParticipant", () => {
|
||||||
|
it("allows any message type for the owner or when there is no participant", () => {
|
||||||
|
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: true, permissionLevel: "read-write" },
|
||||||
|
"resize",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops input from a read-only participant", () => {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-only" },
|
||||||
|
"input",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows input from a read-write non-owner participant", () => {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-write" },
|
||||||
|
"input",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows ping and disconnect for any non-owner participant", () => {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-only" },
|
||||||
|
"ping",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-only" },
|
||||||
|
"disconnect",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
|
||||||
|
for (const type of [
|
||||||
|
"resize",
|
||||||
|
"totp_response",
|
||||||
|
"password_response",
|
||||||
|
"tmux_attach",
|
||||||
|
"tmux_detach",
|
||||||
|
"get_cwd",
|
||||||
|
"vault_start_auth",
|
||||||
|
"opkssh_start_auth",
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
isMessageAllowedForParticipant(
|
||||||
|
{ isOwner: false, permissionLevel: "read-write" },
|
||||||
|
type,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ const ElectronVersionCheck = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=<linkToken>).
|
||||||
|
// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify.
|
||||||
|
const SharedSessionView = lazy(
|
||||||
|
() => import("@/features/session-sharing/SharedSessionView"),
|
||||||
|
);
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
| "verifying"
|
| "verifying"
|
||||||
| "idle-auth"
|
| "idle-auth"
|
||||||
@@ -322,6 +328,16 @@ function RootApp() {
|
|||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const isFullscreen = searchParams.has("view");
|
const isFullscreen = searchParams.has("view");
|
||||||
|
|
||||||
|
// Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's
|
||||||
|
// auth check entirely rather than waiting on a getUserInfo() call that would always fail.
|
||||||
|
if (searchParams.get("view") === "shared") {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<SharedSessionView />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export interface Host {
|
|||||||
enableDocker: boolean;
|
enableDocker: boolean;
|
||||||
enableProxmox: boolean;
|
enableProxmox: boolean;
|
||||||
enableTmuxMonitor: boolean;
|
enableTmuxMonitor: boolean;
|
||||||
|
allowSessionSharing?: boolean;
|
||||||
proxmoxConfig?: ProxmoxConfig | null;
|
proxmoxConfig?: ProxmoxConfig | null;
|
||||||
showTerminalInSidebar: boolean;
|
showTerminalInSidebar: boolean;
|
||||||
showFileManagerInSidebar: boolean;
|
showFileManagerInSidebar: boolean;
|
||||||
@@ -272,6 +273,7 @@ export interface HostData {
|
|||||||
enableDocker?: boolean;
|
enableDocker?: boolean;
|
||||||
enableProxmox?: boolean;
|
enableProxmox?: boolean;
|
||||||
enableTmuxMonitor?: boolean;
|
enableTmuxMonitor?: boolean;
|
||||||
|
allowSessionSharing?: boolean;
|
||||||
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
|
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
|
||||||
showTerminalInSidebar?: boolean;
|
showTerminalInSidebar?: boolean;
|
||||||
showFileManagerInSidebar?: boolean;
|
showFileManagerInSidebar?: boolean;
|
||||||
|
|||||||
@@ -281,6 +281,9 @@ export type Tab = {
|
|||||||
host?: Host;
|
host?: Host;
|
||||||
openedAt: number;
|
openedAt: number;
|
||||||
restoredSessionId?: string | null;
|
restoredSessionId?: string | null;
|
||||||
|
/** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
|
||||||
|
joinSharedSessionId?: string | null;
|
||||||
|
joinShareId?: string | null;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
serialConfig?: SerialConfig;
|
serialConfig?: SerialConfig;
|
||||||
terminalRef?: import("react").RefObject<{
|
terminalRef?: import("react").RefObject<{
|
||||||
@@ -291,6 +294,8 @@ export type Tab = {
|
|||||||
fit?: () => void;
|
fit?: () => void;
|
||||||
notifyResize?: () => void;
|
notifyResize?: () => void;
|
||||||
getApplicationCursorKeysMode?: () => boolean;
|
getApplicationCursorKeysMode?: () => boolean;
|
||||||
|
openShareModal?: () => void;
|
||||||
|
canShare?: () => boolean;
|
||||||
} | null>;
|
} | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+71
-4
@@ -272,10 +272,7 @@ export function AppShell({
|
|||||||
if (id == null) return null;
|
if (id == null) return null;
|
||||||
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
return tabs.find((t) => t.id === id)?.instanceId ?? null;
|
||||||
});
|
});
|
||||||
localStorage.setItem(
|
localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds));
|
||||||
"termix_paneInstanceIds",
|
|
||||||
JSON.stringify(instanceIds),
|
|
||||||
);
|
|
||||||
}, [paneTabIds, tabs]);
|
}, [paneTabIds, tabs]);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -1056,6 +1053,8 @@ export function AppShell({
|
|||||||
savedLabel?: string;
|
savedLabel?: string;
|
||||||
initialFilePath?: string;
|
initialFilePath?: string;
|
||||||
serialConfig?: SerialConfig;
|
serialConfig?: SerialConfig;
|
||||||
|
joinSharedSessionId?: string | null;
|
||||||
|
joinShareId?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||||
@@ -1072,6 +1071,8 @@ export function AppShell({
|
|||||||
const savedLabel = restore?.savedLabel;
|
const savedLabel = restore?.savedLabel;
|
||||||
const initialFilePath = restore?.initialFilePath;
|
const initialFilePath = restore?.initialFilePath;
|
||||||
const serialConfig = restore?.serialConfig;
|
const serialConfig = restore?.serialConfig;
|
||||||
|
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
|
||||||
|
const joinShareId = restore?.joinShareId ?? null;
|
||||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||||
const isCustomLabel =
|
const isCustomLabel =
|
||||||
savedLabel != null &&
|
savedLabel != null &&
|
||||||
@@ -1093,6 +1094,8 @@ export function AppShell({
|
|||||||
openedAt,
|
openedAt,
|
||||||
terminalRef: ref,
|
terminalRef: ref,
|
||||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
joinSharedSessionId,
|
||||||
|
joinShareId,
|
||||||
initialFilePath,
|
initialFilePath,
|
||||||
serialConfig,
|
serialConfig,
|
||||||
},
|
},
|
||||||
@@ -1125,6 +1128,8 @@ export function AppShell({
|
|||||||
openedAt,
|
openedAt,
|
||||||
terminalRef: ref,
|
terminalRef: ref,
|
||||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||||
|
joinSharedSessionId,
|
||||||
|
joinShareId,
|
||||||
initialFilePath,
|
initialFilePath,
|
||||||
serialConfig,
|
serialConfig,
|
||||||
},
|
},
|
||||||
@@ -1380,6 +1385,17 @@ export function AppShell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openShareForTab(id: string) {
|
||||||
|
const tab = tabs.find((t) => t.id === id);
|
||||||
|
if (!tab) return;
|
||||||
|
const ref = tab.terminalRef?.current;
|
||||||
|
if (ref?.canShare?.()) {
|
||||||
|
ref.openShareModal?.();
|
||||||
|
} else {
|
||||||
|
toast.error(t("sessionSharing.notReadyToShare"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeTab(id: string) {
|
function closeTab(id: string) {
|
||||||
const tab = tabs.find((t) => t.id === id);
|
const tab = tabs.find((t) => t.id === id);
|
||||||
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
|
||||||
@@ -1725,6 +1741,56 @@ export function AppShell({
|
|||||||
}}
|
}}
|
||||||
onRenameTab={renameTab}
|
onRenameTab={renameTab}
|
||||||
onReorderTabs={setTabs}
|
onReorderTabs={setTabs}
|
||||||
|
onJoinSharedSession={(session) => {
|
||||||
|
if (!session.shareId) return;
|
||||||
|
const existingHost = allHosts.find(
|
||||||
|
(h) => h.id === String(session.hostId),
|
||||||
|
);
|
||||||
|
const host: Host = existingHost ?? {
|
||||||
|
id: String(session.hostId),
|
||||||
|
name: session.hostName,
|
||||||
|
username: "",
|
||||||
|
ip: "",
|
||||||
|
port: 0,
|
||||||
|
folder: "",
|
||||||
|
online: false,
|
||||||
|
cpu: null,
|
||||||
|
ram: null,
|
||||||
|
lastAccess: new Date().toISOString(),
|
||||||
|
authType: "none",
|
||||||
|
enableTerminal: false,
|
||||||
|
enableCommandHistory: false,
|
||||||
|
enableTunnel: false,
|
||||||
|
enableFileManager: false,
|
||||||
|
enableDocker: false,
|
||||||
|
enableProxmox: false,
|
||||||
|
enableTmuxMonitor: false,
|
||||||
|
enableSsh: false,
|
||||||
|
enableRdp: false,
|
||||||
|
enableVnc: false,
|
||||||
|
enableTelnet: false,
|
||||||
|
sshPort: 22,
|
||||||
|
rdpPort: 3389,
|
||||||
|
vncPort: 5900,
|
||||||
|
telnetPort: 23,
|
||||||
|
serverTunnels: [],
|
||||||
|
quickActions: [],
|
||||||
|
};
|
||||||
|
const instanceId =
|
||||||
|
typeof crypto.randomUUID === "function"
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
openTab(host, "terminal", {
|
||||||
|
instanceId,
|
||||||
|
restoredSessionId: null,
|
||||||
|
joinSharedSessionId: session.sessionId,
|
||||||
|
joinShareId: session.shareId,
|
||||||
|
savedLabel: t("connections.sharedSessionLabel", {
|
||||||
|
hostName: session.hostName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1885,6 +1951,7 @@ export function AppShell({
|
|||||||
const targetTab = tabs.find((t) => t.id === tabId);
|
const targetTab = tabs.find((t) => t.id === tabId);
|
||||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||||
}}
|
}}
|
||||||
|
onOpenShare={openShareForTab}
|
||||||
isAppFullscreen={isAppFullscreen}
|
isAppFullscreen={isAppFullscreen}
|
||||||
onToggleAppFullscreen={toggleAppFullscreen}
|
onToggleAppFullscreen={toggleAppFullscreen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
|
|||||||
|
|
||||||
export interface GuacamoleTokenResponse {
|
export interface GuacamoleTokenResponse {
|
||||||
token: string;
|
token: string;
|
||||||
|
guacamoleConnectionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GuacamoleConfigSource = {
|
type GuacamoleConfigSource = {
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export interface ActiveSessionInfo {
|
|||||||
tabInstanceId: string | null;
|
tabInstanceId: string | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
isOwnSession: boolean;
|
||||||
|
sharedByUsername: string | null;
|
||||||
|
permissionLevel: string | null;
|
||||||
|
shareId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
const activeSessionsCache = createTtlRequestCache<ActiveSessionInfo[]>(2_000);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { getBasePath } from "@/lib/base-path";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { authApi, getServerConfig, handleApiError } from "@/main-axios";
|
||||||
|
|
||||||
|
export interface ResolvedShareLink {
|
||||||
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
permissionLevel: "read-only" | "read-write";
|
||||||
|
wsPath: string;
|
||||||
|
connectParams?: { token: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
||||||
|
|
||||||
|
export class ShareLinkError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly kind: ShareLinkErrorKind,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ShareLinkError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = (): boolean =>
|
||||||
|
!isElectron() &&
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
(window.location.port === "3000" ||
|
||||||
|
window.location.port === "5173" ||
|
||||||
|
window.location.port === "");
|
||||||
|
|
||||||
|
// Guests have no session/JWT, so this deliberately builds a bare base URL
|
||||||
|
// rather than going through main-axios's authenticated instances.
|
||||||
|
async function resolveApiBaseUrl(): Promise<string> {
|
||||||
|
if (isDev()) {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||||
|
return `${protocol}://localhost:30001`;
|
||||||
|
}
|
||||||
|
if (isElectron()) {
|
||||||
|
const serverConfig = await getServerConfig();
|
||||||
|
const configuredUrl = serverConfig?.serverUrl;
|
||||||
|
if (configuredUrl) return configuredUrl.replace(/\/$/, "");
|
||||||
|
return "http://localhost:30001";
|
||||||
|
}
|
||||||
|
return getBasePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveShareLink(
|
||||||
|
linkToken: string,
|
||||||
|
): Promise<ResolvedShareLink> {
|
||||||
|
const baseUrl = await resolveApiBaseUrl();
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
throw new ShareLinkError(
|
||||||
|
"Share link is invalid, expired, or revoked",
|
||||||
|
"not-found",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 429) {
|
||||||
|
throw new ShareLinkError(
|
||||||
|
"Too many attempts, please try again shortly",
|
||||||
|
"rate-limited",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SESSION SHARING (authenticated owner-side API)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
export type SessionShareType = "link" | "user";
|
||||||
|
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||||
|
|
||||||
|
export interface SessionShareRecord {
|
||||||
|
id: string;
|
||||||
|
hostId: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
protocol: SessionShareProtocol;
|
||||||
|
sessionId: string;
|
||||||
|
tabInstanceId: string | null;
|
||||||
|
shareType: SessionShareType;
|
||||||
|
targetUserId: string | null;
|
||||||
|
linkToken: string | null;
|
||||||
|
permissionLevel: SessionSharePermissionLevel;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
lastJoinedAt: string | null;
|
||||||
|
joinCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionShareRequest {
|
||||||
|
hostId: number;
|
||||||
|
sessionId: string;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
protocol: SessionShareProtocol;
|
||||||
|
shareType: SessionShareType;
|
||||||
|
targetUserId?: string;
|
||||||
|
permissionLevel: SessionSharePermissionLevel;
|
||||||
|
expiryHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSessionShareResponse {
|
||||||
|
shareId: string;
|
||||||
|
linkToken: string | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionShare(
|
||||||
|
request: CreateSessionShareRequest,
|
||||||
|
): Promise<CreateSessionShareResponse> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post("/session-sharing/create", request);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "create session share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveSessionShares(
|
||||||
|
hostId: number,
|
||||||
|
): Promise<{ shares: SessionShareRecord[] }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get(
|
||||||
|
`/session-sharing/host/${hostId}/active`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch active session shares");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSessionShare(
|
||||||
|
shareId: string,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.delete(`/session-sharing/${shareId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "revoke session share");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSessionShareSession(
|
||||||
|
shareId: string,
|
||||||
|
): Promise<{ success: true }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(`/session-sharing/${shareId}/end`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "end shared session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GLOBAL ADMIN TOGGLE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getSessionSharingGloballyEnabled(): Promise<{
|
||||||
|
enabled: boolean;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/session-sharing-enabled");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "fetch session sharing enabled setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSessionSharingGloballyEnabled(
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ enabled: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.patch("/users/session-sharing-enabled", {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "update session sharing enabled setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/dialog.tsx";
|
} from "@/components/dialog.tsx";
|
||||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||||
|
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||||
import type { SSHHost } from "@/types";
|
import type { SSHHost } from "@/types";
|
||||||
|
|
||||||
interface GuacamoleAppProps {
|
interface GuacamoleAppProps {
|
||||||
@@ -41,6 +42,8 @@ interface GuacamoleAppProps {
|
|||||||
export interface GuacamoleAppHandle {
|
export interface GuacamoleAppHandle {
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
isConnected: () => boolean;
|
isConnected: () => boolean;
|
||||||
|
openShareModal: () => void;
|
||||||
|
canShare: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
|
||||||
@@ -124,6 +127,10 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
) {
|
) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
@@ -152,6 +159,8 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
disconnect: () => displayRef.current?.disconnect(),
|
disconnect: () => displayRef.current?.disconnect(),
|
||||||
isConnected: () => displayRef.current?.isConnected() === true,
|
isConnected: () => displayRef.current?.isConnected() === true,
|
||||||
|
openShareModal: () => setShareModalOpen(true),
|
||||||
|
canShare: () => guacamoleConnectionId !== null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -161,6 +170,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
}
|
}
|
||||||
|
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setGuacamoleConnectionId(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
getGuacdStatus()
|
getGuacdStatus()
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
@@ -177,6 +187,7 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
setToken(result.token);
|
setToken(result.token);
|
||||||
|
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||||
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
@@ -380,6 +391,16 @@ const GuacamoleAppInner = React.forwardRef<
|
|||||||
touchMode={touchMode}
|
touchMode={touchMode}
|
||||||
onTouchModeChange={setTouchMode}
|
onTouchModeChange={setTouchMode}
|
||||||
/>
|
/>
|
||||||
|
{shareModalOpen && guacamoleConnectionId && (
|
||||||
|
<ShareSessionModal
|
||||||
|
open={shareModalOpen}
|
||||||
|
onClose={() => setShareModalOpen(false)}
|
||||||
|
hostId={hostId}
|
||||||
|
sessionId={guacamoleConnectionId}
|
||||||
|
protocol={resolvedProtocol}
|
||||||
|
tabInstanceId={tabId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
|
import { Input } from "@/components/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/dropdown-menu";
|
||||||
|
import { getUserList } from "@/main-axios";
|
||||||
|
import {
|
||||||
|
createSessionShare,
|
||||||
|
getActiveSessionShares,
|
||||||
|
revokeSessionShare,
|
||||||
|
type SessionShareProtocol,
|
||||||
|
type SessionSharePermissionLevel,
|
||||||
|
type SessionShareRecord,
|
||||||
|
} from "@/api/session-sharing-api";
|
||||||
|
|
||||||
|
const EXPIRY_PRESETS = [
|
||||||
|
{ key: "oneHour", hours: 1 },
|
||||||
|
{ key: "oneDay", hours: 24 },
|
||||||
|
{ key: "sevenDays", hours: 24 * 7 },
|
||||||
|
{ key: "thirtyDays", hours: 24 * 30 },
|
||||||
|
{ key: "custom", hours: undefined },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
|
||||||
|
|
||||||
|
export function ShareSessionModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
protocol,
|
||||||
|
tabInstanceId,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
hostId: number;
|
||||||
|
sessionId: string | null;
|
||||||
|
protocol: SessionShareProtocol;
|
||||||
|
tabInstanceId?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [mode, setMode] = useState<"link" | "user">("link");
|
||||||
|
const [permissionLevel, setPermissionLevel] =
|
||||||
|
useState<SessionSharePermissionLevel>("read-only");
|
||||||
|
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("oneDay");
|
||||||
|
const [customHours, setCustomHours] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [createdLink, setCreatedLink] = useState<string | null>(null);
|
||||||
|
const [shares, setShares] = useState<SessionShareRecord[]>([]);
|
||||||
|
const [sharesLoaded, setSharesLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setMode("link");
|
||||||
|
setPermissionLevel("read-only");
|
||||||
|
setExpiryPreset("oneDay");
|
||||||
|
setCustomHours("");
|
||||||
|
setSearch("");
|
||||||
|
setSelectedUserId(null);
|
||||||
|
setCreatedLink(null);
|
||||||
|
setSharesLoaded(false);
|
||||||
|
setShares([]);
|
||||||
|
}, [open, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || sharesLoaded) return;
|
||||||
|
setSharesLoaded(true);
|
||||||
|
Promise.all([
|
||||||
|
getUserList().catch(() => ({ users: [] })),
|
||||||
|
getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
|
||||||
|
]).then(([usersRes, sharesRes]) => {
|
||||||
|
setUsers(
|
||||||
|
(usersRes.users ?? []).map((u) => ({
|
||||||
|
id: String(u.userId),
|
||||||
|
username: u.username,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setShares(sharesRes.shares ?? []);
|
||||||
|
});
|
||||||
|
}, [open, hostId, sharesLoaded]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return q
|
||||||
|
? users.filter((u) => u.username.toLowerCase().includes(q))
|
||||||
|
: users;
|
||||||
|
}, [users, search]);
|
||||||
|
|
||||||
|
const expiryHours = (() => {
|
||||||
|
if (expiryPreset === "custom") {
|
||||||
|
const hours = Number(customHours);
|
||||||
|
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
|
||||||
|
}
|
||||||
|
return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function refreshShares() {
|
||||||
|
try {
|
||||||
|
const res = await getActiveSessionShares(hostId);
|
||||||
|
setShares(res.shares ?? []);
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!sessionId) return;
|
||||||
|
if (mode === "user" && !selectedUserId) return;
|
||||||
|
if (expiryPreset === "custom" && !expiryHours) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await createSessionShare({
|
||||||
|
hostId,
|
||||||
|
sessionId,
|
||||||
|
tabInstanceId,
|
||||||
|
protocol,
|
||||||
|
shareType: mode,
|
||||||
|
targetUserId:
|
||||||
|
mode === "user" ? (selectedUserId ?? undefined) : undefined,
|
||||||
|
permissionLevel,
|
||||||
|
expiryHours,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === "link" && result.linkToken) {
|
||||||
|
const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
|
||||||
|
setCreatedLink(url);
|
||||||
|
toast.success(t("sessionSharing.linkCreated"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("sessionSharing.shareCreated"));
|
||||||
|
setSelectedUserId(null);
|
||||||
|
}
|
||||||
|
await refreshShares();
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number })?.status;
|
||||||
|
if (mode === "user" && status === 403) {
|
||||||
|
toast.error(t("sessionSharing.userLacksHostAccess"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("sessionSharing.shareFailed"));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyLink() {
|
||||||
|
if (!createdLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(createdLink);
|
||||||
|
toast.success(t("sessionSharing.linkCopied"));
|
||||||
|
} catch {
|
||||||
|
// clipboard API unavailable, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(shareId: string) {
|
||||||
|
try {
|
||||||
|
await revokeSessionShare(shareId);
|
||||||
|
setShares((prev) => prev.filter((s) => s.id !== shareId));
|
||||||
|
toast.success(t("sessionSharing.revoked"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("sessionSharing.revokeFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("sessionSharing.modalTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{mode === "link"
|
||||||
|
? t("sessionSharing.linkModeDescription")
|
||||||
|
: t("sessionSharing.userModeDescription")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{(["link", "user"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(m);
|
||||||
|
setCreatedLink(null);
|
||||||
|
}}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||||
|
>
|
||||||
|
{m === "link" ? (
|
||||||
|
<Link2 className="size-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<User className="size-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
{m === "link"
|
||||||
|
? t("sessionSharing.modeTab.link")
|
||||||
|
: t("sessionSharing.modeTab.user")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "user" && (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("sessionSharing.searchUsersPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col border border-border h-28 overflow-y-auto">
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||||
|
{t("sessionSharing.noUsersFound")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => {
|
||||||
|
const isSelected = selectedUserId === user.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => setSelectedUserId(user.id)}
|
||||||
|
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<Check className="size-2.5 text-background" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<User className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">{user.username}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t("sessionSharing.permissionLevel.label")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={permissionLevel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPermissionLevel(
|
||||||
|
e.target.value as SessionSharePermissionLevel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="read-only">
|
||||||
|
{t("sessionSharing.permissionLevel.readOnly")}
|
||||||
|
</option>
|
||||||
|
<option value="read-write">
|
||||||
|
{t("sessionSharing.permissionLevel.readWrite")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex flex-col gap-1 shrink-0">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
|
||||||
|
{t("sessionSharing.expiryLabel")}
|
||||||
|
</span>
|
||||||
|
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
|
||||||
|
{t(`hosts.sharing.expiry.${expiryPreset}`)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="text-xs">
|
||||||
|
{EXPIRY_PRESETS.map((preset) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={preset.key}
|
||||||
|
onClick={() => setExpiryPreset(preset.key)}
|
||||||
|
>
|
||||||
|
{expiryPreset === preset.key ? (
|
||||||
|
<Check className="size-3 mr-1.5" />
|
||||||
|
) : (
|
||||||
|
<span className="size-3 mr-1.5" />
|
||||||
|
)}
|
||||||
|
{t(`hosts.sharing.expiry.${preset.key}`)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expiryPreset === "custom" && (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
autoFocus
|
||||||
|
placeholder={t("hosts.sharing.customHoursPlaceholder")}
|
||||||
|
value={customHours}
|
||||||
|
onChange={(e) => setCustomHours(e.target.value)}
|
||||||
|
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
{permissionLevel === "read-only"
|
||||||
|
? t("sessionSharing.permissionLevel.readOnlyDescription")
|
||||||
|
: t("sessionSharing.permissionLevel.readWriteDescription")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||||
|
disabled={
|
||||||
|
!sessionId ||
|
||||||
|
submitting ||
|
||||||
|
(mode === "user" && !selectedUserId) ||
|
||||||
|
(expiryPreset === "custom" && !expiryHours)
|
||||||
|
}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{mode === "link"
|
||||||
|
? t("sessionSharing.createLinkButton")
|
||||||
|
: t("sessionSharing.createShareButton")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{createdLink && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input readOnly value={createdLink} className="text-xs" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
title={t("sessionSharing.copyLink")}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0 border-t border-border pt-2">
|
||||||
|
<div className="flex items-center gap-1.5 pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
<Users className="size-3.5" />
|
||||||
|
{t("sessionSharing.activeShares")}
|
||||||
|
{shares.length > 0 && (
|
||||||
|
<span className="text-muted-foreground/40">
|
||||||
|
({shares.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col max-h-40 overflow-y-auto">
|
||||||
|
{shares.length === 0 && (
|
||||||
|
<div className="px-1 py-4 text-xs text-muted-foreground/50 text-center">
|
||||||
|
{t("sessionSharing.noActiveShares")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shares.map((share) => {
|
||||||
|
const targetUser = users.find(
|
||||||
|
(u) => u.id === share.targetUserId,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={share.id}
|
||||||
|
className="flex items-center justify-between gap-2 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{share.shareType === "link" ? (
|
||||||
|
<Link2 className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{share.shareType === "link"
|
||||||
|
? t("sessionSharing.linkShareBadge")
|
||||||
|
: t("sessionSharing.userShareBadge", {
|
||||||
|
username:
|
||||||
|
targetUser?.username ??
|
||||||
|
share.targetUserId ??
|
||||||
|
"?",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
{share.permissionLevel === "read-write"
|
||||||
|
? t("sessionSharing.permissionLevel.readWrite")
|
||||||
|
: t("sessionSharing.permissionLevel.readOnly")}
|
||||||
|
{" · "}
|
||||||
|
{t("sessionSharing.expiresAt", {
|
||||||
|
date: new Date(share.expiresAt).toLocaleString(),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
||||||
|
onClick={() => handleRevoke(share.id)}
|
||||||
|
>
|
||||||
|
{t("sessionSharing.revoke")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useXTerm } from "react-xtermjs";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import { AlertCircle, Eye } from "lucide-react";
|
||||||
|
import {
|
||||||
|
resolveShareLink,
|
||||||
|
type ResolvedShareLink,
|
||||||
|
type ShareLinkErrorKind,
|
||||||
|
} from "@/api/session-sharing-api";
|
||||||
|
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||||
|
import { getBasePath } from "@/lib/base-path";
|
||||||
|
import { isElectron } from "@/lib/electron";
|
||||||
|
import { getServerConfig } from "@/main-axios";
|
||||||
|
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||||
|
|
||||||
|
const PING_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
interface TerminalWsMessage {
|
||||||
|
type: string;
|
||||||
|
data?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod).
|
||||||
|
// Duplicated rather than extracted from that file to avoid touching it here.
|
||||||
|
async function resolveTerminalWsBaseUrl(): Promise<string> {
|
||||||
|
const isDev =
|
||||||
|
!isElectron() &&
|
||||||
|
process.env.NODE_ENV === "development" &&
|
||||||
|
(window.location.port === "3000" ||
|
||||||
|
window.location.port === "5173" ||
|
||||||
|
window.location.port === "");
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||||
|
}
|
||||||
|
if (isElectron()) {
|
||||||
|
const serverConfig = await getServerConfig();
|
||||||
|
const configuredUrl = serverConfig?.serverUrl;
|
||||||
|
if (configuredUrl) {
|
||||||
|
const wsProtocol = configuredUrl.startsWith("https://")
|
||||||
|
? "wss://"
|
||||||
|
: "ws://";
|
||||||
|
const wsHost = configuredUrl
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||||
|
}
|
||||||
|
return "ws://127.0.0.1:30002";
|
||||||
|
}
|
||||||
|
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlyBadge({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-3 right-3 z-20 flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-elevated, rgba(0,0,0,0.6))",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
border: "1px solid var(--border-base)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="size-3.5" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CenteredMessage({
|
||||||
|
icon,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
message: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center h-full gap-4 w-full"
|
||||||
|
style={{ backgroundColor: "var(--bg-base)" }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<p
|
||||||
|
className="text-sm font-semibold text-center max-w-xs"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuestTerminalView({
|
||||||
|
share,
|
||||||
|
linkToken,
|
||||||
|
}: {
|
||||||
|
share: ResolvedShareLink;
|
||||||
|
linkToken: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||||
|
const [ended, setEnded] = useState<string | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminal || !xtermRef.current) return;
|
||||||
|
|
||||||
|
terminal.options.theme = { background: "#0c0d0b" };
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.open(xtermRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
||||||
|
resizeObserver.observe(xtermRef.current);
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
resolveTerminalWsBaseUrl().then((baseWsUrl) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const separator = baseWsUrl.includes("?") ? "&" : "?";
|
||||||
|
ws = new WebSocket(
|
||||||
|
`${baseWsUrl}${separator}shareToken=${encodeURIComponent(linkToken)}`,
|
||||||
|
);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
pingIntervalRef.current = setInterval(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "ping" }));
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let msg: TerminalWsMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case "data":
|
||||||
|
if (typeof msg.data === "string") terminal.write(msg.data);
|
||||||
|
break;
|
||||||
|
case "sessionExpired":
|
||||||
|
case "sessionTerminatedByOwner":
|
||||||
|
case "session_ended":
|
||||||
|
setEnded(t("sessionSharing.guestView.sessionEnded"));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (share.permissionLevel === "read-write") {
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "input", data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
|
||||||
|
ws?.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
};
|
||||||
|
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [terminal, linkToken]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
{share.permissionLevel === "read-only" && (
|
||||||
|
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
||||||
|
)}
|
||||||
|
{ended && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-30 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-base)" }}
|
||||||
|
>
|
||||||
|
<CenteredMessage
|
||||||
|
icon={
|
||||||
|
<AlertCircle
|
||||||
|
className="size-10"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
message={ended}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={xtermRef} className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!share.connectParams?.token) {
|
||||||
|
return (
|
||||||
|
<CenteredMessage
|
||||||
|
icon={
|
||||||
|
<AlertCircle
|
||||||
|
className="size-10"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
message={t("sessionSharing.guestView.linkInvalid")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
{share.permissionLevel === "read-only" && (
|
||||||
|
<ReadOnlyBadge label={t("sessionSharing.guestView.readOnlyBadge")} />
|
||||||
|
)}
|
||||||
|
{connectionError && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-30 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "var(--bg-base)" }}
|
||||||
|
>
|
||||||
|
<CenteredMessage
|
||||||
|
icon={
|
||||||
|
<AlertCircle
|
||||||
|
className="size-10"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
message={connectionError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GuacamoleDisplay
|
||||||
|
connectionConfig={{
|
||||||
|
token: share.connectParams.token,
|
||||||
|
protocol: share.protocol as "rdp" | "vnc" | "telnet",
|
||||||
|
type: share.protocol as "rdp" | "vnc" | "telnet",
|
||||||
|
}}
|
||||||
|
isVisible={true}
|
||||||
|
onError={(err) => setConnectionError(err)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharedSessionView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [share, setShare] = useState<ResolvedShareLink | null>(null);
|
||||||
|
const [linkToken, setLinkToken] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get("token");
|
||||||
|
if (!token) {
|
||||||
|
setError(t("sessionSharing.guestView.linkInvalid"));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkToken(token);
|
||||||
|
|
||||||
|
resolveShareLink(token)
|
||||||
|
.then((resolved) => setShare(resolved))
|
||||||
|
.catch((err) => {
|
||||||
|
const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
|
||||||
|
if (kind === "rate-limited") {
|
||||||
|
setError(t("sessionSharing.guestView.rateLimited"));
|
||||||
|
} else {
|
||||||
|
setError(t("sessionSharing.guestView.linkInvalid"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex flex-col"
|
||||||
|
style={{ backgroundColor: "var(--bg-base)" }}
|
||||||
|
>
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
|
{loading && (
|
||||||
|
<SimpleLoader
|
||||||
|
visible={true}
|
||||||
|
message={t("sessionSharing.guestView.loading")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!loading && error && (
|
||||||
|
<CenteredMessage
|
||||||
|
icon={
|
||||||
|
<AlertCircle
|
||||||
|
className="size-10"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
message={error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
share &&
|
||||||
|
linkToken &&
|
||||||
|
(share.protocol === "ssh" ? (
|
||||||
|
<GuestTerminalView share={share} linkToken={linkToken} />
|
||||||
|
) : (
|
||||||
|
<GuestGuacamoleView share={share} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
||||||
|
import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
|
||||||
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||||
import {
|
import {
|
||||||
getNextTerminalFontSize,
|
getNextTerminalFontSize,
|
||||||
@@ -164,6 +165,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const pongReceivedRef = useRef(true);
|
const pongReceivedRef = useRef(true);
|
||||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||||
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
|
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
|
||||||
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
|
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
@@ -849,8 +851,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
onOpenFileManager?.("/");
|
onOpenFileManager?.("/");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openShareModal: () => setShareModalOpen(true),
|
||||||
|
canShare: () =>
|
||||||
|
isConnected &&
|
||||||
|
!isQuickConnect &&
|
||||||
|
!hostConfig.joinShareId &&
|
||||||
|
typeof hostConfig.id === "number",
|
||||||
}),
|
}),
|
||||||
[isConnected, terminal],
|
[
|
||||||
|
isConnected,
|
||||||
|
terminal,
|
||||||
|
isQuickConnect,
|
||||||
|
hostConfig.joinShareId,
|
||||||
|
hostConfig.id,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
function getUseRightClickCopyPaste() {
|
function getUseRightClickCopyPaste() {
|
||||||
@@ -1079,7 +1093,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const restoredSessionId = pendingRestoredSessionIdRef.current;
|
const restoredSessionId = pendingRestoredSessionIdRef.current;
|
||||||
pendingRestoredSessionIdRef.current = null;
|
pendingRestoredSessionIdRef.current = null;
|
||||||
|
|
||||||
if (restoredSessionId) {
|
if (hostConfig.joinShareId) {
|
||||||
|
isAttachingSessionRef.current = true;
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "joinSharedSession",
|
||||||
|
data: {
|
||||||
|
shareId: hostConfig.joinShareId,
|
||||||
|
tabInstanceId: hostConfig.instanceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (restoredSessionId) {
|
||||||
sessionIdRef.current = restoredSessionId;
|
sessionIdRef.current = restoredSessionId;
|
||||||
isAttachingSessionRef.current = true;
|
isAttachingSessionRef.current = true;
|
||||||
|
|
||||||
@@ -3189,6 +3215,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shareModalOpen && typeof hostConfig.id === "number" && (
|
||||||
|
<ShareSessionModal
|
||||||
|
open={shareModalOpen}
|
||||||
|
onClose={() => setShareModalOpen(false)}
|
||||||
|
hostId={hostConfig.id}
|
||||||
|
sessionId={sessionIdRef.current}
|
||||||
|
protocol="ssh"
|
||||||
|
tabInstanceId={hostConfig.instanceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export interface TerminalHostConfig {
|
|||||||
id?: number;
|
id?: number;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
restoredSessionId?: string | null;
|
restoredSessionId?: string | null;
|
||||||
|
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
|
||||||
|
joinSharedSessionId?: string | null;
|
||||||
|
joinShareId?: string | null;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -28,4 +31,6 @@ export interface TerminalHandle {
|
|||||||
notifyResize: () => void;
|
notifyResize: () => void;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
getApplicationCursorKeysMode: () => boolean;
|
getApplicationCursorKeysMode: () => boolean;
|
||||||
|
openShareModal: () => void;
|
||||||
|
canShare: () => boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-1
@@ -800,6 +800,8 @@
|
|||||||
"enableAutoTmuxDesc": "Automatically launch or attach to tmux session",
|
"enableAutoTmuxDesc": "Automatically launch or attach to tmux session",
|
||||||
"enableSessionLogging": "Session Logging",
|
"enableSessionLogging": "Session Logging",
|
||||||
"enableSessionLoggingDesc": "Record terminal session output for later review",
|
"enableSessionLoggingDesc": "Record terminal session output for later review",
|
||||||
|
"allowSessionSharing": "Allow Session Sharing",
|
||||||
|
"allowSessionSharingDesc": "Let live sessions on this host be shared via link or with other users",
|
||||||
"enableCommandHistory": "Command History",
|
"enableCommandHistory": "Command History",
|
||||||
"enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete",
|
"enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete",
|
||||||
"linkClickBehaviorLabel": "Link Click Behavior",
|
"linkClickBehaviorLabel": "Link Click Behavior",
|
||||||
@@ -1447,7 +1449,60 @@
|
|||||||
"expiresIn": "Expires in {{duration}}",
|
"expiresIn": "Expires in {{duration}}",
|
||||||
"search": "Search connections...",
|
"search": "Search connections...",
|
||||||
"noSearchResults": "No connections match your search",
|
"noSearchResults": "No connections match your search",
|
||||||
"rename": "Rename session"
|
"rename": "Rename session",
|
||||||
|
"sectionSharedWithMe": "Shared with me",
|
||||||
|
"sharedBy": "Shared by {{username}}",
|
||||||
|
"join": "Join",
|
||||||
|
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||||
|
},
|
||||||
|
"sessionSharing": {
|
||||||
|
"guestView": {
|
||||||
|
"loading": "Connecting to shared session...",
|
||||||
|
"linkInvalid": "This share link is invalid, expired, or has been revoked",
|
||||||
|
"rateLimited": "Too many attempts, please try again shortly",
|
||||||
|
"sessionEnded": "This session has ended",
|
||||||
|
"readOnlyBadge": "View only"
|
||||||
|
},
|
||||||
|
"modalTitle": "Share session",
|
||||||
|
"shareButton": "Share",
|
||||||
|
"notReadyToShare": "Session is not ready to share yet",
|
||||||
|
"modeTab": {
|
||||||
|
"link": "Link",
|
||||||
|
"user": "User"
|
||||||
|
},
|
||||||
|
"linkModeDescription": "Anyone with this link can join, no account required.",
|
||||||
|
"userModeDescription": "Share with a specific user who already has access to this host. If they do not have access yet, share the host with them first or use a link instead. Once shared, the session appears in their Connections tab.",
|
||||||
|
"permissionLevel": {
|
||||||
|
"label": "Permission level",
|
||||||
|
"readOnly": "Read-only",
|
||||||
|
"readOnlyDescription": "Can watch the session live but cannot type or interact.",
|
||||||
|
"readWrite": "Read-write",
|
||||||
|
"readWriteDescription": "Can type and interact with the session just like the owner."
|
||||||
|
},
|
||||||
|
"expiryLabel": "Link expiry",
|
||||||
|
"createLinkButton": "Create link",
|
||||||
|
"createShareButton": "Share with user",
|
||||||
|
"searchUsersPlaceholder": "Search users...",
|
||||||
|
"noUsersFound": "No users found",
|
||||||
|
"linkCreated": "Share link created",
|
||||||
|
"linkCopied": "Link copied to clipboard",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"shareCreated": "Session shared. It will appear in their Connections tab.",
|
||||||
|
"shareFailed": "Failed to create share",
|
||||||
|
"userLacksHostAccess": "That user does not have access to this host yet. Share the host with them first, or use a link instead.",
|
||||||
|
"activeShares": "Active shares",
|
||||||
|
"noActiveShares": "No active shares for this session",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokeConfirmTitle": "Revoke this share?",
|
||||||
|
"revokeConfirmDescription": "Anyone using this share will lose access immediately.",
|
||||||
|
"revoked": "Share revoked",
|
||||||
|
"revokeFailed": "Failed to revoke share",
|
||||||
|
"joinCount": "{{count}} join",
|
||||||
|
"joinCount_other": "{{count}} joins",
|
||||||
|
"expiresAt": "Expires {{date}}",
|
||||||
|
"linkShareBadge": "Link",
|
||||||
|
"userShareBadge": "User: {{username}}",
|
||||||
|
"loadSharesFailed": "Failed to load active shares"
|
||||||
},
|
},
|
||||||
"guacamole": {
|
"guacamole": {
|
||||||
"connecting": "Connecting to {{type}} session...",
|
"connecting": "Connecting to {{type}} session...",
|
||||||
@@ -2714,6 +2769,9 @@
|
|||||||
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
"analyticsEnabled": "Share Anonymous Usage Statistics",
|
||||||
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
|
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
|
||||||
"updateAnalyticsFailed": "Failed to update analytics setting",
|
"updateAnalyticsFailed": "Failed to update analytics setting",
|
||||||
|
"sessionSharingGloballyEnabled": "Allow Session Sharing",
|
||||||
|
"sessionSharingGloballyEnabledDesc": "Allow live terminal, RDP, VNC, and Telnet sessions to be shared instance-wide. Overrides every per-host sharing toggle when disabled.",
|
||||||
|
"updateSessionSharingFailed": "Failed to update session sharing setting",
|
||||||
"sessionTimeout": "Session Timeout",
|
"sessionTimeout": "Session Timeout",
|
||||||
"hours": "hours",
|
"hours": "hours",
|
||||||
"sessionTimeoutRange": "Min 1h · Max 720h",
|
"sessionTimeoutRange": "Min 1h · Max 720h",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tabIcon } from "@/shell/tabUtils";
|
import { tabIcon } from "@/shell/tabUtils";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
@@ -42,6 +43,7 @@ export function TabBar({
|
|||||||
onRemoveFromSplit,
|
onRemoveFromSplit,
|
||||||
onRenameTab,
|
onRenameTab,
|
||||||
onOpenFileManager,
|
onOpenFileManager,
|
||||||
|
onOpenShare,
|
||||||
isAppFullscreen,
|
isAppFullscreen,
|
||||||
onToggleAppFullscreen,
|
onToggleAppFullscreen,
|
||||||
}: {
|
}: {
|
||||||
@@ -59,6 +61,7 @@ export function TabBar({
|
|||||||
onRemoveFromSplit: (tabId: string) => void;
|
onRemoveFromSplit: (tabId: string) => void;
|
||||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||||
onOpenFileManager?: (tabId: string) => void;
|
onOpenFileManager?: (tabId: string) => void;
|
||||||
|
onOpenShare?: (tabId: string) => void;
|
||||||
isAppFullscreen: boolean;
|
isAppFullscreen: boolean;
|
||||||
onToggleAppFullscreen: () => void;
|
onToggleAppFullscreen: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -352,6 +355,19 @@ export function TabBar({
|
|||||||
<RefreshCw className="size-3" />
|
<RefreshCw className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{CONNECTION_TAB_TYPES.includes(tab.type) && onOpenShare && (
|
||||||
|
<button
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenShare(tab.id);
|
||||||
|
}}
|
||||||
|
title={t("sessionSharing.shareButton")}
|
||||||
|
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Share2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ function TerminalTabContent({
|
|||||||
sshPort: host.sshPort ?? host.port,
|
sshPort: host.sshPort ?? host.port,
|
||||||
instanceId: tab.instanceId ?? tab.id,
|
instanceId: tab.instanceId ?? tab.id,
|
||||||
restoredSessionId: tab.restoredSessionId ?? null,
|
restoredSessionId: tab.restoredSessionId ?? null,
|
||||||
|
joinSharedSessionId: tab.joinSharedSessionId ?? null,
|
||||||
|
joinShareId: tab.joinShareId ?? null,
|
||||||
} as TerminalHostConfig
|
} as TerminalHostConfig
|
||||||
}
|
}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ import {
|
|||||||
updateAnalyticsEnabled,
|
updateAnalyticsEnabled,
|
||||||
type HostDefaults,
|
type HostDefaults,
|
||||||
} from "@/api/settings-api";
|
} from "@/api/settings-api";
|
||||||
|
import {
|
||||||
|
getSessionSharingGloballyEnabled,
|
||||||
|
updateSessionSharingGloballyEnabled,
|
||||||
|
} from "@/api/session-sharing-api";
|
||||||
import {
|
import {
|
||||||
getAcmeSslSettings,
|
getAcmeSslSettings,
|
||||||
updateAcmeSslSettings,
|
updateAcmeSslSettings,
|
||||||
@@ -130,6 +134,8 @@ export function AdminSettingsPanel({
|
|||||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||||
|
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
|
||||||
|
useState(true);
|
||||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||||
|
|
||||||
// SSO / auto-provision state
|
// SSO / auto-provision state
|
||||||
@@ -289,6 +295,7 @@ export function AdminSettingsPanel({
|
|||||||
tailscale,
|
tailscale,
|
||||||
cmdHistory,
|
cmdHistory,
|
||||||
analytics,
|
analytics,
|
||||||
|
sessionSharingEnabled,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
getRegistrationAllowed(),
|
getRegistrationAllowed(),
|
||||||
getPasswordLoginAllowed(),
|
getPasswordLoginAllowed(),
|
||||||
@@ -302,6 +309,7 @@ export function AdminSettingsPanel({
|
|||||||
getTailscaleSettings(),
|
getTailscaleSettings(),
|
||||||
getCommandHistoryEnabled(),
|
getCommandHistoryEnabled(),
|
||||||
getAnalyticsEnabled(),
|
getAnalyticsEnabled(),
|
||||||
|
getSessionSharingGloballyEnabled(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||||
@@ -336,6 +344,9 @@ export function AdminSettingsPanel({
|
|||||||
if (analytics.status === "fulfilled") {
|
if (analytics.status === "fulfilled") {
|
||||||
setAnalyticsEnabled(analytics.value.enabled);
|
setAnalyticsEnabled(analytics.value.enabled);
|
||||||
}
|
}
|
||||||
|
if (sessionSharingEnabled.status === "fulfilled") {
|
||||||
|
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// non-fatal
|
// non-fatal
|
||||||
}
|
}
|
||||||
@@ -454,6 +465,17 @@ export function AdminSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleSessionSharingGloballyEnabled() {
|
||||||
|
const newVal = !sessionSharingGloballyEnabled;
|
||||||
|
setSessionSharingGloballyEnabled(newVal);
|
||||||
|
try {
|
||||||
|
await updateSessionSharingGloballyEnabled(newVal);
|
||||||
|
} catch {
|
||||||
|
setSessionSharingGloballyEnabled(!newVal);
|
||||||
|
toast.error(t("admin.updateSessionSharingFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSaveSessionTimeout() {
|
async function handleSaveSessionTimeout() {
|
||||||
const hours = parseInt(sessionTimeout, 10);
|
const hours = parseInt(sessionTimeout, 10);
|
||||||
if (isNaN(hours) || hours < 1 || hours > 720) {
|
if (isNaN(hours) || hours < 1 || hours > 720) {
|
||||||
@@ -908,6 +930,10 @@ export function AdminSettingsPanel({
|
|||||||
onToggle={() => toggle("general")}
|
onToggle={() => toggle("general")}
|
||||||
analyticsEnabled={analyticsEnabled}
|
analyticsEnabled={analyticsEnabled}
|
||||||
handleToggleAnalytics={handleToggleAnalytics}
|
handleToggleAnalytics={handleToggleAnalytics}
|
||||||
|
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
|
||||||
|
handleToggleSessionSharingGloballyEnabled={
|
||||||
|
handleToggleSessionSharingGloballyEnabled
|
||||||
|
}
|
||||||
allowRegistration={allowRegistration}
|
allowRegistration={allowRegistration}
|
||||||
handleToggleRegistration={handleToggleRegistration}
|
handleToggleRegistration={handleToggleRegistration}
|
||||||
allowPasswordLogin={allowPasswordLogin}
|
allowPasswordLogin={allowPasswordLogin}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ type GeneralSettingsSectionProps = {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
analyticsEnabled: boolean;
|
analyticsEnabled: boolean;
|
||||||
handleToggleAnalytics: () => void;
|
handleToggleAnalytics: () => void;
|
||||||
|
sessionSharingGloballyEnabled: boolean;
|
||||||
|
handleToggleSessionSharingGloballyEnabled: () => void;
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
handleToggleRegistration: () => void;
|
handleToggleRegistration: () => void;
|
||||||
allowPasswordLogin: boolean;
|
allowPasswordLogin: boolean;
|
||||||
@@ -71,6 +73,8 @@ export function AdminGeneralSettingsSection({
|
|||||||
onToggle,
|
onToggle,
|
||||||
analyticsEnabled,
|
analyticsEnabled,
|
||||||
handleToggleAnalytics,
|
handleToggleAnalytics,
|
||||||
|
sessionSharingGloballyEnabled,
|
||||||
|
handleToggleSessionSharingGloballyEnabled,
|
||||||
allowRegistration,
|
allowRegistration,
|
||||||
handleToggleRegistration,
|
handleToggleRegistration,
|
||||||
allowPasswordLogin,
|
allowPasswordLogin,
|
||||||
@@ -120,6 +124,15 @@ export function AdminGeneralSettingsSection({
|
|||||||
>
|
>
|
||||||
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
|
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("admin.sessionSharingGloballyEnabled")}
|
||||||
|
description={t("admin.sessionSharingGloballyEnabledDesc")}
|
||||||
|
>
|
||||||
|
<AdminToggle
|
||||||
|
on={sessionSharingGloballyEnabled}
|
||||||
|
onToggle={handleToggleSessionSharingGloballyEnabled}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("admin.allowRegistration")}
|
label={t("admin.allowRegistration")}
|
||||||
description={t("admin.allowRegistrationDesc")}
|
description={t("admin.allowRegistrationDesc")}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { tabIcon } from "@/shell/tabUtils";
|
import { tabIcon } from "@/shell/tabUtils";
|
||||||
import type { Tab, TabType } from "@/types/ui-types";
|
import type { Tab, TabType } from "@/types/ui-types";
|
||||||
import { Badge } from "@/components/badge";
|
import { Badge } from "@/components/badge";
|
||||||
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -64,7 +65,11 @@ function sessionsUnchanged(
|
|||||||
a.hostName !== b.hostName ||
|
a.hostName !== b.hostName ||
|
||||||
a.tabInstanceId !== b.tabInstanceId ||
|
a.tabInstanceId !== b.tabInstanceId ||
|
||||||
a.isConnected !== b.isConnected ||
|
a.isConnected !== b.isConnected ||
|
||||||
a.createdAt !== b.createdAt
|
a.createdAt !== b.createdAt ||
|
||||||
|
a.isOwnSession !== b.isOwnSession ||
|
||||||
|
a.sharedByUsername !== b.sharedByUsername ||
|
||||||
|
a.permissionLevel !== b.permissionLevel ||
|
||||||
|
a.shareId !== b.shareId
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -289,6 +294,7 @@ export function ConnectionsPanel({
|
|||||||
onForgetBackground,
|
onForgetBackground,
|
||||||
onRenameTab,
|
onRenameTab,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
|
onJoinSharedSession,
|
||||||
}: {
|
}: {
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
activeTabId: string;
|
activeTabId: string;
|
||||||
@@ -303,6 +309,7 @@ export function ConnectionsPanel({
|
|||||||
onForgetBackground: (recordId: string) => void;
|
onForgetBackground: (recordId: string) => void;
|
||||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||||
onReorderTabs?: (tabs: Tab[]) => void;
|
onReorderTabs?: (tabs: Tab[]) => void;
|
||||||
|
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
@@ -344,6 +351,22 @@ export function ConnectionsPanel({
|
|||||||
})
|
})
|
||||||
: backgroundTabs;
|
: backgroundTabs;
|
||||||
|
|
||||||
|
const joinedInstanceIds = new Set(
|
||||||
|
tabs
|
||||||
|
.map((t) => (t.joinSharedSessionId ? t.instanceId : null))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const sharedWithMe = activeSessions.filter(
|
||||||
|
(s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId),
|
||||||
|
);
|
||||||
|
const filteredSharedWithMe = q
|
||||||
|
? sharedWithMe.filter(
|
||||||
|
(s) =>
|
||||||
|
s.hostName.toLowerCase().includes(q) ||
|
||||||
|
(s.sharedByUsername ?? "").toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: sharedWithMe;
|
||||||
|
|
||||||
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
|
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
|
||||||
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
|
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
|
||||||
|
|
||||||
@@ -432,9 +455,12 @@ export function ConnectionsPanel({
|
|||||||
activeSessions.map((s) => [s.tabInstanceId, s]),
|
activeSessions.map((s) => [s.tabInstanceId, s]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
|
const hasAnything =
|
||||||
|
openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0;
|
||||||
const hasResults =
|
const hasResults =
|
||||||
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
|
filteredOpenTabs.length > 0 ||
|
||||||
|
filteredBackgroundTabs.length > 0 ||
|
||||||
|
filteredSharedWithMe.length > 0;
|
||||||
|
|
||||||
if (!hasAnything) {
|
if (!hasAnything) {
|
||||||
return (
|
return (
|
||||||
@@ -593,6 +619,78 @@ export function ConnectionsPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{filteredSharedWithMe.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col ${filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`}
|
||||||
|
>
|
||||||
|
<SectionHeader
|
||||||
|
label={t("connections.sectionSharedWithMe")}
|
||||||
|
count={filteredSharedWithMe.length}
|
||||||
|
/>
|
||||||
|
{filteredSharedWithMe.map((session) => (
|
||||||
|
<SharedWithMeRow
|
||||||
|
key={session.sessionId}
|
||||||
|
session={session}
|
||||||
|
onJoin={() => onJoinSharedSession?.(session)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SharedWithMeRow({
|
||||||
|
session,
|
||||||
|
onJoin,
|
||||||
|
}: {
|
||||||
|
session: ActiveSessionInfo;
|
||||||
|
onJoin: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isReadWrite = session.permissionLevel === "read-write";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0">
|
||||||
|
<div className="shrink-0 flex items-center justify-center size-7 rounded bg-muted/60 text-muted-foreground">
|
||||||
|
{tabIcon("terminal")}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`shrink-0 size-1.5 rounded-full ${
|
||||||
|
session.isConnected ? "bg-green-500" : "bg-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold truncate flex-1 text-foreground">
|
||||||
|
{session.hostName}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[9px] px-1 py-0 h-4 font-mono shrink-0 border-border/60 ${
|
||||||
|
isReadWrite ? "text-accent-brand" : "text-muted-foreground/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isReadWrite
|
||||||
|
? t("sessionSharing.permissionLevel.readWrite")
|
||||||
|
: t("sessionSharing.permissionLevel.readOnly")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
|
||||||
|
{t("connections.sharedBy", {
|
||||||
|
username: session.sharedByUsername ?? "?",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] px-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={onJoin}
|
||||||
|
>
|
||||||
|
{t("connections.join")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ export function HostEditor({
|
|||||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||||
const [creatingQuickCredential, setCreatingQuickCredential] =
|
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
||||||
useState(false);
|
|
||||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||||
@@ -1254,9 +1253,7 @@ export function HostEditor({
|
|||||||
background: theme.colors.background,
|
background: theme.colors.background,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">
|
<span className="truncate">{theme.name}</span>
|
||||||
{theme.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1602,6 +1599,15 @@ export function HostEditor({
|
|||||||
onChange={(v) => setField("enableSessionLogging", v)}
|
onChange={(v) => setField("enableSessionLogging", v)}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("hosts.allowSessionSharing")}
|
||||||
|
description={t("hosts.allowSessionSharingDesc")}
|
||||||
|
>
|
||||||
|
<FakeSwitch
|
||||||
|
checked={form.allowSessionSharing}
|
||||||
|
onChange={(v) => setField("allowSessionSharing", v)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.enableCommandHistory")}
|
label={t("hosts.enableCommandHistory")}
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function createHostEditorForm(
|
|||||||
enableDocker: host?.enableDocker ?? false,
|
enableDocker: host?.enableDocker ?? false,
|
||||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||||
|
allowSessionSharing: host?.allowSessionSharing ?? true,
|
||||||
enableProxmox: host?.enableProxmox ?? false,
|
enableProxmox: host?.enableProxmox ?? false,
|
||||||
proxmoxConfig: host?.proxmoxConfig ?? {
|
proxmoxConfig: host?.proxmoxConfig ?? {
|
||||||
defaultCredentialId: null as number | null,
|
defaultCredentialId: null as number | null,
|
||||||
@@ -306,6 +307,7 @@ export function buildHostEditorPayload(
|
|||||||
enableDocker: form.enableDocker,
|
enableDocker: form.enableDocker,
|
||||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||||
|
allowSessionSharing: form.allowSessionSharing,
|
||||||
enableProxmox: form.enableProxmox,
|
enableProxmox: form.enableProxmox,
|
||||||
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
|
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
|
||||||
defaultPath: form.defaultPath || "/",
|
defaultPath: form.defaultPath || "/",
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const axiosGetMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: {
|
||||||
|
get: axiosGetMock,
|
||||||
|
isAxiosError: (err: unknown): err is { response?: { status?: number } } =>
|
||||||
|
typeof err === "object" && err !== null && "response" in err,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/base-path", () => ({
|
||||||
|
getBasePath: () => "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/electron", () => ({
|
||||||
|
isElectron: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/main-axios", () => ({
|
||||||
|
getServerConfig: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveShareLink,
|
||||||
|
ShareLinkError,
|
||||||
|
} from "../../api/session-sharing-api";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
axiosGetMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveShareLink", () => {
|
||||||
|
it("returns the resolved share data on success", async () => {
|
||||||
|
axiosGetMock.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
protocol: "ssh",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
wsPath: "/terminal/ws?shareToken=abc123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resolveShareLink("abc123");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
protocol: "ssh",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
wsPath: "/terminal/ws?shareToken=abc123",
|
||||||
|
});
|
||||||
|
expect(axiosGetMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/session-sharing/resolve/abc123"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a not-found ShareLinkError on 404", async () => {
|
||||||
|
axiosGetMock.mockRejectedValueOnce({ response: { status: 404 } });
|
||||||
|
|
||||||
|
await expect(resolveShareLink("bad-token")).rejects.toMatchObject({
|
||||||
|
kind: "not-found",
|
||||||
|
});
|
||||||
|
await expect(resolveShareLink("bad-token")).rejects.toBeInstanceOf(
|
||||||
|
ShareLinkError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a rate-limited ShareLinkError on 429", async () => {
|
||||||
|
axiosGetMock.mockRejectedValueOnce({ response: { status: 429 } });
|
||||||
|
|
||||||
|
await expect(resolveShareLink("token")).rejects.toMatchObject({
|
||||||
|
kind: "rate-limited",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a generic ShareLinkError on unexpected failures", async () => {
|
||||||
|
axiosGetMock.mockRejectedValueOnce(new Error("network down"));
|
||||||
|
|
||||||
|
await expect(resolveShareLink("token")).rejects.toMatchObject({
|
||||||
|
kind: "unknown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL-encodes the link token", async () => {
|
||||||
|
axiosGetMock.mockResolvedValueOnce({
|
||||||
|
data: { protocol: "ssh", permissionLevel: "read-only", wsPath: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolveShareLink("a/b c");
|
||||||
|
|
||||||
|
expect(axiosGetMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(encodeURIComponent("a/b c")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
createSessionShare: vi.fn(),
|
||||||
|
getActiveSessionShares: vi.fn(async () => ({ shares: [] })),
|
||||||
|
revokeSessionShare: vi.fn(async () => ({ success: true as const })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/session-sharing-api", () => api);
|
||||||
|
|
||||||
|
const mainAxios = vi.hoisted(() => ({
|
||||||
|
getUserList: vi.fn(async () => ({
|
||||||
|
users: [
|
||||||
|
{ userId: "u1", username: "alice" },
|
||||||
|
{ userId: "u2", username: "bob" },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/main-axios", () => mainAxios);
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
opts ? `${key}:${JSON.stringify(opts)}` : key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn(async () => {}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
import { ShareSessionModal } from "../../../features/session-sharing/ShareSessionModal";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
api.createSessionShare.mockReset();
|
||||||
|
api.getActiveSessionShares.mockReset();
|
||||||
|
api.getActiveSessionShares.mockResolvedValue({ shares: [] });
|
||||||
|
api.revokeSessionShare.mockReset();
|
||||||
|
mainAxios.getUserList.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ShareSessionModal", () => {
|
||||||
|
it("creates a link share with the default read-only permission and 24h expiry", async () => {
|
||||||
|
api.createSessionShare.mockResolvedValue({
|
||||||
|
shareId: "share-1",
|
||||||
|
linkToken: "tok-123",
|
||||||
|
expiresAt: "2026-07-21T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ShareSessionModal
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
hostId={42}
|
||||||
|
sessionId="sess-1"
|
||||||
|
protocol="ssh"
|
||||||
|
tabInstanceId="tab-1"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createButton = await screen.findByText(
|
||||||
|
"sessionSharing.createLinkButton",
|
||||||
|
);
|
||||||
|
fireEvent.click(createButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.createSessionShare).toHaveBeenCalledWith({
|
||||||
|
hostId: 42,
|
||||||
|
sessionId: "sess-1",
|
||||||
|
tabInstanceId: "tab-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
shareType: "link",
|
||||||
|
targetUserId: undefined,
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit a user share until a target user is selected", async () => {
|
||||||
|
render(
|
||||||
|
<ShareSessionModal
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
hostId={7}
|
||||||
|
sessionId="sess-2"
|
||||||
|
protocol="rdp"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
|
||||||
|
|
||||||
|
const shareButton = await screen.findByText(
|
||||||
|
"sessionSharing.createShareButton",
|
||||||
|
);
|
||||||
|
expect((shareButton as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(api.createSessionShare).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a user share with the selected target and read-write permission", async () => {
|
||||||
|
api.createSessionShare.mockResolvedValue({
|
||||||
|
shareId: "share-2",
|
||||||
|
linkToken: null,
|
||||||
|
expiresAt: "2026-07-21T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ShareSessionModal
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
hostId={7}
|
||||||
|
sessionId="sess-2"
|
||||||
|
protocol="vnc"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("sessionSharing.modeTab.user"));
|
||||||
|
fireEvent.click(await screen.findByText("alice"));
|
||||||
|
|
||||||
|
const select = await screen.findByDisplayValue(
|
||||||
|
"sessionSharing.permissionLevel.readOnly",
|
||||||
|
);
|
||||||
|
fireEvent.change(select, { target: { value: "read-write" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("sessionSharing.createShareButton"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.createSessionShare).toHaveBeenCalledWith({
|
||||||
|
hostId: 7,
|
||||||
|
sessionId: "sess-2",
|
||||||
|
tabInstanceId: undefined,
|
||||||
|
protocol: "vnc",
|
||||||
|
shareType: "user",
|
||||||
|
targetUserId: "u1",
|
||||||
|
permissionLevel: "read-write",
|
||||||
|
expiryHours: 24,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call create when there is no live session id", async () => {
|
||||||
|
render(
|
||||||
|
<ShareSessionModal
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
hostId={1}
|
||||||
|
sessionId={null}
|
||||||
|
protocol="ssh"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const createButton = await screen.findByText(
|
||||||
|
"sessionSharing.createLinkButton",
|
||||||
|
);
|
||||||
|
expect((createButton as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads and renders active shares for the host", async () => {
|
||||||
|
api.getActiveSessionShares.mockResolvedValue({
|
||||||
|
shares: [
|
||||||
|
{
|
||||||
|
id: "share-x",
|
||||||
|
hostId: 42,
|
||||||
|
ownerUserId: "owner-1",
|
||||||
|
protocol: "ssh",
|
||||||
|
sessionId: "sess-1",
|
||||||
|
tabInstanceId: null,
|
||||||
|
shareType: "link",
|
||||||
|
targetUserId: null,
|
||||||
|
linkToken: "tok-abc",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
createdAt: "2026-07-20T00:00:00.000Z",
|
||||||
|
expiresAt: "2026-07-21T00:00:00.000Z",
|
||||||
|
revokedAt: null,
|
||||||
|
lastJoinedAt: null,
|
||||||
|
joinCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ShareSessionModal
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
hostId={42}
|
||||||
|
sessionId="sess-1"
|
||||||
|
protocol="ssh"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("sessionSharing.linkShareBadge")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
resolveShareLink: vi.fn(async () => {
|
||||||
|
throw new Error("resolveShareLink not mocked for this test");
|
||||||
|
}),
|
||||||
|
ShareLinkError: class ShareLinkError extends Error {
|
||||||
|
kind: string;
|
||||||
|
constructor(message: string, kind: string) {
|
||||||
|
super(message);
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api/session-sharing-api", () => api);
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-xtermjs", () => ({
|
||||||
|
useXTerm: () => ({
|
||||||
|
instance: {
|
||||||
|
loadAddon: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
onData: vi.fn(),
|
||||||
|
},
|
||||||
|
ref: { current: document.createElement("div") },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@xterm/addon-fit", () => ({
|
||||||
|
FitAddon: class FitAddon {
|
||||||
|
fit() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/guacamole/GuacamoleDisplay.tsx", () => ({
|
||||||
|
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import SharedSessionView from "../../../features/session-sharing/SharedSessionView";
|
||||||
|
|
||||||
|
function setSearch(search: string) {
|
||||||
|
window.history.pushState({}, "", `/?${search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
api.resolveShareLink.mockReset();
|
||||||
|
api.resolveShareLink.mockImplementation(async () => {
|
||||||
|
throw new Error("resolveShareLink not mocked for this test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SharedSessionView", () => {
|
||||||
|
it("shows a friendly error when no token is present in the URL", async () => {
|
||||||
|
setSearch("view=shared");
|
||||||
|
|
||||||
|
render(<SharedSessionView />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("sessionSharing.guestView.linkInvalid"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(api.resolveShareLink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a generic error when the link is invalid or expired", async () => {
|
||||||
|
setSearch("view=shared&token=bad");
|
||||||
|
api.resolveShareLink.mockRejectedValue(
|
||||||
|
new api.ShareLinkError("not found", "not-found"),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<SharedSessionView />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("sessionSharing.guestView.linkInvalid"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a rate-limit specific message on 429", async () => {
|
||||||
|
setSearch("view=shared&token=abc");
|
||||||
|
api.resolveShareLink.mockRejectedValue(
|
||||||
|
new api.ShareLinkError("slow down", "rate-limited"),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<SharedSessionView />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.resolveShareLink).toHaveBeenCalledWith("abc");
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("sessionSharing.guestView.rateLimited"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the guacamole display for a resolved rdp share", async () => {
|
||||||
|
setSearch("view=shared&token=abc");
|
||||||
|
api.resolveShareLink.mockResolvedValue({
|
||||||
|
protocol: "rdp",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
wsPath: "/guacamole/websocket/",
|
||||||
|
connectParams: { token: "guac-join-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SharedSessionView />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("guacamole-display")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByText("sessionSharing.guestView.readOnlyBadge"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import type { ActiveSessionInfo } from "@/api/open-tabs-api";
|
||||||
|
|
||||||
|
const mainAxios = vi.hoisted(() => ({
|
||||||
|
getActiveSessions: vi.fn(async () => [] as ActiveSessionInfo[]),
|
||||||
|
deleteOpenTab: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/main-axios", () => mainAxios);
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) =>
|
||||||
|
opts ? `${key}:${JSON.stringify(opts)}` : key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ConnectionsPanel } from "../../sidebar/ConnectionsPanel";
|
||||||
|
|
||||||
|
function sharedSession(
|
||||||
|
overrides: Partial<ActiveSessionInfo> = {},
|
||||||
|
): ActiveSessionInfo {
|
||||||
|
return {
|
||||||
|
sessionId: "sess-shared-1",
|
||||||
|
hostId: 5,
|
||||||
|
hostName: "prod-db",
|
||||||
|
tabInstanceId: null,
|
||||||
|
isConnected: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
isOwnSession: false,
|
||||||
|
sharedByUsername: "alice",
|
||||||
|
permissionLevel: "read-only",
|
||||||
|
shareId: "share-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mainAxios.getActiveSessions.mockReset();
|
||||||
|
mainAxios.getActiveSessions.mockResolvedValue([]);
|
||||||
|
mainAxios.deleteOpenTab.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConnectionsPanel - shared with me", () => {
|
||||||
|
it("renders a shared-with-me row for sessions the current user does not own", async () => {
|
||||||
|
mainAxios.getActiveSessions.mockResolvedValue([sharedSession()]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectionsPanel
|
||||||
|
tabs={[]}
|
||||||
|
activeTabId=""
|
||||||
|
allHosts={[]}
|
||||||
|
backgroundTabRecords={[]}
|
||||||
|
onSwitchToTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onReopenTab={() => {}}
|
||||||
|
onForgetBackground={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("connections.sectionSharedWithMe")).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("prod-db")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText('connections.sharedBy:{"username":"alice"}'),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show own sessions in the shared-with-me section", async () => {
|
||||||
|
mainAxios.getActiveSessions.mockResolvedValue([
|
||||||
|
sharedSession({
|
||||||
|
isOwnSession: true,
|
||||||
|
sharedByUsername: null,
|
||||||
|
shareId: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectionsPanel
|
||||||
|
tabs={[]}
|
||||||
|
activeTabId=""
|
||||||
|
allHosts={[]}
|
||||||
|
backgroundTabRecords={[]}
|
||||||
|
onSwitchToTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onReopenTab={() => {}}
|
||||||
|
onForgetBackground={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mainAxios.getActiveSessions).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("connections.sectionSharedWithMe")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches onJoinSharedSession with the session when Join is clicked", async () => {
|
||||||
|
const session = sharedSession();
|
||||||
|
mainAxios.getActiveSessions.mockResolvedValue([session]);
|
||||||
|
const onJoinSharedSession = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectionsPanel
|
||||||
|
tabs={[]}
|
||||||
|
activeTabId=""
|
||||||
|
allHosts={[]}
|
||||||
|
backgroundTabRecords={[]}
|
||||||
|
onSwitchToTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onReopenTab={() => {}}
|
||||||
|
onForgetBackground={() => {}}
|
||||||
|
onJoinSharedSession={onJoinSharedSession}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinButton = await screen.findByText("connections.join");
|
||||||
|
fireEvent.click(joinButton);
|
||||||
|
|
||||||
|
expect(onJoinSharedSession).toHaveBeenCalledWith(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a read-write badge for read-write shared sessions", async () => {
|
||||||
|
mainAxios.getActiveSessions.mockResolvedValue([
|
||||||
|
sharedSession({ permissionLevel: "read-write" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectionsPanel
|
||||||
|
tabs={[]}
|
||||||
|
activeTabId=""
|
||||||
|
allHosts={[]}
|
||||||
|
backgroundTabRecords={[]}
|
||||||
|
onSwitchToTab={() => {}}
|
||||||
|
onCloseTab={() => {}}
|
||||||
|
onReopenTab={() => {}}
|
||||||
|
onForgetBackground={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("sessionSharing.permissionLevel.readWrite"),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user