From 164b63d1fb3865a9cef2d340e98a6e05f632c3b7 Mon Sep 17 00:00:00 2001 From: jettwang Date: Thu, 16 Jul 2026 14:55:53 +0800 Subject: [PATCH] feat(cli): selectively import hosts from ~/.ssh/config (--host-import) Add --host-import for pollution-free host onboarding from the OpenSSH client config. Interactive mode lists importable entries plus every skipped entry with its reason; --host-import= imports the named entries non-interactively (all-or-nothing); --ssh-config= selects the source file. Guards against config pollution: wildcard/negated patterns, existing names, duplicate host:port pairs, 'Host *' defaults, unsupported options (reported as ignored), and %-token IdentityFile values are never imported. Match blocks are skipped and Include directives are reported but not followed. Import is covered by --dry-run plan preview and the audit trail (would_write_local_state). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- AGENT.md | 3 +- CHANGELOG.md | 13 ++ README.md | 1 + README_CN.md | 3 + docs/host-management.md | 43 +++++ docs/zh/host-management.md | 43 +++++ internal/app/audit.go | 2 +- internal/app/config.go | 9 + internal/app/dryrun.go | 2 +- internal/app/host_manager.go | 147 ++++++++++++++++ internal/app/sshconfig.go | 311 +++++++++++++++++++++++++++++++++ internal/app/sshconfig_test.go | 207 ++++++++++++++++++++++ internal/app/usage.go | 3 + internal/sshclient/client.go | 7 + 14 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 internal/app/sshconfig.go create mode 100644 internal/app/sshconfig_test.go diff --git a/AGENT.md b/AGENT.md index db08dd9..45a849a 100644 --- a/AGENT.md +++ b/AGENT.md @@ -76,7 +76,8 @@ cmd/sshx/main.go → app.Run(os.Args); maps errors to exit codes internal/app/ → CLI surface (argument parsing, routing, sub-commands) config.go → ParseArgs: flags + env → sshclient.Config app.go → Run(): dispatch by Config.Mode + host resolution - host_manager.go → --host-* handlers (add/update/list/test/test-all/remove) + host_manager.go → --host-* handlers (add/import/update/list/test/test-all/remove) + sshconfig.go → ~/.ssh/config parsing + selective import planning settings.go → ~/.sshx/settings.json load/save (atomic, 0600) password.go → keyring-backed password get/set/list + secure input usage.go → PrintUsage() help text (keep in sync with flags) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb9ab3..2d53572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Selective host import from the OpenSSH client config: + `sshx --host-import` lists importable `~/.ssh/config` entries (with + everything skipped and why) and lets you pick by number, name, or `all`; + `--host-import=` imports exactly the named entries + non-interactively (all-or-nothing); `--ssh-config=` selects a + different source file. Imports map `HostName`/`Port`/`User`/`IdentityFile` + onto a settings host and always skip wildcard/negated patterns, existing + names, duplicate addresses, `Host *` defaults, unsupported options + (reported as ignored), and `%`-token identity files — nothing is imported + blindly. Covered by `--dry-run` plan preview and the local audit trail. + ## [0.0.13] - 2026-07-02 ### Added diff --git a/README.md b/README.md index 4bb1a87..74e85c3 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,7 @@ Location: `~/.sshx/settings.json` ### Host Management Commands - `--host-add` - Add new host (interactive or with options) +- `--host-import` - Selectively import hosts from `~/.ssh/config` (interactive picker; `--host-import=` for non-interactive, `--ssh-config=` to choose the source file). Wildcard patterns, existing names, duplicate addresses, and unsupported options are always skipped and reported — nothing is imported blindly. - `--host-list` - List all configured hosts - `--host-test=` - Test connection to a host - `--host-test-all` - Test connections to all hosts (per-host 10s dial timeout) and show auth method used diff --git a/README_CN.md b/README_CN.md index d0f57f4..9d3fcbf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -212,6 +212,9 @@ sshx -h=192.168.1.100 -u=root "sudo df -h" # 一次性测试所有已配置的主机(每台主机 10 秒拨号超时),并在报告中标注认证方式 sshx --host-test-all +# 从 ~/.ssh/config 选择性导入主机(交互式勾选;--host-import=<名称> 可非交互按名导入) +sshx --host-import + # 服务器到服务器直接传输文件(流式中转,不落本地磁盘) sshx --transfer=192.168.1.100:/var/log/app.log --to=192.168.1.101:/backup/app.log ``` diff --git a/docs/host-management.md b/docs/host-management.md index d86f998..edd2d49 100644 --- a/docs/host-management.md +++ b/docs/host-management.md @@ -30,6 +30,49 @@ Then run commands by alias: sshx -h=prod-web "hostname && uptime" ``` +## Import from ~/.ssh/config + +If you already maintain hosts in the OpenSSH client config, import them selectively instead of retyping them. Import is never all-or-nothing by default — you choose what enters `~/.sshx/settings.json`. + +Interactive selection: + +```bash +sshx --host-import +``` + +sshx lists importable entries (with the resolved `user@host:port` and key) plus everything it skipped and why, then asks which entries to import (numbers, names, or `all`). + +Non-interactive, by name (script/agent friendly, all-or-nothing): + +```bash +sshx --host-import=web1,db1 +``` + +Import from a different config file: + +```bash +sshx --host-import --ssh-config=~/work/ssh_config +``` + +Preview without writing anything: + +```bash +sshx --host-import=web1 --dry-run --json +``` + +What is imported per entry: `HostName` (or the alias itself when absent), `Port`, `User`, and `IdentityFile` (as the per-host `key`). + +Pollution guards — the importer always skips: + +- wildcard or negated patterns (`Host *`, `web-?`, `!pattern`) — they are rules, not hosts; +- aliases that already exist in settings; +- entries whose `host:port` already exists in settings (or duplicates an earlier entry in the same file); +- options sshx does not support (`ProxyJump`, `ForwardAgent`, …) — shown as `ignored:` so nothing disappears silently; +- options from other blocks: `Host *` defaults are never merged into imported entries; +- `IdentityFile` values containing `%` tokens (reported in a note). + +`Match` blocks are ignored and `Include` directives are not followed; import from included files directly via `--ssh-config=`. + ## Settings File Host definitions live in `~/.sshx/settings.json`. diff --git a/docs/zh/host-management.md b/docs/zh/host-management.md index 602886b..7ef60f0 100644 --- a/docs/zh/host-management.md +++ b/docs/zh/host-management.md @@ -30,6 +30,49 @@ sshx --host-add \ sshx -h=prod-web "hostname && uptime" ``` +## 从 ~/.ssh/config 导入 + +如果你已经在 OpenSSH 客户端配置里维护主机,可以选择性导入,而不是重新输入。导入默认不会"一键全导"——写入 `~/.sshx/settings.json` 的内容由你决定。 + +交互式选择: + +```bash +sshx --host-import +``` + +sshx 会列出可导入条目(含解析后的 `user@host:port` 与 key)以及所有被跳过的条目和原因,然后由你按编号、名称或 `all` 选择。 + +按名称非交互导入(适合脚本 / agent,全部成功或全部失败): + +```bash +sshx --host-import=web1,db1 +``` + +从其他配置文件导入: + +```bash +sshx --host-import --ssh-config=~/work/ssh_config +``` + +预览而不写入: + +```bash +sshx --host-import=web1 --dry-run --json +``` + +每个条目导入的字段:`HostName`(缺省时使用别名本身)、`Port`、`User`、`IdentityFile`(作为该主机的 `key`)。 + +防污染规则——导入器始终跳过: + +- 通配或否定模式(`Host *`、`web-?`、`!pattern`)——它们是规则,不是主机; +- 与 settings 中已有主机同名的条目; +- `host:port` 已存在于 settings(或与同文件中更早条目重复)的条目; +- sshx 不支持的选项(`ProxyJump`、`ForwardAgent` 等)——以 `ignored:` 显示,不会静默丢失; +- 其他块的选项:`Host *` 的默认值绝不会合并进导入条目; +- 含 `%` 令牌的 `IdentityFile`(在提示中说明)。 + +`Match` 块会被忽略,`Include` 指令不会被跟随;被包含的文件请用 `--ssh-config=` 直接导入。 + ## 配置文件 主机定义保存在 `~/.sshx/settings.json`。 diff --git a/internal/app/audit.go b/internal/app/audit.go index 99801a4..642df7c 100644 --- a/internal/app/audit.go +++ b/internal/app/audit.go @@ -356,7 +356,7 @@ func auditWouldWriteLocalState(config *sshclient.Config) bool { case "password": return config.PasswordAction == "set" || config.PasswordAction == "delete" case "host": - return config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove" + return config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove" || config.HostAction == "import" default: return false } diff --git a/internal/app/config.go b/internal/app/config.go index b3dcbaa..01b74b0 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -215,6 +215,15 @@ func ParseArgs(args []string) *sshclient.Config { case arg == "--host-add": config.Mode = "host" config.HostAction = "add" + case arg == "--host-import": + config.Mode = "host" + config.HostAction = "import" + case strings.HasPrefix(arg, "--host-import="): + config.Mode = "host" + config.HostAction = "import" + config.HostImportNames = strings.SplitN(arg, "=", 2)[1] + case strings.HasPrefix(arg, "--ssh-config="): + config.SSHConfigPath = strings.SplitN(arg, "=", 2)[1] case arg == "--host-update": config.Mode = "host" config.HostAction = "update" diff --git a/internal/app/dryrun.go b/internal/app/dryrun.go index 27a93ea..3472915 100644 --- a/internal/app/dryrun.go +++ b/internal/app/dryrun.go @@ -365,7 +365,7 @@ func fillDryRunEffects(config *sshclient.Config, plan *dryRunPlan) { plan.WouldPromptForSecret = canProceed && config.PasswordAction == "set" && config.PasswordValue == "" case "host": plan.WouldConnect = canProceed && (config.HostAction == "test" || config.HostAction == "test-all") - plan.WouldWriteLocalState = canProceed && (config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove") + plan.WouldWriteLocalState = canProceed && (config.HostAction == "add" || config.HostAction == "update" || config.HostAction == "remove" || config.HostAction == "import") switch config.HostAction { case "test": plan.WouldReadSecret = canProceed && plan.hostTestReadsSecret diff --git a/internal/app/host_manager.go b/internal/app/host_manager.go index 5d4ccc6..9646627 100644 --- a/internal/app/host_manager.go +++ b/internal/app/host_manager.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "os" + "strconv" "strings" "time" @@ -18,6 +19,8 @@ func HandleHostManagement(config *sshclient.Config) error { switch config.HostAction { case "add": return handleHostAdd(config) + case "import": + return handleHostImport(config) case "update": return handleHostUpdate(config) case "list": @@ -131,6 +134,150 @@ func handleHostAdd(config *sshclient.Config) error { return nil } +// handleHostImport selectively imports hosts from an OpenSSH client config +// file (default ~/.ssh/config). It never imports everything blindly: wildcard +// patterns, existing names, and duplicate addresses are skipped, and the user +// chooses entries interactively or via --host-import=. +func handleHostImport(config *sshclient.Config) error { + settings, err := LoadSettings() + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + + configPath := config.SSHConfigPath + if configPath == "" { + configPath, err = defaultSSHConfigPath() + if err != nil { + return err + } + } + + file, err := os.Open(configPath) // #nosec G304 -- Path is the user's own ssh config + if err != nil { + return fmt.Errorf("failed to open ssh config %s: %w", configPath, err) + } + defer func() { _ = file.Close() }() //nolint:errcheck // read-only file + + entries, parseNotes, err := parseSSHConfig(file) + if err != nil { + return err + } + + plan := buildImportPlan(entries, settings) + plan.Notes = append(plan.Notes, parseNotes...) + + var selected []importCandidate + if config.HostImportNames != "" { + selected, err = selectCandidatesByName(plan, config.HostImportNames) + if err != nil { + return err + } + } else { + selected, err = selectCandidatesInteractively(plan, configPath) + if err != nil { + return err + } + if len(selected) == 0 { + fmt.Println("Nothing imported.") + return nil + } + } + + for _, candidate := range selected { + if addErr := AddHost(settings, candidate.Host); addErr != nil { + return fmt.Errorf("failed to add host '%s': %w", candidate.Host.Name, addErr) + } + } + if err := SaveSettings(settings); err != nil { + return fmt.Errorf("failed to save settings: %w", err) + } + + for _, candidate := range selected { + logger.GetLogger().Success("Imported host '%s' (%s)", candidate.Host.Name, candidate.Host.Host) + } + fmt.Printf("\nImported %d host(s) from %s\n", len(selected), configPath) + printImportNotes(plan.Notes) + return nil +} + +// selectCandidatesInteractively prints the import plan and asks the user to +// pick entries by number, name, or "all". An empty answer cancels. +func selectCandidatesInteractively(plan importPlan, configPath string) ([]importCandidate, error) { + fmt.Printf("=== Import hosts from %s ===\n\n", configPath) + + if len(plan.Skipped) > 0 { + fmt.Println("Skipped (not importable):") + for _, s := range plan.Skipped { + fmt.Printf(" - %-20s %s\n", s.Alias, s.Reason) + } + fmt.Println() + } + + if len(plan.Candidates) == 0 { + fmt.Println("No importable hosts found.") + printImportNotes(plan.Notes) + return nil, nil + } + + fmt.Println("Importable hosts:") + for i, c := range plan.Candidates { + fmt.Printf(" [%d] %s\n", i+1, candidateSummary(c)) + } + printImportNotes(plan.Notes) + + fmt.Print("\nSelect hosts to import (numbers or names, comma-separated; 'all'; empty to cancel): ") + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil && answer == "" { + return nil, fmt.Errorf("failed to read selection: %w", err) + } + return resolveImportSelection(plan, answer) +} + +// resolveImportSelection maps a user answer ("all", "1,3", "web1 db1", …) to +// candidates. Unknown tokens fail the whole selection so nothing half-applies. +func resolveImportSelection(plan importPlan, answer string) ([]importCandidate, error) { + answer = strings.TrimSpace(answer) + if answer == "" { + return nil, nil + } + if strings.EqualFold(answer, "all") { + return plan.Candidates, nil + } + + byName := make(map[string]int, len(plan.Candidates)) + for i, c := range plan.Candidates { + byName[c.Host.Name] = i + } + + var selected []importCandidate + seen := make(map[int]bool) + for _, token := range strings.FieldsFunc(answer, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) { + var idx int + if n, err := strconv.Atoi(token); err == nil { + if n < 1 || n > len(plan.Candidates) { + return nil, fmt.Errorf("selection %d is out of range (1-%d)", n, len(plan.Candidates)) + } + idx = n - 1 + } else if i, ok := byName[token]; ok { + idx = i + } else { + return nil, fmt.Errorf("unknown selection %q (use listed numbers or names)", token) + } + if !seen[idx] { + seen[idx] = true + selected = append(selected, plan.Candidates[idx]) + } + } + return selected, nil +} + +func printImportNotes(notes []string) { + for _, note := range notes { + fmt.Printf("Note: %s\n", note) + } +} + // handleHostUpdate updates an existing host in settings func handleHostUpdate(config *sshclient.Config) error { // Load settings diff --git a/internal/app/sshconfig.go b/internal/app/sshconfig.go new file mode 100644 index 0000000..d7c7a1e --- /dev/null +++ b/internal/app/sshconfig.go @@ -0,0 +1,311 @@ +package app + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/talkincode/sshx/internal/sshclient" +) + +// sshConfigEntry is one concrete "Host " block parsed from an OpenSSH +// client configuration file, reduced to the fields sshx can represent. +type sshConfigEntry struct { + Alias string + HostName string + Port string + User string + IdentityFile string + // IgnoredOptions lists option keywords present in the block that sshx + // does not import (e.g. ProxyJump, ForwardAgent), so the user can see + // exactly what a selective import leaves behind. + IgnoredOptions []string +} + +// importCandidate is an entry that can be imported as-is. +type importCandidate struct { + Entry sshConfigEntry + Host HostConfig +} + +// skippedEntry records why an entry was excluded from the import plan. +type skippedEntry struct { + Alias string + Reason string +} + +// importPlan is the result of matching parsed ssh_config entries against the +// current settings: what can be imported and what is skipped (with reasons). +type importPlan struct { + Candidates []importCandidate + Skipped []skippedEntry + Notes []string +} + +// defaultSSHConfigPath returns ~/.ssh/config. +func defaultSSHConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return filepath.Join(home, ".ssh", "config"), nil +} + +// isWildcardAlias reports whether an ssh_config Host alias is a pattern +// (wildcard or negation) rather than a concrete host alias. +func isWildcardAlias(alias string) bool { + return strings.ContainsAny(alias, "*?") || strings.HasPrefix(alias, "!") +} + +// importedConfigKeys are the ssh_config keywords sshx maps onto HostConfig. +var importedConfigKeys = map[string]bool{ + "hostname": true, + "port": true, + "user": true, + "identityfile": true, +} + +// parseSSHConfig parses an OpenSSH client config stream into per-alias +// entries. A "Host a b" line yields one entry per alias sharing the same +// options. Match blocks are skipped entirely; Include directives are not +// followed (a note is returned so the user knows). +func parseSSHConfig(r io.Reader) ([]sshConfigEntry, []string, error) { + var ( + entries []sshConfigEntry + notes []string + current []int // indexes into entries for the active Host block + inMatchBlock bool + sawInclude bool + ) + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + key, value := splitSSHConfigLine(line) + if key == "" { + continue + } + + switch key { + case "host": + inMatchBlock = false + current = current[:0] + for _, alias := range strings.Fields(value) { + entries = append(entries, sshConfigEntry{Alias: unquoteSSHValue(alias)}) + current = append(current, len(entries)-1) + } + continue + case "match": + inMatchBlock = true + current = nil + continue + case "include": + sawInclude = true + continue + } + + if inMatchBlock || len(current) == 0 { + continue + } + + for _, idx := range current { + applySSHConfigOption(&entries[idx], key, value) + } + } + if err := scanner.Err(); err != nil { + return nil, nil, fmt.Errorf("failed to read ssh config: %w", err) + } + + if sawInclude { + notes = append(notes, "Include directives are not followed; import from included files separately with --ssh-config=") + } + return entries, notes, nil +} + +// splitSSHConfigLine splits "Key Value", "Key=Value", or "Key = Value" into a +// lowercased keyword and its raw value. +func splitSSHConfigLine(line string) (key, value string) { + if idx := strings.IndexAny(line, " \t="); idx >= 0 { + key = line[:idx] + value = strings.TrimLeft(line[idx:], " \t=") + } else { + key = line + } + return strings.ToLower(key), strings.TrimSpace(value) +} + +// unquoteSSHValue strips one pair of surrounding double quotes. +func unquoteSSHValue(value string) string { + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + return value[1 : len(value)-1] + } + return value +} + +// applySSHConfigOption records a single option on an entry. The first +// occurrence of an imported key wins (matching OpenSSH semantics, where the +// first obtained value is used). Unsupported keys are collected as ignored. +func applySSHConfigOption(entry *sshConfigEntry, key, value string) { + value = unquoteSSHValue(value) + switch key { + case "hostname": + if entry.HostName == "" { + entry.HostName = value + } + case "port": + if entry.Port == "" { + entry.Port = value + } + case "user": + if entry.User == "" { + entry.User = value + } + case "identityfile": + if entry.IdentityFile == "" { + entry.IdentityFile = value + } + default: + if !importedConfigKeys[key] && !containsFold(entry.IgnoredOptions, key) { + entry.IgnoredOptions = append(entry.IgnoredOptions, key) + } + } +} + +func containsFold(items []string, target string) bool { + for _, item := range items { + if strings.EqualFold(item, target) { + return true + } + } + return false +} + +// buildImportPlan filters parsed entries against the current settings and +// produces the selective-import plan. Pollution guards: +// - wildcard/negated aliases are never importable; +// - aliases already present in settings are skipped; +// - host+port pairs already present in settings are skipped; +// - options from other blocks (including "Host *") are never merged in; +// - identity files containing % tokens are dropped from the candidate (noted). +func buildImportPlan(entries []sshConfigEntry, settings *Settings) importPlan { + plan := importPlan{} + + existingNames := make(map[string]bool, len(settings.Hosts)) + existingAddrs := make(map[string]string, len(settings.Hosts)) + for _, h := range settings.Hosts { + existingNames[h.Name] = true + port := h.Port + if port == "" { + port = sshclient.DefaultSSHPort + } + existingAddrs[h.Host+":"+port] = h.Name + } + + plannedAddrs := make(map[string]string) + + for _, entry := range entries { + if isWildcardAlias(entry.Alias) { + plan.Skipped = append(plan.Skipped, skippedEntry{Alias: entry.Alias, Reason: "wildcard/negated pattern, not a concrete host"}) + continue + } + if existingNames[entry.Alias] { + plan.Skipped = append(plan.Skipped, skippedEntry{Alias: entry.Alias, Reason: "already exists in settings"}) + continue + } + + host := HostConfig{ + Name: entry.Alias, + Description: "Imported from ssh_config", + Host: entry.HostName, + Port: entry.Port, + User: entry.User, + Type: DefaultHostType, + } + // "Host web1" with no HostName means the alias itself is the address. + if host.Host == "" { + host.Host = entry.Alias + } + if host.Port == "" { + host.Port = sshclient.DefaultSSHPort + } + + addr := host.Host + ":" + host.Port + if name, ok := existingAddrs[addr]; ok { + plan.Skipped = append(plan.Skipped, skippedEntry{Alias: entry.Alias, Reason: fmt.Sprintf("address %s already configured as '%s'", addr, name)}) + continue + } + if name, ok := plannedAddrs[addr]; ok { + plan.Skipped = append(plan.Skipped, skippedEntry{Alias: entry.Alias, Reason: fmt.Sprintf("address %s duplicates ssh_config entry '%s'", addr, name)}) + continue + } + + if entry.IdentityFile != "" { + if strings.Contains(entry.IdentityFile, "%") { + plan.Notes = append(plan.Notes, fmt.Sprintf("'%s': IdentityFile %q uses %% tokens and was not imported", entry.Alias, entry.IdentityFile)) + } else { + host.Key = entry.IdentityFile + } + } + + plannedAddrs[addr] = entry.Alias + plan.Candidates = append(plan.Candidates, importCandidate{Entry: entry, Host: host}) + } + + return plan +} + +// selectCandidatesByName resolves a comma-separated name list against the +// plan. It is all-or-nothing: any name that is not an importable candidate +// fails the whole selection, so scripted imports stay deterministic. +func selectCandidatesByName(plan importPlan, names string) ([]importCandidate, error) { + byName := make(map[string]importCandidate, len(plan.Candidates)) + for _, c := range plan.Candidates { + byName[c.Host.Name] = c + } + skippedReason := make(map[string]string, len(plan.Skipped)) + for _, s := range plan.Skipped { + skippedReason[s.Alias] = s.Reason + } + + var selected []importCandidate + seen := make(map[string]bool) + for _, raw := range strings.Split(names, ",") { + name := strings.TrimSpace(raw) + if name == "" || seen[name] { + continue + } + seen[name] = true + candidate, ok := byName[name] + if !ok { + if reason, wasSkipped := skippedReason[name]; wasSkipped { + return nil, fmt.Errorf("host '%s' cannot be imported: %s", name, reason) + } + return nil, fmt.Errorf("host '%s' not found in ssh config", name) + } + selected = append(selected, candidate) + } + if len(selected) == 0 { + return nil, fmt.Errorf("no host names given (use --host-import= or interactive --host-import)") + } + return selected, nil +} + +// candidateSummary renders one candidate as a single scannable line. +func candidateSummary(c importCandidate) string { + target := fmt.Sprintf("%s@%s:%s", firstNonEmpty(c.Host.User, sshclient.DefaultSSHUser), c.Host.Host, c.Host.Port) + summary := fmt.Sprintf("%-20s → %s", c.Host.Name, target) + if c.Host.Key != "" { + summary += " key=" + c.Host.Key + } + if len(c.Entry.IgnoredOptions) > 0 { + summary += " (ignored: " + strings.Join(c.Entry.IgnoredOptions, ", ") + ")" + } + return summary +} diff --git a/internal/app/sshconfig_test.go b/internal/app/sshconfig_test.go new file mode 100644 index 0000000..d86692f --- /dev/null +++ b/internal/app/sshconfig_test.go @@ -0,0 +1,207 @@ +package app + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const sampleSSHConfig = ` +# comment +Host * + ForwardAgent yes + User default-user + +Host web1 + HostName 192.168.1.10 + User root + Port 2222 + IdentityFile ~/.ssh/id_web + ProxyJump bastion + +Host db1 db2 + HostName=10.0.0.5 + User admin + +Host bare-alias + +Host web-? + User ops + +Host github.com + HostName github.com + IdentityFile ~/.ssh/id_git + +Match host prod-* + User matched + +Include ~/.ssh/config.d/* +` + +func parseSample(t *testing.T) ([]sshConfigEntry, []string) { + t.Helper() + entries, notes, err := parseSSHConfig(strings.NewReader(sampleSSHConfig)) + require.NoError(t, err) + return entries, notes +} + +func TestParseSSHConfig_Entries(t *testing.T) { + entries, notes := parseSample(t) + + byAlias := map[string]sshConfigEntry{} + for _, e := range entries { + byAlias[e.Alias] = e + } + + web1 := byAlias["web1"] + assert.Equal(t, "192.168.1.10", web1.HostName) + assert.Equal(t, "root", web1.User) + assert.Equal(t, "2222", web1.Port) + assert.Equal(t, "~/.ssh/id_web", web1.IdentityFile) + assert.Contains(t, web1.IgnoredOptions, "proxyjump") + + // "Host db1 db2" expands to two entries sharing options, "=" separator works. + assert.Equal(t, "10.0.0.5", byAlias["db1"].HostName) + assert.Equal(t, "admin", byAlias["db2"].User) + + // Alias with no HostName is kept; wildcard block options are NOT merged in. + bare, ok := byAlias["bare-alias"] + require.True(t, ok) + assert.Empty(t, bare.HostName) + assert.Empty(t, bare.User, "Host * options must not pollute concrete entries") + + // Match block options do not leak anywhere. + for _, e := range entries { + assert.NotEqual(t, "matched", e.User) + } + + // Include is reported, not followed. + require.Len(t, notes, 1) + assert.Contains(t, notes[0], "Include") +} + +func TestBuildImportPlan_PollutionGuards(t *testing.T) { + entries, _ := parseSample(t) + settings := &Settings{Hosts: []HostConfig{ + {Name: "web1", Host: "192.168.1.10", Port: "2222"}, // same alias + {Name: "old-db", Host: "10.0.0.5", Port: "22"}, // same address as db1/db2 + }} + + plan := buildImportPlan(entries, settings) + + candidateNames := make([]string, 0, len(plan.Candidates)) + for _, c := range plan.Candidates { + candidateNames = append(candidateNames, c.Host.Name) + } + // bare-alias and github.com are the only clean imports. + assert.ElementsMatch(t, []string{"bare-alias", "github.com"}, candidateNames) + + skippedReasons := map[string]string{} + for _, s := range plan.Skipped { + skippedReasons[s.Alias] = s.Reason + } + assert.Contains(t, skippedReasons["*"], "wildcard") + assert.Contains(t, skippedReasons["web-?"], "wildcard") + assert.Contains(t, skippedReasons["web1"], "already exists") + assert.Contains(t, skippedReasons["db1"], "already configured as 'old-db'") + assert.Contains(t, skippedReasons["db2"], "already configured as 'old-db'") +} + +func TestBuildImportPlan_DuplicateAddressWithinConfig(t *testing.T) { + entries, _ := parseSample(t) + plan := buildImportPlan(entries, &Settings{}) + + skippedReasons := map[string]string{} + for _, s := range plan.Skipped { + skippedReasons[s.Alias] = s.Reason + } + // db1 imports first; db2 points at the same address within the same file. + assert.Contains(t, skippedReasons["db2"], "duplicates ssh_config entry 'db1'") +} + +func TestBuildImportPlan_Defaults(t *testing.T) { + entries, _, err := parseSSHConfig(strings.NewReader("Host solo\n IdentityFile ~/.ssh/%r_key\n")) + require.NoError(t, err) + + plan := buildImportPlan(entries, &Settings{}) + require.Len(t, plan.Candidates, 1) + + host := plan.Candidates[0].Host + assert.Equal(t, "solo", host.Name) + assert.Equal(t, "solo", host.Host, "alias doubles as address when HostName is absent") + assert.Equal(t, "22", host.Port) + assert.Equal(t, DefaultHostType, host.Type) + assert.Empty(t, host.Key, "IdentityFile with % tokens must not be imported") + require.Len(t, plan.Notes, 1) + assert.Contains(t, plan.Notes[0], "%") +} + +func TestSelectCandidatesByName(t *testing.T) { + entries, _ := parseSample(t) + plan := buildImportPlan(entries, &Settings{}) + + selected, err := selectCandidatesByName(plan, "web1, db1") + require.NoError(t, err) + require.Len(t, selected, 2) + assert.Equal(t, "web1", selected[0].Host.Name) + assert.Equal(t, "db1", selected[1].Host.Name) + + // All-or-nothing: unknown name fails without partial result. + _, err = selectCandidatesByName(plan, "web1,missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing") + + // A skipped alias reports its skip reason. + planWithExisting := buildImportPlan(entries, &Settings{Hosts: []HostConfig{{Name: "web1", Host: "x"}}}) + _, err = selectCandidatesByName(planWithExisting, "web1") + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + _, err = selectCandidatesByName(plan, " , ") + require.Error(t, err) +} + +func TestResolveImportSelection(t *testing.T) { + entries, _ := parseSample(t) + plan := buildImportPlan(entries, &Settings{}) + + all, err := resolveImportSelection(plan, "all\n") + require.NoError(t, err) + assert.Len(t, all, len(plan.Candidates)) + + byMixed, err := resolveImportSelection(plan, "1, db1\n") + require.NoError(t, err) + require.Len(t, byMixed, 2) + assert.Equal(t, plan.Candidates[0].Host.Name, byMixed[0].Host.Name) + assert.Equal(t, "db1", byMixed[1].Host.Name) + + // Duplicates collapse. + dedup, err := resolveImportSelection(plan, "1 1 1\n") + require.NoError(t, err) + assert.Len(t, dedup, 1) + + empty, err := resolveImportSelection(plan, "\n") + require.NoError(t, err) + assert.Nil(t, empty) + + _, err = resolveImportSelection(plan, "99\n") + require.Error(t, err) + + _, err = resolveImportSelection(plan, "nope\n") + require.Error(t, err) +} + +func TestParseArgs_HostImportFlags(t *testing.T) { + config := ParseArgs([]string{"sshx", "--host-import"}) + assert.Equal(t, "host", config.Mode) + assert.Equal(t, "import", config.HostAction) + assert.Empty(t, config.HostImportNames) + + config = ParseArgs([]string{"sshx", "--host-import=web1,db1", "--ssh-config=/tmp/config"}) + assert.Equal(t, "host", config.Mode) + assert.Equal(t, "import", config.HostAction) + assert.Equal(t, "web1,db1", config.HostImportNames) + assert.Equal(t, "/tmp/config", config.SSHConfigPath) +} diff --git a/internal/app/usage.go b/internal/app/usage.go index a353e3f..fb17daf 100644 --- a/internal/app/usage.go +++ b/internal/app/usage.go @@ -139,6 +139,9 @@ Password Management (Cross-Platform): Host Management: --host-add Add new host (interactive or with options) + --host-import Selectively import hosts from ~/.ssh/config (interactive) + --host-import= Import only the named ssh_config hosts (non-interactive) + --ssh-config= ssh_config file to import from (default: ~/.ssh/config) --host-update Update existing host configuration --host-list List all configured hosts (alias: --host-ls) --host-test= Test connection to configured host diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go index fd74fad..7f5aef6 100644 --- a/internal/sshclient/client.go +++ b/internal/sshclient/client.go @@ -100,6 +100,13 @@ type Config struct { HostName string HostDescription string HostType string + // HostImportNames is a comma-separated list of ssh_config aliases to + // import non-interactively (HostAction == "import"). Empty means + // interactive selection. + HostImportNames string + // SSHConfigPath overrides the OpenSSH client config file read by + // --host-import (default ~/.ssh/config). + SSHConfigPath string } // SSHClient wraps an ssh.Client with optional pooled and sftp helpers.