mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* 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>
589 lines
17 KiB
TypeScript
589 lines
17 KiB
TypeScript
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 { 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 {
|
|
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],
|
|
},
|
|
label: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
},
|
|
input: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderRadius: theme.borderRadius.lg,
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
color: theme.colors.foreground,
|
|
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],
|
|
marginTop: theme.spacing[2],
|
|
},
|
|
helper: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
error: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
}));
|
|
|
|
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 {
|
|
if (!message) return null;
|
|
const trimmed = message.trim();
|
|
if (!trimmed) return null;
|
|
return trimmed;
|
|
}
|
|
|
|
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
|
|
const unique = Array.from(
|
|
new Set(
|
|
details
|
|
.map((value) => normalizeTransportMessage(value))
|
|
.filter((value): value is string => Boolean(value))
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0),
|
|
),
|
|
);
|
|
|
|
if (unique.length === 0) return null;
|
|
|
|
const allGeneric = unique.every((value) => {
|
|
const lower = value.toLowerCase();
|
|
return lower === "transport error" || lower === "transport closed";
|
|
});
|
|
|
|
if (allGeneric) {
|
|
return `${unique[0]} (no additional details provided)`;
|
|
}
|
|
|
|
return unique.join(" — ");
|
|
}
|
|
|
|
function buildConnectionFailureCopy(
|
|
endpoint: string,
|
|
error: unknown,
|
|
): { title: string; detail: string | null; raw: string | null } {
|
|
const title = `We failed to connect to ${endpoint}.`;
|
|
|
|
const raw = (() => {
|
|
if (error instanceof DaemonConnectionTestError) {
|
|
return (
|
|
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
|
|
normalizeTransportMessage(error.message)
|
|
);
|
|
}
|
|
if (error instanceof Error) {
|
|
return normalizeTransportMessage(error.message);
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
const rawLower = raw?.toLowerCase() ?? "";
|
|
let detail: string | null = null;
|
|
|
|
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") ||
|
|
rawLower.includes("connection refused") ||
|
|
rawLower.includes("err_connection_refused")
|
|
) {
|
|
detail = "Connection refused. Is the server running at this address?";
|
|
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
|
detail = "Host not found. Check the hostname and try again.";
|
|
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
|
detail = "Host is unreachable. Check your network and firewall.";
|
|
} else if (
|
|
rawLower.includes("certificate") ||
|
|
rawLower.includes("tls") ||
|
|
rawLower.includes("ssl")
|
|
) {
|
|
detail =
|
|
"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.";
|
|
}
|
|
|
|
return { title, detail, raw };
|
|
}
|
|
|
|
export interface AddHostModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onCancel?: () => void;
|
|
onSaved?: (result: {
|
|
profile: HostProfile;
|
|
serverId: string;
|
|
hostname: string | null;
|
|
isNewHost: boolean;
|
|
}) => void;
|
|
}
|
|
|
|
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
|
const { theme } = useUnistyles();
|
|
const daemons = useHosts();
|
|
const { upsertDirectConnection } = useHostMutations();
|
|
const isMobile = useIsCompactFormFactor();
|
|
|
|
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(() => {
|
|
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;
|
|
clearInput();
|
|
setErrorMessage("");
|
|
onClose();
|
|
}, [isSaving, clearInput, onClose]);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
if (isSaving) return;
|
|
clearInput();
|
|
setErrorMessage("");
|
|
(onCancel ?? onClose)();
|
|
}, [isSaving, clearInput, onCancel, onClose]);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (isSaving) return;
|
|
|
|
let connection: PreparedDirectConnection;
|
|
try {
|
|
connection = prepareDirectConnection({ host, port, useTls, password });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Invalid connection";
|
|
setErrorMessage(message);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setErrorMessage("");
|
|
|
|
const { client, serverId, hostname } = await connectToDaemon({
|
|
id: "probe",
|
|
type: "directTcp",
|
|
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: 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(connection.uri, error);
|
|
let combined: string;
|
|
if (rawDetail && detail && rawDetail !== detail) {
|
|
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
|
|
} else if (detail) {
|
|
combined = `${title}\n${detail}`;
|
|
} else {
|
|
combined = title;
|
|
}
|
|
setErrorMessage(combined);
|
|
if (!isMobile) {
|
|
Alert.alert("Connection failed", combined);
|
|
}
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [
|
|
daemons,
|
|
handleClose,
|
|
host,
|
|
isMobile,
|
|
isSaving,
|
|
onSaved,
|
|
password,
|
|
port,
|
|
upsertDirectConnection,
|
|
useTls,
|
|
]);
|
|
|
|
const handleSubmitEditing = useCallback(() => {
|
|
void handleSave();
|
|
}, [handleSave]);
|
|
|
|
const handleSavePress = useCallback(() => {
|
|
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"
|
|
visible={visible}
|
|
onClose={handleClose}
|
|
testID="add-host-modal"
|
|
>
|
|
<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}>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>
|
|
|
|
<View style={styles.actions}>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="secondary"
|
|
onPress={handleCancel}
|
|
disabled={isSaving}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
style={FLEX_ONE_STYLE}
|
|
variant="default"
|
|
onPress={handleSavePress}
|
|
disabled={isSaving}
|
|
leftIcon={connectIcon}
|
|
testID="direct-host-submit"
|
|
>
|
|
{isSaving ? "Connecting..." : "Connect"}
|
|
</Button>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|