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
3 changes: 2 additions & 1 deletion AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name1,name2>` imports exactly the named entries
non-interactively (all-or-nothing); `--ssh-config=<path>` 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name1,name2>` for non-interactive, `--ssh-config=<path>` 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=<name>` - Test connection to a host
- `--host-test-all` - Test connections to all hosts (per-host 10s dial timeout) and show auth method used
Expand Down
3 changes: 3 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
43 changes: 43 additions & 0 deletions docs/host-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path>`.

## Settings File

Host definitions live in `~/.sshx/settings.json`.
Expand Down
43 changes: 43 additions & 0 deletions docs/zh/host-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path>` 直接导入。

## 配置文件

主机定义保存在 `~/.sshx/settings.json`。
Expand Down
2 changes: 1 addition & 1 deletion internal/app/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/app/dryrun.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions internal/app/host_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"

Expand All @@ -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":
Expand Down Expand Up @@ -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=<name1,name2>.
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
Expand Down
Loading
Loading