diff --git a/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx b/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx index 3893f349..16aa9ead 100644 --- a/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalNetworkSecuritySection.tsx @@ -442,7 +442,7 @@ const ConnectionModalNetworkSecuritySection: React.FC diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 863a3b08..42e13e90 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -709,6 +709,44 @@ func normalizeDirectoryDialogPath(currentDir string) string { return defaultDir } +func absDialogPath(path string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "" + } + if abs, err := filepath.Abs(trimmed); err == nil { + return abs + } + return trimmed +} + +// resolveFileOpenDialogDirectory picks the directory for OpenFileDialog. +// currentPath may be a previously selected file, including extensionless SSH keys +// such as id_rsa / id_ed25519 / custom names under ~/.ssh. +func resolveFileOpenDialogDirectory(currentPath string, emptyFallback string) string { + path := strings.TrimSpace(currentPath) + if path == "" { + path = strings.TrimSpace(emptyFallback) + } + if path == "" { + return "" + } + + if info, err := os.Stat(path); err == nil { + if info.IsDir() { + return absDialogPath(path) + } + return absDialogPath(filepath.Dir(path)) + } + + // Path does not exist: treat it as a file location when a parent exists. + parent := filepath.Dir(path) + if parent != "" && parent != "." && parent != path { + return absDialogPath(parent) + } + return absDialogPath(path) +} + type fileBackendTextFunc func(key string, params map[string]any) string func fileBackendText(text fileBackendTextFunc, key string, params map[string]any) string { @@ -2057,32 +2095,24 @@ func normalizeConnectionPackageExportFilename(filename string) string { } func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult { - defaultDir := strings.TrimSpace(currentPath) - if defaultDir == "" { - if home, err := os.UserHomeDir(); err == nil { - defaultDir = filepath.Join(home, ".ssh") - } - } - if filepath.Ext(defaultDir) != "" { - defaultDir = filepath.Dir(defaultDir) - } - if defaultDir != "" && !filepath.IsAbs(defaultDir) { - if abs, err := filepath.Abs(defaultDir); err == nil { - defaultDir = abs - } + fallbackDir := "" + if home, err := os.UserHomeDir(); err == nil { + fallbackDir = filepath.Join(home, ".ssh") } + defaultDir := resolveFileOpenDialogDirectory(currentPath, fallbackDir) + // OpenSSH private keys are commonly extensionless (id_ed25519, id_ecdsa, + // custom names). Wails/macOS treats dialog filters as file extensions only, + // so filename globs never match real key basenames and hide them. + // Allow all files and show hidden items so ~/.ssh keys remain selectable. selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: a.appText("file.backend.dialog.select_ssh_key_file", nil), DefaultDirectory: defaultDir, + ShowHiddenFiles: true, Filters: []runtime.FileFilter{ - { - DisplayName: a.appText("file.backend.filter.private_key_files", nil), - Pattern: "*.pem;*.key;*.ppk;*id_rsa*", - }, { DisplayName: a.appText("file.backend.filter.all_files", nil), - Pattern: "*", + Pattern: "*.*", }, }, }) @@ -2099,24 +2129,18 @@ func (a *App) SelectSSHKeyFile(currentPath string) connection.QueryResult { } func (a *App) SelectCertificateFile(currentPath string, certKind string) connection.QueryResult { - defaultDir := strings.TrimSpace(currentPath) - if defaultDir == "" { - if home, err := os.UserHomeDir(); err == nil { - defaultDir = home - } - } - if filepath.Ext(defaultDir) != "" { - defaultDir = filepath.Dir(defaultDir) - } - if defaultDir != "" && !filepath.IsAbs(defaultDir) { - if abs, err := filepath.Abs(defaultDir); err == nil { - defaultDir = abs - } + fallbackDir := "" + if home, err := os.UserHomeDir(); err == nil { + fallbackDir = home } + defaultDir := resolveFileOpenDialogDirectory(currentPath, fallbackDir) kind := strings.ToLower(strings.TrimSpace(certKind)) titleKey := "file.backend.dialog.select_tls_certificate_file" displayNameKey := "file.backend.filter.certificate_files" + // Certificate material usually has extensions; still include all-files so + // extensionless keys remain selectable (same macOS filter limitation). + filterPattern := "*.pem;*.crt;*.cer;*.cert;*.key" switch kind { case "ca": titleKey = "file.backend.dialog.select_ca_server_certificate_file" @@ -2125,19 +2149,22 @@ func (a *App) SelectCertificateFile(currentPath string, certKind string) connect case "client-key": titleKey = "file.backend.dialog.select_client_private_key_file" displayNameKey = "file.backend.filter.private_key_files" + // Prefer all-files for private keys: extensionless PEM keys are common. + filterPattern = "*.*" } selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: a.appText(titleKey, nil), DefaultDirectory: defaultDir, + ShowHiddenFiles: kind == "client-key", Filters: []runtime.FileFilter{ { DisplayName: a.appText(displayNameKey, nil), - Pattern: "*.pem;*.crt;*.cer;*.cert;*.key", + Pattern: filterPattern, }, { DisplayName: a.appText("file.backend.filter.all_files", nil), - Pattern: "*", + Pattern: "*.*", }, }, }) diff --git a/internal/app/methods_file_ssh_key_test.go b/internal/app/methods_file_ssh_key_test.go new file mode 100644 index 00000000..194fc99d --- /dev/null +++ b/internal/app/methods_file_ssh_key_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveFileOpenDialogDirectoryHandlesExtensionlessSSHKeys(t *testing.T) { + root := t.TempDir() + sshDir := filepath.Join(root, ".ssh") + if err := os.MkdirAll(sshDir, 0o700); err != nil { + t.Fatalf("mkdir .ssh: %v", err) + } + + keyPath := filepath.Join(sshDir, "id_ed25519") + if err := os.WriteFile(keyPath, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + got := resolveFileOpenDialogDirectory(keyPath, filepath.Join(root, "fallback")) + want := absDialogPath(sshDir) + if got != want { + t.Fatalf("existing extensionless key: got %q, want %q", got, want) + } + + missingKey := filepath.Join(sshDir, "custom_deploy_key") + got = resolveFileOpenDialogDirectory(missingKey, filepath.Join(root, "fallback")) + if got != want { + t.Fatalf("missing extensionless key: got %q, want %q", got, want) + } + + got = resolveFileOpenDialogDirectory("", sshDir) + if got != want { + t.Fatalf("empty current path falls back to .ssh: got %q, want %q", got, want) + } + + got = resolveFileOpenDialogDirectory(sshDir, filepath.Join(root, "fallback")) + if got != want { + t.Fatalf("directory path kept as-is: got %q, want %q", got, want) + } +} + +func TestSelectSSHKeyFileSourceAllowsExtensionlessKeys(t *testing.T) { + source, err := os.ReadFile("methods_file.go") + if err != nil { + t.Fatalf("read methods_file.go: %v", err) + } + text := string(source) + + selectFnStart := strings.Index(text, "func (a *App) SelectSSHKeyFile(") + if selectFnStart < 0 { + t.Fatal("SelectSSHKeyFile not found") + } + selectFnEnd := strings.Index(text[selectFnStart:], "\nfunc (a *App) ") + if selectFnEnd < 0 { + t.Fatal("SelectSSHKeyFile end not found") + } + fn := text[selectFnStart : selectFnStart+selectFnEnd] + + if !strings.Contains(fn, "resolveFileOpenDialogDirectory(currentPath, fallbackDir)") { + t.Fatal("SelectSSHKeyFile should resolve default directory for extensionless key paths") + } + if !strings.Contains(fn, "ShowHiddenFiles: true") { + t.Fatal("SelectSSHKeyFile should show hidden files so ~/.ssh keys are visible") + } + if strings.Contains(fn, `Pattern: "*.pem;*.key;*.ppk`) || strings.Contains(fn, "id_rsa*") { + t.Fatal("SelectSSHKeyFile must not restrict filters to extension-only or id_rsa globs") + } + if !strings.Contains(fn, `Pattern: "*.*"`) { + t.Fatal("SelectSSHKeyFile should allow all files for extensionless OpenSSH keys") + } +} diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go index e920b5c1..3adef773 100644 --- a/internal/ssh/ssh.go +++ b/internal/ssh/ssh.go @@ -4,11 +4,13 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net" "os" "strconv" + "strings" "sync" "time" @@ -59,18 +61,22 @@ func connectSSH(config connection.SSHConfig) (*ssh.Client, error) { logger.Infof("开始建立 SSH 连接:地址=%s:%d 用户=%s", config.Host, config.Port, config.User) authMethods := []ssh.AuthMethod{} - if config.KeyPath != "" { - key, err := os.ReadFile(config.KeyPath) + if keyPath := strings.TrimSpace(config.KeyPath); keyPath != "" { + key, err := os.ReadFile(keyPath) if err != nil { - logger.Warnf("读取 SSH 私钥失败:路径=%s,原因:%v", config.KeyPath, err) - } else { - signer, err := ssh.ParsePrivateKey(key) - if err != nil { - logger.Warnf("解析 SSH 私钥失败:路径=%s,原因:%v", config.KeyPath, err) - } else { - authMethods = append(authMethods, ssh.PublicKeys(signer)) - } + logger.Warnf("读取 SSH 私钥失败:路径=%s,原因:%v", keyPath, err) + return nil, fmt.Errorf("failed to read SSH private key %s: %w", keyPath, err) } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + logger.Warnf("解析 SSH 私钥失败:路径=%s,原因:%v", keyPath, err) + var passphraseErr *ssh.PassphraseMissingError + if errors.As(err, &passphraseErr) { + return nil, fmt.Errorf("SSH private key %s is encrypted with a passphrase; passphrase-protected keys are not supported", keyPath) + } + return nil, fmt.Errorf("failed to parse SSH private key %s: %w", keyPath, err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) } if config.Password != "" { diff --git a/internal/ssh/ssh_key_read_test.go b/internal/ssh/ssh_key_read_test.go new file mode 100644 index 00000000..c2c3e9f7 --- /dev/null +++ b/internal/ssh/ssh_key_read_test.go @@ -0,0 +1,58 @@ +package ssh + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestConnectSSHReturnsErrorWhenPrivateKeyMissing(t *testing.T) { + t.Cleanup(CloseAllSSHClients) + + missing := filepath.Join(t.TempDir(), "id_ed25519") + _, err := connectSSH(connection.SSHConfig{ + Host: "127.0.0.1", + Port: 1, + User: "root", + KeyPath: missing, + }) + if err == nil { + t.Fatal("expected error for missing private key") + } + message := err.Error() + if !strings.Contains(message, "failed to read SSH private key") { + t.Fatalf("expected read failure message, got %q", message) + } + if !strings.Contains(message, missing) { + t.Fatalf("expected key path in error, got %q", message) + } +} + +func TestConnectSSHReturnsErrorWhenPrivateKeyInvalid(t *testing.T) { + t.Cleanup(CloseAllSSHClients) + + keyPath := filepath.Join(t.TempDir(), "custom_deploy_key") + if err := os.WriteFile(keyPath, []byte("not-a-private-key"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + _, err := connectSSH(connection.SSHConfig{ + Host: "127.0.0.1", + Port: 1, + User: "root", + KeyPath: keyPath, + }) + if err == nil { + t.Fatal("expected error for invalid private key") + } + message := err.Error() + if !strings.Contains(message, "failed to parse SSH private key") { + t.Fatalf("expected parse failure message, got %q", message) + } + if !strings.Contains(message, keyPath) { + t.Fatalf("expected key path in error, got %q", message) + } +}