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.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# 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
|
# 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).
|
# 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": ">=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": {
|
"node_modules/better-opn": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz",
|
||||||
@@ -38668,6 +38677,7 @@
|
|||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"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();
|
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 }) => {
|
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Alert, Text, TextInput, View } from "react-native";
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
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 type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
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 { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
field: {
|
field: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
@@ -30,6 +48,66 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
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: {
|
actions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
@@ -45,8 +123,51 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function isHostPortOnly(raw: string): boolean {
|
function isIpv6Host(host: string): boolean {
|
||||||
return !raw.includes("://") && !raw.includes("/");
|
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 {
|
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||||
@@ -56,7 +177,7 @@ function normalizeTransportMessage(message: string | null | undefined): string |
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
|
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
|
||||||
const unique = Array.from(
|
const unique = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
details
|
details
|
||||||
@@ -103,7 +224,9 @@ function buildConnectionFailureCopy(
|
|||||||
const rawLower = raw?.toLowerCase() ?? "";
|
const rawLower = raw?.toLowerCase() ?? "";
|
||||||
let detail: string | null = null;
|
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.";
|
detail = "Connection timed out. Check the host/port and your network.";
|
||||||
} else if (
|
} else if (
|
||||||
rawLower.includes("econnrefused") ||
|
rawLower.includes("econnrefused") ||
|
||||||
@@ -121,9 +244,7 @@ function buildConnectionFailureCopy(
|
|||||||
rawLower.includes("ssl")
|
rawLower.includes("ssl")
|
||||||
) {
|
) {
|
||||||
detail =
|
detail =
|
||||||
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
|
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
|
||||||
} else if (raw) {
|
|
||||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
|
||||||
} else {
|
} else {
|
||||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
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 { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
|
||||||
const endpointRawRef = useRef("");
|
|
||||||
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
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(() => {
|
const clearInput = useCallback(() => {
|
||||||
endpointRawRef.current = "";
|
setHost("");
|
||||||
hostInputRef.current?.clear();
|
setPort("6767");
|
||||||
|
setUseTls(false);
|
||||||
|
setPassword("");
|
||||||
|
setIsPasswordVisible(false);
|
||||||
|
setIsAdvancedOpen(false);
|
||||||
|
setAdvancedUri("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connectIcon = useMemo(
|
const connectIcon = useMemo(
|
||||||
() => <Link2 size={16} color={theme.colors.palette.white} />,
|
() => <Link2 size={16} color={theme.colors.palette.white} />,
|
||||||
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
@@ -182,21 +323,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
|
|
||||||
const raw = endpointRawRef.current.trim();
|
let connection: PreparedDirectConnection;
|
||||||
if (!raw) {
|
|
||||||
setErrorMessage("Host is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isHostPortOnly(raw)) {
|
|
||||||
setErrorMessage("Enter host:port only (no ws://, no /ws)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoint: string;
|
|
||||||
try {
|
try {
|
||||||
endpoint = normalizeHostPort(raw);
|
connection = prepareDirectConnection({ host, port, useTls, password });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Invalid host:port";
|
const message = error instanceof Error ? error.message : "Invalid connection";
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,20 +339,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
const { client, serverId, hostname } = await connectToDaemon({
|
const { client, serverId, hostname } = await connectToDaemon({
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint,
|
endpoint: connection.endpoint,
|
||||||
|
useTls: connection.useTls,
|
||||||
|
...(connection.password ? { password: connection.password } : {}),
|
||||||
});
|
});
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
endpoint,
|
endpoint: connection.endpoint,
|
||||||
|
useTls: connection.useTls,
|
||||||
|
...(connection.password ? { password: connection.password } : {}),
|
||||||
label: hostname ?? undefined,
|
label: hostname ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
|
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
|
||||||
let combined: string;
|
let combined: string;
|
||||||
if (rawDetail && detail && rawDetail !== detail) {
|
if (rawDetail && detail && rawDetail !== detail) {
|
||||||
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
|
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
|
||||||
@@ -232,17 +367,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
}
|
}
|
||||||
setErrorMessage(combined);
|
setErrorMessage(combined);
|
||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
// Desktop/web: also surface it as a dialog for quick visibility.
|
|
||||||
Alert.alert("Connection failed", combined);
|
Alert.alert("Connection failed", combined);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
|
}, [
|
||||||
|
daemons,
|
||||||
const handleChangeEndpoint = useCallback((next: string) => {
|
handleClose,
|
||||||
endpointRawRef.current = next;
|
host,
|
||||||
}, []);
|
isMobile,
|
||||||
|
isSaving,
|
||||||
|
onSaved,
|
||||||
|
password,
|
||||||
|
port,
|
||||||
|
upsertDirectConnection,
|
||||||
|
useTls,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmitEditing = useCallback(() => {
|
const handleSubmitEditing = useCallback(() => {
|
||||||
void handleSave();
|
void handleSave();
|
||||||
@@ -252,6 +393,43 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
void handleSave();
|
void handleSave();
|
||||||
}, [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 (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
title="Direct connection"
|
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>
|
<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}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Host</Text>
|
<Text style={styles.label}>Password</Text>
|
||||||
<AdaptiveTextInput
|
<View style={styles.passwordRow}>
|
||||||
ref={hostInputRef}
|
<AdaptiveTextInput
|
||||||
testID="direct-host-input"
|
testID="direct-password-input"
|
||||||
nativeID="direct-host-input"
|
nativeID="direct-password-input"
|
||||||
accessibilityLabel="direct-host-input"
|
accessibilityLabel="Password"
|
||||||
onChangeText={handleChangeEndpoint}
|
value={password}
|
||||||
placeholder="hostname:port"
|
onChangeText={setPassword}
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholder="Optional"
|
||||||
style={styles.input}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
autoCapitalize="none"
|
style={passwordInputStyle}
|
||||||
autoCorrect={false}
|
autoCapitalize="none"
|
||||||
keyboardType="url"
|
autoCorrect={false}
|
||||||
editable={!isSaving}
|
secureTextEntry={!isPasswordVisible}
|
||||||
returnKeyType="done"
|
editable={!isSaving}
|
||||||
onSubmitEditing={handleSubmitEditing}
|
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}
|
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class FakeDaemonClient {
|
|||||||
public closeCalls = 0;
|
public closeCalls = 0;
|
||||||
public ensureConnectedCalls = 0;
|
public ensureConnectedCalls = 0;
|
||||||
public fetchAgentsCalls: FetchAgentsOptions[] = [];
|
public fetchAgentsCalls: FetchAgentsOptions[] = [];
|
||||||
public fetchAgentsResponses: Array<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> = [];
|
public fetchAgentsResponses: Awaited<ReturnType<DaemonClient["fetchAgents"]>>[] = [];
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
this.connectCalls += 1;
|
this.connectCalls += 1;
|
||||||
@@ -283,9 +283,10 @@ function updateControllerSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeProbeMap(
|
function makeProbeMap(
|
||||||
entries: Array<
|
entries: [
|
||||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
string,
|
||||||
>,
|
HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never,
|
||||||
|
][],
|
||||||
): HostRuntimeSnapshot["probeByConnectionId"] {
|
): HostRuntimeSnapshot["probeByConnectionId"] {
|
||||||
return new Map(entries);
|
return new Map(entries);
|
||||||
}
|
}
|
||||||
@@ -1559,6 +1560,41 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
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 () => {
|
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
||||||
const store = new HostRuntimeStore({
|
const store = new HostRuntimeStore({
|
||||||
deps: {
|
deps: {
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ import {
|
|||||||
type HostConnection,
|
type HostConnection,
|
||||||
type HostProfile,
|
type HostProfile,
|
||||||
} from "@/types/host-connection";
|
} 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 { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
|
||||||
import { getOrCreateClientId } from "@/utils/client-id";
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
import {
|
import {
|
||||||
selectBestConnection,
|
selectBestConnection,
|
||||||
@@ -463,13 +468,17 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
|||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
url: buildDaemonWebSocketUrl(connection.endpoint, {
|
||||||
|
useTls: connection.useTls ?? false,
|
||||||
|
}),
|
||||||
|
...(connection.password ? { password: connection.password } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...base,
|
...base,
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
|
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
}),
|
}),
|
||||||
e2ee: {
|
e2ee: {
|
||||||
@@ -905,7 +914,7 @@ export class HostRuntimeController {
|
|||||||
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
||||||
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
||||||
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
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];
|
const incomingValue = patch[key];
|
||||||
if (equal(this.snapshot[key], incomingValue)) {
|
if (equal(this.snapshot[key], incomingValue)) {
|
||||||
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
|
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
|
||||||
@@ -1383,10 +1392,13 @@ export class HostRuntimeStore {
|
|||||||
async upsertDirectConnection(input: {
|
async upsertDirectConnection(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
useTls?: boolean;
|
||||||
|
password?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
existingClient?: DaemonClient;
|
existingClient?: DaemonClient;
|
||||||
}): Promise<HostProfile> {
|
}): Promise<HostProfile> {
|
||||||
const endpoint = normalizeHostPort(input.endpoint);
|
const endpoint = normalizeHostPort(input.endpoint);
|
||||||
|
const password = input.password?.trim();
|
||||||
return this.upsertHostConnection({
|
return this.upsertHostConnection({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
label: input.label,
|
label: input.label,
|
||||||
@@ -1394,6 +1406,8 @@ export class HostRuntimeStore {
|
|||||||
id: `direct:${endpoint}`,
|
id: `direct:${endpoint}`,
|
||||||
type: "directTcp",
|
type: "directTcp",
|
||||||
endpoint,
|
endpoint,
|
||||||
|
useTls: input.useTls ?? false,
|
||||||
|
...(password ? { password } : {}),
|
||||||
},
|
},
|
||||||
existingClient: input.existingClient,
|
existingClient: input.existingClient,
|
||||||
});
|
});
|
||||||
@@ -1964,6 +1978,8 @@ export interface HostMutations {
|
|||||||
upsertDirectConnection: (input: {
|
upsertDirectConnection: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
useTls?: boolean;
|
||||||
|
password?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) => Promise<HostProfile>;
|
}) => Promise<HostProfile>;
|
||||||
upsertRelayConnection: (input: {
|
upsertRelayConnection: (input: {
|
||||||
|
|||||||
@@ -242,14 +242,16 @@ interface DownloadTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
||||||
const endpoint = daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
|
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
|
||||||
if (!endpoint) {
|
if (!connection) {
|
||||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(buildDaemonWebSocketUrl(endpoint));
|
parsed = new URL(
|
||||||
|
buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
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 { normalizeHostPort, normalizeLoopbackToLocalhost } from "@server/shared/daemon-endpoints";
|
||||||
|
import {
|
||||||
|
DirectTcpHostConnectionSchema,
|
||||||
|
type DirectTcpHostConnection,
|
||||||
|
} from "@server/shared/host-connection-schema";
|
||||||
|
|
||||||
export interface DirectTcpHostConnection {
|
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
|
||||||
id: string;
|
|
||||||
type: "directTcp";
|
|
||||||
endpoint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectSocketHostConnection {
|
export interface DirectSocketHostConnection {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,7 +58,11 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (left.type === "directTcp" && right.type === "directTcp") {
|
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") {
|
if (left.type === "directSocket" && right.type === "directSocket") {
|
||||||
return left.path === right.path;
|
return left.path === right.path;
|
||||||
@@ -238,7 +242,13 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
|||||||
const endpoint = normalizeLoopbackToLocalhost(
|
const endpoint = normalizeLoopbackToLocalhost(
|
||||||
normalizeHostPort(String(record.endpoint ?? "")),
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
deriveLabelFromEndpoint,
|
deriveLabelFromEndpoint,
|
||||||
extractHostPortFromWebSocketUrl,
|
extractHostPortFromWebSocketUrl,
|
||||||
normalizeHostPort,
|
normalizeHostPort,
|
||||||
|
parseConnectionUri,
|
||||||
parseHostPort,
|
parseHostPort,
|
||||||
|
serializeConnectionUri,
|
||||||
|
serializeConnectionUriForStorage,
|
||||||
|
shouldUseTlsForDefaultHostedRelay,
|
||||||
type HostPortParts,
|
type HostPortParts,
|
||||||
} from "@server/shared/daemon-endpoints";
|
} from "@server/shared/daemon-endpoints";
|
||||||
|
|
||||||
@@ -17,9 +21,17 @@ export {
|
|||||||
deriveLabelFromEndpoint,
|
deriveLabelFromEndpoint,
|
||||||
extractHostPortFromWebSocketUrl,
|
extractHostPortFromWebSocketUrl,
|
||||||
normalizeHostPort,
|
normalizeHostPort,
|
||||||
|
parseConnectionUri,
|
||||||
parseHostPort,
|
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" });
|
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const daemonClientMock = vi.hoisted(() => {
|
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 {
|
class MockDaemonClient {
|
||||||
public lastError: string | null = null;
|
public lastError: string | null = nextLastError;
|
||||||
private lastServerInfo = {
|
private lastServerInfo = {
|
||||||
status: "server_info" as const,
|
status: "server_info" as const,
|
||||||
serverId: "srv_probe_test",
|
serverId: "srv_probe_test",
|
||||||
@@ -12,7 +14,7 @@ const daemonClientMock = vi.hoisted(() => {
|
|||||||
version: "0.0.0",
|
version: "0.0.0",
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config: { clientId?: string; url?: string }) {
|
constructor(config: { clientId?: string; url?: string; password?: string }) {
|
||||||
createdConfigs.push(config);
|
createdConfigs.push(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +27,9 @@ const daemonClientMock = vi.hoisted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
|
if (nextConnectError) {
|
||||||
|
throw nextConnectError;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +49,15 @@ const daemonClientMock = vi.hoisted(() => {
|
|||||||
return {
|
return {
|
||||||
MockDaemonClient,
|
MockDaemonClient,
|
||||||
createdConfigs,
|
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", () => {
|
describe("test-daemon-connection connectToDaemon", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
daemonClientMock.createdConfigs.length = 0;
|
daemonClientMock.reset();
|
||||||
clientIdMock.getOrCreateClientId.mockClear();
|
clientIdMock.getOrCreateClientId.mockClear();
|
||||||
|
vi.stubGlobal("__DEV__", false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reuses the app clientId for direct connections", async () => {
|
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",
|
"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 type { HostConnection } from "@/types/host-connection";
|
||||||
import { getOrCreateClientId } from "./client-id";
|
import { getOrCreateClientId } from "./client-id";
|
||||||
import { resolveAppVersion } from "./app-version";
|
import { resolveAppVersion } from "./app-version";
|
||||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
|
import {
|
||||||
|
buildDaemonWebSocketUrl,
|
||||||
|
buildRelayWebSocketUrl,
|
||||||
|
shouldUseTlsForDefaultHostedRelay,
|
||||||
|
} from "./daemon-endpoints";
|
||||||
import {
|
import {
|
||||||
buildLocalDaemonTransportUrl,
|
buildLocalDaemonTransportUrl,
|
||||||
createDesktopLocalDaemonTransportFactory,
|
createDesktopLocalDaemonTransportFactory,
|
||||||
@@ -34,6 +38,23 @@ function pickBestReason(reason: string | null, lastError: string | null): string
|
|||||||
return "Unable to connect";
|
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 {
|
export class DaemonConnectionTestError extends Error {
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
@@ -78,7 +99,8 @@ export async function buildClientConfig(
|
|||||||
if (connection.type === "directTcp") {
|
if (connection.type === "directTcp") {
|
||||||
return {
|
return {
|
||||||
...base,
|
...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,
|
...base,
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint: connection.relayEndpoint,
|
endpoint: connection.relayEndpoint,
|
||||||
|
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
|
||||||
serverId,
|
serverId,
|
||||||
}),
|
}),
|
||||||
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
|
||||||
@@ -142,7 +165,9 @@ export function connectAndProbe(
|
|||||||
error instanceof Error ? error.message : String(error),
|
error instanceof Error ? error.message : String(error),
|
||||||
);
|
);
|
||||||
const lastError = normalizeNonEmptyString(client.lastError);
|
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);
|
void client.close().catch(() => undefined);
|
||||||
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
reject(new DaemonConnectionTestError(message, { reason, lastError }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { startCommand } from "./start.js";
|
|||||||
import { runStatusCommand } from "./status.js";
|
import { runStatusCommand } from "./status.js";
|
||||||
import { runStopCommand } from "./stop.js";
|
import { runStopCommand } from "./stop.js";
|
||||||
import { runRestartCommand } from "./restart.js";
|
import { runRestartCommand } from "./restart.js";
|
||||||
|
import { runSetPasswordCommand } from "./set-password.js";
|
||||||
import { pairCommand } from "./pair.js";
|
import { pairCommand } from "./pair.js";
|
||||||
import { withOutput } from "../../output/index.js";
|
import { withOutput } from "../../output/index.js";
|
||||||
import { addJsonOption } from "../../utils/command-options.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;
|
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 { existsSync, readFileSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
buildDaemonWebSocketUrl,
|
||||||
resolvePaseoHome,
|
|
||||||
DaemonClient,
|
|
||||||
buildRelayWebSocketUrl,
|
buildRelayWebSocketUrl,
|
||||||
|
loadConfig,
|
||||||
|
normalizeHostPort,
|
||||||
|
parseConnectionUri,
|
||||||
parseConnectionOfferFromUrl,
|
parseConnectionOfferFromUrl,
|
||||||
|
DaemonClient,
|
||||||
|
resolvePaseoHome,
|
||||||
|
shouldUseTlsForDefaultHostedRelay,
|
||||||
type ConnectionOffer,
|
type ConnectionOffer,
|
||||||
type WebSocketLike,
|
type WebSocketLike,
|
||||||
} from "@getpaseo/server";
|
} from "@getpaseo/server";
|
||||||
@@ -46,6 +50,27 @@ export function normalizeDaemonHost(raw: string): string | null {
|
|||||||
return 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 (
|
if (
|
||||||
trimmed.startsWith("unix://") ||
|
trimmed.startsWith("unix://") ||
|
||||||
trimmed.startsWith("pipe://") ||
|
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 {
|
return {
|
||||||
type: "tcp",
|
type: "tcp",
|
||||||
url: `ws://${trimmed}/ws`,
|
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
|
* Create a WebSocket factory that works in Node.js
|
||||||
*/
|
*/
|
||||||
function createNodeWebSocketFactory() {
|
function createNodeWebSocketFactory() {
|
||||||
return (
|
return (
|
||||||
url: string,
|
url: string,
|
||||||
options?: { headers?: Record<string, string>; socketPath?: string },
|
options?: { headers?: Record<string, string>; protocols?: string[]; socketPath?: string },
|
||||||
): WebSocketLike => {
|
): WebSocketLike => {
|
||||||
return new WebSocket(url, {
|
return new WebSocket(url, options?.protocols, {
|
||||||
headers: options?.headers,
|
headers: options?.headers,
|
||||||
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
||||||
}) as unknown as WebSocketLike;
|
}) as unknown as WebSocketLike;
|
||||||
@@ -201,6 +242,7 @@ function createNodeWebSocketFactory() {
|
|||||||
*/
|
*/
|
||||||
async function tryConnectHost(
|
async function tryConnectHost(
|
||||||
host: string,
|
host: string,
|
||||||
|
password: string | undefined,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||||
@@ -211,10 +253,15 @@ async function tryConnectHost(
|
|||||||
clientId,
|
clientId,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
appVersion: resolveCliVersion(),
|
appVersion: resolveCliVersion(),
|
||||||
|
password,
|
||||||
connectTimeoutMs: timeout,
|
connectTimeoutMs: timeout,
|
||||||
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
|
webSocketFactory: (
|
||||||
|
url: string,
|
||||||
|
config?: { headers?: Record<string, string>; protocols?: string[] },
|
||||||
|
) =>
|
||||||
nodeWebSocketFactory(url, {
|
nodeWebSocketFactory(url, {
|
||||||
headers: config?.headers,
|
headers: config?.headers,
|
||||||
|
protocols: config?.protocols,
|
||||||
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
||||||
}),
|
}),
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
@@ -239,6 +286,7 @@ async function connectViaRelayOffer(
|
|||||||
endpoint: offer.relay.endpoint,
|
endpoint: offer.relay.endpoint,
|
||||||
serverId: offer.serverId,
|
serverId: offer.serverId,
|
||||||
role: "client",
|
role: "client",
|
||||||
|
useTls: shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
@@ -247,8 +295,10 @@ async function connectViaRelayOffer(
|
|||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
appVersion: resolveCliVersion(),
|
appVersion: resolveCliVersion(),
|
||||||
connectTimeoutMs: timeout,
|
connectTimeoutMs: timeout,
|
||||||
webSocketFactory: (target: string, config?: { headers?: Record<string, string> }) =>
|
webSocketFactory: (
|
||||||
nodeWebSocketFactory(target, { headers: config?.headers }),
|
target: string,
|
||||||
|
config?: { headers?: Record<string, string>; protocols?: string[] },
|
||||||
|
) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }),
|
||||||
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||||
reconnect: { enabled: false },
|
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(", ")}`);
|
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
|
||||||
}
|
}
|
||||||
const host = hosts[index] as string;
|
const host = hosts[index] as string;
|
||||||
const result = await tryConnectHost(host, clientId, timeout, nodeWebSocketFactory);
|
const password = resolveDaemonPassword(host);
|
||||||
if ("client" in result) return result.client;
|
const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory);
|
||||||
|
if ("client" in result) {
|
||||||
|
return result.client;
|
||||||
|
}
|
||||||
return tryNext(index + 1, result.error);
|
return tryNext(index + 1, result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Command } from "commander";
|
|||||||
|
|
||||||
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
|
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
|
||||||
const DAEMON_HOST_OPTION_DESCRIPTION =
|
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[] {
|
export function collectMultiple(value: string, previous: string[]): string[] {
|
||||||
return previous.concat([value]);
|
return previous.concat([value]);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
|||||||
import {
|
import {
|
||||||
getDaemonHost,
|
getDaemonHost,
|
||||||
normalizeDaemonHost,
|
normalizeDaemonHost,
|
||||||
|
resolveDaemonPassword,
|
||||||
resolveDaemonTarget,
|
resolveDaemonTarget,
|
||||||
resolveDefaultDaemonHosts,
|
resolveDefaultDaemonHosts,
|
||||||
} from "../src/utils/client.js";
|
} 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");
|
assert.strictEqual(normalizeDaemonHost("/tmp/paseo.sock"), "unix:///tmp/paseo.sock");
|
||||||
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
|
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("C:\\Users\\foo\\.paseo\\paseo.sock"), null);
|
||||||
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
|
assert.strictEqual(normalizeDaemonHost("D:\\project\\socket"), null);
|
||||||
console.log("✓ Windows absolute paths are not treated as unix sockets\n");
|
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-"));
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
|
||||||
try {
|
try {
|
||||||
mkdirSync(paseoHome, { recursive: true });
|
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-"));
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
|
||||||
try {
|
try {
|
||||||
assert.deepStrictEqual(
|
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+/);
|
assert.match(resolveCliVersion(), /^\d+\.\d+\.\d+/);
|
||||||
console.log("✓ CLI app version resolves for daemon hello compatibility\n");
|
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-"));
|
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
|
||||||
try {
|
try {
|
||||||
mkdirSync(paseoHome, { recursive: true });
|
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("✓ 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 ===");
|
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",
|
"@xterm/headless": "^6.0.0",
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ export interface DaemonTransport {
|
|||||||
export type DaemonTransportFactory = (options: {
|
export type DaemonTransportFactory = (options: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
protocols?: string[];
|
||||||
}) => DaemonTransport;
|
}) => DaemonTransport;
|
||||||
|
|
||||||
export type WebSocketFactory = (
|
export type WebSocketFactory = (
|
||||||
url: string,
|
url: string,
|
||||||
options?: { headers?: Record<string, string> },
|
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||||
) => WebSocketLike;
|
) => WebSocketLike;
|
||||||
|
|
||||||
export interface WebSocketLike {
|
export interface WebSocketLike {
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ describe("daemon-client transport helpers", () => {
|
|||||||
expect(close).toHaveBeenCalledWith(1000, "bye");
|
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", () => {
|
test("createWebSocketTransportFactory rejects sends when socket is not open", () => {
|
||||||
const factory = createWebSocketTransportFactory(() => ({
|
const factory = createWebSocketTransportFactory(() => ({
|
||||||
readyState: 3,
|
readyState: 3,
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import type {
|
|||||||
|
|
||||||
export function defaultWebSocketFactory(
|
export function defaultWebSocketFactory(
|
||||||
url: string,
|
url: string,
|
||||||
_options?: { headers?: Record<string, string> },
|
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||||
): WebSocketLike {
|
): 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) {
|
if (!globalWs) {
|
||||||
throw new Error("WebSocket is not available in this runtime");
|
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 {
|
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
|
||||||
return ({ url, headers }) => {
|
return ({ url, headers, protocols }) => {
|
||||||
const ws = factory(url, { headers });
|
const ws = factory(url, { headers, protocols });
|
||||||
if ("binaryType" in ws) {
|
if ("binaryType" in ws) {
|
||||||
try {
|
try {
|
||||||
ws.binaryType = "arraybuffer";
|
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 () => {
|
test("does not reconnect after close when ensureConnected is called", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ export interface ImportAgentInput {
|
|||||||
labels?: Record<string, string>;
|
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 {
|
export type {
|
||||||
DaemonTransport,
|
DaemonTransport,
|
||||||
DaemonTransportFactory,
|
DaemonTransportFactory,
|
||||||
@@ -198,6 +205,7 @@ export interface DaemonClientConfig {
|
|||||||
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
||||||
appVersion?: string;
|
appVersion?: string;
|
||||||
runtimeGeneration?: number | null;
|
runtimeGeneration?: number | null;
|
||||||
|
password?: string;
|
||||||
authHeader?: string;
|
authHeader?: string;
|
||||||
suppressSendErrors?: boolean;
|
suppressSendErrors?: boolean;
|
||||||
transportFactory?: DaemonTransportFactory;
|
transportFactory?: DaemonTransportFactory;
|
||||||
@@ -786,9 +794,13 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (this.config.authHeader) {
|
const password = normalizePassword(this.config.password);
|
||||||
headers["Authorization"] = this.config.authHeader;
|
if (password) {
|
||||||
|
headers.Authorization = `Bearer ${password}`;
|
||||||
|
} else if (this.config.authHeader) {
|
||||||
|
headers.Authorization = this.config.authHeader;
|
||||||
}
|
}
|
||||||
|
const protocols = password ? [`paseo.bearer.${password}`] : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Reconnect can overlap with browser close/error delivery ordering.
|
// Reconnect can overlap with browser close/error delivery ordering.
|
||||||
@@ -813,7 +825,11 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const transportUrl = this.resolveTransportUrlForAttempt();
|
const transportUrl = this.resolveTransportUrlForAttempt();
|
||||||
const transport = transportFactory({ url: transportUrl, headers });
|
const transport = transportFactory({
|
||||||
|
url: transportUrl,
|
||||||
|
headers,
|
||||||
|
...(protocols ? { protocols } : {}),
|
||||||
|
});
|
||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
this.lastServerInfoMessage = null;
|
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",
|
statusText: "test cleanup",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
await daemonHandle.close();
|
await daemonHandle.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
|||||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||||
|
import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js";
|
||||||
|
|
||||||
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ export interface PaseoDaemonConfig {
|
|||||||
relayEndpoint?: string;
|
relayEndpoint?: string;
|
||||||
relayPublicEndpoint?: string;
|
relayPublicEndpoint?: string;
|
||||||
appBaseUrl?: string;
|
appBaseUrl?: string;
|
||||||
|
auth?: DaemonAuthConfig;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
voiceLlmProvider?: AgentProvider | null;
|
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
|
// CORS - allow same-origin + configured origins
|
||||||
const allowedOrigins = new Set([
|
const allowedOrigins = new Set([
|
||||||
...config.corsAllowedOrigins,
|
...config.corsAllowedOrigins,
|
||||||
@@ -330,6 +326,17 @@ export async function createPaseoDaemon(
|
|||||||
next();
|
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
|
// Serve static files from public directory
|
||||||
app.use("/public", express.static(staticDir));
|
app.use("/public", express.static(staticDir));
|
||||||
|
|
||||||
@@ -771,16 +778,24 @@ export async function createPaseoDaemon(
|
|||||||
{
|
{
|
||||||
host: boundListenTarget.host,
|
host: boundListenTarget.host,
|
||||||
port: boundListenTarget.port,
|
port: boundListenTarget.port,
|
||||||
|
authRequired: !!config.auth?.password,
|
||||||
elapsed: elapsed(),
|
elapsed: elapsed(),
|
||||||
},
|
},
|
||||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ path: boundListenTarget.path, elapsed: elapsed() },
|
{
|
||||||
|
path: boundListenTarget.path,
|
||||||
|
authRequired: !!config.auth?.password,
|
||||||
|
elapsed: elapsed(),
|
||||||
|
},
|
||||||
`Server listening on ${boundListenTarget.path}`,
|
`Server listening on ${boundListenTarget.path}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (config.auth?.password) {
|
||||||
|
logger.info("Daemon password authentication enabled");
|
||||||
|
}
|
||||||
|
|
||||||
wsServer = new VoiceAssistantWebSocketServer(
|
wsServer = new VoiceAssistantWebSocketServer(
|
||||||
httpServer,
|
httpServer,
|
||||||
@@ -793,6 +808,7 @@ export async function createPaseoDaemon(
|
|||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
mcpBaseUrl,
|
mcpBaseUrl,
|
||||||
{ allowedOrigins, hostnames: configuredHostnames },
|
{ allowedOrigins, hostnames: configuredHostnames },
|
||||||
|
config.auth,
|
||||||
speechService,
|
speechService,
|
||||||
terminalManager,
|
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";
|
} from "./agent/provider-launch-config.js";
|
||||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||||
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
import { AgentProviderSchema } from "./agent/provider-manifest.js";
|
||||||
|
import { hashDaemonPassword } from "./auth.js";
|
||||||
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||||
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.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(
|
function resolveStaticLoadConfigSettings(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
cli: CliConfigOverrides | undefined,
|
cli: CliConfigOverrides | undefined,
|
||||||
@@ -249,6 +263,7 @@ export function loadConfig(
|
|||||||
relayEndpoint: relay.endpoint,
|
relayEndpoint: relay.endpoint,
|
||||||
relayPublicEndpoint: relay.publicEndpoint,
|
relayPublicEndpoint: relay.publicEndpoint,
|
||||||
appBaseUrl,
|
appBaseUrl,
|
||||||
|
auth: resolveAuthConfig(env, persisted),
|
||||||
openai,
|
openai,
|
||||||
speech,
|
speech,
|
||||||
voiceLlmProvider: voiceLlm.provider,
|
voiceLlmProvider: voiceLlm.provider,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type DaemonTestContext,
|
type DaemonTestContext,
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
} from "./test-utils/index.js";
|
} from "./test-utils/index.js";
|
||||||
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js";
|
||||||
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -74,6 +75,52 @@ function tmpCwd(): string {
|
|||||||
return mkdtempSync(path.join(tmpdir(), "daemon-client-"));
|
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>(
|
function waitForSignal<T>(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
setup: (resolve: (value: T) => void, reject: (error: Error) => void) => () => void,
|
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 serverId = `probe-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||||
const url = buildRelayWebSocketUrl({
|
const url = buildRelayWebSocketUrl({
|
||||||
endpoint: `127.0.0.1:${port}`,
|
endpoint: `127.0.0.1:${port}`,
|
||||||
|
useTls: false,
|
||||||
serverId,
|
serverId,
|
||||||
role: "server",
|
role: "server",
|
||||||
});
|
});
|
||||||
@@ -225,6 +226,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
buildRelayWebSocketUrl({
|
buildRelayWebSocketUrl({
|
||||||
endpoint: `127.0.0.1:${relayPort}`,
|
endpoint: `127.0.0.1:${relayPort}`,
|
||||||
|
useTls: false,
|
||||||
serverId,
|
serverId,
|
||||||
role: "client",
|
role: "client",
|
||||||
}),
|
}),
|
||||||
@@ -392,6 +394,7 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
|
|||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
buildRelayWebSocketUrl({
|
buildRelayWebSocketUrl({
|
||||||
endpoint: `127.0.0.1:${relayPort}`,
|
endpoint: `127.0.0.1:${relayPort}`,
|
||||||
|
useTls: false,
|
||||||
serverId,
|
serverId,
|
||||||
role: "client",
|
role: "client",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ export { loadConfig, type CliConfigOverrides } from "./config.js";
|
|||||||
export { resolvePaseoHome } from "./paseo-home.js";
|
export { resolvePaseoHome } from "./paseo-home.js";
|
||||||
export { getOrCreateServerId } from "./server-id.js";
|
export { getOrCreateServerId } from "./server-id.js";
|
||||||
export { createRootLogger, type LogLevel, type LogFormat } from "./logger.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 { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js";
|
||||||
export {
|
export {
|
||||||
ConnectionOfferSchema,
|
ConnectionOfferSchema,
|
||||||
@@ -20,6 +25,18 @@ export {
|
|||||||
type DaemonEvent,
|
type DaemonEvent,
|
||||||
} from "../client/daemon-client.js";
|
} from "../client/daemon-client.js";
|
||||||
export type { WebSocketFactory, WebSocketLike } from "../client/daemon-client-transport-types.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 {
|
export {
|
||||||
ensureLocalSpeechModels,
|
ensureLocalSpeechModels,
|
||||||
listLocalSpeechModels,
|
listLocalSpeechModels,
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ const DEFAULT_FILE_LEVEL: LogLevel = "debug";
|
|||||||
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
const DEFAULT_FILE_ROTATE_SIZE = "10m";
|
||||||
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
const DEFAULT_FILE_ROTATE_MAX_FILES = 2;
|
||||||
const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log";
|
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 {
|
function parseLogLevel(value: string | undefined): LogLevel | undefined {
|
||||||
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
|
if (!value || !LOG_LEVELS.has(value as LogLevel)) {
|
||||||
@@ -285,7 +299,7 @@ export function createRootLogger(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return pino(
|
return pino(
|
||||||
{ level: config.level },
|
{ level: config.level, redact: { paths: REDACT_PATHS, remove: true } },
|
||||||
pino.multistream([
|
pino.multistream([
|
||||||
{ level: config.console.level, stream: consoleStream },
|
{ level: config.console.level, stream: consoleStream },
|
||||||
{ level: config.file.level, stream: fileStream },
|
{ level: config.file.level, stream: fileStream },
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import { describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import { PersistedConfigSchema } from "./persisted-config.js";
|
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", () => {
|
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||||
test("legacy append entries are skipped during migration", () => {
|
test("legacy append entries are skipped during migration", () => {
|
||||||
const parsed = PersistedConfigSchema.parse({
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ const ProvidersSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
const SpeechProviderIdSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -251,6 +261,7 @@ export const PersistedConfigSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
auth: DaemonAuthSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.transform(({ allowedHosts, ...daemon }) => {
|
.transform(({ allowedHosts, ...daemon }) => {
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
type Transport as RelayTransport,
|
type Transport as RelayTransport,
|
||||||
type KeyPair,
|
type KeyPair,
|
||||||
} from "@getpaseo/relay/e2ee";
|
} 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";
|
import type { ExternalSocketMetadata } from "./websocket-server.js";
|
||||||
|
|
||||||
interface RelayTransportOptions {
|
interface RelayTransportOptions {
|
||||||
@@ -151,6 +154,7 @@ export function startRelayTransport({
|
|||||||
const connectionId = ++controlConnectionSeq;
|
const connectionId = ++controlConnectionSeq;
|
||||||
const url = buildRelayWebSocketUrl({
|
const url = buildRelayWebSocketUrl({
|
||||||
endpoint: relayEndpoint,
|
endpoint: relayEndpoint,
|
||||||
|
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
|
||||||
serverId,
|
serverId,
|
||||||
role: "server",
|
role: "server",
|
||||||
});
|
});
|
||||||
@@ -326,6 +330,7 @@ export function startRelayTransport({
|
|||||||
|
|
||||||
const url = buildRelayWebSocketUrl({
|
const url = buildRelayWebSocketUrl({
|
||||||
endpoint: relayEndpoint,
|
endpoint: relayEndpoint,
|
||||||
|
useTls: shouldUseTlsForDefaultHostedRelay(relayEndpoint),
|
||||||
serverId,
|
serverId,
|
||||||
role: "server",
|
role: "server",
|
||||||
connectionId,
|
connectionId,
|
||||||
|
|||||||
@@ -1080,6 +1080,10 @@ export class Session {
|
|||||||
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
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> {
|
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export class DaemonClient extends SharedDaemonClient {
|
|||||||
...config,
|
...config,
|
||||||
clientId,
|
clientId,
|
||||||
webSocketFactory: (url, options) =>
|
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;
|
voiceLlmProviderExplicit?: boolean;
|
||||||
voiceLlmModel?: string | null;
|
voiceLlmModel?: string | null;
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
|
auth?: PaseoDaemonConfig["auth"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestPaseoDaemon {
|
export interface TestPaseoDaemon {
|
||||||
@@ -155,6 +156,7 @@ async function prepareTestDaemonConfig(
|
|||||||
relayEnabled: options.relayEnabled ?? false,
|
relayEnabled: options.relayEnabled ?? false,
|
||||||
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
|
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
|
||||||
appBaseUrl: "https://app.paseo.sh",
|
appBaseUrl: "https://app.paseo.sh",
|
||||||
|
auth: options.auth,
|
||||||
openai: options.openai,
|
openai: options.openai,
|
||||||
speech: options.speech,
|
speech: options.speech,
|
||||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
false,
|
false,
|
||||||
"1.2.3-test",
|
"1.2.3-test",
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
|
|||||||
daemonConfigStore as unknown as DaemonConfigStore,
|
daemonConfigStore as unknown as DaemonConfigStore,
|
||||||
null,
|
null,
|
||||||
{ allowedOrigins: new Set() },
|
{ allowedOrigins: new Set() },
|
||||||
|
undefined,
|
||||||
speechReadiness
|
speechReadiness
|
||||||
? {
|
? {
|
||||||
getReadiness: () => speechReadiness,
|
getReadiness: () => speechReadiness,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ function createServer(logger: ReturnType<typeof createLogger>) {
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
false,
|
false,
|
||||||
"1.2.3-test",
|
"1.2.3-test",
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import type { IncomingMessage, Server as HTTPServer } from "http";
|
import type { IncomingMessage, Server as HTTPServer } from "http";
|
||||||
import { basename, join } from "path";
|
import { basename, join } from "path";
|
||||||
import { hostname as getHostname } from "node:os";
|
import { hostname as getHostname } from "node:os";
|
||||||
@@ -52,6 +52,14 @@ import {
|
|||||||
findLatestPermissionRequest,
|
findLatestPermissionRequest,
|
||||||
} from "../shared/agent-attention-notification.js";
|
} from "../shared/agent-attention-notification.js";
|
||||||
import { createGitHubService, type GitHubService } from "../services/github-service.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 {
|
export interface ExternalSocketMetadata {
|
||||||
transport: "relay";
|
transport: "relay";
|
||||||
@@ -415,6 +423,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
daemonConfigStore: DaemonConfigStore,
|
daemonConfigStore: DaemonConfigStore,
|
||||||
mcpBaseUrl: string | null,
|
mcpBaseUrl: string | null,
|
||||||
wsConfig: WebSocketServerConfig,
|
wsConfig: WebSocketServerConfig,
|
||||||
|
auth?: DaemonAuthConfig,
|
||||||
speech?: SpeechService | null,
|
speech?: SpeechService | null,
|
||||||
terminalManager?: TerminalManager | null,
|
terminalManager?: TerminalManager | null,
|
||||||
dictation?: {
|
dictation?: {
|
||||||
@@ -527,7 +536,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.wss = this.createWebSocketServer(server, wsConfig);
|
this.wss = this.createWebSocketServer(server, wsConfig, auth);
|
||||||
this.startRuntimeMetricsInterval();
|
this.startRuntimeMetricsInterval();
|
||||||
|
|
||||||
this.logger.info("WebSocket server initialized on /ws");
|
this.logger.info("WebSocket server initialized on /ws");
|
||||||
@@ -568,17 +577,20 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private createWebSocketServer(
|
private createWebSocketServer(
|
||||||
server: HTTPServer,
|
server: HTTPServer,
|
||||||
wsConfig: WebSocketServerConfig,
|
wsConfig: WebSocketServerConfig,
|
||||||
|
auth: DaemonAuthConfig | undefined,
|
||||||
): WebSocketServer {
|
): WebSocketServer {
|
||||||
const { allowedOrigins, hostnames } = wsConfig;
|
const { allowedOrigins, hostnames } = wsConfig;
|
||||||
|
const password = auth?.password;
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
server,
|
server,
|
||||||
path: "/ws",
|
path: "/ws",
|
||||||
|
handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
|
||||||
verifyClient: ({ req }, callback) => {
|
verifyClient: ({ req }, callback) => {
|
||||||
this.verifyWsClient(req, allowedOrigins, hostnames, callback);
|
this.verifyWsUpgrade(req, allowedOrigins, hostnames, callback);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
wss.on("connection", (ws, request) => {
|
wss.on("connection", (ws, request) => {
|
||||||
void this.attachSocket(ws, request);
|
void this.attachAuthenticatedSocket(ws, request, password);
|
||||||
});
|
});
|
||||||
return wss;
|
return wss;
|
||||||
}
|
}
|
||||||
@@ -591,7 +603,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
(runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.();
|
(runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private verifyWsClient(
|
private verifyWsUpgrade(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
allowedOrigins: Set<string>,
|
allowedOrigins: Set<string>,
|
||||||
hostnames: HostnamesConfig | undefined,
|
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 {
|
public broadcast(message: WSOutboundMessage): void {
|
||||||
const payload = JSON.stringify(message);
|
const payload = JSON.stringify(message);
|
||||||
for (const ws of this.sessions.keys()) {
|
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 {
|
function stringifyCloseReason(reason: unknown): string | null {
|
||||||
if (typeof reason === "string") {
|
if (typeof reason === "string") {
|
||||||
return reason.length > 0 ? reason : null;
|
return reason.length > 0 ? reason : null;
|
||||||
|
|||||||
@@ -1,16 +1,105 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildDaemonWebSocketUrl,
|
||||||
buildRelayWebSocketUrl,
|
buildRelayWebSocketUrl,
|
||||||
CURRENT_RELAY_PROTOCOL_VERSION,
|
CURRENT_RELAY_PROTOCOL_VERSION,
|
||||||
normalizeRelayProtocolVersion,
|
normalizeRelayProtocolVersion,
|
||||||
|
parseConnectionUri,
|
||||||
|
serializeConnectionUri,
|
||||||
|
serializeConnectionUriForStorage,
|
||||||
} from "./daemon-endpoints.js";
|
} 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", () => {
|
describe("relay websocket URL versioning", () => {
|
||||||
test("defaults relay URLs to v2", () => {
|
test("defaults relay URLs to v2", () => {
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
buildRelayWebSocketUrl({
|
buildRelayWebSocketUrl({
|
||||||
endpoint: "relay.paseo.sh:443",
|
endpoint: "relay.paseo.sh:443",
|
||||||
|
useTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
role: "client",
|
role: "client",
|
||||||
}),
|
}),
|
||||||
@@ -24,6 +113,7 @@ describe("relay websocket URL versioning", () => {
|
|||||||
const url = new URL(
|
const url = new URL(
|
||||||
buildRelayWebSocketUrl({
|
buildRelayWebSocketUrl({
|
||||||
endpoint: "relay.paseo.sh:443",
|
endpoint: "relay.paseo.sh:443",
|
||||||
|
useTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
role: "server",
|
role: "server",
|
||||||
connectionId: "conn_abc123",
|
connectionId: "conn_abc123",
|
||||||
@@ -37,6 +127,7 @@ describe("relay websocket URL versioning", () => {
|
|||||||
const url = new URL(
|
const url = new URL(
|
||||||
buildRelayWebSocketUrl({
|
buildRelayWebSocketUrl({
|
||||||
endpoint: "relay.paseo.sh:443",
|
endpoint: "relay.paseo.sh:443",
|
||||||
|
useTls: true,
|
||||||
serverId: "srv_test",
|
serverId: "srv_test",
|
||||||
role: "server",
|
role: "server",
|
||||||
version: "1",
|
version: "1",
|
||||||
@@ -55,3 +146,31 @@ describe("relay websocket URL versioning", () => {
|
|||||||
expect(() => normalizeRelayProtocolVersion("3")).toThrow('Relay version must be "1" or "2"');
|
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;
|
isIpv6: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConnectionUriParts extends HostPortParts {
|
||||||
|
useTls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedConnectionUri extends ConnectionUriParts {
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type RelayRole = "server" | "client";
|
export type RelayRole = "server" | "client";
|
||||||
export type RelayProtocolVersion = "1" | "2";
|
export type RelayProtocolVersion = "1" | "2";
|
||||||
|
|
||||||
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
|
export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2";
|
||||||
|
export const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||||
|
|
||||||
export function normalizeRelayProtocolVersion(
|
export function normalizeRelayProtocolVersion(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
@@ -73,6 +82,66 @@ export function normalizeHostPort(input: string): string {
|
|||||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
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 {
|
export function normalizeLoopbackToLocalhost(endpoint: string): string {
|
||||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||||
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
|
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 {
|
export interface WebSocketUrlOptions {
|
||||||
return port === 443;
|
useTls: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDaemonWebSocketUrl(endpoint: string): string {
|
export function buildDaemonWebSocketUrl(endpoint: string, opts: WebSocketUrlOptions): string {
|
||||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
const protocol = opts.useTls ? "wss" : "ws";
|
||||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||||
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
|
return new URL(`${protocol}://${hostPart}:${port}/ws`).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRelayWebSocketUrl(params: {
|
export function buildRelayWebSocketUrl(params: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
useTls: boolean;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
role: RelayRole;
|
role: RelayRole;
|
||||||
/**
|
/**
|
||||||
@@ -116,7 +186,7 @@ export function buildRelayWebSocketUrl(params: {
|
|||||||
version?: RelayProtocolVersion | 1 | 2;
|
version?: RelayProtocolVersion | 1 | 2;
|
||||||
}): string {
|
}): string {
|
||||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
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 hostPart = isIpv6 ? `[${host}]` : host;
|
||||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||||
url.searchParams.set("serverId", params.serverId);
|
url.searchParams.set("serverId", params.serverId);
|
||||||
@@ -128,6 +198,14 @@ export function buildRelayWebSocketUrl(params: {
|
|||||||
return url.toString();
|
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 {
|
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
|
||||||
const parsed = new URL(wsUrl);
|
const parsed = new URL(wsUrl);
|
||||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
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