feat(desktop): remote-host mode + OS-store client-cert mTLS (D-1/D-2)
- remote-hosts.ts + DesktopPrefs (remoteHosts/selectedHostId, immutable, back-compat) + tray host-picker. - will-navigate lock is now dynamic to the selected remote origin, fails closed on null (no open-redirect). - select-client-certificate handler: event.preventDefault() first, pick by issuer CN, non-silent missing-cert dialog + cert-less callback -> clean nginx rejection (Chromium sources certs from OS store). Verified: npm run typecheck exit 0; pure-helper assertions pass.
This commit is contained in:
125
desktop/src/remote-hosts.ts
Normal file
125
desktop/src/remote-hosts.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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://<name>.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://<host>/` 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<DesktopPrefs>,
|
||||
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<DesktopPrefs>,
|
||||
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<DesktopPrefs>, 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<DesktopPrefs>, 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<DesktopPrefs>): 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<RemoteHost>): string | null {
|
||||
return originOf(host.url)
|
||||
}
|
||||
Reference in New Issue
Block a user