/** * desktop/src/remote-hosts.ts — D-1: remote-host mode (PLAN_NATIVE_TUNNEL, * Track C-Desktop). Pure, Electron-free helpers over the RemoteHost list held in * DesktopPrefs, plus the URL validation that guards what the BrowserWindow is * allowed to load. * * A "remote host" is one of the user's own machines exposed through the mTLS * reverse tunnel at `https://.terminal.yaojia.wang/`. The desktop app can * switch the main window between "This machine (local)" (the embedded loopback * server, which keeps running) and any configured remote host; the device client * certificate from the OS keychain (see main.ts / D-2) is the only auth gate. * * Every function is PURE and returns NEW objects (immutability — coding-style * CRITICAL rule); none mutate their arguments. No `electron` import, so this file * is unit-testable in plain Node. */ import { randomUUID } from 'node:crypto' import type { DesktopPrefs, RemoteHost } from './types.js' /** * The tunnel DNS zone (PLAN_NATIVE_TUNNEL: wildcard `*.terminal.yaojia.wang`). * Overridable so a differently-named deployment isn't hard-blocked. A remote host * URL must be https (mixed-content + mTLS both require TLS) and, by default, live * under this zone — a boundary sanity check, not the security boundary itself * (that is mTLS at nginx + the will-navigate origin lock). */ export const TERMINAL_ZONE_SUFFIX = process.env.WEBTERM_TUNNEL_ZONE ?? '.terminal.yaojia.wang' /** Parse an origin, returning null for anything malformed. */ function originOf(url: string): string | null { try { return new URL(url).origin } catch { return null } } /** * Validate + normalize a user-entered remote-host URL. Returns the canonical * `https:///` form, or null when the input is not a usable https tunnel URL * (unparseable, non-https, or — by default — outside the tunnel zone). Validation * at the boundary: everything downstream may trust the returned string. */ export function normalizeRemoteUrl(raw: string): string | null { let parsed: URL try { parsed = new URL(raw.trim()) } catch { return null } if (parsed.protocol !== 'https:') return null if (parsed.hostname === '') return null if (TERMINAL_ZONE_SUFFIX !== '' && !parsed.hostname.endsWith(TERMINAL_ZONE_SUFFIX)) { return null } // Canonical form: origin + trailing slash (path/query/hash stripped — the // frontend is served from the root, and the origin is all the lock compares). return `${parsed.origin}/` } /** * Build a RemoteHost from user input, or null if the name is blank or the URL is * not a valid https tunnel URL. The id is a fresh uuid (stable across renames). */ export function makeRemoteHost(name: string, url: string): RemoteHost | null { const trimmedName = name.trim() const normalizedUrl = normalizeRemoteUrl(url) if (trimmedName === '' || normalizedUrl === null) return null return { id: randomUUID(), name: trimmedName, url: normalizedUrl } } /** Look up a configured host by id. */ export function findRemoteHost( prefs: Readonly, id: string, ): RemoteHost | undefined { return prefs.remoteHosts.find((host) => host.id === id) } /** * Immutably append a host, de-duplicating by URL so re-adding the same machine * replaces the old entry (and its name) rather than piling up duplicates. */ export function addRemoteHost( prefs: Readonly, host: RemoteHost, ): DesktopPrefs { const withoutDup = prefs.remoteHosts.filter((existing) => existing.url !== host.url) return { ...prefs, remoteHosts: [...withoutDup, host] } } /** * Immutably remove a host by id. If the removed host was selected, selection * falls back to local (selectedHostId → null) so we never point at a gone host. */ export function removeRemoteHost(prefs: Readonly, id: string): DesktopPrefs { const remoteHosts = prefs.remoteHosts.filter((host) => host.id !== id) const selectedHostId = prefs.selectedHostId === id ? null : prefs.selectedHostId return { ...prefs, remoteHosts, selectedHostId } } /** * Immutably select a host (null → local). Selecting an unknown id is a no-op — * the caller stays on whatever was selected, never a dangling reference. */ export function selectHost(prefs: Readonly, id: string | null): DesktopPrefs { if (id === null) return { ...prefs, selectedHostId: null } if (findRemoteHost(prefs, id) === undefined) return { ...prefs } return { ...prefs, selectedHostId: id } } /** * The currently-selected remote host, or null when local is selected (either * selectedHostId is null, or it dangles at a host that no longer exists). */ export function selectedRemoteHost(prefs: Readonly): RemoteHost | null { if (prefs.selectedHostId === null) return null return findRemoteHost(prefs, prefs.selectedHostId) ?? null } /** The origin a remote host's window should be locked to, or null if malformed. */ export function remoteHostOrigin(host: Readonly): string | null { return originOf(host.url) }