mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
co-authored by
royenheart
parent
2f9a786061
commit
7b43f21940
@@ -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}
|
||||
|
||||
@@ -1703,6 +1703,12 @@ export const ar: TranslationResources = {
|
||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||
fields: {
|
||||
target: "مضيف SSH",
|
||||
password: "كلمة مرور الدايمون",
|
||||
optional: "خياري",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "إظهار كلمة المرور",
|
||||
hide: "إخفاء كلمة المرور",
|
||||
},
|
||||
actions: {
|
||||
cancel: "إلغاء",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1719,6 +1719,12 @@ export const ja: TranslationResources = {
|
||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||
fields: {
|
||||
target: "SSH ホスト",
|
||||
password: "デーモンのパスワード",
|
||||
optional: "任意",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "パスワードを表示",
|
||||
hide: "パスワードを非表示",
|
||||
},
|
||||
actions: {
|
||||
cancel: "キャンセル",
|
||||
|
||||
@@ -1711,6 +1711,12 @@ export const ko: TranslationResources = {
|
||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||
fields: {
|
||||
target: "SSH 호스트",
|
||||
password: "데몬 비밀번호",
|
||||
optional: "선택 사항",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "비밀번호 표시",
|
||||
hide: "비밀번호 숨기기",
|
||||
},
|
||||
actions: {
|
||||
cancel: "취소",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1732,6 +1732,12 @@ export const ru: TranslationResources = {
|
||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||
fields: {
|
||||
target: "Хост SSH",
|
||||
password: "Пароль демона",
|
||||
optional: "Необязательно",
|
||||
},
|
||||
passwordVisibility: {
|
||||
show: "Показать пароль",
|
||||
hide: "Скрыть пароль",
|
||||
},
|
||||
actions: {
|
||||
cancel: "Отмена",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user