feat: finish the web-endpoint plugin, add ctx.desktop (B8)

This commit is contained in:
LukeGus committed 2026-09-23 18:36:41 -05:00
1 parent 1682861853
commit 535126fd1b
50 files changed
+2018 -898

No files matched your search

+42 -32
View File
@@ -13,7 +13,7 @@ import {
createCurrentPluginSettingsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { serializeWebUiConfig } from "./host-web-endpoints.js";
import { applyPluginHostImportSettings } from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
@@ -286,10 +286,6 @@ export function registerHostBulkRoutes(
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableDocker === "boolean")
simpleUpdates.enableDocker = updates.enableDocker;
if (typeof updates.enableWebUi === "boolean") {
simpleUpdates.enableWebUi = updates.enableWebUi;
if (!updates.enableWebUi) simpleUpdates.webUiConfig = null;
}
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
@@ -377,6 +373,39 @@ export function registerHostBulkRoutes(
}
}
// Web endpoint enable/disable lives in the plugin's own host-scope
// settings. Disabling clears webUiConfig, matching the old column
// write it replaces.
if (typeof updates.enableWebUi === "boolean") {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
for (const host of ownedHosts) {
try {
const scopeId = String(host.id);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(updates.enableWebUi),
);
if (!updates.enableWebUi) {
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(null),
);
}
} catch {
errors.push(
`Failed to ${updates.enableWebUi ? "enable" : "disable"} web endpoints for host ${host.id}`,
);
}
}
}
return res.json({
updated: ownedIds.length,
failed: unauthorizedIds.length,
@@ -737,7 +766,6 @@ export function registerHostBulkRoutes(
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
enableWebUi: hostData.enableWebUi || false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
enableAiAssistant: hostData.enableAiAssistant || false,
@@ -762,9 +790,6 @@ export function registerHostBulkRoutes(
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
webUiConfig: hostData.enableWebUi
? serializeWebUiConfig(hostData.webUiConfig)
: null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
@@ -860,27 +885,14 @@ export function registerHostBulkRoutes(
results.success++;
}
if (hostData.enableProxmox || hostData.proxmoxConfig) {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
const scopeId = String(savedHostId);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(hostData.enableProxmox || false),
);
if (hostData.proxmoxConfig) {
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(hostData.proxmoxConfig),
);
}
}
// 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
// exist. See host-plugin-settings.ts.
await applyPluginHostImportSettings(
savedHostId,
hostData as Record<string, unknown>,
);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
@@ -1038,7 +1050,6 @@ export function registerHostBulkRoutes(
enableTunnel: true,
enableFileManager: true,
enableDocker: false,
enableWebUi: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
@@ -1056,7 +1067,6 @@ export function registerHostBulkRoutes(
quickActions: null,
statsConfig: null,
dockerConfig: null,
webUiConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
notes: null,
@@ -1,5 +1,4 @@
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { parseWebUiConfig } from "./host-web-endpoints.js";
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
@@ -364,8 +363,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
"enableTunnel",
"enableFileManager",
"enableDocker",
"enableWebUi",
"webUiConfig",
"enableTmuxMonitor",
"enableTerminalToolbar",
"enableAiAssistant",
@@ -467,7 +464,6 @@ export function transformHostResponse(
enableTunnel: !!host.enableTunnel,
enableFileManager: host.enableFileManager !== false,
enableDocker: !!host.enableDocker,
enableWebUi: !!host.enableWebUi,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
enableAiAssistant: !!host.enableAiAssistant,
@@ -519,9 +515,6 @@ export function transformHostResponse(
dockerConfig: host.dockerConfig
? JSON.parse(host.dockerConfig as string)
: undefined,
// Guarded, unlike dockerConfig directly above: parseWebUiConfig never
// throws, so a half-written config cannot take out the whole host listing.
webUiConfig: parseWebUiConfig(host.webUiConfig),
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
useWarpgate: !!host.useWarpgate,
socks5ProxyChain: host.socks5ProxyChain
@@ -15,12 +15,13 @@ import { createCurrentPluginSettingsRepository } from "../repositories/factory.j
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/** Enabled plugins that declare host-scope settings, with their manifests. */
function hostSettingsPlugins(): PluginManifest[] {
export function hostSettingsPlugins(): PluginManifest[] {
try {
const { loader } = getPluginRuntime();
return loader
@@ -147,3 +148,48 @@ export async function writeHostPluginSettings(
await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value));
}
}
/**
* A plugin's own validator for the fields it accepts inline on a bulk host
* import row (host-bulk-routes.ts's Termix-JSON import path). Registered
* through ctx.registry.provide("<pluginId>.hostImportNormalizer", fn) - not
* part of the SDK's typed ctx surface, because only this one bulk-import
* path calls it and a full contract is speculative until a second caller
* needs it. Returns the fields to write (JSON-serializable, matching
* writeHostPluginSettings's input), or null to write nothing for this row.
*/
export type PluginHostImportNormalizer = (
raw: Record<string, unknown>,
) => Record<string, unknown> | null;
/**
* Runs every enabled plugin's registered import normalizer over one imported
* host row and writes whatever each one returns, so a plugin's host-scope
* settings are validated the same way on bulk import as they are anywhere
* else - instead of host-bulk-routes.ts hardcoding one plugin's shape.
*
* Best-effort per plugin: one plugin's normalizer throwing must not fail the
* whole import, so it is logged and skipped rather than propagated.
*/
export async function applyPluginHostImportSettings(
hostId: number,
raw: Record<string, unknown>,
): Promise<void> {
for (const manifest of hostSettingsPlugins()) {
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
if (!normalizer) continue;
try {
const values = normalizer(raw);
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
} catch (error) {
sshLogger.warn("Plugin host import normalizer failed", {
operation: "host_import_plugin_settings",
pluginId: manifest.id,
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -1,201 +0,0 @@
import {
MAX_WEB_ENDPOINTS,
MAX_WEB_ENDPOINT_LABEL_LENGTH,
} from "../../../types/index.js";
import type { WebEndpoint, WebUiConfig } from "../../../types/index.js";
export { MAX_WEB_ENDPOINTS, MAX_WEB_ENDPOINT_LABEL_LENGTH };
const SCHEMES = new Set(["http", "https"]);
const ACCESS_VALUES = new Set(["direct", "tunnel"]);
const RENDER_VALUES = new Set(["external", "embedded"]);
const MIN_PORT = 1;
const MAX_PORT = 65535;
function isValidPort(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isInteger(value) &&
value >= MIN_PORT &&
value <= MAX_PORT
);
}
/**
* C0 controls and DEL. Checked by codepoint rather than by regex character
* class so no escaping subtlety can let one through: a path like
* "\t//evil.example" defeats a naive startsWith("//") guard, because the
* browser strips the control character and then follows the authority.
*/
function hasControlCharacter(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Coerces a path to begin with "/", or returns null to drop the endpoint.
*
* This is THE enforcement point for a value that reaches an href and an
* iframe src -- the editor's checks are UX. Anything that could redirect the
* frame away from the endpoint is rejected rather than sanitized, because
* sanitizing invites a second, subtly different implementation elsewhere.
*/
function normalizePath(raw: unknown): string | null {
if (raw === undefined || raw === null || raw === "") return "/";
if (typeof raw !== "string") return null;
if (hasControlCharacter(raw)) return null;
const path = raw.startsWith("/") ? raw : `/${raw}`;
if (path.startsWith("//")) return null;
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return null;
if (path.includes("\\")) return null;
return path;
}
/**
* A bare host literal, or null to drop the endpoint, or undefined to leave the
* field unset (the route then falls back to loopback).
*
* The value lands both in a TCP listener and in a URL authority, so nothing
* carrying a scheme, port, path or credentials may survive.
*/
function normalizeBindHost(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null || raw === "") return undefined;
if (typeof raw !== "string") return null;
const value = raw.trim();
if (!value) return undefined;
if (value.length > 253) return null;
if (hasControlCharacter(value)) return null;
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
const HOSTNAME =
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
const IPV6_BRACKETED = /^\[[0-9A-Fa-f:.]+\]$/;
if (IPV4.test(value)) {
return value.split(".").every((part) => Number(part) <= 255) ? value : null;
}
if (IPV6_BRACKETED.test(value) || HOSTNAME.test(value)) return value;
return null;
}
function normalizeEndpoint(raw: unknown): WebEndpoint | null {
if (!raw || typeof raw !== "object") return null;
const input = raw as Record<string, unknown>;
const id = typeof input.id === "string" ? input.id.trim() : "";
if (!id) return null;
const label = typeof input.label === "string" ? input.label.trim() : "";
if (!label) return null;
const scheme = input.scheme;
if (typeof scheme !== "string" || !SCHEMES.has(scheme)) return null;
if (!isValidPort(input.port)) return null;
const path = normalizePath(input.path);
if (path === null) return null;
const access = input.access;
if (typeof access !== "string" || !ACCESS_VALUES.has(access)) return null;
const render = input.render;
if (typeof render !== "string" || !RENDER_VALUES.has(render)) return null;
const isTunnel = access === "tunnel";
const endpoint: WebEndpoint = {
id,
label: label.slice(0, MAX_WEB_ENDPOINT_LABEL_LENGTH),
scheme: scheme as WebEndpoint["scheme"],
port: input.port,
path,
access: access as WebEndpoint["access"],
render: render as WebEndpoint["render"],
// Meaningless for a tunnel, whose host component is loopback and already
// exempt from certificate checks. Cleared rather than carried so nothing
// downstream has to re-derive that.
ignoreCert: !isTunnel && input.ignoreCert === true,
};
// Both are tunnel-only. Silently ignored on a direct endpoint rather than
// rejected: they describe a forward that a direct endpoint never creates.
if (isTunnel) {
const bindHost = normalizeBindHost(input.bindHost);
if (bindHost === null) return null;
if (bindHost !== undefined) endpoint.bindHost = bindHost;
if (input.localPort !== undefined && input.localPort !== null) {
if (!isValidPort(input.localPort)) return null;
endpoint.localPort = input.localPort;
}
}
return endpoint;
}
/**
* Drops any endpoint it refuses rather than rejecting the whole host -- one
* bad row must not make a host unsaveable or unlistable. The editor is
* responsible for telling the user before that happens
* (plugins/web-endpoint/src/frontend/web-endpoint-validation.ts).
*/
export function normalizeWebEndpoints(raw: unknown): WebEndpoint[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const endpoints: WebEndpoint[] = [];
for (const candidate of raw) {
if (endpoints.length >= MAX_WEB_ENDPOINTS) break;
const endpoint = normalizeEndpoint(candidate);
if (!endpoint) continue;
if (seen.has(endpoint.id)) continue;
seen.add(endpoint.id);
endpoints.push(endpoint);
}
return endpoints;
}
/**
* Never throws. A malformed stored value yields an empty endpoint list, so a
* half-written config cannot take out the whole host listing -- which is what
* dockerConfig's bare JSON.parse does today.
*/
export function parseWebUiConfig(raw: unknown): WebUiConfig {
if (raw === undefined || raw === null) return { endpoints: [] };
let value: unknown = raw;
if (typeof raw === "string") {
try {
value = JSON.parse(raw);
} catch {
return { endpoints: [] };
}
}
if (!value || typeof value !== "object") return { endpoints: [] };
return {
endpoints: normalizeWebEndpoints(
(value as { endpoints?: unknown }).endpoints,
),
};
}
/** Null when nothing survives normalization, so the column is cleared. */
export function serializeWebUiConfig(config: unknown): string | null {
const endpoints = normalizeWebEndpoints(
(config as { endpoints?: unknown } | null | undefined)?.endpoints,
);
if (endpoints.length === 0) return null;
return JSON.stringify({ endpoints });
}
+19 -37
View File
@@ -72,10 +72,6 @@ import type {
HostResolutionHostRecord,
} from "../repositories/host-resolution-repository.js";
import { AUTH_PROTOCOL_METADATA } from "../../../types/auth-protocols.js";
import {
parseWebUiConfig,
serializeWebUiConfig,
} from "./host-web-endpoints.js";
import {
requiresPersonalHostAuthentication,
resolveRecipientSharedHostAuthentication,
@@ -202,7 +198,6 @@ router.post(
enableFileManager,
scpLegacy,
enableDocker,
enableWebUi,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
@@ -219,7 +214,6 @@ router.post(
quickActions,
statsConfig,
dockerConfig,
webUiConfig,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
@@ -348,7 +342,6 @@ router.post(
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableWebUi: enableWebUi ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
enableAiAssistant: enableAiAssistant ? 1 : 0,
@@ -369,13 +362,6 @@ router.post(
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
webUiConfig: enableWebUi
? serializeWebUiConfig(
typeof webUiConfig === "string"
? safeParseJson(webUiConfig)
: webUiConfig,
)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -930,7 +916,6 @@ router.put(
enableFileManager,
scpLegacy,
enableDocker,
enableWebUi,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
@@ -947,7 +932,6 @@ router.put(
quickActions,
statsConfig,
dockerConfig,
webUiConfig,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
@@ -1079,7 +1063,6 @@ router.put(
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
enableWebUi: enableWebUi ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
enableAiAssistant: enableAiAssistant ? 1 : 0,
@@ -1100,13 +1083,6 @@ router.put(
? dockerConfig
: JSON.stringify(dockerConfig)
: null,
webUiConfig: enableWebUi
? serializeWebUiConfig(
typeof webUiConfig === "string"
? safeParseJson(webUiConfig)
: webUiConfig,
)
: null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
@@ -2100,9 +2076,10 @@ router.get(
}
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
const proxmoxSettings = (
const hostPluginSettings = (
await loadHostPluginSettings([Number(hostId)])
).get(Number(hostId))?.proxmox as
).get(Number(hostId));
const proxmoxSettings = hostPluginSettings?.proxmox as
| {
enableProxmox?: boolean;
proxmoxConfig?: unknown;
@@ -2110,6 +2087,8 @@ router.get(
proxmoxStatsConfig?: unknown;
}
| undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2177,7 +2156,7 @@ router.get(
enableFileManager: resolvedHost.enableFileManager !== false,
scpLegacy: !!resolvedHost.scpLegacy,
enableDocker: !!resolvedHost.enableDocker,
enableWebUi: !!resolvedHost.enableWebUi,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
@@ -2202,7 +2181,7 @@ router.get(
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig),
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
@@ -2282,7 +2261,7 @@ router.get(
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const proxmoxSettingsByHost = await loadHostPluginSettings(
const pluginSettingsByHost = await loadHostPluginSettings(
allHosts.map((h) => h.id as number),
);
@@ -2293,9 +2272,11 @@ router.get(
const resolvedHost = shareMode
? host
: (await resolveHostCredentials(host, userId)) || host;
const proxmoxSettings = proxmoxSettingsByHost.get(host.id as number)
?.proxmox as
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
const proxmoxSettings = hostPluginSettings?.proxmox as
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2344,7 +2325,7 @@ router.get(
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: resolvedHost.enableFileManager !== false,
enableDocker: !!resolvedHost.enableDocker,
enableWebUi: !!resolvedHost.enableWebUi,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar:
@@ -2371,7 +2352,9 @@ router.get(
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: parseWebUiConfig(resolvedHost.webUiConfig),
webUiConfig: webEndpointSettings?.webUiConfig ?? {
endpoints: [],
},
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
: null,
@@ -3010,10 +2993,9 @@ registerHostNetworkRoutes(router, {
export default router;
/**
* A webUiConfig arriving as a JSON string (an import, or a client that
* stringified it) must still reach serializeWebUiConfig as an object.
* Malformed input becomes null, which serializes to a cleared column rather
* than throwing inside a host save.
* A config field arriving as a JSON string (an import, or a client that
* stringified it) must still reach storage as an object. Malformed input
* becomes null rather than throwing inside a host save.
*/
function safeParseJson(raw: string): unknown {
try {