fix(app): let Remote SSH connections authenticate to a password-protected daemon (#6081)

Co-authored-by: royenheart <royenheart@outlook.com>
This commit is contained in:
paseo-bot[bot]
2026-10-05 06:02:33 +02:00
committed by GitHub
co-authored by royenheart
parent 2f9a786061
commit 7b43f21940
13 changed files with 254 additions and 15 deletions
@@ -1,13 +1,25 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Terminal } from "lucide-react-native";
import { Eye, EyeOff, Terminal } from "lucide-react-native";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
CONTROL_HEIGHTS,
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
}));
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
alignSelf: "stretch",
width: "100%",
gap: theme.spacing[2],
},
passwordInputWrap: {
flex: 1,
minWidth: 0,
},
// Boxed adornment next to the field: same height and radius as the field
// chrome of the active form size, and the shared interaction phases.
iconButtonSm: {
minHeight: CONTROL_HEIGHTS.compact,
width: CONTROL_HEIGHTS.compact,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
iconButtonMd: {
minHeight: CONTROL_HEIGHTS.field,
width: CONTROL_HEIGHTS.field,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
};
});
export interface AddRemoteSshHostModalProps {
visible: boolean;
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
const isCompact = useIsCompactFormFactor();
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
const targetRef = useRef("");
const passwordRef = useRef("");
const inputRef = useRef<EditingTextInputHandle>(null);
const passwordInputRef = useRef<EditingTextInputHandle>(null);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
const clear = useCallback(() => {
targetRef.current = "";
passwordRef.current = "";
inputRef.current?.replaceText("");
passwordInputRef.current?.replaceText("");
setIsPasswordVisible(false);
setErrorMessage("");
}, []);
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
try {
setIsSaving(true);
setErrorMessage("");
result = await probeAndUpsertRemoteSshConnection(target);
result = await probeAndUpsertRemoteSshConnection({
...target,
password: passwordRef.current === "" ? undefined : passwordRef.current,
});
} catch (error) {
const message =
error instanceof DaemonConnectionTestError
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
}, []);
const handlePasswordChange = useCallback((value: string) => {
passwordRef.current = value;
}, []);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
}, []);
const iconButtonStyle = useCallback(
(
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
): StyleProp<ViewStyle> => [
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
controlDisabled: styles.controlDisabled,
},
{
hovered: state.hovered,
pressed: state.pressed,
focused: state.focused,
disabled: isSaving,
},
),
],
[isCompact, isSaving],
);
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
return (
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</Field>
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
<View style={styles.passwordRow}>
<View style={styles.passwordInputWrap}>
<FormTextInput
ref={passwordInputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-password-input"
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
initialValue=""
onChangeText={handlePasswordChange}
placeholder={t("pairing.remoteSsh.fields.optional")}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</View>
<Pressable
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={
isPasswordVisible
? t("pairing.remoteSsh.passwordVisibility.hide")
: t("pairing.remoteSsh.passwordVisibility.show")
}
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable>
</View>
</Field>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
+6
View File
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
fields: {
target: "مضيف SSH",
password: "كلمة مرور الدايمون",
optional: "خياري",
},
passwordVisibility: {
show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور",
},
actions: {
cancel: "إلغاء",
+6
View File
@@ -1727,6 +1727,12 @@ export const en = {
helper: "Connect to a Paseo daemon running on the remote host.",
fields: {
target: "SSH host",
password: "Daemon password",
optional: "Optional",
},
passwordVisibility: {
show: "Show password",
hide: "Hide password",
},
actions: {
cancel: "Cancel",
+6
View File
@@ -1749,6 +1749,12 @@ export const es: TranslationResources = {
helper: "Conéctate a un daemon de Paseo en el host remoto.",
fields: {
target: "Host SSH",
password: "Contraseña del daemon",
optional: "Opcional",
},
passwordVisibility: {
show: "Mostrar contraseña",
hide: "Ocultar contraseña",
},
actions: {
cancel: "Cancelar",
+6
View File
@@ -1753,6 +1753,12 @@ export const fr: TranslationResources = {
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
fields: {
target: "Hôte SSH",
password: "Mot de passe du démon",
optional: "Facultatif",
},
passwordVisibility: {
show: "Afficher le mot de passe",
hide: "Masquer le mot de passe",
},
actions: {
cancel: "Annuler",
+6
View File
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
helper: "リモートホストで動作する Paseo デーモンに接続します。",
fields: {
target: "SSH ホスト",
password: "デーモンのパスワード",
optional: "任意",
},
passwordVisibility: {
show: "パスワードを表示",
hide: "パスワードを非表示",
},
actions: {
cancel: "キャンセル",
+6
View File
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
fields: {
target: "SSH 호스트",
password: "데몬 비밀번호",
optional: "선택 사항",
},
passwordVisibility: {
show: "비밀번호 표시",
hide: "비밀번호 숨기기",
},
actions: {
cancel: "취소",
+6
View File
@@ -1734,6 +1734,12 @@ export const ptBR: TranslationResources = {
helper: "Conecte-se a um daemon Paseo no host remoto.",
fields: {
target: "Host SSH",
password: "Senha do daemon",
optional: "Opcional",
},
passwordVisibility: {
show: "Mostrar senha",
hide: "Ocultar senha",
},
actions: {
cancel: "Cancelar",
+6
View File
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
helper: "Подключитесь к демону Paseo на удалённом хосте.",
fields: {
target: "Хост SSH",
password: "Пароль демона",
optional: "Необязательно",
},
passwordVisibility: {
show: "Показать пароль",
hide: "Скрыть пароль",
},
actions: {
cancel: "Отмена",
+6
View File
@@ -1683,6 +1683,12 @@ export const zhCN: TranslationResources = {
helper: "连接到远程主机上运行的 Paseo 守护进程。",
fields: {
target: "SSH 主机",
password: "守护进程密码",
optional: "可选",
},
passwordVisibility: {
show: "显示密码",
hide: "隐藏密码",
},
actions: {
cancel: "取消",
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
const store = createPairingStore({
password: " s3cret ",
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
});
answerHostConfirmations(store, false);
await store.boot();
await store.probeAndUpsertRemoteSshConnection({
host: "deploy@example.com",
password: " s3cret ",
});
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
expect(host?.password).toBe(" s3cret ");
expect(host?.connections[0]).toMatchObject({
type: "remoteSsh",
host: "deploy@example.com",
});
// The fix promises the saved password is sent on every connection: a later
// cycle must authenticate from the stored profile alone.
await store.runProbeCycleNow("srv_ssh");
await waitForHostOnline(store, "srv_ssh");
store.syncHosts([]);
});
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
const store = createPairingStore({
password: "s3cret",
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
});
answerHostConfirmations(store, false);
await store.boot();
const failure = await store
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
.then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
+5
View File
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
host: string;
sshPort?: number;
daemonPort?: number;
password?: string;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
return this.probeAndUpsertConnection({
label: input.label,
// The daemon hashes and compares the password verbatim (`paseo daemon
// set-password` keeps what it reads), so whitespace is significant here.
password: input.password ? input.password : undefined,
connection: createRemoteSshHostConnection(input),
});
}
@@ -2844,6 +2848,7 @@ export interface HostMutations {
host: string;
sshPort?: number;
daemonPort?: number;
password?: string;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
beginLinkPairing: () => LinkPairing;
+4 -1
View File
@@ -45,7 +45,9 @@ paseo --host 'ssh://user@host?daemonPort=7777' ls -a
Put `--host` before the command. `paseo daemon status` observes the default local home; use `paseo --host ssh://user@host daemon status` to query a remote daemon. `paseo --host ssh://user@host run --cwd /path/on/remote ...` requires a working directory that exists on the remote host.
In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination.
In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination, including `:port` or `?daemonPort=` when those differ from the defaults.
If the remote daemon has a password, enter it in **Daemon password**; it is stored with the host and sent on every connection, the same as a direct connection's password. SSH login itself stays key-based — Paseo never prompts for an SSH password.
## Paseo relay
@@ -123,6 +125,7 @@ If the host was already paired through the relay, Paseo adds the direct connecti
- **SSH authentication failed:** Run `ssh user@host` in a terminal and fix the key, agent, host key, or `~/.ssh/config` entry there. Paseo does not prompt for SSH passwords.
- **SSH connects but Paseo is refused:** Run `paseo daemon status` on the remote host. SSH transport does not start the daemon.
- **SSH connects but Paseo reports "Password required":** The remote daemon is password-protected. Remove the SSH connection from the host and add it again, this time entering the daemon password in **Daemon password**.
- **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP.
- **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port.
- **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it.