mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: finish the web-endpoint plugin, add ctx.desktop (B8)
This commit is contained in:
1 parent
1682861853
commit
535126fd1b
50 files changed
+2018
-898
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
PluginCapabilityError,
|
||||
type PluginContext,
|
||||
type PluginModule,
|
||||
type PluginOpenIsolatedWindowRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
@@ -299,6 +300,28 @@ export function createPluginContext(
|
||||
{ action: "db_refs" },
|
||||
);
|
||||
|
||||
const desktopOpenIsolatedWindow = guarded(
|
||||
manifest,
|
||||
"desktop:window",
|
||||
async (request: PluginOpenIsolatedWindowRequest) => {
|
||||
const { isElectronIpcAvailable, requestFromElectronMain } =
|
||||
await import("../utils/electron-ipc-bridge.js");
|
||||
if (!isElectronIpcAvailable()) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} tried to open an isolated window outside the desktop app`,
|
||||
);
|
||||
}
|
||||
return requestFromElectronMain<{ success: true }>(
|
||||
"open-isolated-window",
|
||||
request,
|
||||
);
|
||||
},
|
||||
{
|
||||
action: "desktop_open_isolated_window",
|
||||
details: () => "opened an isolated Electron window",
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
pluginId,
|
||||
manifest,
|
||||
@@ -613,6 +636,10 @@ export function createPluginContext(
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
},
|
||||
|
||||
/**
|
||||
* Background work acts as a named user. Always audited, because "this ran
|
||||
* as someone" is exactly the thing an operator needs to be able to see.
|
||||
|
||||
@@ -357,6 +357,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runTunnelsSettingsMigration } =
|
||||
await import("./utils/crypto-migration/tunnels-settings-migration.js");
|
||||
await runTunnelsSettingsMigration();
|
||||
|
||||
const { runWebEndpointSettingsMigration } =
|
||||
await import("./utils/crypto-migration/web-endpoint-settings-migration.js");
|
||||
await runWebEndpointSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -24,6 +24,18 @@ const getAllForScopeIds = vi.fn(async (scope: string, scopeIds: string[]) =>
|
||||
(row) => row.scope === scope && scopeIds.includes(row.scopeId ?? ""),
|
||||
),
|
||||
);
|
||||
const setCalls: Array<[string, string, string, string, string]> = [];
|
||||
const set = vi.fn(
|
||||
async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
setCalls.push([pluginId, scope, scopeId, key, value]);
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
@@ -37,7 +49,7 @@ vi.mock("../../../utils/logger.js", () => {
|
||||
});
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds }),
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
@@ -50,10 +62,17 @@ vi.mock("../../../utils/system-secret-crypto.js", () => ({
|
||||
decryptSystemSecret: async (stored: string) => stored,
|
||||
}));
|
||||
|
||||
const registryProviders = new Map<string, unknown>();
|
||||
vi.mock("../../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => registryProviders.get(key),
|
||||
}));
|
||||
|
||||
const {
|
||||
attachHostPluginSettings,
|
||||
loadHostPluginSettings,
|
||||
withHostPluginSettings,
|
||||
writeHostPluginSettings,
|
||||
applyPluginHostImportSettings,
|
||||
} = await import("../../../database/routes/host-plugin-settings.js");
|
||||
|
||||
function manifest(id: string, host: unknown): PluginManifest {
|
||||
@@ -80,7 +99,10 @@ const DOCKER = manifest("docker", {
|
||||
beforeEach(() => {
|
||||
rows.length = 0;
|
||||
loaded.length = 0;
|
||||
setCalls.length = 0;
|
||||
registryProviders.clear();
|
||||
getAllForScopeIds.mockClear();
|
||||
set.mockClear();
|
||||
});
|
||||
|
||||
describe("loadHostPluginSettings", () => {
|
||||
@@ -242,3 +264,81 @@ describe("withHostPluginSettings", () => {
|
||||
expect(await withHostPluginSettings(host)).toBe(host);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeHostPluginSettings", () => {
|
||||
it("JSON-stringifies each value under the plugin's own namespace", async () => {
|
||||
await writeHostPluginSettings("docker", 7, {
|
||||
enableDocker: true,
|
||||
socketPath: null,
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([
|
||||
["docker", "host", "7", "enableDocker", "true"],
|
||||
["docker", "host", "7", "socketPath", "null"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a field whose value is undefined", async () => {
|
||||
await writeHostPluginSettings("docker", 7, {
|
||||
enableDocker: true,
|
||||
socketPath: undefined,
|
||||
});
|
||||
|
||||
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPluginHostImportSettings", () => {
|
||||
it("runs every enabled plugin's registered normalizer and writes what it returns", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
loaded.push({
|
||||
id: "web-endpoint",
|
||||
manifest: manifest("web-endpoint", {
|
||||
enableKey: "enableWebUi",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
|
||||
}),
|
||||
state: "active",
|
||||
});
|
||||
registryProviders.set("docker.hostImportNormalizer", () => ({
|
||||
enableDocker: true,
|
||||
}));
|
||||
registryProviders.set("web-endpoint.hostImportNormalizer", () => null);
|
||||
|
||||
await applyPluginHostImportSettings(7, { enableDocker: true });
|
||||
|
||||
expect(setCalls).toEqual([["docker", "host", "7", "enableDocker", "true"]]);
|
||||
});
|
||||
|
||||
it("skips a plugin with no registered normalizer", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
|
||||
await applyPluginHostImportSettings(7, {});
|
||||
|
||||
expect(setCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("logs and continues past a normalizer that throws", async () => {
|
||||
loaded.push({ id: "docker", manifest: DOCKER, state: "active" });
|
||||
loaded.push({
|
||||
id: "web-endpoint",
|
||||
manifest: manifest("web-endpoint", {
|
||||
enableKey: "enableWebUi",
|
||||
enableLabelKey: "k",
|
||||
fields: [{ key: "webUiConfig", type: "json", labelKey: "k" }],
|
||||
}),
|
||||
state: "active",
|
||||
});
|
||||
registryProviders.set("docker.hostImportNormalizer", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
registryProviders.set("web-endpoint.hostImportNormalizer", () => ({
|
||||
enableWebUi: true,
|
||||
}));
|
||||
|
||||
await expect(applyPluginHostImportSettings(7, {})).resolves.toBeUndefined();
|
||||
expect(setCalls).toEqual([
|
||||
["web-endpoint", "host", "7", "enableWebUi", "true"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { createIsolatedWindows } = createRequire(import.meta.url)(
|
||||
"../../../../electron/isolated-window.cjs",
|
||||
);
|
||||
const created: FakeWindow[] = [];
|
||||
class FakeWindow extends EventEmitter {
|
||||
options: Record<string, unknown>;
|
||||
destroyed = false;
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
mainFrame: {},
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
});
|
||||
setMenu = vi.fn();
|
||||
loadURL = vi.fn().mockResolvedValue(undefined);
|
||||
constructor(options: Record<string, unknown>) {
|
||||
super();
|
||||
this.options = options;
|
||||
created.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
let main: FakeWindow;
|
||||
type TestSession = EventEmitter & {
|
||||
setPermissionRequestHandler: ReturnType<typeof vi.fn>;
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>;
|
||||
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> };
|
||||
clearStorageData: ReturnType<typeof vi.fn>;
|
||||
clearCache: ReturnType<typeof vi.fn>;
|
||||
closeAllConnections: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let sessions: TestSession[];
|
||||
let fromPartition: ReturnType<typeof vi.fn>;
|
||||
let manager: ReturnType<typeof createIsolatedWindows>;
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
sessions = [];
|
||||
main = new FakeWindow({});
|
||||
created.length = 0;
|
||||
fromPartition = vi.fn(() => {
|
||||
const session = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
closeAllConnections: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
manager = createIsolatedWindows({
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { fromPartition },
|
||||
getMainWindow: () => main,
|
||||
});
|
||||
});
|
||||
describe("isolated windows", () => {
|
||||
it("creates unique non-persistent sessions without preload or Node privileges", async () => {
|
||||
await manager.open({ url: "https://service.test" });
|
||||
await manager.open({ url: "https://service.test" });
|
||||
expect(fromPartition.mock.calls[0][0]).not.toBe(
|
||||
fromPartition.mock.calls[1][0],
|
||||
);
|
||||
expect(fromPartition.mock.calls[0][0]).not.toMatch(/^persist:/);
|
||||
expect(created[0].options.webPreferences).toEqual({
|
||||
session: sessions[0],
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
});
|
||||
});
|
||||
it("uses a caller-supplied partition when given one", async () => {
|
||||
await manager.open({ url: "https://service.test", partition: "fixed" });
|
||||
expect(fromPartition.mock.calls[0][0]).toBe("fixed");
|
||||
});
|
||||
it("rejects non-web destinations before creating a session", async () => {
|
||||
for (const url of [
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://user:secret@service.test",
|
||||
]) {
|
||||
await expect(manager.open({ url })).rejects.toThrow();
|
||||
}
|
||||
expect(fromPartition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("refuses when there is no window to attach to", async () => {
|
||||
manager = createIsolatedWindows({
|
||||
BrowserWindow: FakeWindow,
|
||||
session: { fromPartition },
|
||||
getMainWindow: () => null,
|
||||
});
|
||||
await expect(manager.open({ url: "https://service.test" })).rejects.toThrow(
|
||||
/No window/,
|
||||
);
|
||||
expect(fromPartition).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps login popups in the same isolated session and blocks native-protocol navigation", async () => {
|
||||
await manager.open({ url: "https://service.test" });
|
||||
const win = created[0],
|
||||
open = win.webContents.setWindowOpenHandler.mock.calls[0][0];
|
||||
expect(
|
||||
open({ url: "https://login.test" }).overrideBrowserWindowOptions
|
||||
.webPreferences.session,
|
||||
).toBe(sessions[0]);
|
||||
expect(open({ url: "file:///tmp/secret" }).action).toBe("deny");
|
||||
const navigation = { preventDefault: vi.fn() };
|
||||
win.webContents.emit("will-redirect", navigation, "file:///tmp/secret");
|
||||
expect(navigation.preventDefault).toHaveBeenCalled();
|
||||
const callback = vi.fn();
|
||||
sessions[0].webRequest.onBeforeRequest.mock.calls[0][0](
|
||||
{ url: "file:///tmp/secret" },
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenCalledWith({ cancel: true });
|
||||
});
|
||||
it("limits invalid certificates to the opted-in origin, including its port", async () => {
|
||||
await manager.open({
|
||||
url: "https://service.test:8443",
|
||||
ignoreCert: true,
|
||||
});
|
||||
const e = { preventDefault: vi.fn() },
|
||||
callback = vi.fn();
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:8443/path",
|
||||
callback,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(callback).toHaveBeenLastCalledWith(true);
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
created[0].webContents,
|
||||
"https://service.test:9443",
|
||||
callback,
|
||||
);
|
||||
expect(callback).toHaveBeenLastCalledWith(false);
|
||||
expect(
|
||||
manager.handleCertificateError(
|
||||
e,
|
||||
main.webContents,
|
||||
"https://service.test:8443",
|
||||
callback,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("closes login popups and clears session data when the endpoint closes", async () => {
|
||||
await manager.open({ url: "https://service.test" });
|
||||
const root = created[0],
|
||||
popup = new FakeWindow({});
|
||||
root.webContents.emit("did-create-window", popup);
|
||||
root.destroy();
|
||||
expect(popup.isDestroyed()).toBe(true);
|
||||
expect(sessions[0].clearStorageData).toHaveBeenCalledOnce();
|
||||
expect(sessions[0].closeAllConnections).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* ctx.desktop.openIsolatedWindow: desktop:window is checked and audited, and
|
||||
* the call only reaches Electron's main process when the backend is actually
|
||||
* running embedded in it.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const grants = new Map<string, string[]>();
|
||||
const auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
(grants.get(pluginId) ?? []).map((capability) => ({
|
||||
pluginId,
|
||||
capability,
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
}));
|
||||
|
||||
const bridge = vi.hoisted(() => ({
|
||||
available: false,
|
||||
requestFromElectronMain: vi.fn(async () => ({ success: true as const })),
|
||||
}));
|
||||
vi.mock("../../utils/electron-ipc-bridge.js", () => ({
|
||||
isElectronIpcAvailable: () => bridge.available,
|
||||
requestFromElectronMain: bridge.requestFromElectronMain,
|
||||
}));
|
||||
|
||||
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
|
||||
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
author: { name: "test" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities,
|
||||
} as PluginManifest;
|
||||
}
|
||||
|
||||
function contextFor(pluginId: string, capabilities: string[]) {
|
||||
const manifest = manifestFor(pluginId, capabilities);
|
||||
const handle = createPluginHandle(pluginId, { activate: () => {} });
|
||||
return { ctx: createPluginContext(manifest, handle), handle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
grants.clear();
|
||||
auditEntries.length = 0;
|
||||
invalidatePluginPermissionCache();
|
||||
bridge.available = false;
|
||||
bridge.requestFromElectronMain.mockClear();
|
||||
});
|
||||
|
||||
describe("ctx.desktop.openIsolatedWindow", () => {
|
||||
it("refuses without desktop:window, before reaching Electron", async () => {
|
||||
grants.set("demo", []);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop:window/);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_open_isolated_window",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses when granted but not declared in the manifest", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
const { ctx } = contextFor("demo", []);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop:window/);
|
||||
});
|
||||
|
||||
it("refuses outside the desktop app even with the capability", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = false;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
await expect(
|
||||
ctx.desktop.openIsolatedWindow({ url: "https://example.test" }),
|
||||
).rejects.toThrow(/desktop app/);
|
||||
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("relays to Electron main and audits success", async () => {
|
||||
grants.set("demo", ["desktop:window"]);
|
||||
bridge.available = true;
|
||||
const { ctx } = contextFor("demo", ["desktop:window"]);
|
||||
|
||||
const request = { url: "https://example.test", title: "Example" };
|
||||
await expect(ctx.desktop.openIsolatedWindow(request)).resolves.toEqual({
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
|
||||
"open-isolated-window",
|
||||
request,
|
||||
);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_desktop_open_isolated_window",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
isElectronIpcAvailable,
|
||||
requestFromElectronMain,
|
||||
} from "../../utils/electron-ipc-bridge.js";
|
||||
|
||||
const originalEnv = process.env.ELECTRON_EMBEDDED;
|
||||
const originalSend = process.send;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ELECTRON_EMBEDDED = originalEnv;
|
||||
process.send = originalSend;
|
||||
});
|
||||
|
||||
describe("isElectronIpcAvailable", () => {
|
||||
it("is false outside the embedded desktop backend", () => {
|
||||
delete process.env.ELECTRON_EMBEDDED;
|
||||
process.send = (() => true) as typeof process.send;
|
||||
expect(isElectronIpcAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when there is no fork IPC channel", () => {
|
||||
process.send = undefined;
|
||||
expect(isElectronIpcAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when embedded with a live channel", () => {
|
||||
process.send = (() => true) as typeof process.send;
|
||||
expect(isElectronIpcAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestFromElectronMain", () => {
|
||||
it("refuses when Electron IPC is unavailable", async () => {
|
||||
process.send = undefined;
|
||||
await expect(
|
||||
requestFromElectronMain("open-isolated-window", {}),
|
||||
).rejects.toThrow(/desktop app/);
|
||||
});
|
||||
|
||||
// The module attaches its "message" listener to the real process.on only
|
||||
// once, the first time requestFromElectronMain actually reaches that point
|
||||
// (ensureListening's module-scoped flag mirrors how a real forked
|
||||
// process's IPC channel is a single persistent thing it listens on for the
|
||||
// process lifetime). vi.spyOn(process, "on") does not reliably intercept
|
||||
// that call in this environment, so this replaces process.on directly for
|
||||
// the one call the module makes and hands the captured listener a plain
|
||||
// function this file can invoke to simulate a message from main -
|
||||
// simpler and more direct than getting a spy to survive across a Node
|
||||
// global.
|
||||
let deliver: (msg: unknown) => void = () => {};
|
||||
|
||||
beforeAll(() => {
|
||||
const originalOn = process.on.bind(process);
|
||||
process.on = ((event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === "message") {
|
||||
deliver = listener;
|
||||
return process;
|
||||
}
|
||||
return originalOn(event, listener);
|
||||
}) as typeof process.on;
|
||||
});
|
||||
|
||||
let sent: unknown[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
process.send = ((msg: unknown) => {
|
||||
sent.push(msg);
|
||||
return true;
|
||||
}) as typeof process.send;
|
||||
});
|
||||
|
||||
it("resolves with main's response, matched by request id", async () => {
|
||||
const pending = requestFromElectronMain<{ success: true }>(
|
||||
"open-isolated-window",
|
||||
{ url: "https://example.test" },
|
||||
);
|
||||
const [message] = sent as Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
}>;
|
||||
expect(message).toMatchObject({
|
||||
type: "backend-request",
|
||||
channel: "open-isolated-window",
|
||||
payload: { url: "https://example.test" },
|
||||
});
|
||||
|
||||
deliver({
|
||||
type: "backend-response",
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: { success: true },
|
||||
});
|
||||
await expect(pending).resolves.toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("rejects with main's error", async () => {
|
||||
const pending = requestFromElectronMain("open-isolated-window", {});
|
||||
const [message] = sent as Array<{ id: string }>;
|
||||
|
||||
deliver({
|
||||
type: "backend-response",
|
||||
id: message.id,
|
||||
ok: false,
|
||||
error: "refused",
|
||||
});
|
||||
await expect(pending).rejects.toThrow(/refused/);
|
||||
});
|
||||
|
||||
it("ignores a response for a different request id", async () => {
|
||||
const pending = requestFromElectronMain("open-isolated-window", {});
|
||||
const [message] = sent as Array<{ id: string }>;
|
||||
|
||||
deliver({ type: "backend-response", id: "other", ok: true, result: {} });
|
||||
deliver({
|
||||
type: "backend-response",
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: { success: true },
|
||||
});
|
||||
await expect(pending).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* The web endpoint host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host with web endpoints on and a saved
|
||||
* config must keep both once the ssh_data columns are dropped. Running it
|
||||
* twice must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_web_ui: boolean;
|
||||
web_ui_config: string | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runWebEndpointSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
const endpoint = {
|
||||
id: "e1",
|
||||
label: "Proxmox",
|
||||
scheme: "https",
|
||||
port: 8006,
|
||||
path: "/",
|
||||
access: "direct",
|
||||
render: "embedded",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runWebEndpointSettingsMigration", () => {
|
||||
it("moves a host's switch and saved config into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_web_ui: true,
|
||||
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
|
||||
});
|
||||
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableWebUi")).toBe(true);
|
||||
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
|
||||
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a disabled host's saved config and its disabled switch", async () => {
|
||||
hostRows.push({
|
||||
id: 3,
|
||||
enable_web_ui: false,
|
||||
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
|
||||
});
|
||||
|
||||
await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(storedValue(3, "enableWebUi")).toBe(false);
|
||||
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
|
||||
});
|
||||
|
||||
it("stores an unreadable config as null rather than failing", async () => {
|
||||
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
|
||||
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "webUiConfig")).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
|
||||
|
||||
await runWebEndpointSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({
|
||||
id: 5,
|
||||
enable_web_ui: true,
|
||||
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
|
||||
});
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
"5",
|
||||
"enableWebUi",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runWebEndpointSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableWebUi")).toBe(false);
|
||||
expect(storedValue(5, "webUiConfig")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Moves the two web endpoint host columns into the web-endpoint plugin's
|
||||
* host-scope settings, before those columns are dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a web-endpoint settings row, so
|
||||
* it is safe to run on every boot. Lossless: reads straight off the
|
||||
* still-present ssh_data columns, so it must run and finish before those
|
||||
* columns are ever dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
export interface WebEndpointSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyWebEndpointRow {
|
||||
id: number;
|
||||
enable_web_ui: number | boolean | null;
|
||||
web_ui_config: string | null;
|
||||
}
|
||||
|
||||
function parseConfig(raw: string | null): unknown {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWebEndpointSettingsMigration(): Promise<WebEndpointSettingsMigrationResult> {
|
||||
const result: WebEndpointSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
const drizzleDb = getDb();
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring these columns.
|
||||
const rows = await drizzleDb.all<LegacyWebEndpointRow>(sql`
|
||||
SELECT id, enable_web_ui, web_ui_config
|
||||
FROM ssh_data
|
||||
WHERE enable_web_ui = true OR web_ui_config IS NOT NULL
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableWebUi",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableWebUi",
|
||||
JSON.stringify(!!row.enable_web_ui),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"web-endpoint",
|
||||
"host",
|
||||
scopeId,
|
||||
"webUiConfig",
|
||||
JSON.stringify(parseConfig(row.web_ui_config)),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved web endpoint settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "web_endpoint_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep
|
||||
// whatever web endpoint settings they already had (none, on a fresh run).
|
||||
databaseLogger.warn("Web endpoint settings migration failed", {
|
||||
operation: "web_endpoint_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Request/response layer over the fork IPC channel between the embedded
|
||||
* backend and Electron's main process (electron/main.cjs forks this backend
|
||||
* with stdio: [..., "ipc"]). Main already sends a one-way "shutdown" message
|
||||
* on this channel; this adds a matching request/response shape so the
|
||||
* backend can ask main to do something only main can do - open a real
|
||||
* BrowserWindow - and get a typed result back.
|
||||
*
|
||||
* Not available outside Electron: process.send is undefined for a plain
|
||||
* `node` or Docker start, and ELECTRON_EMBEDDED distinguishes the embedded
|
||||
* backend from a standalone one that happens to run under a process manager
|
||||
* that also sets stdio to "ipc".
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface ElectronIpcRequestMessage {
|
||||
type: "backend-request";
|
||||
id: string;
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface ElectronIpcResponseMessage {
|
||||
type: "backend-response";
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function isResponseMessage(msg: unknown): msg is ElectronIpcResponseMessage {
|
||||
return (
|
||||
!!msg &&
|
||||
typeof msg === "object" &&
|
||||
(msg as { type?: unknown }).type === "backend-response"
|
||||
);
|
||||
}
|
||||
|
||||
export function isElectronIpcAvailable(): boolean {
|
||||
return (
|
||||
process.env.ELECTRON_EMBEDDED === "true" &&
|
||||
typeof process.send === "function"
|
||||
);
|
||||
}
|
||||
|
||||
const pending = new Map<
|
||||
string,
|
||||
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
let listening = false;
|
||||
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
listening = true;
|
||||
process.on("message", (msg: unknown) => {
|
||||
if (!isResponseMessage(msg)) return;
|
||||
const waiter = pending.get(msg.id);
|
||||
if (!waiter) return;
|
||||
pending.delete(msg.id);
|
||||
if (msg.ok) waiter.resolve(msg.result);
|
||||
else waiter.reject(new Error(msg.error || "Electron IPC request failed"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to Electron's main process over the fork IPC channel and
|
||||
* waits for its response. Rejects if main never answers (main.cjs is where
|
||||
* a killed or hung renderer would surface, not here) or process.send is
|
||||
* unavailable.
|
||||
*/
|
||||
export async function requestFromElectronMain<T = unknown>(
|
||||
channel: string,
|
||||
payload: unknown,
|
||||
): Promise<T> {
|
||||
if (!isElectronIpcAvailable()) {
|
||||
throw new Error("Electron IPC is only available in the desktop app");
|
||||
}
|
||||
ensureListening();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const message: ElectronIpcRequestMessage = {
|
||||
type: "backend-request",
|
||||
id,
|
||||
channel,
|
||||
payload,
|
||||
};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`Electron IPC request "${channel}" timed out`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
process.send?.(message);
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(id);
|
||||
systemLogger.warn("Failed to send Electron IPC request", {
|
||||
operation: "electron_ipc_send_failed",
|
||||
channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GuacamoleConfig } from "./guacamole-config.js";
|
||||
import type { TerminalConfig, WebUiConfig } from "./index.js";
|
||||
import type { TerminalConfig } from "./index.js";
|
||||
import type { StatsConfig } from "./stats-widgets.js";
|
||||
import type { HostAuthOverrides } from "./auth-protocols.js";
|
||||
|
||||
@@ -108,7 +108,6 @@ export type Host = {
|
||||
runtime?: "docker" | "podman";
|
||||
} | null;
|
||||
enableWebUi?: boolean;
|
||||
webUiConfig?: WebUiConfig | null;
|
||||
enableProxmox: boolean;
|
||||
enableTmuxMonitor: boolean;
|
||||
enableTerminalToolbar: boolean;
|
||||
|
||||
@@ -31,7 +31,8 @@ export function hostToSSHHost(h: Host): SSHHost {
|
||||
enableAiAssistant: h.enableAiAssistant ?? false,
|
||||
dockerConfig: h.dockerConfig ?? null,
|
||||
enableWebUi: h.enableWebUi ?? false,
|
||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
||||
webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as
|
||||
SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] },
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
|
||||
@@ -195,8 +195,6 @@ export function createHostEditorForm(
|
||||
enableFileManager: host?.enableFileManager ?? false,
|
||||
scpLegacy: host?.scpLegacy ?? false,
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
enableWebUi: host?.enableWebUi ?? false,
|
||||
webUiConfig: host?.webUiConfig ?? { endpoints: [] },
|
||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
|
||||
@@ -508,8 +506,6 @@ export function buildHostEditorPayload(
|
||||
scpLegacy: form.scpLegacy,
|
||||
enableDocker: form.enableDocker,
|
||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||
enableWebUi: form.enableWebUi,
|
||||
webUiConfig: form.enableWebUi ? form.webUiConfig : null,
|
||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||
enableTerminalToolbar: form.enableTerminalToolbar,
|
||||
enableAiAssistant: form.enableAiAssistant,
|
||||
|
||||
@@ -98,7 +98,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
dockerConfig: h.dockerConfig ?? null,
|
||||
enableWebUi: h.enableWebUi ?? false,
|
||||
webUiConfig: h.webUiConfig ?? { endpoints: [] },
|
||||
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
|
||||
enableProxmoxStats:
|
||||
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,
|
||||
|
||||
Reference in new issue
Block a user