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,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user