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