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:
Mohamed Boudra
2026-04-30 22:05:49 +08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 30f7842e8f
commit 088a6c14c0
48 changed files with 1829 additions and 129 deletions
+336 -55
View File
@@ -1,17 +1,35 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link2 } from "lucide-react-native";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { normalizeHostPort } from "@/utils/daemon-endpoints";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
interface DirectConnectionDraft {
host: string;
port: string;
useTls: boolean;
password: string;
}
interface PreparedDirectConnection {
uri: string;
endpoint: string;
useTls: boolean;
password?: string;
}
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -30,6 +48,66 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.border,
},
portRow: {
flexDirection: "row",
gap: theme.spacing[3],
},
hostField: {
flex: 1,
minWidth: 0,
},
portField: {
width: 112,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
passwordInput: {
flex: 1,
minWidth: 0,
},
iconButton: {
width: 44,
height: 44,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
},
checkboxRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accent,
},
advancedToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
alignSelf: "flex-start",
paddingVertical: theme.spacing[1],
},
advancedText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
@@ -45,8 +123,51 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function isHostPortOnly(raw: string): boolean {
return !raw.includes("://") && !raw.includes("/");
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error("Host is required");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
return {
uri: serializeConnectionUri(parsed),
endpoint,
useTls: parsed.useTls,
...(parsed.password ? { password: parsed.password } : {}),
};
}
function draftFromConnectionUri(uri: string): DirectConnectionDraft {
const parsed = parseConnectionUri(uri);
return {
host: parsed.host,
port: String(parsed.port),
useTls: parsed.useTls,
password: parsed.password ?? "",
};
}
function normalizeTransportMessage(message: string | null | undefined): string | null {
@@ -56,7 +177,7 @@ function normalizeTransportMessage(message: string | null | undefined): string |
return trimmed;
}
function formatTechnicalTransportDetails(details: Array<string | null>): string | null {
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
const unique = Array.from(
new Set(
details
@@ -103,7 +224,9 @@ function buildConnectionFailureCopy(
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (rawLower.includes("timed out")) {
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
} else if (rawLower.includes("timed out")) {
detail = "Connection timed out. Check the host/port and your network.";
} else if (
rawLower.includes("econnrefused") ||
@@ -121,9 +244,7 @@ function buildConnectionFailureCopy(
rawLower.includes("ssl")
) {
detail =
"TLS error. Direct connections use an unencrypted local connection. Use relay for remote access.";
} else if (raw) {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
} else {
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
}
@@ -149,21 +270,41 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [host, setHost] = useState("");
const [port, setPort] = useState("6767");
const [useTls, setUseTls] = useState(false);
const [password, setPassword] = useState("");
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const clearInput = useCallback(() => {
endpointRawRef.current = "";
hostInputRef.current?.clear();
setHost("");
setPort("6767");
setUseTls(false);
setPassword("");
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
() => [styles.checkbox, useTls ? styles.checkboxChecked : null],
[useTls],
);
const passwordInputStyle = useMemo(() => [styles.input, styles.passwordInput], []);
const useTlsAccessibilityState = useMemo(
() => ({ checked: useTls, disabled: isSaving }),
[isSaving, useTls],
);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -182,21 +323,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
}
if (!isHostPortOnly(raw)) {
setErrorMessage("Enter host:port only (no ws://, no /ws)");
return;
}
let endpoint: string;
let connection: PreparedDirectConnection;
try {
endpoint = normalizeHostPort(raw);
connection = prepareDirectConnection({ host, port, useTls, password });
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid host:port";
const message = error instanceof Error ? error.message : "Invalid connection";
setErrorMessage(message);
return;
}
@@ -208,20 +339,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
endpoint,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
});
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
endpoint,
endpoint: connection.endpoint,
useTls: connection.useTls,
...(connection.password ? { password: connection.password } : {}),
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
@@ -232,17 +367,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
Alert.alert("Connection failed", combined);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
const handleChangeEndpoint = useCallback((next: string) => {
endpointRawRef.current = next;
}, []);
}, [
daemons,
handleClose,
host,
isMobile,
isSaving,
onSaved,
password,
port,
upsertDirectConnection,
useTls,
]);
const handleSubmitEditing = useCallback(() => {
void handleSave();
@@ -252,6 +393,43 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
void handleSave();
}, [handleSave]);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
}, [isSaving]);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((current) => !current);
}, []);
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
try {
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
} catch {
setAdvancedUri("");
}
setErrorMessage("");
setIsAdvancedOpen(true);
return;
}
try {
const next = draftFromConnectionUri(advancedUri);
setHost(next.host);
setPort(next.port);
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
} catch {
setErrorMessage("");
}
setIsAdvancedOpen(false);
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
title="Direct connection"
@@ -261,24 +439,127 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
>
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="Host"
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="next"
/>
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>Port</Text>
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel="Port"
value={port}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="number-pad"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
</View>
</View>
<Pressable
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
accessibilityRole="checkbox"
accessibilityLabel="Use SSL"
accessibilityState={useTlsAccessibilityState}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.palette.white} />
</View>
) : null}
</View>
<Text style={styles.label}>Use SSL</Text>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={handleChangeEndpoint}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Text style={styles.label}>Password</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel="Password"
value={password}
onChangeText={setPassword}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<View style={styles.field}>
<Pressable
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>Advanced</Text>
</Pressable>
{isAdvancedOpen ? (
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI"
value={advancedUri}
onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleToggleAdvanced}
/>
) : null}
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
+40 -4
View File
@@ -23,7 +23,7 @@ class FakeDaemonClient {
public closeCalls = 0;
public ensureConnectedCalls = 0;
public fetchAgentsCalls: FetchAgentsOptions[] = [];
public fetchAgentsResponses: Array<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> = [];
public fetchAgentsResponses: Awaited<ReturnType<DaemonClient["fetchAgents"]>>[] = [];
async connect(): Promise<void> {
this.connectCalls += 1;
@@ -283,9 +283,10 @@ function updateControllerSnapshot(
}
function makeProbeMap(
entries: Array<
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
>,
entries: [
string,
HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never,
][],
): HostRuntimeSnapshot["probeByConnectionId"] {
return new Map(entries);
}
@@ -1559,6 +1560,41 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("upsertDirectConnection stores SSL and password settings", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertDirectConnection({
serverId: "srv_tls_password",
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
label: "tls host",
});
const host = store.getHosts().find((entry) => entry.serverId === "srv_tls_password");
expect(host?.connections).toEqual([
{
id: "direct:example.paseo.test:7443",
type: "directTcp",
endpoint: "example.paseo.test:7443",
useTls: true,
password: "shared-secret",
},
]);
store.syncHosts([]);
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
+20 -4
View File
@@ -16,12 +16,17 @@ import {
type HostConnection,
type HostProfile,
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
decodeOfferFragmentPayload,
normalizeHostPort,
shouldUseTlsForDefaultHostedRelay,
} from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
import {
selectBestConnection,
@@ -463,13 +468,17 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
url: buildDaemonWebSocketUrl(connection.endpoint),
url: buildDaemonWebSocketUrl(connection.endpoint, {
useTls: connection.useTls ?? false,
}),
...(connection.password ? { password: connection.password } : {}),
});
}
return new DaemonClient({
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId: host.serverId,
}),
e2ee: {
@@ -905,7 +914,7 @@ export class HostRuntimeController {
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
let hasChanged = this.host.serverId !== this.snapshot.serverId;
for (const key of Object.keys(patch) as Array<keyof HostRuntimeSnapshotPatch>) {
for (const key of Object.keys(patch) as (keyof HostRuntimeSnapshotPatch)[]) {
const incomingValue = patch[key];
if (equal(this.snapshot[key], incomingValue)) {
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
@@ -1383,10 +1392,13 @@ export class HostRuntimeStore {
async upsertDirectConnection(input: {
serverId: string;
endpoint: string;
useTls?: boolean;
password?: string;
label?: string;
existingClient?: DaemonClient;
}): Promise<HostProfile> {
const endpoint = normalizeHostPort(input.endpoint);
const password = input.password?.trim();
return this.upsertHostConnection({
serverId: input.serverId,
label: input.label,
@@ -1394,6 +1406,8 @@ export class HostRuntimeStore {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
},
existingClient: input.existingClient,
});
@@ -1964,6 +1978,8 @@ export interface HostMutations {
upsertDirectConnection: (input: {
serverId: string;
endpoint: string;
useTls?: boolean;
password?: string;
label?: string;
}) => Promise<HostProfile>;
upsertRelayConnection: (input: {
+5 -3
View File
@@ -242,14 +242,16 @@ interface DownloadTarget {
}
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const endpoint = daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
if (!endpoint) {
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
if (!connection) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
try {
parsed = new URL(buildDaemonWebSocketUrl(endpoint));
parsed = new URL(
buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
);
} catch {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { normalizeStoredHostProfile } from "./host-connection";
describe("normalizeStoredHostProfile", () => {
it("loads direct TCP connections stored before TLS and password fields existed", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_old",
label: "Old Host",
connections: [
{
id: "direct:127.0.0.1:6767",
type: "directTcp",
endpoint: "127.0.0.1:6767",
},
],
preferredConnectionId: "direct:127.0.0.1:6767",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
expect(profile).not.toBeNull();
expect(profile?.connections[0]).toEqual({
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
useTls: false,
});
expect(profile?.connections[0]).not.toHaveProperty("password");
});
});
+17 -7
View File
@@ -1,10 +1,10 @@
import { normalizeHostPort, normalizeLoopbackToLocalhost } from "@server/shared/daemon-endpoints";
import {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection,
} from "@server/shared/host-connection-schema";
export interface DirectTcpHostConnection {
id: string;
type: "directTcp";
endpoint: string;
}
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
export interface DirectSocketHostConnection {
id: string;
@@ -58,7 +58,11 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
}
if (left.type === "directTcp" && right.type === "directTcp") {
return left.endpoint === right.endpoint;
return (
left.endpoint === right.endpoint &&
(left.useTls ?? false) === (right.useTls ?? false) &&
left.password === right.password
);
}
if (left.type === "directSocket" && right.type === "directSocket") {
return left.path === right.path;
@@ -238,7 +242,13 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
const endpoint = normalizeLoopbackToLocalhost(
normalizeHostPort(String(record.endpoint ?? "")),
);
return { id: `direct:${endpoint}`, type: "directTcp", endpoint };
return DirectTcpHostConnectionSchema.parse({
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: record.useTls,
...(typeof record.password === "string" ? { password: record.password } : {}),
});
} catch {
return null;
}
+13 -1
View File
@@ -4,7 +4,11 @@ import {
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
shouldUseTlsForDefaultHostedRelay,
type HostPortParts,
} from "@server/shared/daemon-endpoints";
@@ -17,9 +21,17 @@ export {
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseConnectionUri,
parseHostPort,
serializeConnectionUri,
serializeConnectionUriForStorage,
shouldUseTlsForDefaultHostedRelay,
};
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
useTls: boolean;
}): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
@@ -1,10 +1,12 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const daemonClientMock = vi.hoisted(() => {
const createdConfigs: Array<{ clientId?: string; url?: string }> = [];
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
let nextConnectError: Error | null = null;
let nextLastError: string | null = null;
class MockDaemonClient {
public lastError: string | null = null;
public lastError: string | null = nextLastError;
private lastServerInfo = {
status: "server_info" as const,
serverId: "srv_probe_test",
@@ -12,7 +14,7 @@ const daemonClientMock = vi.hoisted(() => {
version: "0.0.0",
};
constructor(config: { clientId?: string; url?: string }) {
constructor(config: { clientId?: string; url?: string; password?: string }) {
createdConfigs.push(config);
}
@@ -25,6 +27,9 @@ const daemonClientMock = vi.hoisted(() => {
}
async connect(): Promise<void> {
if (nextConnectError) {
throw nextConnectError;
}
return;
}
@@ -44,6 +49,15 @@ const daemonClientMock = vi.hoisted(() => {
return {
MockDaemonClient,
createdConfigs,
setNextConnectFailure: (error: Error, lastError: string | null) => {
nextConnectError = error;
nextLastError = lastError;
},
reset: () => {
createdConfigs.length = 0;
nextConnectError = null;
nextLastError = null;
},
};
});
@@ -74,8 +88,9 @@ vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
describe("test-daemon-connection connectToDaemon", () => {
beforeEach(() => {
daemonClientMock.createdConfigs.length = 0;
daemonClientMock.reset();
clientIdMock.getOrCreateClientId.mockClear();
vi.stubGlobal("__DEV__", false);
});
it("reuses the app clientId for direct connections", async () => {
@@ -115,4 +130,53 @@ describe("test-daemon-connection connectToDaemon", () => {
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
});
it("passes direct TCP connection passwords into the client config", async () => {
const mod = await import("./test-daemon-connection");
const result = await mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
});
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
});
it("surfaces auth rejection as an incorrect password", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(
new Error("Transport closed (code 4001)"),
"Transport closed (code 4001)",
);
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "wrong-secret",
}),
).rejects.toMatchObject({
message: "Incorrect password",
});
});
it("keeps generic transport failures generic when a password was supplied", async () => {
const mod = await import("./test-daemon-connection");
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
await expect(
mod.connectToDaemon({
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
password: "shared-secret",
}),
).rejects.toMatchObject({
message: "Transport error",
});
});
});
@@ -3,7 +3,11 @@ import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
shouldUseTlsForDefaultHostedRelay,
} from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
@@ -34,6 +38,23 @@ function pickBestReason(reason: string | null, lastError: string | null): string
return "Unable to connect";
}
function isIncorrectPasswordFailure(input: {
config: DaemonClientConfig;
reason: string | null;
lastError: string | null;
}): boolean {
if (!input.config.password) {
return false;
}
const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase();
return (
details.includes("401") ||
details.includes("4001") ||
details.includes("unauthorized") ||
details.includes("code 1006")
);
}
export class DaemonConnectionTestError extends Error {
reason: string | null;
lastError: string | null;
@@ -78,7 +99,8 @@ export async function buildClientConfig(
if (connection.type === "directTcp") {
return {
...base,
url: buildDaemonWebSocketUrl(connection.endpoint),
url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }),
...(connection.password ? { password: connection.password } : {}),
};
}
@@ -90,6 +112,7 @@ export async function buildClientConfig(
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId,
}),
e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 },
@@ -142,7 +165,9 @@ export function connectAndProbe(
error instanceof Error ? error.message : String(error),
);
const lastError = normalizeNonEmptyString(client.lastError);
const message = pickBestReason(reason, lastError);
const message = isIncorrectPasswordFailure({ config, reason, lastError })
? "Incorrect password"
: pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError }));
});