feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

@@ -3,6 +3,11 @@ import {
remapImportedJumpHosts,
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
keepUsableProtocolCredentials,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
@@ -707,13 +712,6 @@ export function registerHostBulkRoutes(
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
sshDataObj.rdpUser = hostData.rdpUser || null;
sshDataObj.rdpPassword = hostData.rdpPassword || null;
sshDataObj.rdpDomain = hostData.rdpDomain || null;
sshDataObj.vncUser = hostData.vncUser || null;
sshDataObj.vncPassword = hostData.vncPassword || null;
sshDataObj.telnetUser = hostData.telnetUser || null;
sshDataObj.telnetPassword = hostData.telnetPassword || null;
} else {
sshDataObj.password =
hostData.authType === "password" ? hostData.password : null;
@@ -725,7 +723,6 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyPassword || null : null;
sshDataObj.keyType =
hostData.authType === "key" ? hostData.keyType || "auto" : null;
sshDataObj.domain = null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
@@ -753,6 +750,18 @@ export function registerHostBulkRoutes(
results.success++;
}
const protocolAuth = readProtocolAuthPayload(
hostData as Record<string, unknown>,
);
if (protocolAuth) {
await writeProtocolAuth(
userId,
savedHostId,
await keepUsableProtocolCredentials(protocolAuth, userId),
{ isOwner: true },
);
}
// Every enabled plugin that declares host-scope settings and
// registered a hostImportNormalizer validates and writes its own
// fields here, so this loop does not need to know which plugins
+38 -45
View File
@@ -1,8 +1,9 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import {
parseSshOptions,
type HostSshOptions,
} from "../../hosts/ssh-options.js";
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -41,6 +42,10 @@ export function normalizeProtocolEnableFields(
);
}
/**
* The owner's SSH authentication, which a shared editor may not change. A
* plugin protocol's login is guarded in writeProtocolAuth instead.
*/
export const OWNER_PRIVATE_AUTH_FIELDS = {
ssh: [
"authType",
@@ -54,21 +59,7 @@ export const OWNER_PRIVATE_AUTH_FIELDS = {
"keyType",
"sudoPassword",
],
rdp: [
"rdpAuthType",
"rdpCredentialId",
"rdpUser",
"rdpPassword",
"rdpDomain",
],
vnc: ["vncAuthType", "vncCredentialId", "vncUser", "vncPassword"],
telnet: [
"telnetAuthType",
"telnetCredentialId",
"telnetUser",
"telnetPassword",
],
} as const satisfies Record<AuthOverrideProtocol, readonly string[]>;
} as const;
export const OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS = [
"sudoPassword",
@@ -80,7 +71,7 @@ export const OWNER_PRIVATE_SSH_OPTION_FIELDS = ["agentSocketPath"] as const;
export function containsOwnerPrivateAuthUpdate(
hostData: Record<string, unknown>,
protocol: AuthOverrideProtocol,
protocol: keyof typeof OWNER_PRIVATE_AUTH_FIELDS,
): boolean {
return OWNER_PRIVATE_AUTH_FIELDS[protocol].some((field) =>
Object.prototype.hasOwnProperty.call(hostData, field),
@@ -193,34 +184,41 @@ export type NormalizedImportedHost = Record<string, unknown> & {
socks5ProxyChain?: unknown;
portKnockSequence?: unknown;
overrideCredentialUsername?: unknown;
domain?: unknown;
enableSsh: boolean;
};
function legacyPrefix(protocolId: string): string {
return protocolId.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
}
function legacyKey(protocolId: string): string {
const prefix = legacyPrefix(protocolId);
return prefix.charAt(0).toUpperCase() + prefix.slice(1);
}
export function normalizeImportedHost(
hostData: Record<string, unknown>,
): NormalizedImportedHost {
const credentialAlias =
asString(hostData.credentialAlias) || asString(hostData.credentialName);
// A row with no connectionType names its protocol the way a 2.8 export
// did: an "enable<Protocol>" flag and a "<protocol>Port".
const flagged = listHostProtocols().find((protocol) =>
asBoolean(hostData[`enable${legacyKey(protocol.id)}`]),
);
const connectionType =
asString(hostData.connectionType) ||
(asBoolean(hostData.enableRdp)
? "rdp"
: asBoolean(hostData.enableVnc)
? "vnc"
: asBoolean(hostData.enableTelnet)
? "telnet"
: "ssh");
asString(hostData.connectionType) || flagged?.id || "ssh";
const protocol = listHostProtocols().find(
(entry) => entry.id === connectionType,
);
const port =
asPort(hostData.port) ||
(connectionType === "rdp"
? asPort(hostData.rdpPort) || 3389
: connectionType === "vnc"
? asPort(hostData.vncPort) || 5900
: connectionType === "telnet"
? asPort(hostData.telnetPort) || 23
: asPort(hostData.sshPort) || 22);
(protocol
? asPort(hostData[`${legacyPrefix(protocol.id)}Port`]) ||
protocol.defaultPort ||
22
: asPort(hostData.sshPort) || 22);
return {
...hostData,
@@ -260,9 +258,6 @@ const SENSITIVE_FIELDS = [
"password",
"sudoPassword",
"socks5Password",
"rdpPassword",
"vncPassword",
"telnetPassword",
"autostartPassword",
];
@@ -282,9 +277,6 @@ export function stripSensitiveFields(
// 2.8 editors kept the sudo password inside terminal_config.
result.hasSudoPassword =
!!host.sudoPassword || !!terminalConfigForSudo?.sudoPassword;
result.hasRdpPassword = !!host.rdpPassword;
result.hasVncPassword = !!host.vncPassword;
result.hasTelnetPassword = !!host.telnetPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
@@ -326,7 +318,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
"statusCheckInterval",
"enableSsh",
"sshPort",
"rdpAuthType",
"protocolAuth",
"jumpHosts",
"createdAt",
"updatedAt",
@@ -368,6 +360,12 @@ export function sanitizeHostForRecipient(
!Array.isArray(authOverrides.ssh)
? (authOverrides.ssh as Record<string, unknown>)
: undefined;
if (stripped.protocolAuth !== undefined) {
stripped.protocolAuth = sanitizeProtocolAuthForRecipient(
stripped.protocolAuth,
permissionLevel,
);
}
if (!sshOverride?.credentialId) {
stripped.hasPassword = false;
stripped.hasKey = false;
@@ -468,10 +466,6 @@ export function transformHostResponse(
shareSshAuth: !!host.shareSshAuth,
enableSsh: !!host.enableSsh,
sshPort: host.sshPort ?? host.port ?? 22,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
// Moved into a plugin's host settings; its hostPayloadLegacy puts it
// back for 2.8 clients.
@@ -490,6 +484,5 @@ export function transformHostResponse(
portKnockSequence: host.portKnockSequence
? JSON.parse(host.portKnockSequence as string)
: [],
domain: host.domain || undefined,
};
}
+139 -146
View File
@@ -71,6 +71,24 @@ import {
} from "../../utils/shared-host-auth-resolver.js";
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyProtocolAuthPlan,
attachProtocolAuth,
firstProtocolUsername,
listProtocolLogins,
loadProtocolAuthSummaries,
planProtocolAuthWrite,
ProtocolAuthWriteError,
readProtocolAuthPayload,
toPortableLogins,
withProtocolAuth,
writeProtocolAuth,
type PlannedProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import {
findHostProtocol,
listHostProtocols,
} from "../../hosts/protocol-auth/registry.js";
import {
mergeStoredTerminalFields,
parseTerminalConfig,
@@ -199,7 +217,6 @@ router.post(
terminalConfig,
sshOptions,
forceKeyboardInteractive,
domain,
notes,
useSocks5,
socks5Host,
@@ -213,20 +230,8 @@ router.post(
overrideCredentialUsername,
enableSsh,
sshPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
vncAuthType,
vncCredentialId,
vncPassword,
vncUser,
telnetAuthType,
telnetCredentialId,
telnetUser,
telnetPassword,
} = hostData;
const protocolAuthPatch = readProtocolAuthPayload(hostData);
databaseLogger.info("Creating SSH host", {
operation: "host_create",
userId,
@@ -274,7 +279,7 @@ router.post(
authMethod ||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
const sshDataObj: Record<string, unknown> = {
@@ -304,7 +309,6 @@ router.post(
: null,
sshOptions: sshOptionsForWrite({ sshOptions, terminalConfig }) ?? null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -325,28 +329,9 @@ router.post(
: null,
...normalizeProtocolEnableFields(hostData),
sshPort: sshPort || port || 22,
rdpAuthType: rdpAuthType || null,
rdpCredentialId:
rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
vncAuthType: vncAuthType || null,
vncCredentialId:
vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: telnetAuthType || null,
telnetCredentialId:
telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
};
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
// A host whose main protocol is a plugin's keeps any password it is given.
if (effectiveConnectionType !== "ssh") {
sshDataObj.password = password || null;
sshDataObj.key = null;
@@ -399,10 +384,6 @@ router.post(
sshDataObj.keyType = null;
}
sshDataObj.rdpPassword = rdpPassword || null;
sshDataObj.vncPassword = vncPassword || null;
sshDataObj.telnetPassword = telnetPassword || null;
try {
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
@@ -421,6 +402,14 @@ router.post(
}
const createdHost = result;
if (protocolAuthPatch) {
await writeProtocolAuth(
userId,
createdHost.id as number,
protocolAuthPatch,
{ isOwner: true },
);
}
// Standing folder shares apply to the newcomer.
try {
@@ -464,7 +453,7 @@ router.post(
name: String(name ?? ip),
});
res.json(stripSensitiveFields(resolvedHost));
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -794,7 +783,6 @@ router.put(
terminalConfig,
sshOptions,
forceKeyboardInteractive,
domain,
notes,
useSocks5,
socks5Host,
@@ -808,20 +796,8 @@ router.put(
overrideCredentialUsername,
enableSsh,
sshPort,
rdpAuthType,
rdpCredentialId,
rdpUser,
rdpPassword,
rdpDomain,
vncAuthType,
vncCredentialId,
vncPassword,
vncUser,
telnetAuthType,
telnetCredentialId,
telnetUser,
telnetPassword,
} = hostData;
const protocolAuthPatch = readProtocolAuthPayload(hostData);
databaseLogger.info("Updating SSH host", {
operation: "host_update",
userId,
@@ -871,7 +847,7 @@ router.put(
const effectiveAuthType = authType || authMethod;
const effectiveUsername =
username || rdpUser || vncUser || telnetUser || "";
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
const sshDataObj: Record<string, unknown> = {
@@ -899,7 +875,6 @@ router.put(
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
domain: domain || null,
notes: notes || null,
useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null,
@@ -919,25 +894,6 @@ router.put(
: null,
...normalizeProtocolEnableFields(hostData),
sshPort: sshPort || port || 22,
rdpAuthType: rdpAuthType || null,
rdpCredentialId:
rdpAuthType === "credential" && rdpCredentialId
? rdpCredentialId
: null,
rdpUser: rdpUser || null,
rdpDomain: rdpDomain || null,
vncAuthType: vncAuthType || null,
vncCredentialId:
vncAuthType === "credential" && vncCredentialId
? vncCredentialId
: null,
vncUser: vncUser || null,
telnetAuthType: telnetAuthType || null,
telnetCredentialId:
telnetAuthType === "credential" && telnetCredentialId
? telnetCredentialId
: null,
telnetUser: telnetUser || null,
};
const nextSshOptions = sshOptionsForWrite({ sshOptions, terminalConfig });
@@ -947,7 +903,7 @@ router.put(
sshDataObj.sudoPassword = sudoPassword || null;
}
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
// A host whose main protocol is a plugin's keeps any password it is given.
if ((connectionType || "ssh") !== "ssh") {
if (password) {
sshDataObj.password = password;
@@ -1007,10 +963,6 @@ router.put(
sshDataObj.keyType = null;
}
if (rdpPassword) sshDataObj.rdpPassword = rdpPassword;
if (vncPassword) sshDataObj.vncPassword = vncPassword;
if (telnetPassword) sshDataObj.telnetPassword = telnetPassword;
if (validatedParentHostId !== undefined) {
sshDataObj.parentHostId = validatedParentHostId;
} else if (folder !== undefined) {
@@ -1087,37 +1039,28 @@ router.put(
});
}
const referenceViolations: Array<[unknown, number | null, string]> = [
[
hostData.rdpCredentialId,
hostRecord.rdpCredentialId,
"RDP credential",
],
[
hostData.vncCredentialId,
hostRecord.vncCredentialId,
"VNC credential",
],
[
hostData.telnetCredentialId,
hostRecord.telnetCredentialId,
"Telnet credential",
],
];
for (const [incoming, current, label] of referenceViolations) {
if (incoming !== undefined && (incoming ?? null) !== current) {
return res.status(403).json({
error: `Only the host owner can change the ${label}`,
});
}
}
for (const field of OWNER_PRIVATE_AUTH_FIELDS.ssh) {
delete sshDataObj[field];
}
}
let protocolAuthPlan: PlannedProtocolAuth | null = null;
if (protocolAuthPatch) {
try {
protocolAuthPlan = await planProtocolAuthWrite(
ownerId,
Number(hostId),
protocolAuthPatch,
{ isOwner: accessInfo.isOwner },
);
} catch (error) {
if (error instanceof ProtocolAuthWriteError) {
return res.status(error.status).json({ error: error.message });
}
throw error;
}
}
const terminalFieldsError = await mergeStoredTerminalFields(
sshDataObj,
hostData,
@@ -1134,6 +1077,9 @@ router.put(
Number(hostId),
sshDataObj,
);
if (protocolAuthPlan) {
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
}
// A host that moved into a folder inherits that folder's standing shares.
try {
@@ -1202,7 +1148,7 @@ router.put(
success: true,
});
res.json(stripSensitiveFields(resolvedHost));
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
notifyStatsHostUpdated(parseInt(hostId), userId, "host_update");
} catch (err) {
sshLogger.error("Failed to update SSH host in database", err, {
@@ -1338,6 +1284,7 @@ router.get(
}),
);
attachProtocolAuth(result, await loadProtocolAuthSummaries(result));
const sanitized = result.map((host) =>
host.isShared
? sanitizeHostForRecipient(
@@ -1425,7 +1372,9 @@ router.get(
(await resolveHostCredentials(result, userId)) || result;
return res.json(
await withHostPluginSettings(stripSensitiveFields(resolved)),
await withHostPluginSettings(
await withProtocolAuth(stripSensitiveFields(resolved)),
),
);
}
@@ -1473,8 +1422,9 @@ router.get(
sharedExpiresAt: accessInfo.expiresAt || undefined,
ownerUsername,
};
const resolvedSharedResult =
(await resolveHostCredentials(sharedResult, userId)) || sharedResult;
const resolvedSharedResult = await withProtocolAuth(
(await resolveHostCredentials(sharedResult, userId)) || sharedResult,
);
res.json(
await withHostPluginSettings(
@@ -1585,7 +1535,12 @@ router.get(
* name: field
* schema:
* type: string
* enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword]
* enum: [password, sudoPassword, key, keyPassword]
* - in: query
* name: protocol
* schema:
* type: string
* description: A plugin protocol id; returns that protocol's saved password instead of field.
* responses:
* 200:
* description: The requested password value.
@@ -1600,18 +1555,22 @@ router.get(
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
const field = (req.query.field as string) || "password";
const coreField = [
"password",
"sudoPassword",
"key",
"keyPassword",
].includes(field);
// 2.8 clients ask for "<protocol>Password".
const protocol =
typeof req.query.protocol === "string"
? req.query.protocol
: coreField
? undefined
: /^([a-z][a-zA-Z0-9]*)Password$/.exec(field)?.[1];
const protocolId = protocol ? findHostProtocol(protocol)?.id : undefined;
if (
![
"password",
"sudoPassword",
"rdpPassword",
"vncPassword",
"telnetPassword",
"key",
"keyPassword",
].includes(field)
) {
if (!protocolId && (protocol || !coreField)) {
return res.status(400).json({ error: "Invalid field" });
}
@@ -1626,6 +1585,16 @@ router.get(
return res.status(404).json({ error: "Host not found" });
}
if (protocolId) {
const login = (await listProtocolLogins(hostId, userId)).find(
(entry) => entry.protocol === protocolId,
);
if (!login?.password) {
return res.status(404).json({ error: "No password set" });
}
return res.json({ value: login.password });
}
const resolved = (await resolveHostCredentials(host, userId)) || host;
let value = resolved[field];
@@ -1724,25 +1693,14 @@ router.get(
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
protocolAuth: toPortableLogins(
await listProtocolLogins(Number(hostId), userId),
),
};
const exportData = isRemoteDesktop
? {
...baseExportData,
rdpAuthType: resolvedHost.rdpAuthType || null,
rdpCredentialId: resolvedHost.rdpCredentialId || null,
rdpUser: resolvedHost.rdpUser || null,
rdpPassword: resolvedHost.rdpPassword || null,
rdpDomain: resolvedHost.rdpDomain || null,
vncAuthType: resolvedHost.vncAuthType || null,
vncCredentialId: resolvedHost.vncCredentialId || null,
vncUser: resolvedHost.vncUser || null,
vncPassword: resolvedHost.vncPassword || null,
telnetAuthType: resolvedHost.telnetAuthType || null,
telnetCredentialId: resolvedHost.telnetCredentialId || null,
telnetUser: resolvedHost.telnetUser || null,
telnetPassword: resolvedHost.telnetPassword || null,
}
? baseExportData
: {
...baseExportData,
authType: resolvedHost.authType,
@@ -1867,13 +1825,16 @@ router.get(
notes: resolvedHost.notes || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: hostPluginSettings ?? {},
protocolAuth: shareableLogins(
toPortableLogins(
await listProtocolLogins(host.id as number, userId),
),
shareMode,
),
};
const exportData = isRemoteDesktop
? {
...baseExportData,
domain: resolvedHost.domain || null,
}
? baseExportData
: {
...baseExportData,
authType: resolvedHost.authType,
@@ -2091,6 +2052,38 @@ router.delete(
// the file-manager plugin, under /plugin-api/file-manager/, and command
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
/**
* A share export leaves out every secret and credential link, the same as
* it does for SSH.
*/
function shareableLogins(
logins: ReturnType<typeof toPortableLogins>,
shareMode: boolean,
): ReturnType<typeof toPortableLogins> {
if (!shareMode) return logins;
const out: ReturnType<typeof toPortableLogins> = {};
for (const [protocol, login] of Object.entries(logins)) {
const declared = findHostProtocol(protocol);
const secret = new Set(
(declared?.credentialFields ?? [])
.filter((field) => field.secret)
.map((field) => field.key),
);
out[protocol] = {
authType: login.authType === "credential" ? "direct" : login.authType,
credentialId: null,
username: login.username,
password: null,
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([key]) => declared && !secret.has(key),
),
),
};
}
return out;
}
async function resolveHostCredentials(
host: Record<string, unknown>,
requestingUserId?: string,
@@ -2128,9 +2121,9 @@ async function resolveHostCredentials(
host.id,
requestingUserId,
);
// Whether a protocol is on is the remote-desktop plugin's host
// setting; the client only offers an override for one that is.
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
// Whether a plugin protocol is on is its plugin's host setting; the
// client only offers an override for one that is.
for (const { id: protocol } of listHostProtocols()) {
authOverrides[protocol] = {
credentialId: overrideCredentialIds[protocol],
required: false,
+2 -2
View File
@@ -8,7 +8,7 @@ import {
getAuditUsername,
logAudit,
} from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError,
@@ -1945,7 +1945,7 @@ router.delete(
* /rbac/host-access/{hostId}/auth/{protocol}:
* put:
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. Only SSH is currently supported.
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
* tags: [RBAC]
* security:
* - bearerAuth: []
@@ -346,7 +346,7 @@ export function registerUserSettingsRoutes(
* /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.
* description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
* tags:
* - Users
* responses: