Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,10 @@ export function App() {
return;
} catch (err) {
const raw = String(err);
if (host && host.authMethod !== "password" && SSH_PASSPHRASE_RETRY_HINT.test(raw)) {
// When host is not yet in sshHosts state (e.g. just added via upsertSshHost
// and state hasn't refreshed), assume non-password auth so the passphrase
// dialog is still shown instead of falling through to a misleading error.
if ((!host || host.authMethod !== "password") && SSH_PASSPHRASE_RETRY_HINT.test(raw)) {
const passphrase = await requestPassphrase(hostLabel);
if (passphrase !== null) {
try {
Expand All @@ -785,7 +788,9 @@ export function App() {
return;
} catch (passphraseErr) {
const passphraseRaw = String(passphraseErr);
const fallbackMessage = buildSshPassphraseConnectErrorMessage(passphraseRaw, hostLabel, t);
const fallbackMessage = buildSshPassphraseConnectErrorMessage(
passphraseRaw, hostLabel, t, { passphraseWasSubmitted: true },
);
if (fallbackMessage) {
throw new Error(fallbackMessage);
}
Expand Down
16 changes: 12 additions & 4 deletions src/lib/__tests__/sshConnectErrors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import {
} from "../sshConnectErrors";

const t = (key: string, opts: Record<string, string | number | boolean> = {}) => {
const text = {
const text: Record<string, string> = {
"ssh.passphraseValidationFailed": "PASS_FAIL_{{host}}",
"ssh.missingKeyFile": "MISSING_KEY_{{host}}",
"ssh.publicKeyRejected": "PUBLIC_KEY_REJECTED_{{host}}",
"ssh.publicKeyAuthFailed": "PUBLIC_KEY_AUTH_FAILED_{{host}}",
"ssh.passphraseCancelled": "CANCEL_{{host}}",
}[key] || key;
return text.replace("{{host}}", String(opts.host ?? ""));
};
return (text[key] || key).replace("{{host}}", String(opts.host ?? ""));
};

describe("sshConnectErrors", () => {
Expand Down Expand Up @@ -46,8 +47,15 @@ describe("sshConnectErrors", () => {
expect(msg).toBe("MISSING_KEY_hetzner");
});

test("maps permission-denied error to localized message", () => {
test("maps permission-denied error to publicKeyAuthFailed when no passphrase was submitted", () => {
const msg = buildSshPassphraseConnectErrorMessage("public key authentication failed", "hetzner", t);
expect(msg).toBe("PUBLIC_KEY_AUTH_FAILED_hetzner");
});

test("maps permission-denied error to publicKeyRejected when passphrase was submitted", () => {
const msg = buildSshPassphraseConnectErrorMessage(
"public key authentication failed", "hetzner", t, { passphraseWasSubmitted: true },
);
expect(msg).toBe("PUBLIC_KEY_REJECTED_hetzner");
});

Expand Down
7 changes: 6 additions & 1 deletion src/lib/sshConnectErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function buildSshPassphraseConnectErrorMessage(
rawError: string,
hostLabel: string,
t: SshTranslate,
options?: { passphraseWasSubmitted?: boolean },
): string | null {
if (SSH_PASSPHRASE_REJECT_HINT.test(rawError)) {
return t("ssh.passphraseValidationFailed", { host: hostLabel });
Expand All @@ -26,7 +27,11 @@ export function buildSshPassphraseConnectErrorMessage(
return t("ssh.missingKeyFile", { host: hostLabel });
}
if (SSH_PUBLIC_KEY_PERMISSION_HINT.test(rawError)) {
return t("ssh.publicKeyRejected", { host: hostLabel });
// Distinguish between "passphrase was entered but key still rejected"
// and "no passphrase was ever entered, auth simply failed".
return options?.passphraseWasSubmitted
? t("ssh.publicKeyRejected", { host: hostLabel })
: t("ssh.publicKeyAuthFailed", { host: hostLabel });
}
return null;
}
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@
"ssh.passphraseValidationFailed": "SSH passphrase check failed (host: {{host}}). Verify the passphrase is correct or unlock the key first.",
"ssh.missingKeyFile": "No usable private key file found (host: {{host}}). Check IdentityFile in SSH config and confirm the key is readable.",
"ssh.publicKeyRejected": "SSH authentication failed (host: {{host}}). Passphrase was submitted, but the remote host still rejected it. Check authorized_keys, use user root, and confirm the host fingerprint.",
"ssh.publicKeyAuthFailed": "SSH authentication failed (host: {{host}}). The remote host rejected the public key. Check authorized_keys, verify the key file, and confirm the user and host fingerprint.",
"ssh.passphraseCancelled": "SSH passphrase input cancelled (host: {{host}}). Retry and enter a passphrase if the key is encrypted.",
"ssh.passphrasePrompt": "Enter SSH key passphrase for {{host}}",
"ssh.passphraseTitle": "SSH Key Passphrase",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@
"ssh.passphraseValidationFailed": "SSH 口令校验失败(host: {{host}})。请确认私钥口令正确,或先解锁对应密钥后重试。",
"ssh.missingKeyFile": "未找到可用私钥文件(host: {{host}})。请检查 SSH 配置里的 IdentityFile 是否可读。",
"ssh.publicKeyRejected": "SSH 认证失败(host: {{host}})。已提交口令但远端仍拒绝。请确认 authorized_keys、用户为 root 且主机指纹匹配。",
"ssh.publicKeyAuthFailed": "SSH 认证失败(host: {{host}})。远端拒绝了公钥。请确认 authorized_keys、密钥文件、用户名及主机指纹。",
"ssh.passphraseCancelled": "已取消输入 SSH 私钥口令(host: {{host}})。如果该密钥加密,请重试并输入口令。",
"ssh.passphrasePrompt": "请输入 {{host}} 的 SSH 私钥口令",
"ssh.passphraseTitle": "SSH 私钥口令",
Expand Down