feat: direct TCP URI with SSL toggle and optional password auth (#635)
* 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 <pw>` on HTTP and `Sec-WebSocket-Protocol: paseo.bearer.<pw>` 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] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
30f7842e8f
commit
088a6c14c0
+1
-1
@@ -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).
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>): 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<TextInput>(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(
|
||||
() => <Link2 size={16} color={theme.colors.palette.white} />,
|
||||
[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 (
|
||||
<AdaptiveModalSheet
|
||||
title="Direct connection"
|
||||
@@ -261,24 +439,127 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
>
|
||||
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||
|
||||
<View style={styles.portRow}>
|
||||
<View style={hostFieldStyle}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="Host"
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
<View style={portFieldStyle}>
|
||||
<Text style={styles.label}>Port</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel="Port"
|
||||
value={port}
|
||||
onChangeText={setPort}
|
||||
placeholder="6767"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="number-pad"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={styles.checkboxRow}
|
||||
onPress={handleToggleUseTls}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel="Use SSL"
|
||||
accessibilityState={useTlsAccessibilityState}
|
||||
testID="direct-ssl-toggle"
|
||||
>
|
||||
<View style={checkboxStyle}>
|
||||
{useTls ? (
|
||||
<View testID="direct-ssl-toggle-checked">
|
||||
<Check size={14} color={theme.colors.palette.white} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.label}>Use SSL</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={hostInputRef}
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="direct-host-input"
|
||||
onChangeText={handleChangeEndpoint}
|
||||
placeholder="hostname:port"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Optional"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={passwordInputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
|
||||
testID="direct-password-visibility-toggle"
|
||||
>
|
||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Pressable
|
||||
style={styles.advancedToggle}
|
||||
onPress={handleToggleAdvanced}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
|
||||
testID="direct-host-advanced-toggle"
|
||||
>
|
||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel="Connection URI"
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
editable={!isSaving}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleToggleAdvanced}
|
||||
/>
|
||||
) : null}
|
||||
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ class FakeDaemonClient {
|
||||
public closeCalls = 0;
|
||||
public ensureConnectedCalls = 0;
|
||||
public fetchAgentsCalls: FetchAgentsOptions[] = [];
|
||||
public fetchAgentsResponses: Array<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> = [];
|
||||
public fetchAgentsResponses: Awaited<ReturnType<DaemonClient["fetchAgents"]>>[] = [];
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connectCalls += 1;
|
||||
@@ -283,9 +283,10 @@ function updateControllerSnapshot(
|
||||
}
|
||||
|
||||
function makeProbeMap(
|
||||
entries: Array<
|
||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||
>,
|
||||
entries: [
|
||||
string,
|
||||
HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? 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: {
|
||||
|
||||
@@ -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<keyof HostRuntimeSnapshotPatch>) {
|
||||
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<HostProfile> {
|
||||
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<HostProfile>;
|
||||
upsertRelayConnection: (input: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
|
||||
@@ -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 <path>", "Paseo home directory (default: ~/.paseo)")
|
||||
.action(withOutput(runSetPasswordCommand));
|
||||
|
||||
return daemon;
|
||||
}
|
||||
|
||||
@@ -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<string | symbol>;
|
||||
|
||||
export interface SetPasswordOptions {
|
||||
home?: string;
|
||||
promptPassword?: PromptPassword;
|
||||
}
|
||||
|
||||
const setPasswordResultSchema: OutputSchema<SetPasswordResult> = {
|
||||
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<string> {
|
||||
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<SetPasswordResult> {
|
||||
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<SingleResult<SetPasswordResult>> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, string>; socketPath?: string },
|
||||
options?: { headers?: Record<string, string>; 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<typeof createNodeWebSocketFactory>,
|
||||
@@ -211,10 +253,15 @@ async function tryConnectHost(
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
password,
|
||||
connectTimeoutMs: timeout,
|
||||
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
||||
webSocketFactory: (
|
||||
url: string,
|
||||
config?: { headers?: Record<string, string>; 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<string, string> }) =>
|
||||
nodeWebSocketFactory(target, { headers: config?.headers }),
|
||||
webSocketFactory: (
|
||||
target: string,
|
||||
config?: { headers?: Record<string, string>; 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<DaemonC
|
||||
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
|
||||
}
|
||||
const host = hosts[index] as string;
|
||||
const result = await tryConnectHost(host, clientId, timeout, nodeWebSocketFactory);
|
||||
if ("client" in result) return result.client;
|
||||
const password = resolveDaemonPassword(host);
|
||||
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
|
||||
if ("client" in result) {
|
||||
return result.client;
|
||||
}
|
||||
return tryNext(index + 1, result.error);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Command } from "commander";
|
||||
|
||||
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
|
||||
const DAEMON_HOST_OPTION_DESCRIPTION =
|
||||
"Daemon host target (default: local socket/pipe, then localhost:6767)";
|
||||
"Daemon host target: host:port or tcp://host:port?ssl=true&password=secret (default: local socket/pipe, then localhost:6767)";
|
||||
|
||||
export function collectMultiple(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value]);
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
||||
import {
|
||||
getDaemonHost,
|
||||
normalizeDaemonHost,
|
||||
resolveDaemonPassword,
|
||||
resolveDaemonTarget,
|
||||
resolveDefaultDaemonHosts,
|
||||
} from "../src/utils/client.js";
|
||||
@@ -37,20 +38,39 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3: local unix socket paths normalize into IPC daemon targets");
|
||||
console.log("Test 3: tcp URI host targets honor ssl=true");
|
||||
const target = resolveDaemonTarget("tcp://example.com:6767?ssl=true&password=query-secret");
|
||||
assert.deepStrictEqual(target, {
|
||||
type: "tcp",
|
||||
url: "wss://example.com:6767/ws",
|
||||
});
|
||||
console.log("✓ tcp URI host targets honor ssl=true\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: tcp URI hosts normalize into canonical direct TCP targets");
|
||||
assert.strictEqual(
|
||||
normalizeDaemonHost("tcp://Example.com:6767?ssl=true&password=query-secret"),
|
||||
"tcp://Example.com:6767?ssl=true&password=query-secret",
|
||||
);
|
||||
console.log("✓ tcp URI hosts normalize into canonical direct TCP targets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: local unix socket paths normalize into IPC daemon targets");
|
||||
assert.strictEqual(normalizeDaemonHost("/tmp/paseo.sock"), "unix:///tmp/paseo.sock");
|
||||
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3b: Windows absolute paths are NOT treated as unix sockets");
|
||||
console.log("Test 5b: Windows absolute paths are NOT treated as unix sockets");
|
||||
assert.strictEqual(normalizeDaemonHost("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
|
||||
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
|
||||
console.log("Test 6: default host resolution tries local IPC first, then localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||
try {
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
@@ -78,7 +98,7 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: configured TCP host is preserved before the localhost fallback");
|
||||
console.log("Test 7: configured TCP host is preserved before the localhost fallback");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
|
||||
try {
|
||||
assert.deepStrictEqual(
|
||||
@@ -95,13 +115,13 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 6: CLI app version resolves for daemon hello compatibility");
|
||||
console.log("Test 8: CLI app version resolves for daemon hello compatibility");
|
||||
assert.match(resolveCliVersion(), /^\d+\.\d+\.\d+/);
|
||||
console.log("✓ CLI app version resolves for daemon hello compatibility\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 7: local IPC still takes priority over configured TCP hosts");
|
||||
console.log("Test 9: local IPC still takes priority over configured TCP hosts");
|
||||
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
|
||||
try {
|
||||
mkdirSync(paseoHome, { recursive: true });
|
||||
@@ -122,4 +142,15 @@ console.log("=== CLI IPC Target Helpers ===\n");
|
||||
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 10: daemon password resolution uses only the TCP URI query");
|
||||
assert.strictEqual(
|
||||
resolveDaemonPassword("tcp://example.com:6767?ssl=true&password=query-secret"),
|
||||
"query-secret",
|
||||
);
|
||||
assert.strictEqual(resolveDaemonPassword("tcp://missing.example:6767"), undefined);
|
||||
assert.strictEqual(resolveDaemonPassword("example.com:6767"), undefined);
|
||||
console.log("✓ daemon password resolution uses only the TCP URI query\n");
|
||||
}
|
||||
|
||||
console.log("=== All CLI IPC target tests passed ===");
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import { isBearerTokenValid } from "@getpaseo/server";
|
||||
import {
|
||||
runSetPasswordCommand,
|
||||
setDaemonPasswordInConfig,
|
||||
type PromptPassword,
|
||||
} from "../src/commands/daemon/set-password.ts";
|
||||
|
||||
console.log("=== Daemon Set Password Command ===\n");
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-set-password-"));
|
||||
const paseoHome = join(root, ".paseo");
|
||||
|
||||
function promptSequence(values: string[]): PromptPassword {
|
||||
return async () => {
|
||||
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 ===");
|
||||
@@ -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",
|
||||
|
||||
@@ -10,11 +10,12 @@ export interface DaemonTransport {
|
||||
export type DaemonTransportFactory = (options: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
protocols?: string[];
|
||||
}) => DaemonTransport;
|
||||
|
||||
export type WebSocketFactory = (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string> },
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) => WebSocketLike;
|
||||
|
||||
export interface WebSocketLike {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,18 +6,22 @@ import type {
|
||||
|
||||
export function defaultWebSocketFactory(
|
||||
url: string,
|
||||
_options?: { headers?: Record<string, string> },
|
||||
options?: { headers?: Record<string, string>; 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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -129,6 +129,13 @@ export interface ImportAgentInput {
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<string, string> = {};
|
||||
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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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";
|
||||
}
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,8 @@ describe("paseo daemon bootstrap", () => {
|
||||
statusText: "test cleanup",
|
||||
}),
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
globalThis.fetch = originalFetch;
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, StreamableHTTPServerTransport>;
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof loadPersistedConfig>,
|
||||
): 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,
|
||||
|
||||
@@ -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<T>(
|
||||
timeoutMs: number,
|
||||
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1080,6 +1080,10 @@ export class Session {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
|
||||
await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -107,6 +107,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
|
||||
@@ -187,6 +187,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
||||
daemonConfigStore as unknown as DaemonConfigStore,
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
speechReadiness
|
||||
? {
|
||||
getReadiness: () => speechReadiness,
|
||||
|
||||
@@ -108,6 +108,7 @@ function createServer(logger: ReturnType<typeof createLogger>) {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
|
||||
@@ -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<string>,
|
||||
hostnames: HostnamesConfig | undefined,
|
||||
@@ -623,6 +635,30 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async attachAuthenticatedSocket(
|
||||
ws: WebSocket,
|
||||
request: IncomingMessage,
|
||||
password: string | undefined,
|
||||
): Promise<void> {
|
||||
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<string>,
|
||||
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;
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:") {
|
||||
|
||||
@@ -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<typeof DirectTcpHostConnectionSchema>;
|
||||
export type NormalizedDirectTcpHostConnection = z.output<typeof DirectTcpHostConnectionSchema>;
|
||||
Reference in New Issue
Block a user