From 088a6c14c0e152abf2fed8def6e95444d9c2841a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Apr 2026 22:05:49 +0800 Subject: [PATCH] feat: direct TCP URI with SSL toggle and optional password auth (#635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: direct TCP URI with SSL toggle and optional password auth Replaces the heuristic-driven direct-connection model with a user-controlled one. The Add Host dialog now exposes structured Host, Port, "Use SSL", and masked Password fields that compose the canonical `tcp://host:port?ssl=true&password=xxx` URI used as the storage form. The daemon gains optional shared-secret auth: `Authorization: Bearer ` on HTTP and `Sec-WebSocket-Protocol: paseo.bearer.` on the WS upgrade (browser WebSocket can't set custom headers). Configured via config.json `auth.password` or `PASEO_PASSWORD` env. Off by default — old clients keep working unchanged. The `port === 443` heuristic for ws/wss is gone; the explicit `useTls` flag drives scheme selection at every call site. * fix: stabilize direct tcp auth ci checks * fix: restore fetch stub in bootstrap smoke test * fix(app): collapse Advanced section in add-host modal * feat(server): hash daemon password in config * fix: update lockfile signatures and Nix hash * Improve direct TCP auth failures * Fix workspace cwd updates after rebase --------- Co-authored-by: github-actions[bot] --- nix/package.nix | 2 +- package-lock.json | 10 + packages/app/e2e/settings-navigation.spec.ts | 29 ++ .../app/src/components/add-host-modal.tsx | 391 +++++++++++++++--- packages/app/src/runtime/host-runtime.test.ts | 44 +- packages/app/src/runtime/host-runtime.ts | 24 +- packages/app/src/stores/download-store.ts | 8 +- .../app/src/types/host-connection.test.ts | 30 ++ packages/app/src/types/host-connection.ts | 24 +- packages/app/src/utils/daemon-endpoints.ts | 14 +- .../src/utils/test-daemon-connection.test.ts | 72 +++- .../app/src/utils/test-daemon-connection.ts | 31 +- packages/cli/src/commands/daemon/index.ts | 9 + .../cli/src/commands/daemon/set-password.ts | 126 ++++++ packages/cli/src/utils/client.ts | 73 +++- packages/cli/src/utils/command-options.ts | 2 +- .../cli/tests/28-client-ipc-targets.test.ts | 43 +- .../cli/tests/32-daemon-set-password.test.ts | 107 +++++ packages/server/package.json | 1 + .../client/daemon-client-transport-types.ts | 3 +- .../client/daemon-client-transport.test.ts | 21 + .../daemon-client-websocket-transport.ts | 14 +- .../server/src/client/daemon-client.test.ts | 26 ++ packages/server/src/client/daemon-client.ts | 22 +- packages/server/src/server/auth.test.ts | 55 +++ packages/server/src/server/auth.ts | 127 ++++++ .../server/src/server/bootstrap-auth.test.ts | 133 ++++++ .../server/src/server/bootstrap.smoke.test.ts | 2 + packages/server/src/server/bootstrap.ts | 30 +- .../server/src/server/config-auth.test.ts | 58 +++ packages/server/src/server/config.ts | 15 + .../src/server/daemon-client.e2e.test.ts | 47 +++ .../daemon-e2e/relay-transport.e2e.test.ts | 3 + packages/server/src/server/exports.ts | 19 +- packages/server/src/server/logger.ts | 16 +- .../src/server/persisted-config.test.ts | 13 + .../server/src/server/persisted-config.ts | 11 + packages/server/src/server/relay-transport.ts | 7 +- packages/server/src/server/session.ts | 4 + .../src/server/test-utils/daemon-client.ts | 4 +- .../src/server/test-utils/paseo-daemon.ts | 2 + .../websocket-server.notifications.test.ts | 1 + .../websocket-server.relay-reconnect.test.ts | 1 + .../websocket-server.runtime-metrics.test.ts | 1 + .../server/src/server/websocket-server.ts | 64 ++- .../src/shared/daemon-endpoints.test.ts | 119 ++++++ .../server/src/shared/daemon-endpoints.ts | 88 +++- .../src/shared/host-connection-schema.ts | 12 + 48 files changed, 1829 insertions(+), 129 deletions(-) create mode 100644 packages/app/src/types/host-connection.test.ts create mode 100644 packages/cli/src/commands/daemon/set-password.ts create mode 100644 packages/cli/tests/32-daemon-set-password.test.ts create mode 100644 packages/server/src/server/auth.test.ts create mode 100644 packages/server/src/server/auth.ts create mode 100644 packages/server/src/server/bootstrap-auth.test.ts create mode 100644 packages/server/src/server/config-auth.test.ts create mode 100644 packages/server/src/shared/host-connection-schema.ts diff --git a/nix/package.nix b/nix/package.nix index 5ac2f857b..e9a9ddb0a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-GVJ9z48+9tohv1AbD517XNKoLFO+qFkjNUO5KBFfgyM="; + npmDepsHash = "sha256-Wc/BRKf6bRq30FazffKkXcXTF9bo5WFDm6OIxwiNLb4="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index c8c6148e3..491ea8624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16691,6 +16691,15 @@ "node": ">=10.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-opn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", @@ -38668,6 +38677,7 @@ "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.17.1", + "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts index f5425c4cc..73cfac086 100644 --- a/packages/app/e2e/settings-navigation.spec.ts +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -59,6 +59,35 @@ test.describe("Settings sidebar navigation", () => { await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible(); }); + test("direct connection advanced URI round-trips SSL and password into the form", async ({ + page, + }) => { + await gotoAppShell(page); + await openSettings(page); + + await page.getByTestId("settings-add-host").click(); + await page.getByRole("button", { name: "Direct connection" }).click(); + + await page.getByTestId("direct-host-advanced-toggle").click(); + await page + .getByTestId("direct-host-uri-input") + .fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret"); + await page.getByTestId("direct-host-advanced-toggle").click(); + + await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test"); + await expect(page.getByTestId("direct-port-input")).toHaveValue("7443"); + await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible(); + await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret"); + await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0); + + await page.getByTestId("direct-host-advanced-toggle").click(); + await expect(page.getByTestId("direct-host-uri-input")).toHaveValue( + "tcp://example.paseo.test:7443?ssl=true&password=shared-secret", + ); + await page.getByTestId("direct-host-advanced-toggle").click(); + await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0); + }); + test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => { await gotoAppShell(page); await openSettings(page); diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 5c81e79ba..93296b37a 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -1,17 +1,35 @@ -import { useCallback, useMemo, useRef, useState } from "react"; -import { Alert, Text, TextInput, View } from "react-native"; +import { useCallback, useMemo, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { Link2 } from "lucide-react-native"; +import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; -import { normalizeHostPort } from "@/utils/daemon-endpoints"; +import { + parseConnectionUri, + serializeConnectionUri, + serializeConnectionUriForStorage, +} from "@/utils/daemon-endpoints"; import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection"; import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; const FLEX_ONE_STYLE = { flex: 1 } as const; +interface DirectConnectionDraft { + host: string; + port: string; + useTls: boolean; + password: string; +} + +interface PreparedDirectConnection { + uri: string; + endpoint: string; + useTls: boolean; + password?: string; +} + const styles = StyleSheet.create((theme) => ({ field: { gap: theme.spacing[2], @@ -30,6 +48,66 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 1, borderColor: theme.colors.border, }, + portRow: { + flexDirection: "row", + gap: theme.spacing[3], + }, + hostField: { + flex: 1, + minWidth: 0, + }, + portField: { + width: 112, + }, + passwordRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + passwordInput: { + flex: 1, + minWidth: 0, + }, + iconButton: { + width: 44, + height: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface2, + borderWidth: 1, + borderColor: theme.colors.border, + }, + checkboxRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + }, + checkbox: { + width: 22, + height: 22, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + checkboxChecked: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, + advancedToggle: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + alignSelf: "flex-start", + paddingVertical: theme.spacing[1], + }, + advancedText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, actions: { flexDirection: "row", gap: theme.spacing[3], @@ -45,8 +123,51 @@ const styles = StyleSheet.create((theme) => ({ }, })); -function isHostPortOnly(raw: string): boolean { - return !raw.includes("://") && !raw.includes("/"); +function isIpv6Host(host: string): boolean { + return host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); +} + +function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string { + const host = draft.host.trim(); + const port = Number(draft.port.trim()); + if (!host) { + throw new Error("Host is required"); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Port must be between 1 and 65535"); + } + + return serializeConnectionUriForStorage({ + host, + port, + isIpv6: isIpv6Host(host), + useTls: draft.useTls, + ...(draft.password ? { password: draft.password } : {}), + }); +} + +function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection { + const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft)); + const endpoint = parsed.isIpv6 + ? `[${parsed.host}]:${parsed.port}` + : `${parsed.host}:${parsed.port}`; + + return { + uri: serializeConnectionUri(parsed), + endpoint, + useTls: parsed.useTls, + ...(parsed.password ? { password: parsed.password } : {}), + }; +} + +function draftFromConnectionUri(uri: string): DirectConnectionDraft { + const parsed = parseConnectionUri(uri); + return { + host: parsed.host, + port: String(parsed.port), + useTls: parsed.useTls, + password: parsed.password ?? "", + }; } function normalizeTransportMessage(message: string | null | undefined): string | null { @@ -56,7 +177,7 @@ function normalizeTransportMessage(message: string | null | undefined): string | return trimmed; } -function formatTechnicalTransportDetails(details: Array): string | null { +function formatTechnicalTransportDetails(details: (string | null)[]): string | null { const unique = Array.from( new Set( details @@ -103,7 +224,9 @@ function buildConnectionFailureCopy( const rawLower = raw?.toLowerCase() ?? ""; let detail: string | null = null; - if (rawLower.includes("timed out")) { + if (raw === "Incorrect password" || raw === "Password required") { + detail = raw; + } else if (rawLower.includes("timed out")) { detail = "Connection timed out. Check the host/port and your network."; } else if ( rawLower.includes("econnrefused") || @@ -121,9 +244,7 @@ function buildConnectionFailureCopy( rawLower.includes("ssl") ) { detail = - "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access."; - } else if (raw) { - detail = "Unable to connect. Check the host/port and that the daemon is reachable."; + "TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon."; } else { detail = "Unable to connect. Check the host/port and that the daemon is reachable."; } @@ -149,21 +270,41 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const { upsertDirectConnection } = useHostMutations(); const isMobile = useIsCompactFormFactor(); - const hostInputRef = useRef(null); - const endpointRawRef = useRef(""); - const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("6767"); + const [useTls, setUseTls] = useState(false); + const [password, setPassword] = useState(""); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [advancedUri, setAdvancedUri] = useState(""); const clearInput = useCallback(() => { - endpointRawRef.current = ""; - hostInputRef.current?.clear(); + setHost(""); + setPort("6767"); + setUseTls(false); + setPassword(""); + setIsPasswordVisible(false); + setIsAdvancedOpen(false); + setAdvancedUri(""); }, []); const connectIcon = useMemo( () => , [theme.colors.palette.white], ); + const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); + const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); + const checkboxStyle = useMemo( + () => [styles.checkbox, useTls ? styles.checkboxChecked : null], + [useTls], + ); + const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []); + const useTlsAccessibilityState = useMemo( + () => ({ checked: useTls, disabled: isSaving }), + [isSaving, useTls], + ); const handleClose = useCallback(() => { if (isSaving) return; @@ -182,21 +323,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const handleSave = useCallback(async () => { if (isSaving) return; - const raw = endpointRawRef.current.trim(); - if (!raw) { - setErrorMessage("Host is required"); - return; - } - if (!isHostPortOnly(raw)) { - setErrorMessage("Enter host:port only (no ws://, no /ws)"); - return; - } - - let endpoint: string; + let connection: PreparedDirectConnection; try { - endpoint = normalizeHostPort(raw); + connection = prepareDirectConnection({ host, port, useTls, password }); } catch (error) { - const message = error instanceof Error ? error.message : "Invalid host:port"; + const message = error instanceof Error ? error.message : "Invalid connection"; setErrorMessage(message); return; } @@ -208,20 +339,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod const { client, serverId, hostname } = await connectToDaemon({ id: "probe", type: "directTcp", - endpoint, + endpoint: connection.endpoint, + useTls: connection.useTls, + ...(connection.password ? { password: connection.password } : {}), }); await client.close().catch(() => undefined); const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); const profile = await upsertDirectConnection({ serverId, - endpoint, + endpoint: connection.endpoint, + useTls: connection.useTls, + ...(connection.password ? { password: connection.password } : {}), label: hostname ?? undefined, }); onSaved?.({ profile, serverId, hostname, isNewHost }); handleClose(); } catch (error) { - const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error); + const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error); let combined: string; if (rawDetail && detail && rawDetail !== detail) { combined = `${title}\n${detail}\nDetails: ${rawDetail}`; @@ -232,17 +367,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod } setErrorMessage(combined); if (!isMobile) { - // Desktop/web: also surface it as a dialog for quick visibility. Alert.alert("Connection failed", combined); } } finally { setIsSaving(false); } - }, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]); - - const handleChangeEndpoint = useCallback((next: string) => { - endpointRawRef.current = next; - }, []); + }, [ + daemons, + handleClose, + host, + isMobile, + isSaving, + onSaved, + password, + port, + upsertDirectConnection, + useTls, + ]); const handleSubmitEditing = useCallback(() => { void handleSave(); @@ -252,6 +393,43 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod void handleSave(); }, [handleSave]); + const handleToggleUseTls = useCallback(() => { + if (isSaving) return; + setUseTls((current) => !current); + }, [isSaving]); + + const handleTogglePasswordVisibility = useCallback(() => { + setIsPasswordVisible((current) => !current); + }, []); + + const handleToggleAdvanced = useCallback(() => { + if (!isAdvancedOpen) { + try { + setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password })); + } catch { + setAdvancedUri(""); + } + setErrorMessage(""); + setIsAdvancedOpen(true); + return; + } + + try { + const next = draftFromConnectionUri(advancedUri); + setHost(next.host); + setPort(next.port); + setUseTls(next.useTls); + setPassword(next.password); + setErrorMessage(""); + } catch { + setErrorMessage(""); + } + setIsAdvancedOpen(false); + }, [advancedUri, host, isAdvancedOpen, password, port, useTls]); + + const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight; + const PasswordIcon = isPasswordVisible ? EyeOff : Eye; + return ( Enter the address of a Paseo server. + + + Host + + + + Port + + + + + + + {useTls ? ( + + + + ) : null} + + Use SSL + + - Host - + Password + + + + + + + + + + + + Advanced + + {isAdvancedOpen ? ( + + ) : null} {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 73a2635be..2507d2782 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -23,7 +23,7 @@ class FakeDaemonClient { public closeCalls = 0; public ensureConnectedCalls = 0; public fetchAgentsCalls: FetchAgentsOptions[] = []; - public fetchAgentsResponses: Array>> = []; + public fetchAgentsResponses: Awaited>[] = []; async connect(): Promise { this.connectCalls += 1; @@ -283,9 +283,10 @@ function updateControllerSnapshot( } function makeProbeMap( - entries: Array< - [string, HostRuntimeSnapshot["probeByConnectionId"] extends Map ? T : never] - >, + entries: [ + string, + HostRuntimeSnapshot["probeByConnectionId"] extends Map ? T : never, + ][], ): HostRuntimeSnapshot["probeByConnectionId"] { return new Map(entries); } @@ -1559,6 +1560,41 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("upsertDirectConnection stores SSL and password settings", async () => { + const store = new HostRuntimeStore({ + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await store.upsertDirectConnection({ + serverId: "srv_tls_password", + endpoint: "example.paseo.test:7443", + useTls: true, + password: "shared-secret", + label: "tls host", + }); + + const host = store.getHosts().find((entry) => entry.serverId === "srv_tls_password"); + expect(host?.connections).toEqual([ + { + id: "direct:example.paseo.test:7443", + type: "directTcp", + endpoint: "example.paseo.test:7443", + useTls: true, + password: "shared-secret", + }, + ]); + + store.syncHosts([]); + }); + it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { const store = new HostRuntimeStore({ deps: { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index a0b8c4743..ae2b7658d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -16,12 +16,17 @@ import { type HostConnection, type HostProfile, } from "@/types/host-connection"; -import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl, + decodeOfferFragmentPayload, + normalizeHostPort, + shouldUseTlsForDefaultHostedRelay, +} from "@/utils/daemon-endpoints"; import { resolveAppVersion } from "@/utils/app-version"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { connectToDaemon } from "@/utils/test-daemon-connection"; -import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectBestConnection, @@ -463,13 +468,17 @@ function createDefaultDeps(): HostRuntimeControllerDeps { if (connection.type === "directTcp") { return new DaemonClient({ ...base, - url: buildDaemonWebSocketUrl(connection.endpoint), + url: buildDaemonWebSocketUrl(connection.endpoint, { + useTls: connection.useTls ?? false, + }), + ...(connection.password ? { password: connection.password } : {}), }); } return new DaemonClient({ ...base, url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, + useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId: host.serverId, }), e2ee: { @@ -905,7 +914,7 @@ export class HostRuntimeController { private updateSnapshot(patch: HostRuntimeSnapshotPatch): void { const preservedPatch: HostRuntimeSnapshotPatch = { ...patch }; let hasChanged = this.host.serverId !== this.snapshot.serverId; - for (const key of Object.keys(patch) as Array) { + for (const key of Object.keys(patch) as (keyof HostRuntimeSnapshotPatch)[]) { const incomingValue = patch[key]; if (equal(this.snapshot[key], incomingValue)) { setSnapshotPatchField(preservedPatch, key, this.snapshot[key]); @@ -1383,10 +1392,13 @@ export class HostRuntimeStore { async upsertDirectConnection(input: { serverId: string; endpoint: string; + useTls?: boolean; + password?: string; label?: string; existingClient?: DaemonClient; }): Promise { const endpoint = normalizeHostPort(input.endpoint); + const password = input.password?.trim(); return this.upsertHostConnection({ serverId: input.serverId, label: input.label, @@ -1394,6 +1406,8 @@ export class HostRuntimeStore { id: `direct:${endpoint}`, type: "directTcp", endpoint, + useTls: input.useTls ?? false, + ...(password ? { password } : {}), }, existingClient: input.existingClient, }); @@ -1964,6 +1978,8 @@ export interface HostMutations { upsertDirectConnection: (input: { serverId: string; endpoint: string; + useTls?: boolean; + password?: string; label?: string; }) => Promise; upsertRelayConnection: (input: { diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index c2cd51c65..f043f4c64 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -242,14 +242,16 @@ interface DownloadTarget { } function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget { - const endpoint = daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null; - if (!endpoint) { + const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null; + if (!connection) { return { baseUrl: null, authHeader: null, authCredentials: null }; } let parsed: URL; try { - parsed = new URL(buildDaemonWebSocketUrl(endpoint)); + parsed = new URL( + buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }), + ); } catch { return { baseUrl: null, authHeader: null, authCredentials: null }; } diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts new file mode 100644 index 000000000..a476334fe --- /dev/null +++ b/packages/app/src/types/host-connection.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { normalizeStoredHostProfile } from "./host-connection"; + +describe("normalizeStoredHostProfile", () => { + it("loads direct TCP connections stored before TLS and password fields existed", () => { + const profile = normalizeStoredHostProfile({ + serverId: "srv_old", + label: "Old Host", + connections: [ + { + id: "direct:127.0.0.1:6767", + type: "directTcp", + endpoint: "127.0.0.1:6767", + }, + ], + preferredConnectionId: "direct:127.0.0.1:6767", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + + expect(profile).not.toBeNull(); + expect(profile?.connections[0]).toEqual({ + id: "direct:localhost:6767", + type: "directTcp", + endpoint: "localhost:6767", + useTls: false, + }); + expect(profile?.connections[0]).not.toHaveProperty("password"); + }); +}); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 7d0191de5..6cbcb3c4a 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -1,10 +1,10 @@ import { normalizeHostPort, normalizeLoopbackToLocalhost } from "@server/shared/daemon-endpoints"; +import { + DirectTcpHostConnectionSchema, + type DirectTcpHostConnection, +} from "@server/shared/host-connection-schema"; -export interface DirectTcpHostConnection { - id: string; - type: "directTcp"; - endpoint: string; -} +export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; export interface DirectSocketHostConnection { id: string; @@ -58,7 +58,11 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool } if (left.type === "directTcp" && right.type === "directTcp") { - return left.endpoint === right.endpoint; + return ( + left.endpoint === right.endpoint && + (left.useTls ?? false) === (right.useTls ?? false) && + left.password === right.password + ); } if (left.type === "directSocket" && right.type === "directSocket") { return left.path === right.path; @@ -238,7 +242,13 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { const endpoint = normalizeLoopbackToLocalhost( normalizeHostPort(String(record.endpoint ?? "")), ); - return { id: `direct:${endpoint}`, type: "directTcp", endpoint }; + return DirectTcpHostConnectionSchema.parse({ + id: `direct:${endpoint}`, + type: "directTcp", + endpoint, + useTls: record.useTls, + ...(typeof record.password === "string" ? { password: record.password } : {}), + }); } catch { return null; } diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts index 2b903df91..31b9f46b0 100644 --- a/packages/app/src/utils/daemon-endpoints.ts +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -4,7 +4,11 @@ import { deriveLabelFromEndpoint, extractHostPortFromWebSocketUrl, normalizeHostPort, + parseConnectionUri, parseHostPort, + serializeConnectionUri, + serializeConnectionUriForStorage, + shouldUseTlsForDefaultHostedRelay, type HostPortParts, } from "@server/shared/daemon-endpoints"; @@ -17,9 +21,17 @@ export { deriveLabelFromEndpoint, extractHostPortFromWebSocketUrl, normalizeHostPort, + parseConnectionUri, parseHostPort, + serializeConnectionUri, + serializeConnectionUriForStorage, + shouldUseTlsForDefaultHostedRelay, }; -export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string { +export function buildRelayWebSocketUrl(params: { + endpoint: string; + serverId: string; + useTls: boolean; +}): string { return buildSharedRelayWebSocketUrl({ ...params, role: "client" }); } diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index b33819c81..5f495f86b 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const daemonClientMock = vi.hoisted(() => { - const createdConfigs: Array<{ clientId?: string; url?: string }> = []; + const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = []; + let nextConnectError: Error | null = null; + let nextLastError: string | null = null; class MockDaemonClient { - public lastError: string | null = null; + public lastError: string | null = nextLastError; private lastServerInfo = { status: "server_info" as const, serverId: "srv_probe_test", @@ -12,7 +14,7 @@ const daemonClientMock = vi.hoisted(() => { version: "0.0.0", }; - constructor(config: { clientId?: string; url?: string }) { + constructor(config: { clientId?: string; url?: string; password?: string }) { createdConfigs.push(config); } @@ -25,6 +27,9 @@ const daemonClientMock = vi.hoisted(() => { } async connect(): Promise { + if (nextConnectError) { + throw nextConnectError; + } return; } @@ -44,6 +49,15 @@ const daemonClientMock = vi.hoisted(() => { return { MockDaemonClient, createdConfigs, + setNextConnectFailure: (error: Error, lastError: string | null) => { + nextConnectError = error; + nextLastError = lastError; + }, + reset: () => { + createdConfigs.length = 0; + nextConnectError = null; + nextLastError = null; + }, }; }); @@ -74,8 +88,9 @@ vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({ describe("test-daemon-connection connectToDaemon", () => { beforeEach(() => { - daemonClientMock.createdConfigs.length = 0; + daemonClientMock.reset(); clientIdMock.getOrCreateClientId.mockClear(); + vi.stubGlobal("__DEV__", false); }); it("reuses the app clientId for direct connections", async () => { @@ -115,4 +130,53 @@ describe("test-daemon-connection connectToDaemon", () => { "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock", ); }); + + it("passes direct TCP connection passwords into the client config", async () => { + const mod = await import("./test-daemon-connection"); + + const result = await mod.connectToDaemon({ + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + password: "shared-secret", + }); + await result.client.close(); + + expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret"); + }); + + it("surfaces auth rejection as an incorrect password", async () => { + const mod = await import("./test-daemon-connection"); + daemonClientMock.setNextConnectFailure( + new Error("Transport closed (code 4001)"), + "Transport closed (code 4001)", + ); + + await expect( + mod.connectToDaemon({ + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + password: "wrong-secret", + }), + ).rejects.toMatchObject({ + message: "Incorrect password", + }); + }); + + it("keeps generic transport failures generic when a password was supplied", async () => { + const mod = await import("./test-daemon-connection"); + daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error"); + + await expect( + mod.connectToDaemon({ + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + password: "shared-secret", + }), + ).rejects.toMatchObject({ + message: "Transport error", + }); + }); }); diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index c0885b3e8..0491a21a5 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -3,7 +3,11 @@ import type { DaemonClientConfig } from "@server/client/daemon-client"; import type { HostConnection } from "@/types/host-connection"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; -import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl, + shouldUseTlsForDefaultHostedRelay, +} from "./daemon-endpoints"; import { buildLocalDaemonTransportUrl, createDesktopLocalDaemonTransportFactory, @@ -34,6 +38,23 @@ function pickBestReason(reason: string | null, lastError: string | null): string return "Unable to connect"; } +function isIncorrectPasswordFailure(input: { + config: DaemonClientConfig; + reason: string | null; + lastError: string | null; +}): boolean { + if (!input.config.password) { + return false; + } + const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase(); + return ( + details.includes("401") || + details.includes("4001") || + details.includes("unauthorized") || + details.includes("code 1006") + ); +} + export class DaemonConnectionTestError extends Error { reason: string | null; lastError: string | null; @@ -78,7 +99,8 @@ export async function buildClientConfig( if (connection.type === "directTcp") { return { ...base, - url: buildDaemonWebSocketUrl(connection.endpoint), + url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }), + ...(connection.password ? { password: connection.password } : {}), }; } @@ -90,6 +112,7 @@ export async function buildClientConfig( ...base, url: buildRelayWebSocketUrl({ endpoint: connection.relayEndpoint, + useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId, }), e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, @@ -142,7 +165,9 @@ export function connectAndProbe( error instanceof Error ? error.message : String(error), ); const lastError = normalizeNonEmptyString(client.lastError); - const message = pickBestReason(reason, lastError); + const message = isIncorrectPasswordFailure({ config, reason, lastError }) + ? "Incorrect password" + : pickBestReason(reason, lastError); void client.close().catch(() => undefined); reject(new DaemonConnectionTestError(message, { reason, lastError })); }); diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 74c0dffeb..7eb0a91ca 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -3,6 +3,7 @@ import { startCommand } from "./start.js"; import { runStatusCommand } from "./status.js"; import { runStopCommand } from "./stop.js"; import { runRestartCommand } from "./restart.js"; +import { runSetPasswordCommand } from "./set-password.js"; import { pairCommand } from "./pair.js"; import { withOutput } from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; @@ -60,5 +61,13 @@ export function createDaemonCommand(): Command { }), ); + addJsonOption( + daemon + .command("set-password") + .description("Prompt for and save a hashed daemon password to config.json"), + ) + .option("--home ", "Paseo home directory (default: ~/.paseo)") + .action(withOutput(runSetPasswordCommand)); + return daemon; } diff --git a/packages/cli/src/commands/daemon/set-password.ts b/packages/cli/src/commands/daemon/set-password.ts new file mode 100644 index 000000000..3fd407f58 --- /dev/null +++ b/packages/cli/src/commands/daemon/set-password.ts @@ -0,0 +1,126 @@ +import path from "node:path"; +import type { Command } from "commander"; +import { isCancel, password as passwordPrompt } from "@clack/prompts"; +import { + hashDaemonPassword, + loadPersistedConfig, + savePersistedConfig, + type PersistedConfig, +} from "@getpaseo/server"; +import type { + CommandError, + CommandOptions, + OutputOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; +import { resolveLocalPaseoHome } from "./local-daemon.js"; + +const CONFIG_FILENAME = "config.json"; + +interface SetPasswordResult { + action: "password_set"; + configPath: string; + restartCommand: string; + message: string; +} + +export type PromptPassword = (message: string) => Promise; + +export interface SetPasswordOptions { + home?: string; + promptPassword?: PromptPassword; +} + +const setPasswordResultSchema: OutputSchema = { + idField: "action", + columns: [ + { header: "STATUS", field: "action", color: () => "green" }, + { header: "CONFIG", field: "configPath" }, + { header: "RESTART", field: "restartCommand" }, + ], + renderHuman: (result, options: OutputOptions) => { + const data = result.data as SetPasswordResult; + const rows = [ + `Password written to ${data.configPath}`, + "Restart the daemon for the change to take effect.", + `Run: ${data.restartCommand}`, + ]; + if (options.format === "table") { + return rows.join("\n"); + } + return data.message; + }, +}; + +function createCommandError(code: string, message: string, details?: string): CommandError { + return { code, message, ...(details ? { details } : {}) }; +} + +async function promptForPassword(promptPassword: PromptPassword): Promise { + const first = await promptPassword("New daemon password"); + if (isCancel(first)) { + throw createCommandError("PASSWORD_CANCELLED", "Password update cancelled"); + } + if (typeof first !== "string" || first.length === 0) { + throw createCommandError("PASSWORD_REQUIRED", "Password cannot be empty"); + } + + const second = await promptPassword("Confirm daemon password"); + if (isCancel(second)) { + throw createCommandError("PASSWORD_CANCELLED", "Password update cancelled"); + } + if (first !== second) { + throw createCommandError("PASSWORD_MISMATCH", "Passwords do not match"); + } + + return first; +} + +export async function setDaemonPasswordInConfig( + newPassword: string, + options: SetPasswordOptions = {}, +): Promise { + const paseoHome = resolveLocalPaseoHome(options.home); + const configPath = path.join(paseoHome, CONFIG_FILENAME); + const persisted = loadPersistedConfig(paseoHome); + const nextConfig: PersistedConfig = { + ...persisted, + daemon: { + ...persisted.daemon, + auth: { + ...persisted.daemon?.auth, + password: hashDaemonPassword(newPassword), + }, + }, + }; + + savePersistedConfig(paseoHome, nextConfig); + + return { + action: "password_set", + configPath, + restartCommand: "paseo daemon restart", + message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`, + }; +} + +export async function runSetPasswordCommand( + options: CommandOptions, + _command: Command, +): Promise> { + const promptPassword = + typeof options.promptPassword === "function" + ? (options.promptPassword as PromptPassword) + : (message: string) => passwordPrompt({ message }); + const newPassword = await promptForPassword(promptPassword); + const result = await setDaemonPasswordInConfig(newPassword, { + home: typeof options.home === "string" ? options.home : undefined, + }); + + return { + type: "single", + data: result, + schema: setPasswordResultSchema, + }; +} diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 87916ca75..e30b5f7f9 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,10 +1,14 @@ import { existsSync, readFileSync } from "node:fs"; import { - loadConfig, - resolvePaseoHome, - DaemonClient, + buildDaemonWebSocketUrl, buildRelayWebSocketUrl, + loadConfig, + normalizeHostPort, + parseConnectionUri, parseConnectionOfferFromUrl, + DaemonClient, + resolvePaseoHome, + shouldUseTlsForDefaultHostedRelay, type ConnectionOffer, type WebSocketLike, } from "@getpaseo/server"; @@ -46,6 +50,27 @@ export function normalizeDaemonHost(raw: string): string | null { return null; } + if (trimmed.startsWith("tcp://")) { + try { + const parsed = parseConnectionUri(trimmed); + const endpoint = normalizeHostPort( + parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`, + ); + const query = new URLSearchParams(); + if (parsed.useTls) { + query.set("ssl", "true"); + } + if (parsed.password) { + query.set("password", parsed.password); + } + const queryString = query.toString(); + const suffix = queryString ? `?${queryString}` : ""; + return `tcp://${endpoint}${suffix}`; + } catch { + return null; + } + } + if ( trimmed.startsWith("unix://") || trimmed.startsWith("pipe://") || @@ -174,21 +199,37 @@ export function resolveDaemonTarget(host: string): DaemonTarget { }; } + if (trimmed.startsWith("tcp://")) { + const parsed = parseConnectionUri(trimmed); + const endpoint = normalizeHostPort( + parsed.isIpv6 ? `[${parsed.host}]:${parsed.port}` : `${parsed.host}:${parsed.port}`, + ); + return { + type: "tcp", + url: buildDaemonWebSocketUrl(endpoint, { useTls: parsed.useTls }), + }; + } + return { type: "tcp", url: `ws://${trimmed}/ws`, }; } +export function resolveDaemonPassword(host: string): string | undefined { + const trimmed = host.trim(); + return trimmed.startsWith("tcp://") ? parseConnectionUri(trimmed).password : undefined; +} + /** * Create a WebSocket factory that works in Node.js */ function createNodeWebSocketFactory() { return ( url: string, - options?: { headers?: Record; socketPath?: string }, + options?: { headers?: Record; protocols?: string[]; socketPath?: string }, ): WebSocketLike => { - return new WebSocket(url, { + return new WebSocket(url, options?.protocols, { headers: options?.headers, ...(options?.socketPath ? { socketPath: options.socketPath } : {}), }) as unknown as WebSocketLike; @@ -201,6 +242,7 @@ function createNodeWebSocketFactory() { */ async function tryConnectHost( host: string, + password: string | undefined, clientId: string, timeout: number, nodeWebSocketFactory: ReturnType, @@ -211,10 +253,15 @@ async function tryConnectHost( clientId, clientType: "cli", appVersion: resolveCliVersion(), + password, connectTimeoutMs: timeout, - webSocketFactory: (url: string, config?: { headers?: Record }) => + webSocketFactory: ( + url: string, + config?: { headers?: Record; protocols?: string[] }, + ) => nodeWebSocketFactory(url, { headers: config?.headers, + protocols: config?.protocols, ...(target.type === "ipc" ? { socketPath: target.socketPath } : {}), }), reconnect: { enabled: false }, @@ -239,6 +286,7 @@ async function connectViaRelayOffer( endpoint: offer.relay.endpoint, serverId: offer.serverId, role: "client", + useTls: shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint), }); const client = new DaemonClient({ @@ -247,8 +295,10 @@ async function connectViaRelayOffer( clientType: "cli", appVersion: resolveCliVersion(), connectTimeoutMs: timeout, - webSocketFactory: (target: string, config?: { headers?: Record }) => - nodeWebSocketFactory(target, { headers: config?.headers }), + webSocketFactory: ( + target: string, + config?: { headers?: Record; protocols?: string[] }, + ) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }), e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 }, reconnect: { enabled: false }, }); @@ -293,8 +343,11 @@ export async function connectToDaemon(options?: ConnectOptions): Promise { + const value = values.shift(); + if (value === undefined) { + throw new Error("prompt called too many times"); + } + return value; + }; +} + +try { + { + console.log("Test 1: setDaemonPasswordInConfig writes hash and preserves config fields"); + await mkdir(paseoHome, { recursive: true }); + await writeFile( + join(paseoHome, "config.json"), + `${JSON.stringify( + { + version: 1, + daemon: { + listen: "127.0.0.1:9999", + relay: { enabled: false }, + }, + app: { baseUrl: "https://app.paseo.sh" }, + }, + null, + 2, + )}\n`, + ); + + const result = await setDaemonPasswordInConfig("shared-secret", { home: paseoHome }); + const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8")); + + assert.strictEqual(result.configPath, join(paseoHome, "config.json")); + assert.strictEqual(result.restartCommand, "paseo daemon restart"); + assert.strictEqual(config.daemon.listen, "127.0.0.1:9999"); + assert.strictEqual(config.daemon.relay.enabled, false); + assert.notStrictEqual(config.daemon.auth.password, "shared-secret"); + assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/); + assert.strictEqual( + isBearerTokenValid({ password: config.daemon.auth.password, token: "shared-secret" }), + true, + ); + console.log("✓ set-password writes bcrypt hash without clobbering config\n"); + } + + { + console.log("Test 2: command prompts twice and accepts matching confirmation"); + const result = await runSetPasswordCommand( + { + home: paseoHome, + promptPassword: promptSequence(["new-secret", "new-secret"]), + }, + {} as Command, + ); + const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8")); + + assert.strictEqual(result.data.action, "password_set"); + assert.strictEqual( + isBearerTokenValid({ password: config.daemon.auth.password, token: "new-secret" }), + true, + ); + console.log("✓ command accepts matching confirmation\n"); + } + + { + console.log("Test 3: command refuses mismatched confirmation"); + await assert.rejects( + runSetPasswordCommand( + { + home: paseoHome, + promptPassword: promptSequence(["first-secret", "second-secret"]), + }, + {} as Command, + ), + (error: unknown) => + typeof error === "object" && + error !== null && + "code" in error && + error.code === "PASSWORD_MISMATCH", + ); + console.log("✓ command refuses password mismatch\n"); + } +} finally { + await rm(root, { recursive: true, force: true }); +} + +console.log("=== Daemon Set Password Command Tests Passed ==="); diff --git a/packages/server/package.json b/packages/server/package.json index 760123d0b..9e035101c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -70,6 +70,7 @@ "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.17.1", + "bcryptjs": "^3.0.3", "dotenv": "^17.2.3", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", diff --git a/packages/server/src/client/daemon-client-transport-types.ts b/packages/server/src/client/daemon-client-transport-types.ts index 92c043d0b..6531ee205 100644 --- a/packages/server/src/client/daemon-client-transport-types.ts +++ b/packages/server/src/client/daemon-client-transport-types.ts @@ -10,11 +10,12 @@ export interface DaemonTransport { export type DaemonTransportFactory = (options: { url: string; headers?: Record; + protocols?: string[]; }) => DaemonTransport; export type WebSocketFactory = ( url: string, - options?: { headers?: Record }, + options?: { headers?: Record; protocols?: string[] }, ) => WebSocketLike; export interface WebSocketLike { diff --git a/packages/server/src/client/daemon-client-transport.test.ts b/packages/server/src/client/daemon-client-transport.test.ts index f6dd1bb1b..f70d09eec 100644 --- a/packages/server/src/client/daemon-client-transport.test.ts +++ b/packages/server/src/client/daemon-client-transport.test.ts @@ -75,6 +75,27 @@ describe("daemon-client transport helpers", () => { expect(close).toHaveBeenCalledWith(1000, "bye"); }); + test("createWebSocketTransportFactory passes WebSocket protocols to the socket factory", () => { + const socketFactory = vi.fn(() => ({ + readyState: 1, + send: vi.fn(), + close: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); + + createWebSocketTransportFactory(socketFactory)({ + url: "ws://example.test", + headers: { Authorization: "Bearer shared-secret" }, + protocols: ["paseo.bearer.shared-secret"], + }); + + expect(socketFactory).toHaveBeenCalledWith("ws://example.test", { + headers: { Authorization: "Bearer shared-secret" }, + protocols: ["paseo.bearer.shared-secret"], + }); + }); + test("createWebSocketTransportFactory rejects sends when socket is not open", () => { const factory = createWebSocketTransportFactory(() => ({ readyState: 3, diff --git a/packages/server/src/client/daemon-client-websocket-transport.ts b/packages/server/src/client/daemon-client-websocket-transport.ts index d11c3fe47..59c9b93aa 100644 --- a/packages/server/src/client/daemon-client-websocket-transport.ts +++ b/packages/server/src/client/daemon-client-websocket-transport.ts @@ -6,18 +6,22 @@ import type { export function defaultWebSocketFactory( url: string, - _options?: { headers?: Record }, + options?: { headers?: Record; protocols?: string[] }, ): WebSocketLike { - const globalWs = (globalThis as { WebSocket?: new (url: string) => WebSocketLike }).WebSocket; + const globalWs = ( + globalThis as { + WebSocket?: new (url: string, protocols?: string | string[]) => WebSocketLike; + } + ).WebSocket; if (!globalWs) { throw new Error("WebSocket is not available in this runtime"); } - return new globalWs(url); + return new globalWs(url, options?.protocols); } export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory { - return ({ url, headers }) => { - const ws = factory(url, { headers }); + return ({ url, headers, protocols }) => { + const ws = factory(url, { headers, protocols }); if ("binaryType" in ws) { try { ws.binaryType = "arraybuffer"; diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index ec5a96a1f..98fe32efb 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -184,6 +184,32 @@ test("dedupes in-flight checkout status requests per agentId", async () => { }); }); +test("passes password as HTTP bearer header and WebSocket subprotocol", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const transportFactory = vi.fn(() => mock.transport); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + password: "shared-secret", + logger, + reconnect: { enabled: false }, + transportFactory, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + expect(transportFactory).toHaveBeenCalledWith({ + url: "ws://test", + headers: { Authorization: "Bearer shared-secret" }, + protocols: ["paseo.bearer.shared-secret"], + }); +}); + test("does not reconnect after close when ensureConnected is called", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index d7c2f52f6..ba81bc3f7 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -129,6 +129,13 @@ export interface ImportAgentInput { labels?: Record; } +function normalizePassword(value: string | undefined): string | null { + if (typeof value !== "string") { + return null; + } + return value.length > 0 ? value : null; +} + export type { DaemonTransport, DaemonTransportFactory, @@ -198,6 +205,7 @@ export interface DaemonClientConfig { clientType?: "mobile" | "browser" | "cli" | "mcp"; appVersion?: string; runtimeGeneration?: number | null; + password?: string; authHeader?: string; suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; @@ -786,9 +794,13 @@ export class DaemonClient { } const headers: Record = {}; - if (this.config.authHeader) { - headers["Authorization"] = this.config.authHeader; + const password = normalizePassword(this.config.password); + if (password) { + headers.Authorization = `Bearer ${password}`; + } else if (this.config.authHeader) { + headers.Authorization = this.config.authHeader; } + const protocols = password ? [`paseo.bearer.${password}`] : undefined; try { // Reconnect can overlap with browser close/error delivery ordering. @@ -813,7 +825,11 @@ export class DaemonClient { }); } const transportUrl = this.resolveTransportUrlForAttempt(); - const transport = transportFactory({ url: transportUrl, headers }); + const transport = transportFactory({ + url: transportUrl, + headers, + ...(protocols ? { protocols } : {}), + }); this.transport = transport; this.lastServerInfoMessage = null; diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts new file mode 100644 index 000000000..6cf208ae4 --- /dev/null +++ b/packages/server/src/server/auth.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; + +import { + extractHttpBearerToken, + extractWsBearerProtocol, + extractWsBearerToken, + hashDaemonPassword, + isBearerTokenValidAsync, + isBearerTokenValid, +} from "./auth.js"; + +const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; + +describe("daemon bearer validator", () => { + test("allows any token when no password is configured", () => { + expect(isBearerTokenValid({ password: undefined, token: null })).toBe(true); + expect(isBearerTokenValid({ password: undefined, token: "anything" })).toBe(true); + }); + + test("accepts the plaintext token against the bcrypt hash and rejects missing or wrong tokens", async () => { + expect( + await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "correct-password" }), + ).toBe(true); + expect(isBearerTokenValid({ password: CORRECT_PASSWORD_HASH, token: "correct-password" })).toBe( + true, + ); + expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: null })).toBe( + false, + ); + expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "wrong" })).toBe( + false, + ); + }); + + test("hashes a password into a bcrypt value", () => { + const hash = hashDaemonPassword("correct-password"); + + expect(hash).toMatch(/^\$2[aby]\$12\$/); + expect(isBearerTokenValid({ password: hash, token: "correct-password" })).toBe(true); + }); + + test("extracts HTTP bearer tokens", () => { + expect(extractHttpBearerToken("Bearer secret")).toBe("secret"); + expect(extractHttpBearerToken("Basic secret")).toBeNull(); + expect(extractHttpBearerToken(undefined)).toBeNull(); + }); + + test("extracts WebSocket paseo bearer subprotocol tokens", () => { + const protocol = extractWsBearerProtocol("chat, paseo.bearer.secret.with.dots"); + + expect(protocol).toBe("paseo.bearer.secret.with.dots"); + expect(extractWsBearerToken(protocol)).toBe("secret.with.dots"); + expect(extractWsBearerToken("paseo.other.secret")).toBeNull(); + }); +}); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts new file mode 100644 index 000000000..10cbb5911 --- /dev/null +++ b/packages/server/src/server/auth.ts @@ -0,0 +1,127 @@ +import { compare, compareSync, hashSync } from "bcryptjs"; +import type { RequestHandler } from "express"; + +export const DAEMON_PASSWORD_BCRYPT_COST = 12; + +export interface DaemonAuthConfig { + password?: string; +} + +export interface BearerAuthRejectContext { + path: string; + method: string; + hasToken: boolean; +} + +interface BearerValidationInput { + password: string | undefined; + token: string | null; +} + +export function isBearerTokenValid(input: BearerValidationInput): boolean { + return isBearerTokenValidSync(input); +} + +export async function isBearerTokenValidAsync(input: BearerValidationInput): Promise { + if (!input.password) { + return true; + } + if (input.token === null) { + return false; + } + + return compare(input.token, input.password); +} + +export function isBearerTokenValidSync(input: BearerValidationInput): boolean { + if (!input.password) { + return true; + } + if (input.token === null) { + return false; + } + + return compareSync(input.token, input.password); +} + +export function hashDaemonPassword(password: string): string { + return hashSync(password, DAEMON_PASSWORD_BCRYPT_COST); +} + +export function extractHttpBearerToken(value: string | undefined): string | null { + if (!value) { + return null; + } + const [scheme, ...tokenParts] = value.trim().split(/\s+/); + if (scheme !== "Bearer" || tokenParts.length !== 1) { + return null; + } + return tokenParts[0] ?? null; +} + +export function extractWsBearerProtocol(value: string | undefined): string | null { + if (!value) { + return null; + } + + for (const protocol of value.split(",")) { + const trimmed = protocol.trim(); + const segments = trimmed.split("."); + if (segments[0] === "paseo" && segments[1] === "bearer" && segments.length >= 3) { + return trimmed; + } + } + + return null; +} + +export function extractWsBearerToken(protocol: string | null): string | null { + if (!protocol) { + return null; + } + const segments = protocol.split("."); + if (segments[0] !== "paseo" || segments[1] !== "bearer" || segments.length < 3) { + return null; + } + return segments.slice(2).join("."); +} + +export function createRequireBearerMiddleware( + auth: DaemonAuthConfig | undefined, + onReject?: (context: BearerAuthRejectContext) => void, +): RequestHandler { + const password = auth?.password; + return (req, res, next) => { + if (!password || shouldBypassBearerAuth(req.method, req.path)) { + next(); + return; + } + + void (async () => { + try { + const token = extractHttpBearerToken(req.header("authorization")); + if (!(await isBearerTokenValidAsync({ password, token }))) { + onReject?.({ + path: req.path, + method: req.method, + hasToken: token !== null, + }); + res.status(401).json({ error: "Unauthorized" }); + return; + } + + next(); + } catch (error) { + next(error); + } + })(); + }; +} + +export function shouldBypassBearerAuth(method: string, path: string): boolean { + if (method === "OPTIONS") { + return true; + } + // Public liveness/version endpoints used by local supervisors and health probes. + return path === "/api/health" || path === "/api/status"; +} diff --git a/packages/server/src/server/bootstrap-auth.test.ts b/packages/server/src/server/bootstrap-auth.test.ts new file mode 100644 index 000000000..91e25f19a --- /dev/null +++ b/packages/server/src/server/bootstrap-auth.test.ts @@ -0,0 +1,133 @@ +import { WebSocket } from "ws"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; + +const originalEnv = { ...process.env }; +const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; + +function connectWebSocket(params: { + port: number; + protocol?: string; +}): Promise<{ ws: WebSocket; protocol: string }> { + return new Promise((resolve, reject) => { + const ws = new WebSocket( + `ws://127.0.0.1:${params.port}/ws`, + params.protocol ? [params.protocol] : undefined, + ); + ws.once("open", () => resolve({ ws, protocol: ws.protocol })); + ws.once("error", reject); + }); +} + +async function expectWebSocketCloses(params: { + port: number; + protocol?: string; + code: number; + reason: string; +}): Promise { + const { ws } = await connectWebSocket(params); + await expect( + new Promise<{ code: number; reason: string }>((resolve) => { + ws.once("close", (code, reason) => { + resolve({ code, reason: reason.toString() }); + }); + }), + ).resolves.toEqual({ + code: params.code, + reason: params.reason, + }); +} + +describe("daemon bearer auth", () => { + beforeEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + process.env = { ...originalEnv, PASEO_SUPERVISED: "0" }; + }); + + test("leaves HTTP and WebSocket open when no password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon(); + try { + const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); + expect(response.status).toBe(200); + + const { ws, protocol } = await connectWebSocket({ port: daemonHandle.port }); + expect(protocol).toBe(""); + ws.close(); + } finally { + await daemonHandle.close(); + } + }); + + test("requires Authorization bearer on protected HTTP routes when password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + expect(missing.status).toBe(401); + + const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + headers: { Authorization: "Bearer wrong-password" }, + }); + expect(wrong.status).toBe(401); + + const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + headers: { Authorization: "Bearer correct-password" }, + }); + expect(correct.status).toBe(400); + } finally { + await daemonHandle.close(); + } + }); + + test("bypasses bearer auth for preflight and liveness endpoints", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + const preflight = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + method: "OPTIONS", + headers: { Origin: "https://app.paseo.sh" }, + }); + expect(preflight.status).toBe(204); + + const health = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/health`); + expect(health.status).toBe(200); + + const status = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); + expect(status.status).toBe(200); + } finally { + await daemonHandle.close(); + } + }); + + test("closes WebSocket connections with readable auth failures when password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + await expectWebSocketCloses({ + port: daemonHandle.port, + code: 4401, + reason: "Password required", + }); + await expectWebSocketCloses({ + port: daemonHandle.port, + protocol: "paseo.bearer.wrong-password", + code: 4401, + reason: "Incorrect password", + }); + + const { ws, protocol } = await connectWebSocket({ + port: daemonHandle.port, + protocol: "paseo.bearer.correct-password", + }); + expect(protocol).toBe("paseo.bearer.correct-password"); + ws.close(); + } finally { + await daemonHandle.close(); + } + }); +}); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 9d1096aea..b1f715e19 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -118,6 +118,8 @@ describe("paseo daemon bootstrap", () => { statusText: "test cleanup", }), ); + vi.unstubAllGlobals(); + globalThis.fetch = originalFetch; await daemonHandle.close(); } }); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 1827745cf..6e7161525 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -135,6 +135,7 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; +import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js"; type AgentMcpTransportMap = Map; @@ -190,6 +191,7 @@ export interface PaseoDaemonConfig { relayEndpoint?: string; relayPublicEndpoint?: string; appBaseUrl?: string; + auth?: DaemonAuthConfig; openai?: PaseoOpenAIConfig; speech?: PaseoSpeechConfig; voiceLlmProvider?: AgentProvider | null; @@ -294,12 +296,6 @@ export async function createPaseoDaemon( }); } - // Script proxy — intercepts requests for registered *.localhost hostnames - // and forwards them to the corresponding local script port. Placed after - // the host allowlist (*.localhost is already allowed) but before CORS and - // the rest of the routes so proxied requests skip unnecessary middleware. - app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger })); - // CORS - allow same-origin + configured origins const allowedOrigins = new Set([ ...config.corsAllowedOrigins, @@ -330,6 +326,17 @@ export async function createPaseoDaemon( next(); }); + app.use( + createRequireBearerMiddleware(config.auth, (context) => { + logger.warn(context, "Rejected HTTP request with invalid daemon password"); + }), + ); + + // Script proxy — intercepts requests for registered *.localhost hostnames + // and forwards them to the corresponding local script port. Placed after + // host/CORS/auth checks but before the rest of the routes. + app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger })); + // Serve static files from public directory app.use("/public", express.static(staticDir)); @@ -771,16 +778,24 @@ export async function createPaseoDaemon( { host: boundListenTarget.host, port: boundListenTarget.port, + authRequired: !!config.auth?.password, elapsed: elapsed(), }, `Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`, ); } else { logger.info( - { path: boundListenTarget.path, elapsed: elapsed() }, + { + path: boundListenTarget.path, + authRequired: !!config.auth?.password, + elapsed: elapsed(), + }, `Server listening on ${boundListenTarget.path}`, ); } + if (config.auth?.password) { + logger.info("Daemon password authentication enabled"); + } wsServer = new VoiceAssistantWebSocketServer( httpServer, @@ -793,6 +808,7 @@ export async function createPaseoDaemon( daemonConfigStore, mcpBaseUrl, { allowedOrigins, hostnames: configuredHostnames }, + config.auth, speechService, terminalManager, { diff --git a/packages/server/src/server/config-auth.test.ts b/packages/server/src/server/config-auth.test.ts new file mode 100644 index 000000000..0951a2438 --- /dev/null +++ b/packages/server/src/server/config-auth.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { loadConfig } from "./config.js"; +import { isBearerTokenValid } from "./auth.js"; + +const roots: string[] = []; +const CONFIG_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; + +async function createPaseoHome(config: unknown): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-")); + roots.push(root); + const paseoHome = path.join(root, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); + return paseoHome; +} + +describe("daemon auth config", () => { + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + test("loads optional auth password hash from config.json", async () => { + const paseoHome = await createPaseoHome({ + version: 1, + daemon: { + auth: { password: CONFIG_PASSWORD_HASH }, + }, + }); + + const config = loadConfig(paseoHome, { env: {} }); + + expect(config.auth?.password).toBe(CONFIG_PASSWORD_HASH); + expect(isBearerTokenValid({ password: config.auth?.password, token: "correct-password" })).toBe( + true, + ); + }); + + test("lets PASEO_PASSWORD override config.json auth password hash", async () => { + const paseoHome = await createPaseoHome({ + version: 1, + daemon: { + auth: { password: CONFIG_PASSWORD_HASH }, + }, + }); + + const config = loadConfig(paseoHome, { + env: { PASEO_PASSWORD: "from-env" }, + }); + + expect(config.auth?.password).not.toBe(CONFIG_PASSWORD_HASH); + expect(config.auth?.password).toMatch(/^\$2[aby]\$12\$/); + expect(isBearerTokenValid({ password: config.auth?.password, token: "from-env" })).toBe(true); + }); +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 6137f037e..eda33690f 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -11,6 +11,7 @@ import type { } from "./agent/provider-launch-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "./agent/provider-manifest.js"; +import { hashDaemonPassword } from "./auth.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; @@ -184,6 +185,19 @@ function resolveListenAddress( ); } +function resolveAuthConfig( + env: NodeJS.ProcessEnv, + persisted: ReturnType, +): PaseoDaemonConfig["auth"] { + const envPassword = env.PASEO_PASSWORD?.trim(); + if (envPassword) { + return { password: hashDaemonPassword(envPassword) }; + } + return persisted.daemon?.auth?.password + ? { password: persisted.daemon.auth.password } + : undefined; +} + function resolveStaticLoadConfigSettings( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, @@ -249,6 +263,7 @@ export function loadConfig( relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, appBaseUrl, + auth: resolveAuthConfig(env, persisted), openai, speech, voiceLlmProvider: voiceLlm.provider, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 8c54542e0..3a2915f60 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -12,6 +12,7 @@ import { type DaemonTestContext, DaemonClient, } from "./test-utils/index.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js"; import type { @@ -74,6 +75,52 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-client-")); } +test("DaemonClient connects to a password-protected daemon", async () => { + const daemon = await createTestPaseoDaemon({ + auth: { password: "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76" }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + password: "shared-secret", + }); + + try { + await client.connect(); + const agents = await client.fetchAgents(); + expect(agents.entries).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("DaemonClient surfaces password auth failures from WebSocket close reasons", async () => { + const daemon = await createTestPaseoDaemon({ + auth: { password: "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76" }, + }); + const missingPasswordClient = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + reconnect: { enabled: false }, + }); + const wrongPasswordClient = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + password: "wrong-secret", + reconnect: { enabled: false }, + }); + + try { + await expect(missingPasswordClient.connect()).rejects.toThrow("Password required"); + expect(missingPasswordClient.lastError).toBe("Password required"); + + await expect(wrongPasswordClient.connect()).rejects.toThrow("Incorrect password"); + expect(wrongPasswordClient.lastError).toBe("Incorrect password"); + } finally { + await missingPasswordClient.close(); + await wrongPasswordClient.close(); + await daemon.close(); + } +}); + function waitForSignal( timeoutMs: number, setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void, diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index 6d5018b3d..7fbcf609b 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -106,6 +106,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis const serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const url = buildRelayWebSocketUrl({ endpoint: `127.0.0.1:${port}`, + useTls: false, serverId, role: "server", }); @@ -225,6 +226,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis const ws = new WebSocket( buildRelayWebSocketUrl({ endpoint: `127.0.0.1:${relayPort}`, + useTls: false, serverId, role: "client", }), @@ -392,6 +394,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis const ws = new WebSocket( buildRelayWebSocketUrl({ endpoint: `127.0.0.1:${relayPort}`, + useTls: false, serverId, role: "client", }), diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 9b081b1e6..b221ec5d1 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -4,7 +4,12 @@ export { loadConfig, type CliConfigOverrides } from "./config.js"; export { resolvePaseoHome } from "./paseo-home.js"; export { getOrCreateServerId } from "./server-id.js"; export { createRootLogger, type LogLevel, type LogFormat } from "./logger.js"; -export { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; +export { + loadPersistedConfig, + savePersistedConfig, + type PersistedConfig, +} from "./persisted-config.js"; +export { hashDaemonPassword, isBearerTokenValid } from "./auth.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; export { ConnectionOfferSchema, @@ -20,6 +25,18 @@ export { type DaemonEvent, } from "../client/daemon-client.js"; export type { WebSocketFactory, WebSocketLike } from "../client/daemon-client-transport-types.js"; +export { + buildDaemonWebSocketUrl, + deriveLabelFromEndpoint, + normalizeHostPort, + parseConnectionUri, + shouldUseTlsForDefaultHostedRelay, +} from "../shared/daemon-endpoints.js"; +export { + DirectTcpHostConnectionSchema, + type DirectTcpHostConnection, + type NormalizedDirectTcpHostConnection, +} from "../shared/host-connection-schema.js"; export { ensureLocalSpeechModels, listLocalSpeechModels, diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index d900fecbd..2c303ca4a 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -54,6 +54,20 @@ const DEFAULT_FILE_LEVEL: LogLevel = "debug"; const DEFAULT_FILE_ROTATE_SIZE = "10m"; const DEFAULT_FILE_ROTATE_MAX_FILES = 2; const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; +const REDACT_PATHS = [ + "authorization", + "Authorization", + "headers.authorization", + "headers.Authorization", + "req.headers.authorization", + "req.headers.Authorization", + '["sec-websocket-protocol"]', + "Sec-WebSocket-Protocol", + 'headers["sec-websocket-protocol"]', + "headers.Sec-WebSocket-Protocol", + 'req.headers["sec-websocket-protocol"]', + "req.headers.Sec-WebSocket-Protocol", +]; function parseLogLevel(value: string | undefined): LogLevel | undefined { if (!value || !LOG_LEVELS.has(value as LogLevel)) { @@ -285,7 +299,7 @@ export function createRootLogger( }); return pino( - { level: config.level }, + { level: config.level, redact: { paths: REDACT_PATHS, remove: true } }, pino.multistream([ { level: config.console.level, stream: consoleStream }, { level: config.file.level, stream: fileStream }, diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 636cbe32b..96b848725 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -2,6 +2,19 @@ import { describe, expect, test } from "vitest"; import { PersistedConfigSchema } from "./persisted-config.js"; +describe("PersistedConfigSchema daemon auth config", () => { + test("accepts optional daemon password hash", () => { + const hash = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; + const parsed = PersistedConfigSchema.parse({ + daemon: { + auth: { password: hash }, + }, + }); + + expect(parsed.daemon?.auth?.password).toBe(hash); + }); +}); + describe("PersistedConfigSchema agent provider runtime settings", () => { test("legacy append entries are skipped during migration", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index a24acb47f..ecc00a843 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -62,6 +62,16 @@ const ProvidersSchema = z }) .strict(); +const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, { + message: "Expected a bcrypt hash", +}); + +const DaemonAuthSchema = z + .object({ + password: BcryptHashSchema.optional(), + }) + .strict(); + const SpeechProviderIdSchema = z .string() .trim() @@ -251,6 +261,7 @@ export const PersistedConfigSchema = z }) .strict() .optional(), + auth: DaemonAuthSchema.optional(), }) .strict() .transform(({ allowedHosts, ...daemon }) => { diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index fc93a1d11..62e6cfe5f 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -8,7 +8,10 @@ import { type Transport as RelayTransport, type KeyPair, } from "@getpaseo/relay/e2ee"; -import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js"; +import { + buildRelayWebSocketUrl, + shouldUseTlsForDefaultHostedRelay, +} from "../shared/daemon-endpoints.js"; import type { ExternalSocketMetadata } from "./websocket-server.js"; interface RelayTransportOptions { @@ -151,6 +154,7 @@ export function startRelayTransport({ const connectionId = ++controlConnectionSeq; const url = buildRelayWebSocketUrl({ endpoint: relayEndpoint, + useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint), serverId, role: "server", }); @@ -326,6 +330,7 @@ export function startRelayTransport({ const url = buildRelayWebSocketUrl({ endpoint: relayEndpoint, + useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint), serverId, role: "server", connectionId, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ce2c5734c..cf7d3c026 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1080,6 +1080,10 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds); } + async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable): Promise { + await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd))); + } + async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { await this.syncWorkspaceGitObserverForWorkspace(workspace); await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index b3a177518..d795c3b22 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -30,7 +30,9 @@ export class DaemonClient extends SharedDaemonClient { ...config, clientId, webSocketFactory: (url, options) => - new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike, + new WebSocket(url, options?.protocols, { + headers: options?.headers, + }) as unknown as WebSocketLike, }); } } diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index ae410ce81..59a78ce08 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -29,6 +29,7 @@ interface TestPaseoDaemonOptions { voiceLlmProviderExplicit?: boolean; voiceLlmModel?: string | null; dictationFinalTimeoutMs?: number; + auth?: PaseoDaemonConfig["auth"]; } export interface TestPaseoDaemon { @@ -155,6 +156,7 @@ async function prepareTestDaemonConfig( relayEnabled: options.relayEnabled ?? false, relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", appBaseUrl: "https://app.paseo.sh", + auth: options.auth, openai: options.openai, speech: options.speech, voiceLlmProvider: options.voiceLlmProvider ?? null, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index d52d830bd..4552de94a 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -107,6 +107,7 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, undefined, + undefined, false, "1.2.3-test", undefined, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index f7e8644a7..4e9765866 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -187,6 +187,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu daemonConfigStore as unknown as DaemonConfigStore, null, { allowedOrigins: new Set() }, + undefined, speechReadiness ? { getReadiness: () => speechReadiness, diff --git a/packages/server/src/server/websocket-server.runtime-metrics.test.ts b/packages/server/src/server/websocket-server.runtime-metrics.test.ts index 8435b0a55..3424b5f27 100644 --- a/packages/server/src/server/websocket-server.runtime-metrics.test.ts +++ b/packages/server/src/server/websocket-server.runtime-metrics.test.ts @@ -108,6 +108,7 @@ function createServer(logger: ReturnType) { undefined, undefined, undefined, + undefined, false, "1.2.3-test", undefined, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 25429ccac..fac6adef9 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,4 +1,4 @@ -import { WebSocketServer } from "ws"; +import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { basename, join } from "path"; import { hostname as getHostname } from "node:os"; @@ -52,6 +52,14 @@ import { findLatestPermissionRequest, } from "../shared/agent-attention-notification.js"; import { createGitHubService, type GitHubService } from "../services/github-service.js"; +import { + extractWsBearerProtocol, + extractWsBearerToken, + isBearerTokenValid, + type DaemonAuthConfig, +} from "./auth.js"; + +const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; export interface ExternalSocketMetadata { transport: "relay"; @@ -415,6 +423,7 @@ export class VoiceAssistantWebSocketServer { daemonConfigStore: DaemonConfigStore, mcpBaseUrl: string | null, wsConfig: WebSocketServerConfig, + auth?: DaemonAuthConfig, speech?: SpeechService | null, terminalManager?: TerminalManager | null, dictation?: { @@ -527,7 +536,7 @@ export class VoiceAssistantWebSocketServer { }); }); - this.wss = this.createWebSocketServer(server, wsConfig); + this.wss = this.createWebSocketServer(server, wsConfig, auth); this.startRuntimeMetricsInterval(); this.logger.info("WebSocket server initialized on /ws"); @@ -568,17 +577,20 @@ export class VoiceAssistantWebSocketServer { private createWebSocketServer( server: HTTPServer, wsConfig: WebSocketServerConfig, + auth: DaemonAuthConfig | undefined, ): WebSocketServer { const { allowedOrigins, hostnames } = wsConfig; + const password = auth?.password; const wss = new WebSocketServer({ server, path: "/ws", + handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password), verifyClient: ({ req }, callback) => { - this.verifyWsClient(req, allowedOrigins, hostnames, callback); + this.verifyWsUpgrade(req, allowedOrigins, hostnames, callback); }, }); wss.on("connection", (ws, request) => { - void this.attachSocket(ws, request); + void this.attachAuthenticatedSocket(ws, request, password); }); return wss; } @@ -591,7 +603,7 @@ export class VoiceAssistantWebSocketServer { (runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.(); } - private verifyWsClient( + private verifyWsUpgrade( req: IncomingMessage, allowedOrigins: Set, hostnames: HostnamesConfig | undefined, @@ -623,6 +635,30 @@ export class VoiceAssistantWebSocketServer { } } + private async attachAuthenticatedSocket( + ws: WebSocket, + request: IncomingMessage, + password: string | undefined, + ): Promise { + if (password) { + const requestMetadata = extractSocketRequestMetadata(request); + const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]); + const token = extractWsBearerToken(protocol); + const isAuthorized = isBearerTokenValid({ password, token }); + if (!isAuthorized) { + const reason = token === null ? "Password required" : "Incorrect password"; + this.logger.warn( + { ...requestMetadata, hasToken: token !== null }, + "Rejected WebSocket connection with invalid daemon password", + ); + ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason); + return; + } + } + + await this.attachSocket(ws, request); + } + public broadcast(message: WSOutboundMessage): void { const payload = JSON.stringify(message); for (const ws of this.sessions.keys()) { @@ -1840,6 +1876,24 @@ function extractSocketRequestMetadata(request: unknown): SocketRequestMetadata { }; } +function selectWebSocketProtocol( + protocols: Set, + password: string | undefined, +): string | false { + if (!password) { + return protocols.values().next().value ?? false; + } + + for (const protocol of protocols) { + const token = extractWsBearerToken(protocol); + if (token !== null) { + return protocol; + } + } + + return false; +} + function stringifyCloseReason(reason: unknown): string | null { if (typeof reason === "string") { return reason.length > 0 ? reason : null; diff --git a/packages/server/src/shared/daemon-endpoints.test.ts b/packages/server/src/shared/daemon-endpoints.test.ts index 3103cdbc9..0a4a69540 100644 --- a/packages/server/src/shared/daemon-endpoints.test.ts +++ b/packages/server/src/shared/daemon-endpoints.test.ts @@ -1,16 +1,105 @@ import { describe, expect, test } from "vitest"; import { + buildDaemonWebSocketUrl, buildRelayWebSocketUrl, CURRENT_RELAY_PROTOCOL_VERSION, normalizeRelayProtocolVersion, + parseConnectionUri, + serializeConnectionUri, + serializeConnectionUriForStorage, } from "./daemon-endpoints.js"; +describe("connection URI parsing", () => { + test("round-trips a tcp host and port", () => { + const parsed = parseConnectionUri("tcp://localhost:6767"); + + expect(parsed).toEqual({ + host: "localhost", + port: 6767, + isIpv6: false, + useTls: false, + }); + expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767"); + }); + + test("round-trips an SSL-enabled tcp host and port", () => { + const parsed = parseConnectionUri("tcp://example.com:443?ssl=true"); + + expect(parsed).toEqual({ + host: "example.com", + port: 443, + isIpv6: false, + useTls: true, + }); + expect(serializeConnectionUri(parsed)).toBe("tcp://example.com:443?ssl=true"); + }); + + test("round-trips an IPv6 host", () => { + const parsed = parseConnectionUri("tcp://[::1]:6767?ssl=true"); + + expect(parsed).toEqual({ + host: "::1", + port: 6767, + isIpv6: true, + useTls: true, + }); + expect(serializeConnectionUri(parsed)).toBe("tcp://[::1]:6767?ssl=true"); + }); + + test("rejects a missing port", () => { + expect(() => parseConnectionUri("tcp://localhost")).toThrow("Connection URI port is required"); + }); + + test("rejects an invalid scheme", () => { + expect(() => parseConnectionUri("http://localhost:6767")).toThrow( + "Connection URI protocol must be tcp:", + ); + }); + + test("parses password without including it in the public serializer", () => { + const parsed = parseConnectionUri("tcp://localhost:6767?ssl=true&password=secret"); + + expect(parsed).toEqual({ + host: "localhost", + port: 6767, + isIpv6: false, + useTls: true, + password: "secret", + }); + expect(serializeConnectionUri(parsed)).toBe("tcp://localhost:6767?ssl=true"); + expect(serializeConnectionUriForStorage(parsed)).toBe( + "tcp://localhost:6767?ssl=true&password=secret", + ); + }); + + test("rejects userinfo passwords", () => { + expect(() => parseConnectionUri("tcp://:secret@localhost:6767?ssl=true")).toThrow( + "Connection URI userinfo is not supported", + ); + }); +}); + +describe("daemon websocket URLs", () => { + test("uses ws for port 443 when TLS is disabled", () => { + expect(buildDaemonWebSocketUrl("example.com:443", { useTls: false })).toBe( + "ws://example.com:443/ws", + ); + }); + + test("uses wss for non-443 ports when TLS is enabled", () => { + expect(buildDaemonWebSocketUrl("example.com:6767", { useTls: true })).toBe( + "wss://example.com:6767/ws", + ); + }); +}); + describe("relay websocket URL versioning", () => { test("defaults relay URLs to v2", () => { const url = new URL( buildRelayWebSocketUrl({ endpoint: "relay.paseo.sh:443", + useTls: true, serverId: "srv_test", role: "client", }), @@ -24,6 +113,7 @@ describe("relay websocket URL versioning", () => { const url = new URL( buildRelayWebSocketUrl({ endpoint: "relay.paseo.sh:443", + useTls: true, serverId: "srv_test", role: "server", connectionId: "conn_abc123", @@ -37,6 +127,7 @@ describe("relay websocket URL versioning", () => { const url = new URL( buildRelayWebSocketUrl({ endpoint: "relay.paseo.sh:443", + useTls: true, serverId: "srv_test", role: "server", version: "1", @@ -55,3 +146,31 @@ describe("relay websocket URL versioning", () => { expect(() => normalizeRelayProtocolVersion("3")).toThrow('Relay version must be "1" or "2"'); }); }); + +describe("relay websocket URLs", () => { + test("uses ws for port 443 when TLS is disabled", () => { + const url = new URL( + buildRelayWebSocketUrl({ + endpoint: "relay.paseo.sh:443", + useTls: false, + serverId: "srv_test", + role: "client", + }), + ); + + expect(url.protocol).toBe("ws:"); + }); + + test("uses wss for non-443 ports when TLS is enabled", () => { + const url = new URL( + buildRelayWebSocketUrl({ + endpoint: "relay.paseo.sh:6767", + useTls: true, + serverId: "srv_test", + role: "client", + }), + ); + + expect(url.protocol).toBe("wss:"); + }); +}); diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts index 69366f9e9..5a4040814 100644 --- a/packages/server/src/shared/daemon-endpoints.ts +++ b/packages/server/src/shared/daemon-endpoints.ts @@ -4,10 +4,19 @@ export interface HostPortParts { isIpv6: boolean; } +export interface ConnectionUriParts extends HostPortParts { + useTls: boolean; +} + +export interface ParsedConnectionUri extends ConnectionUriParts { + password?: string; +} + export type RelayRole = "server" | "client"; export type RelayProtocolVersion = "1" | "2"; export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2"; +export const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; export function normalizeRelayProtocolVersion( value: unknown, @@ -73,6 +82,66 @@ export function normalizeHostPort(input: string): string { return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; } +export function parseConnectionUri(input: string): ParsedConnectionUri { + const trimmed = input.trim(); + if (!trimmed) { + throw new Error("Connection URI is required"); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Invalid connection URI"); + } + + if (parsed.protocol !== "tcp:") { + throw new Error("Connection URI protocol must be tcp:"); + } + if (!parsed.hostname) { + throw new Error("Connection URI host is required"); + } + if (!parsed.port) { + throw new Error("Connection URI port is required"); + } + if (parsed.username || parsed.password) { + throw new Error("Connection URI userinfo is not supported"); + } + + const isIpv6 = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]"); + const host = isIpv6 ? parsed.hostname.slice(1, -1) : parsed.hostname; + const password = parsed.searchParams.get("password") || undefined; + + return { + host, + port: parsePort(parsed.port, "Invalid connection URI"), + isIpv6, + useTls: parsed.searchParams.get("ssl") === "true", + ...(password ? { password } : {}), + }; +} + +export function serializeConnectionUri(parts: ConnectionUriParts): string { + return createConnectionUri(parts).toString(); +} + +export function serializeConnectionUriForStorage(parts: ParsedConnectionUri): string { + const url = createConnectionUri(parts); + if (parts.password) { + url.searchParams.set("password", parts.password); + } + return url.toString(); +} + +function createConnectionUri(parts: ConnectionUriParts): URL { + const hostPart = parts.isIpv6 ? `[${parts.host}]` : parts.host; + const url = new URL(`tcp://${hostPart}:${parts.port}`); + if (parts.useTls) { + url.searchParams.set("ssl", "true"); + } + return url; +} + export function normalizeLoopbackToLocalhost(endpoint: string): string { const { host, port, isIpv6 } = parseHostPort(endpoint); if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) { @@ -93,19 +162,20 @@ export function deriveLabelFromEndpoint(endpoint: string): string { } } -function shouldUseSecureWebSocket(port: number): boolean { - return port === 443; +export interface WebSocketUrlOptions { + useTls: boolean; } -export function buildDaemonWebSocketUrl(endpoint: string): string { +export function buildDaemonWebSocketUrl(endpoint: string, opts: WebSocketUrlOptions): string { const { host, port, isIpv6 } = parseHostPort(endpoint); - const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; + const protocol = opts.useTls ? "wss" : "ws"; const hostPart = isIpv6 ? `[${host}]` : host; return new URL(`${protocol}://${hostPart}:${port}/ws`).toString(); } export function buildRelayWebSocketUrl(params: { endpoint: string; + useTls: boolean; serverId: string; role: RelayRole; /** @@ -116,7 +186,7 @@ export function buildRelayWebSocketUrl(params: { version?: RelayProtocolVersion | 1 | 2; }): string { const { host, port, isIpv6 } = parseHostPort(params.endpoint); - const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; + const protocol = params.useTls ? "wss" : "ws"; const hostPart = isIpv6 ? `[${host}]` : host; const url = new URL(`${protocol}://${hostPart}:${port}/ws`); url.searchParams.set("serverId", params.serverId); @@ -128,6 +198,14 @@ export function buildRelayWebSocketUrl(params: { return url.toString(); } +export function shouldUseTlsForDefaultHostedRelay(endpoint: string): boolean { + try { + return normalizeHostPort(endpoint) === DEFAULT_RELAY_ENDPOINT; + } catch { + return false; + } +} + export function extractHostPortFromWebSocketUrl(wsUrl: string): string { const parsed = new URL(wsUrl); if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { diff --git a/packages/server/src/shared/host-connection-schema.ts b/packages/server/src/shared/host-connection-schema.ts new file mode 100644 index 000000000..86a98b0e4 --- /dev/null +++ b/packages/server/src/shared/host-connection-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const DirectTcpHostConnectionSchema = z.object({ + id: z.string(), + type: z.literal("directTcp"), + endpoint: z.string(), + useTls: z.boolean().optional().default(false), + password: z.string().optional(), +}); + +export type DirectTcpHostConnection = z.input; +export type NormalizedDirectTcpHostConnection = z.output;