diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6099df9..86eec33 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -34,7 +34,7 @@ jobs: out="dist/netssh_${GOOS}_${GOARCH}" echo "→ $out" CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \ - go build -trimpath -ldflags="-s -w" \ + go build -trimpath -ldflags="-s -w -X main.version=${GITHUB_REF_NAME}" \ -o "$out" ./cmd/netssh done diff --git a/CHANGELOG.md b/CHANGELOG.md index de01afc..1e95b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.0.21 + +- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script. +- Add `completion.max_results` to limit the number of hostnames offered per completion. +- Register zsh completion in the managed `.zshrc` block (`fpath` and `compdef`), also when `compinit` already ran, e.g. with oh-my-zsh. The installer reports which completions it set up. +- Add `netssh uninstall [--keep-config] [--dry-run] [--yes]` to remove the binary, profile entries of all versions, completions, cache, and config (with a private backup). +- Write marked `# >>> netssh >>>` blocks from `netssh hook install` and the wizard, replacing unmarked entries of older versions instead of appending. +- Add jump host support: `ssh.proxy_jump` globally, per NetBox instance, and per rule, plus `--jump HOST` and `--no-jump`. +- Add `ssh.host_key_checking` (`strict`, `accept-new`, `off`) for netssh connections only. +- Add `ssh.target: name|hostname` so `~/.ssh/config` host patterns apply and known_hosts entries belong to the device, and `ssh.address_family`. +- Add `ssh.options` and `ssh.extra_args`, and `ssh.rules` that match by tag, instance, kind, or name pattern. +- Add `netssh --version` / `netssh version` and `netssh --dry-run` to print the final ssh command. + ## v0.0.20 - Configure multiple named NetBox instances and search them together in parallel. diff --git a/README.md b/README.md index d332cc3..4466e07 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ netssh -p 2222 admin@app-server-03 uptime - **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding - **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts - **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours +- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config` +- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting +- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config ## Installation @@ -135,6 +138,11 @@ cache: ssh: default_user: admin # used when no user is specified on the command line default_port: 2222 # optional; injected as -p if not specified on the command line + # See "SSH options" below for proxy_jump, host_key_checking, target, + # address_family, extra_args, options, and rules. + +completion: + max_results: 0 # hostnames offered per ; 0 = unlimited shortcuts: domains: # strip these suffixes to create short aliases @@ -240,6 +248,74 @@ netssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no db-primary The process is replaced by `ssh` with the resolved IP — your `~/.ssh/config`, agent, and keys all work as normal. +### Dry run and version + +```sh +netssh --dry-run web01 # print the final ssh command instead of connecting +netssh --version # or: netssh version +``` + +Long options (`--dry-run`, `--jump`, `--no-jump`) are netssh's own; OpenSSH has +no long options. They must come before the hostname — everything after the +hostname is the remote command. + +### SSH options + +All settings below go under `ssh:`. The same keys (except `rules`) can be set +per NetBox instance under `netboxes[].ssh` (or `netbox.ssh`), and per rule. +More specific settings win: global → instance → matching rules (in order) → +command-line flags. Options you pass on the command line always take precedence +over the config. + +```yaml +ssh: + proxy_jump: "jumphost.example.com" # passed as -J; "none" disables an inherited jump host + host_key_checking: accept-new # strict | accept-new | off; unset = ssh's own setting + target: ip # ip (default) | name | hostname + address_family: inet # any | inet (-4) | inet6 (-6) + extra_args: ["-A"] # inserted before your own arguments + options: # passed as -o Key=Value + ServerAliveInterval: 60 + ServerAliveCountMax: 5 + rules: # every set match field must match + - match: {tag: dmz} # tag name or slug + proxy_jump: "dmz-jump.example.com" + - match: {instance: lab, name: "*.lab.example.com", kind: vm} + host_key_checking: off + +netboxes: + - name: prod + url: "https://netbox.example.com" + token: "nbt_…" + ssh: + proxy_jump: "jumphost.example.com" + - name: staging + url: "https://netbox-staging.example.com" + token: "nbt_…" + ssh: + host_key_checking: off +``` + +**Jump hosts.** Override the configured jump host for a single call with +`--jump other.example.com` (or ssh's own `-J`), or disable all jump hosts — +including `ProxyJump` from `~/.ssh/config` — with `--no-jump`. + +**Host key checking.** `strict` sets `StrictHostKeyChecking=yes`, `accept-new` +accepts keys of new hosts but rejects changed ones. `off` sets +`StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`, and `LogLevel=ERROR` +for netssh connections only and prints a short notice. Use it only for hosts +that are reinstalled often, such as staging; it disables protection against +man-in-the-middle attacks. + +**Connection target.** By default netssh calls `ssh `. With `target: name`, +it calls `ssh -o HostName= -o HostKeyAlias= `: the resolved IP +is still used, but `Host *.example.com` rules in `~/.ssh/config` apply, and +known_hosts entries belong to the device instead of an IP that may be reused. +With `target: hostname`, ssh resolves the NetBox name itself (DNS or +`~/.ssh/config`). Names that are not valid hostnames fall back to the IP. +Switching to `name` stores new known_hosts entries under the device name, so +ssh asks once to confirm each host key. + ### Default username Set `ssh.default_user` in the config to avoid typing a username every time: @@ -422,7 +498,7 @@ netssh hook install --shell zsh netssh hook install --shell fish ``` -This appends a single line (`netssh shell-init`) to your shell profile. To remove it: +This adds a marked block (`# >>> netssh >>>` … `# <<< netssh <<<`) to your shell profile. To remove it: ```sh netssh hook uninstall @@ -465,14 +541,20 @@ netssh completion install --shell fish | zsh | `~/.zfunc/_netssh` | | fish | `~/.config/fish/completions/netssh.fish` | -For zsh, make sure `~/.zfunc` is in your `fpath` (add to `~/.zshrc`): +For zsh, the netssh block in `~/.zshrc` (written by the installer and by +`netssh hook install --shell zsh`) adds `~/.zfunc` to `fpath` and registers the +completion with `compdef`, also when `compinit` already ran earlier — for +example via oh-my-zsh. Without the block, add `fpath=(~/.zfunc $fpath)` before +`compinit`. If `` still completes files, clear the completion cache: ```zsh -fpath=(~/.zfunc $fpath) -autoload -Uz compinit && compinit +rm -f ~/.zcompdump* && exec zsh ``` Completions are served from the local cache — no network request on every ``. +The zsh script that `netssh completion zsh` prints is built for large caches: +5000 hosts complete in well under a second. To limit the number of hostnames +offered, set `completion.max_results`. ## Shell Hook @@ -502,15 +584,25 @@ Reload with: source /home/user/.bashrc What gets written to the profile: -**bash / zsh** (`~/.bashrc` or `~/.zshrc`): +**bash** (`~/.bashrc`): ```bash -netssh shell-init # netssh cache auto-refresh -source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases +# >>> netssh >>> +netssh shell-init +[ ! -f '/home/user/.cache/netssh/aliases.sh' ] || . '/home/user/.cache/netssh/aliases.sh' +# <<< netssh <<< +``` + +**zsh** (`~/.zshrc`) additionally registers the completion: +```zsh +fpath=('/home/user/.zfunc' $fpath) +if (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi ``` **fish** (`~/.config/fish/config.fish`): ```fish -netssh shell-init # netssh cache auto-refresh +# >>> netssh >>> +netssh shell-init +# <<< netssh <<< ``` Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes. @@ -521,7 +613,7 @@ Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically a | zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` | | fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` | -The install is idempotent — running it again changes nothing if the lines are already present. +The install is idempotent — running it again changes nothing if the block is already present. Unmarked lines written by older versions are replaced by the block. Reload your profile after installation to activate immediately: @@ -550,7 +642,7 @@ netssh hook uninstall # auto-detects $SHELL netssh hook uninstall --shell zsh ``` -Removes both the `netssh shell-init` line and the `source aliases.sh` line from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk — delete them manually if desired. +Removes the netssh block and unmarked entries of older versions from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk. To remove netssh completely, use `netssh uninstall`. ### Relation to the connect-time auto-refresh @@ -563,6 +655,24 @@ The shell hook and the connect-time trigger (which fires on each `netssh` invoca Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened. +## Uninstall + +```sh +netssh uninstall --dry-run # show what would be removed +netssh uninstall # asks for confirmation +netssh uninstall --keep-config # keep config and notes for a reinstall +``` + +`netssh uninstall` removes: + +- netssh blocks and unmarked entries of older versions from `~/.bashrc`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`, `~/.zshrc`, `~/.zprofile`, `~/.zshenv`, and `config.fish` +- completion files for bash, zsh (also the oh-my-zsh cache), fish, and the command alias +- the cache directory `~/.cache/netssh/` including per-instance caches and aliases +- `~/.config/netssh.yaml` (a private backup `netssh.yaml.backup-` is kept, because it contains API tokens) and local notes +- the netssh binary; if it is in a protected directory, netssh prints the `sudo rm` command to run + +Afterwards open a new terminal; for zsh also run `rm -f ~/.zcompdump*`. + ## Development Renovate monitors the release workflow's actions, the Go version in `go.mod`, and @@ -582,9 +692,9 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen ## How it works -1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode. +1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`, `version`, `uninstall`, …). If not, it enters SSH wrapper mode. 2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically. 3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …). 4. It resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made. 5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP. -6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP. +6. It adds the configured SSH options for the host (jump host, host key checking, `-o` options) before the destination and calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP (or the name, with `ssh.target: name`). diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 7d52e17..fbd7f7d 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -39,6 +40,8 @@ var managedSubcommands = map[string]bool{ "completion": true, "__complete": true, "help": true, + "version": true, + "uninstall": true, } func main() { @@ -48,6 +51,10 @@ func main() { runTUI() return } + if args[0] == "--version" { + fmt.Printf("netssh %s\n", buildVersion()) + return + } if managedSubcommands[args[0]] { if err := rootCmd().Execute(); err != nil { @@ -80,16 +87,21 @@ func loadConfigOrSetup() *config.Config { // runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary. func runSSHWrapper(args []string) { + flags, args, err := internalssh.ExtractWrapperFlags(args) + if err != nil { + fatalf("%v", err) + } cfg := loadConfigOrSetup() parsed := internalssh.Parse(args) if parsed == nil { // No destination found — pass arguments straight through to ssh. - if err := internalssh.Exec(args); err != nil { - fatalf("%v", err) - } + execSSH(internalssh.Result{Args: args}, flags) return } + connect := func(name, kind, ip string, tags []string) { + connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args) + } // Inject the configured default user if none was given on the command line. if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) { @@ -113,7 +125,7 @@ func runSSHWrapper(args []string) { if entry, fresh := c.Get(parsed.Host); fresh && useCache { c.MarkUsed(parsed.Host) _ = c.Save() - connect(entry.IP, parsed, args) + connect(entry.Name, entry.Kind, entry.IP, entry.Tags) return } @@ -131,7 +143,7 @@ func runSSHWrapper(args []string) { if fresh { c.MarkUsed(entry.Name) _ = c.Save() - connect(entry.IP, parsed, args) + connect(entry.Name, entry.Kind, entry.IP, entry.Tags) return } // Stale shortcut match — use canonical name for NetBox re-fetch. @@ -164,7 +176,7 @@ func runSSHWrapper(args []string) { if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind { c.MarkUsed(target.Target()) _ = c.Save() - connect(cached.IP, parsed, args) + connect(cached.Name, cached.Kind, cached.IP, cached.Tags) return } @@ -182,7 +194,7 @@ func runSSHWrapper(args []string) { c.MarkUsed(target.Target()) _ = c.Save() - connect(ip, parsed, args) + connect(target.Target(), target.Kind, ip, target.Tags) } // findSSHHost uses the paginated, ambiguity-checking lookup for connection @@ -205,9 +217,24 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name) } -func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) { - newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip) - if err := internalssh.Exec(newArgs); err != nil { +// connectHost applies the SSH options configured for the host and execs ssh. +// target is the cache/selector name, which may carry an instance prefix. +func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) { + instance, name := netbox.SplitTarget(target) + opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags}) + execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags) +} + +// execSSH replaces the process with ssh, or prints the command for --dry-run. +func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) { + if flags.DryRun { + fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...))) + return + } + if result.Notice != "" { + fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice) + } + if err := internalssh.Exec(result.Args); err != nil { fatalf("%v", err) } } @@ -243,8 +270,10 @@ func runTUI() { } // Revalidate stale or live TUI results through the configured resolver chain. + var tags []string if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind { host.IP = cached.IP + tags = cached.Tags } else { if nbClient == nil { fatalf("cannot resolve %q: NetBox is not configured", host.Name) @@ -264,6 +293,7 @@ func runTUI() { fatalf("IP resolution: %v", err) } c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags}) + tags = target.Tags } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) @@ -291,9 +321,7 @@ func runTUI() { c.MarkUsed(host.Name) _ = c.Save() - if err := internalssh.Exec(sshArgs); err != nil { - fatalf("%v", err) - } + connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs) } // --- cobra root + subcommands --- @@ -311,8 +339,13 @@ func rootCmd() *cobra.Command { c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() - var completions []cobra.Completion - for _, e := range c.Search(toComplete) { + hosts := c.Search(toComplete) + sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name }) + if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { + hosts = hosts[:limit] + } + completions := make([]cobra.Completion, 0, len(hosts)) + for _, e := range hosts { completions = append(completions, cobra.Completion(e.Name)) } // Subcommands at the end, after all hostnames. @@ -325,13 +358,24 @@ func rootCmd() *cobra.Command { }, } - root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd()) + root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd()) // cobra builds the "completion" command lazily; force init so we can extend it. root.InitDefaultCompletionCmd() for _, cmd := range root.Commands() { if cmd.Name() == "completion" { cmd.AddCommand(completionInstallCmd(root)) + // Cobra's zsh script spawns a subshell per line and sends every host + // through _describe, which hangs with thousands of cached hosts. + for _, sub := range cmd.Commands() { + if sub.Name() == "zsh" { + sub.RunE = func(cmd *cobra.Command, args []string) error { + _, err := io.WriteString(cmd.OutOrStdout(), hook.ZshCompletionScript) + return err + } + sub.Run = nil + } + } break } } @@ -369,12 +413,8 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command { case "zsh": dir = filepath.Join(os.Getenv("HOME"), ".zfunc") file = filepath.Join(dir, "_netssh") - gen = func() ([]byte, error) { - var buf strings.Builder - err := root.GenZshCompletion(&buf) - return []byte(buf.String()), err - } - note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit" + gen = func() ([]byte, error) { return []byte(hook.ZshCompletionScript), nil } + note = hook.ZshCompletionNote(dir) case "fish": dir = filepath.Join(hook.FishConfigDir(), "completions") file = filepath.Join(dir, "netssh.fish") @@ -697,9 +737,11 @@ func hookInstallCmd() *cobra.Command { cmd := &cobra.Command{ Use: "install", Short: "Add netssh shell-init to your shell profile", - Long: `Appends a single line to your shell profile that runs 'netssh shell-init' -on every new shell session. shell-init checks whether the cache is older -than 24 hours and, if so, starts a background refresh — no delay to your prompt. + Long: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell +profile that runs 'netssh shell-init' on every new shell session and, for zsh, +registers the completion. Unmarked entries from older versions are replaced. +shell-init checks whether the cache is older than 24 hours and, if so, starts +a background refresh — no delay to your prompt. After installation, reload your profile or open a new shell.`, RunE: func(cmd *cobra.Command, args []string) error { @@ -711,17 +753,12 @@ After installation, reload your profile or open a new shell.`, return err } - installed, err := hook.Install(profile) + // The managed block also sources the aliases (bash/zsh) and registers + // zsh completion; fish uses conf.d auto-sourcing instead. + installed, err := hook.Install(profile, shell) if err != nil { return err } - // For bash/zsh: also add the source line so aliases are loaded on startup. - // Fish uses conf.d auto-sourcing — no explicit source line needed. - if shell != "fish" { - if _, err := hook.InstallAliasesSource(profile); err != nil { - return err - } - } if !installed { fmt.Printf("Hook already installed in %s\n", profile) diff --git a/cmd/netssh/uninstall.go b/cmd/netssh/uninstall.go new file mode 100644 index 0000000..dc01240 --- /dev/null +++ b/cmd/netssh/uninstall.go @@ -0,0 +1,250 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" +) + +// uninstallPlan lists everything netssh has written to this machine. +type uninstallPlan struct { + Profiles []string // shell profiles with a netssh block or legacy lines + Paths []string // files and directories to delete + Config string // config file to back up and delete; empty when kept + Binaries []string +} + +func buildUninstallPlan(cfg *config.Config, keepConfig bool) uninstallPlan { + var plan uninstallPlan + seen := make(map[string]bool) + addPath := func(path string) { + if path == "" || seen[path] { + return + } + if _, err := os.Lstat(path); err == nil { + seen[path] = true + plan.Paths = append(plan.Paths, path) + } + } + + for _, profile := range hook.ProfileCandidates() { + target, err := filepath.EvalSymlinks(profile) + if err != nil || seen[target] { + continue + } + seen[target] = true + if hook.HasEntries(profile) { + plan.Profiles = append(plan.Profiles, profile) + } + } + + aliases := []string{hook.CompletionAliasState()} + if cfg != nil { + aliases = append(aliases, cfg.Shell.Alias) + } + for _, path := range hook.CompletionFiles(aliases...) { + if hook.IsGenerated(path) { + addPath(path) + } + } + + // The cache directory holds hosts, aliases, refresh stamps, and instance caches. + cacheDir := filepath.Dir(hook.AliasesPath()) + addPath(cacheDir) + if cfg != nil { + for _, path := range []string{cfg.Cache.Path, cfg.CachePath()} { + if path == "" || strings.HasPrefix(path, cacheDir+string(filepath.Separator)) { + continue + } + addPath(path) + addPath(path + ".lock") + addPath(filepath.Join(filepath.Dir(path), "last_refresh")) + if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") { + addPath(filepath.Dir(path)) + } + } + } + + if !keepConfig { + if _, err := os.Stat(config.Path()); err == nil { + plan.Config = config.Path() + } + // Local server notes live next to the config file. + addPath(filepath.Join(filepath.Dir(config.Path()), "netssh")) + } + + plan.Binaries = installedBinaries() + return plan +} + +// installedBinaries finds the running binary and any other netssh in PATH. +func installedBinaries() []string { + var out []string + seen := make(map[string]bool) + add := func(path string) { + real, err := filepath.EvalSymlinks(path) + if err != nil || seen[real] { + return + } + if info, err := os.Stat(real); err != nil || info.IsDir() || filepath.Base(real) != "netssh" { + return + } + seen[real] = true + out = append(out, real) + } + if self, err := os.Executable(); err == nil { + add(self) + } + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if dir != "" { + add(filepath.Join(dir, "netssh")) + } + } + return out +} + +func (p uninstallPlan) empty() bool { + return len(p.Profiles) == 0 && len(p.Paths) == 0 && p.Config == "" && len(p.Binaries) == 0 +} + +func (p uninstallPlan) print(w io.Writer) { + for _, profile := range p.Profiles { + fmt.Fprintf(w, " edit %s (remove netssh entries)\n", profile) + } + for _, path := range p.Paths { + fmt.Fprintf(w, " delete %s\n", path) + } + if p.Config != "" { + fmt.Fprintf(w, " delete %s (a backup is kept)\n", p.Config) + } + for _, binary := range p.Binaries { + fmt.Fprintf(w, " delete %s\n", binary) + } +} + +// execute applies the plan. It continues after errors so that one protected +// file does not leave the rest behind, and returns commands that need sudo. +func (p uninstallPlan) execute(w io.Writer) (manual []string, err error) { + var errs []error + for _, profile := range p.Profiles { + if _, err := hook.Uninstall(profile); err != nil { + errs = append(errs, err) + continue + } + fmt.Fprintf(w, "cleaned %s\n", profile) + } + for _, path := range p.Paths { + if err := os.RemoveAll(path); err != nil { + errs = append(errs, err) + continue + } + fmt.Fprintf(w, "removed %s\n", path) + } + if p.Config != "" { + backup, err := backupConfig(p.Config) + if err != nil { + errs = append(errs, fmt.Errorf("config not removed: %w", err)) + } else if err := os.Remove(p.Config); err != nil { + errs = append(errs, err) + } else { + fmt.Fprintf(w, "removed %s (backup: %s)\n", p.Config, backup) + } + } + for _, binary := range p.Binaries { + if err := os.Remove(binary); err != nil { + if errors.Is(err, fs.ErrPermission) { + manual = append(manual, "sudo rm -f "+shellQuote(binary)) + continue + } + errs = append(errs, err) + continue + } + fmt.Fprintf(w, "removed %s\n", binary) + } + return manual, errors.Join(errs...) +} + +// backupConfig keeps a private copy, because the config holds API tokens. +func backupConfig(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + backup := path + ".backup-" + time.Now().Format("20060102-150405") + if err := storage.WriteFile(backup, data, 0o600); err != nil { + return "", err + } + return backup, nil +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func uninstallCmd() *cobra.Command { + var keepConfig, yes, dryRun bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove netssh, its shell integration, cache, and config", + Long: `Removes everything netssh and install.sh have set up: + + - netssh blocks and unmarked entries of older versions in bash, zsh, and fish profiles + - shell completion files (bash, zsh, fish, oh-my-zsh cache, command alias) + - the cache directory with hosts, aliases, and per-instance caches + - the config file (a private backup is kept) and local server notes + - the netssh binary + +Use --keep-config to keep the config file and notes for a reinstall.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + // A broken config must not block the uninstall. + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + cfg = nil + } + plan := buildUninstallPlan(cfg, keepConfig) + out := cmd.OutOrStdout() + if plan.empty() { + fmt.Fprintln(out, "Nothing to remove.") + return nil + } + fmt.Fprintln(out, "netssh uninstall will:") + plan.print(out) + if dryRun { + return nil + } + if !yes { + fmt.Fprint(out, "Proceed? [y/N] ") + answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n') + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return fmt.Errorf("aborted") + } + } + manual, err := plan.execute(out) + for _, command := range manual { + fmt.Fprintf(out, "Permission denied. Run: %s\n", command) + } + if len(plan.Profiles) > 0 { + fmt.Fprintln(out, "Open a new terminal. For zsh, also run: rm -f ~/.zcompdump*") + } + return err + }, + } + cmd.Flags().BoolVar(&keepConfig, "keep-config", false, "Keep the config file and local notes") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Do not ask for confirmation") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would be removed") + return cmd +} diff --git a/cmd/netssh/uninstall_test.go b/cmd/netssh/uninstall_test.go new file mode 100644 index 0000000..e4d26f6 --- /dev/null +++ b/cmd/netssh/uninstall_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" +) + +func uninstallFixture(t *testing.T) (home string) { + t.Helper() + home = t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ZDOTDIR", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("ZSH", "") + t.Setenv("ZSH_CACHE_DIR", "") + t.Setenv("NETSSH_SHELL_ALIAS", "") + t.Setenv("NETSSH_CACHE_PATH", "") + t.Setenv("PATH", filepath.Join(home, "nobin")) + files := map[string]string{ + ".bashrc": "export KEEP=1\n# >>> netssh >>>\nnetssh shell-init\n# <<< netssh <<<\n", + ".zshrc": "plugins=(git)\nnetssh shell-init # netssh cache auto-refresh\nsource ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases\n", + ".profile": "export UNRELATED=1\n", + ".config/netssh.yaml": "netbox:\n url: \"https://netbox.example.com\"\n token: \"secret\"\nshell:\n alias: \"j\"\n", + ".config/netssh/notes/abc.txt": "note", + ".cache/netssh/hosts.json": "{}", + ".cache/netssh/instances-0011/hosts.json": "{}", + ".local/share/bash-completion/completions/netssh": "# bash completion for netssh", + ".zfunc/_netssh": hook.ZshCompletionScript, + ".zfunc/_j": "#compdef j\n# netssh command alias completion — generated automatically\n", + ".zfunc/_other": "#compdef other\n", + } + for name, content := range files { + path := filepath.Join(home, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + return home +} + +func runUninstall(t *testing.T, args ...string) string { + t.Helper() + cmd := rootCmd() + var out strings.Builder + cmd.SetOut(&out) + cmd.SetArgs(append([]string{"uninstall"}, args...)) + if err := cmd.Execute(); err != nil { + t.Fatalf("uninstall: %v\n%s", err, out.String()) + } + return out.String() +} + +func TestUninstallRemovesEverythingAndBacksUpConfig(t *testing.T) { + home := uninstallFixture(t) + if out := runUninstall(t, "--dry-run"); !strings.Contains(out, ".zshrc") || strings.Contains(out, ".profile") { + t.Fatalf("plan:\n%s", out) + } + if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); err != nil { + t.Fatal("dry run removed files") + } + runUninstall(t, "--yes") + + for name, want := range map[string]string{".bashrc": "export KEEP=1\n", ".zshrc": "plugins=(git)\n", ".profile": "export UNRELATED=1\n"} { + data, _ := os.ReadFile(filepath.Join(home, name)) + if string(data) != want { + t.Errorf("%s: %q", name, data) + } + } + for _, gone := range []string{".cache/netssh", ".config/netssh.yaml", ".config/netssh", ".zfunc/_netssh", ".zfunc/_j", ".local/share/bash-completion/completions/netssh"} { + if _, err := os.Stat(filepath.Join(home, gone)); !os.IsNotExist(err) { + t.Errorf("%s not removed: %v", gone, err) + } + } + if _, err := os.Stat(filepath.Join(home, ".zfunc", "_other")); err != nil { + t.Error("unrelated completion removed") + } + backups, _ := filepath.Glob(filepath.Join(home, ".config", "netssh.yaml.backup-*")) + if len(backups) != 1 { + t.Fatalf("backups: %v", backups) + } + if info, _ := os.Stat(backups[0]); info.Mode().Perm() != 0o600 { + t.Error("backup with token is not private") + } +} + +func TestUninstallKeepConfig(t *testing.T) { + home := uninstallFixture(t) + runUninstall(t, "--yes", "--keep-config") + for _, kept := range []string{".config/netssh.yaml", ".config/netssh/notes/abc.txt"} { + if _, err := os.Stat(filepath.Join(home, kept)); err != nil { + t.Errorf("%s removed", kept) + } + } + if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); !os.IsNotExist(err) { + t.Error("cache kept") + } +} + +func TestUninstallAbortsWithoutConfirmation(t *testing.T) { + home := uninstallFixture(t) + cmd := rootCmd() + cmd.SetOut(&strings.Builder{}) + cmd.SetIn(strings.NewReader("n\n")) + cmd.SetArgs([]string{"uninstall"}) + if err := cmd.Execute(); err == nil { + t.Fatal("no confirmation required") + } + if _, err := os.Stat(filepath.Join(home, ".config", "netssh.yaml")); err != nil { + t.Fatal("config removed without confirmation") + } +} diff --git a/cmd/netssh/version.go b/cmd/netssh/version.go new file mode 100644 index 0000000..80e2357 --- /dev/null +++ b/cmd/netssh/version.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "runtime/debug" + + "github.com/spf13/cobra" +) + +// version is set at build time: -ldflags "-X main.version=v1.2.3". +var version = "" + +// buildVersion falls back to the module version for `go install` builds. +func buildVersion() string { + if version != "" { + return version + } + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + return "dev" +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the netssh version", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintf(cmd.OutOrStdout(), "netssh %s\n", buildVersion()) + }, + } +} diff --git a/install.sh b/install.sh index 1380a9e..2d5b892 100755 --- a/install.sh +++ b/install.sh @@ -168,6 +168,11 @@ plan_profile() { printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")" printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" fi + if [[ "$kind" == zsh ]]; then + # Register completion also when compinit already ran (e.g. oh-my-zsh). + printf 'fpath=(%s $fpath)\n' "$(quote_sh "$HOME/.zfunc")" + printf 'if (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n' + fi printf '# <<< netssh <<<\n' } >> "$data" fi @@ -231,10 +236,26 @@ for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do mv -f "$staged" "$target" info "updated : ${PROFILE_PATHS[$index]}" done +COMPLETIONS=() for kind in "${SHELLS[@]}"; do - if ! "$INSTALL_DIR/$BINARY" completion install --shell "$kind"; then + if "$INSTALL_DIR/$BINARY" completion install --shell "$kind" >/dev/null; then + COMPLETIONS+=("$kind") + else info "warning: could not update $kind completion" fi done printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY" +for kind in "${COMPLETIONS[@]}"; do + case "$kind" in + bash) info "completion: bash -> $HOME/.local/share/bash-completion/completions/netssh" ;; + zsh) info "completion: zsh -> $HOME/.zfunc/_netssh (registered in ${ZSH_DIR}/.zshrc)" ;; + fish) info "completion: fish -> $CONFIG_DIR/fish/completions/netssh.fish" ;; + esac +done info "Open a new terminal to load the current hooks and aliases." +for kind in "${COMPLETIONS[@]}"; do + if [[ "$kind" == zsh ]]; then + info "zsh: if still shows files, run: rm -f $(quote_sh "$ZSH_DIR")/.zcompdump* && exec zsh" + fi +done +info "Remove netssh again with: netssh uninstall" diff --git a/installer_test.go b/installer_test.go index 639e615..68ec9e3 100644 --- a/installer_test.go +++ b/installer_test.go @@ -108,7 +108,14 @@ func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) { t.Fatalf("bad managed blocks: %s", data) } } + zshrc, _ := os.ReadFile(filepath.Join(f.home, ".zshrc")) + if !strings.Contains(string(zshrc), "compdef _netssh netssh") || !strings.Contains(string(zshrc), "/.zfunc' $fpath)") { + t.Fatalf("zsh completion not registered: %s", zshrc) + } data, _ := os.ReadFile(bashrc) + if strings.Contains(string(data), "compdef") { + t.Fatalf("zsh code in bash profile: %s", data) + } for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} { if !strings.Contains(string(data), kept) { t.Fatalf("unrelated line lost: %s", kept) diff --git a/internal/config/config.go b/internal/config/config.go index 6c686c0..c498b9c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,13 +11,14 @@ import ( ) type Config struct { - Shell ShellConfig `mapstructure:"shell"` - NetBox NetBoxConfig `mapstructure:"netbox"` - NetBoxes []NetBoxConfig `mapstructure:"netboxes"` - Resolver ResolverConfig `mapstructure:"resolver"` - Cache CacheConfig `mapstructure:"cache"` - SSH SSHConfig `mapstructure:"ssh"` - Shortcuts ShortcutsConfig `mapstructure:"shortcuts"` + Shell ShellConfig `mapstructure:"shell"` + NetBox NetBoxConfig `mapstructure:"netbox"` + NetBoxes []NetBoxConfig `mapstructure:"netboxes"` + Resolver ResolverConfig `mapstructure:"resolver"` + Cache CacheConfig `mapstructure:"cache"` + SSH SSHConfig `mapstructure:"ssh"` + Shortcuts ShortcutsConfig `mapstructure:"shortcuts"` + Completion CompletionConfig `mapstructure:"completion"` } type ShellConfig struct { @@ -25,10 +26,11 @@ type ShellConfig struct { } type NetBoxConfig struct { - Name string `mapstructure:"name"` - URL string `mapstructure:"url"` - Token string `mapstructure:"token"` - TokenVersion int `mapstructure:"token_version"` + Name string `mapstructure:"name"` + URL string `mapstructure:"url"` + Token string `mapstructure:"token"` + TokenVersion int `mapstructure:"token_version"` + SSH SSHOptions `mapstructure:"ssh"` } type ResolverConfig struct { @@ -45,6 +47,13 @@ type CacheConfig struct { type SSHConfig struct { DefaultUser string `mapstructure:"default_user"` DefaultPort int `mapstructure:"default_port"` + SSHOptions `mapstructure:",squash"` + Rules []SSHRule `mapstructure:"rules"` +} + +type CompletionConfig struct { + // MaxResults limits the number of hostnames offered per completion; 0 = unlimited. + MaxResults int `mapstructure:"max_results"` } type ShortcutsConfig struct { @@ -79,7 +88,7 @@ func Load() (*Config, error) { v.SetEnvPrefix("NETSSH") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() - for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias"} { + for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} { if err := v.BindEnv(key); err != nil { return nil, err } @@ -116,6 +125,9 @@ func Load() (*Config, error) { if err := cfg.ValidateNetBoxes(); err != nil { return nil, err } + if err := cfg.ValidateSSH(); err != nil { + return nil, err + } for i := range cfg.NetBoxes { source := &cfg.NetBoxes[i] source.Name = strings.ToLower(source.Name) diff --git a/internal/config/ssh.go b/internal/config/ssh.go new file mode 100644 index 0000000..0e55e09 --- /dev/null +++ b/internal/config/ssh.go @@ -0,0 +1,181 @@ +package config + +import ( + "fmt" + "path" + "regexp" + "strings" +) + +// SSHOptions are connection settings that can be set globally, per NetBox +// instance, and per rule. More specific layers override less specific ones. +type SSHOptions struct { + // ProxyJump is passed as -J; "none" disables a jump host set by a broader layer. + ProxyJump string `mapstructure:"proxy_jump"` + // HostKeyChecking is strict, accept-new, or off. Empty keeps ssh's own setting. + HostKeyChecking string `mapstructure:"host_key_checking"` + // Target selects the ssh destination: ip (default), name, or hostname. + Target string `mapstructure:"target"` + // AddressFamily is any, inet, or inet6. + AddressFamily string `mapstructure:"address_family"` + // ExtraArgs are inserted verbatim before the destination. + ExtraArgs []string `mapstructure:"extra_args"` + // Options are passed as -o Key=Value. + Options map[string]string `mapstructure:"options"` +} + +// SSHRule applies SSH options to hosts that match every non-empty field. +type SSHRule struct { + Match SSHMatch `mapstructure:"match"` + SSHOptions `mapstructure:",squash"` +} + +// SSHMatch selects hosts by NetBox attributes available in the cache. +type SSHMatch struct { + Tag string `mapstructure:"tag"` + Instance string `mapstructure:"instance"` + Kind string `mapstructure:"kind"` + Name string `mapstructure:"name"` // shell-style glob, e.g. "*.dmz.example.com" +} + +// SSHHost identifies the resolved host that SSH options are computed for. +type SSHHost struct { + Instance string + Name string // NetBox name without the instance prefix + Kind string + Tags []string +} + +var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`) + +// ValidateSSH rejects values that would be passed to ssh in an unexpected form. +func (c *Config) ValidateSSH() error { + if err := c.SSH.SSHOptions.validate("ssh"); err != nil { + return err + } + for i, rule := range c.SSH.Rules { + if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil { + return err + } + if rule.Match == (SSHMatch{}) { + return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i) + } + if _, err := path.Match(rule.Match.Name, ""); err != nil { + return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name) + } + } + if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil { + return err + } + for _, source := range c.NetBoxes { + if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil { + return err + } + } + return nil +} + +func (o SSHOptions) validate(scope string) error { + switch o.HostKeyChecking { + case "", "strict", "accept-new", "off": + default: + return fmt.Errorf("%s.host_key_checking must be strict, accept-new, or off", scope) + } + switch o.Target { + case "", "ip", "name", "hostname": + default: + return fmt.Errorf("%s.target must be ip, name, or hostname", scope) + } + switch o.AddressFamily { + case "", "any", "inet", "inet6": + default: + return fmt.Errorf("%s.address_family must be any, inet, or inet6", scope) + } + if strings.HasPrefix(o.ProxyJump, "-") || strings.ContainsAny(o.ProxyJump, " \t\n") { + return fmt.Errorf("%s.proxy_jump %q is not a valid jump host", scope, o.ProxyJump) + } + for key, value := range o.Options { + if !sshOptionKey.MatchString(key) || strings.ContainsAny(value, "\n\r") { + return fmt.Errorf("%s.options: invalid option %q", scope, key) + } + } + return nil +} + +// SSHOptionsFor merges global, instance, and matching rule options for host. +func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions { + merged := c.SSH.SSHOptions.merge(SSHOptions{}) + if host.Instance == "" { + merged = merged.merge(c.NetBox.SSH) + } + for _, source := range c.NetBoxes { + if strings.EqualFold(source.Name, host.Instance) { + merged = merged.merge(source.SSH) + } + } + for _, rule := range c.SSH.Rules { + if rule.Match.matches(host) { + merged = merged.merge(rule.SSHOptions) + } + } + return merged +} + +// merge returns o overridden by the non-empty fields of override. +// Extra arguments accumulate; options override per key. +func (o SSHOptions) merge(override SSHOptions) SSHOptions { + out := o + out.ExtraArgs = append(append([]string(nil), o.ExtraArgs...), override.ExtraArgs...) + out.Options = make(map[string]string, len(o.Options)+len(override.Options)) + for key, value := range o.Options { + out.Options[strings.ToLower(key)] = value + } + for key, value := range override.Options { + out.Options[strings.ToLower(key)] = value + } + if override.ProxyJump != "" { + out.ProxyJump = override.ProxyJump + } + if override.HostKeyChecking != "" { + out.HostKeyChecking = override.HostKeyChecking + } + if override.Target != "" { + out.Target = override.Target + } + if override.AddressFamily != "" { + out.AddressFamily = override.AddressFamily + } + return out +} + +func (m SSHMatch) matches(host SSHHost) bool { + if m.Instance != "" && !strings.EqualFold(m.Instance, host.Instance) { + return false + } + if m.Kind != "" && !strings.EqualFold(m.Kind, host.Kind) { + return false + } + if m.Name != "" { + if ok, _ := path.Match(strings.ToLower(m.Name), strings.ToLower(host.Name)); !ok { + return false + } + } + if m.Tag != "" { + found := false + for _, tag := range host.Tags { + if strings.EqualFold(tag, m.Tag) || strings.EqualFold(tagSlug(tag), m.Tag) { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// tagSlug approximates NetBox's slug so rules may use either the tag name or slug. +func tagSlug(name string) string { + return strings.Join(strings.Fields(strings.ToLower(name)), "-") +} diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go new file mode 100644 index 0000000..a6780bc --- /dev/null +++ b/internal/config/ssh_test.go @@ -0,0 +1,90 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func loadYAML(t *testing.T, data string) (*Config, error) { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("HOME", t.TempDir()) + if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil { + t.Fatal(err) + } + return Load() +} + +func TestSSHOptionsLayering(t *testing.T) { + cfg, err := loadYAML(t, ` +netboxes: + - name: prod + url: https://prod.example.com + ssh: + proxy_jump: jh-prod.example.com + host_key_checking: strict + - name: staging + url: https://staging.example.com + ssh: + host_key_checking: off +ssh: + proxy_jump: jh.example.com + extra_args: ["-4"] + options: + ServerAliveInterval: 60 + ServerAliveCountMax: 5 + rules: + - match: {tag: dmz} + proxy_jump: jh-dmz.example.com + options: {ServerAliveInterval: 10} + - match: {instance: prod, name: "db*"} + proxy_jump: none +completion: + max_results: 200 +`) + if err != nil { + t.Fatal(err) + } + if cfg.Completion.MaxResults != 200 { + t.Fatalf("max_results: %d", cfg.Completion.MaxResults) + } + for _, tc := range []struct { + host SSHHost + jump, keys, alive string + }{ + {SSHHost{Name: "web"}, "jh.example.com", "", "60"}, + {SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"}, + {SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"}, + {SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"}, + } { + got := cfg.SSHOptionsFor(tc.host) + if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" { + t.Errorf("%+v: got %+v", tc.host, got) + } + if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) { + t.Errorf("extra args: %v", got.ExtraArgs) + } + } +} + +func TestSSHOptionsValidation(t *testing.T) { + for _, data := range []string{ + "ssh:\n host_key_checking: maybe\n", + "ssh:\n target: dns\n", + "ssh:\n address_family: ipx\n", + "ssh:\n proxy_jump: \"-oProxyCommand=x\"\n", + "ssh:\n options:\n \"bad key\": 1\n", + "ssh:\n rules:\n - proxy_jump: jh\n", + "netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n", + } { + if _, err := loadYAML(t, data); err == nil { + t.Errorf("accepted invalid config:\n%s", data) + } else if !strings.Contains(err.Error(), "ssh") { + t.Errorf("unclear error %v", err) + } + } +} diff --git a/internal/hook/completion.go b/internal/hook/completion.go index 1f42980..e7d8b4d 100644 --- a/internal/hook/completion.go +++ b/internal/hook/completion.go @@ -83,7 +83,7 @@ func commandCompletionPath(shell, alias string) string { case "bash": return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias) case "zsh": - return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias) + return filepath.Join(ZshFuncDir(), "_"+alias) default: return filepath.Join(FishConfigDir(), "completions", alias+".fish") } @@ -114,3 +114,46 @@ complete --command %s --wraps netssh `, completionMarker, alias) } } + +// CompletionFiles lists the completion and alias files netssh may have +// installed for itself and for the given command aliases. +func CompletionFiles(aliases ...string) []string { + files := []string{ + filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "netssh"), + filepath.Join(ZshFuncDir(), "_netssh"), + filepath.Join(FishConfigDir(), "completions", "netssh.fish"), + FishAliasesPath(), + } + // oh-my-zsh caches generated completions separately. + for _, dir := range []string{os.Getenv("ZSH_CACHE_DIR"), filepath.Join(os.Getenv("ZSH"), "cache"), filepath.Join(os.Getenv("HOME"), ".oh-my-zsh", "cache")} { + if dir != "" && dir != "cache" { + files = append(files, filepath.Join(dir, "completions", "_netssh")) + } + } + for _, alias := range aliases { + if config.ValidateCommandAlias(alias) != nil || alias == "" { + continue + } + for _, shell := range []string{"bash", "zsh", "fish"} { + files = append(files, commandCompletionPath(shell, alias)) + } + } + return files +} + +// CompletionAliasState returns the command alias whose completion files were +// last generated, or "" when there is none. +func CompletionAliasState() string { + data, err := os.ReadFile(filepath.Join(filepath.Dir(AliasesPath()), "completion_alias")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +// IsGenerated reports whether a completion file was written by netssh, +// so uninstall never removes a user's own file of the same name. +func IsGenerated(path string) bool { + data, err := os.ReadFile(path) + return err == nil && strings.Contains(string(data), "netssh") +} diff --git a/internal/hook/completion_test.go b/internal/hook/completion_test.go index 215175b..c5f3f14 100644 --- a/internal/hook/completion_test.go +++ b/internal/hook/completion_test.go @@ -88,7 +88,7 @@ func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) { case "bash": err = root.GenBashCompletionV2(&canonical, true) case "zsh": - err = root.GenZshCompletion(&canonical) + _, err = canonical.WriteString(hook.ZshCompletionScript) case "fish": err = root.GenFishCompletion(&canonical, true) } @@ -149,7 +149,8 @@ compinit -u -D alias j=netssh autoload -Uz _j # Capture matches without requiring an interactive ZLE completion widget. -_describe() { print -rl -- "${completions[@]}"; } +_describe() { print -rl -- "${(P)4}"; } +compadd() { print -rl -- "$@"; } words=(j ser) CURRENT=2 _j @@ -169,3 +170,38 @@ complete --do-complete 'j ser' }) } } + +func TestZshCompletionSplitsHostsAndCommands(t *testing.T) { + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Skip("zsh unavailable") + } + dir := t.TempDir() + backend := "#!/bin/sh\nprintf 'web01\\nweb:02\\ncache\\tManage the cache\\n:36\\n'\n" + if err := os.WriteFile(filepath.Join(dir, "netssh"), []byte(backend), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil { + t.Fatal(err) + } + script := `compdef() { :; } +source "$DIR/_netssh" +_describe() { print -r -- "describe ${(P)4}"; } +compadd() { print -r -- "compadd $*"; } +_files() { print -r -- files; } +words=(netssh "") +CURRENT=2 +_netssh +` + cmd := exec.Command(zsh, "-f", "-c", script) + cmd.Env = append(os.Environ(), "DIR="+dir, "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + // Directive 36 = NoFileComp|KeepOrder: unsorted host group, no file fallback. + want := "describe cache:Manage the cache\ncompadd -V hosts -X %Bhosts%b -- web01 web:02\n" + if string(out) != want { + t.Fatalf("got:\n%s\nwant:\n%s", out, want) + } +} diff --git a/internal/hook/hook.go b/internal/hook/hook.go index dc574d8..d8ea77f 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -62,27 +62,63 @@ func IsInstalled(profilePath string) bool { return false } -// Install appends the hook line to the profile if it is not already present. -// Returns (true, nil) when newly installed, (false, nil) when already present. +// Install writes the managed netssh block into the profile. Entries written by +// older versions without markers are replaced instead of duplicated; an +// existing managed block (e.g. from install.sh) is left untouched. +// Returns (true, nil) when the profile changed, (false, nil) when already present. // Creates the profile file (and any parent directories) if they do not exist. -func Install(profilePath string) (installed bool, err error) { - if IsInstalled(profilePath) { - return false, nil +func Install(profilePath, shell string) (installed bool, err error) { + data, err := os.ReadFile(profilePath) + if err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("reading profile: %w", err) } + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == BlockStart { + return false, nil + } + } + cleaned, _, err := clean(string(data), profilePath) + if err != nil { + return false, err + } + if cleaned != "" && !strings.HasSuffix(cleaned, "\n") { + cleaned += "\n" + } + if cleaned != "" { + cleaned += "\n" + } + cleaned += Block(shell) if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil { return false, fmt.Errorf("creating directory: %w", err) } - f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return false, fmt.Errorf("opening profile: %w", err) - } - defer f.Close() - if _, err := fmt.Fprintf(f, "\n%s\n", Line); err != nil { + if err := writeProfile(profilePath, cleaned); err != nil { return false, err } return true, nil } +// Block returns the managed profile block for shell. +func Block(shell string) string { + var b strings.Builder + b.WriteString(BlockStart + "\n") + b.WriteString("netssh shell-init\n") + if shell != "fish" { + aliases := shellQuote(AliasesPath(), "bash") + fmt.Fprintf(&b, "[ ! -f %s ] || . %s\n", aliases, aliases) + } + if shell == "zsh" { + b.WriteString(ZshRegistration(ZshFuncDir())) + } + b.WriteString(BlockEnd + "\n") + return b.String() +} + +// ZshRegistration adds dir to fpath and registers the completion even when +// compinit already ran earlier in .zshrc (e.g. via oh-my-zsh). +func ZshRegistration(dir string) string { + return fmt.Sprintf("fpath=(%s $fpath)\nif (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n", shellQuote(dir, "bash")) +} + // InstallAliasesSource appends the aliases source line to a bash/zsh profile. // This is a no-op for fish (which uses conf.d auto-sourcing instead). // Returns (true, nil) when newly added, (false, nil) when already present. @@ -110,6 +146,36 @@ func InstallAliasesSource(profilePath string) (installed bool, err error) { var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`) var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`) +// legacyPatterns match the unmarked lines written by older versions and by +// manual setup instructions. They mirror the patterns in install.sh. +var legacyPatterns = []*regexp.Regexp{ + hookLinePattern, + aliasesLinePattern, + regexp.MustCompile(`^eval\s+["']?\$\((?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+(?:bash|zsh))?\)["']?\s*(?:#.*)?$`), + regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+fish)?\s*\|\s*source\s*(?:#.*)?$`), + regexp.MustCompile(`^source\s+<\((?:[^\s]*/)?netssh\s+alias\)\s*(?:#.*)?$`), + regexp.MustCompile(`^alias\s+[A-Za-z0-9_.-]+(?:=|\s+)["']netssh(?:\s+[^"']+)?["']\s*(?:#.*)?$`), +} + +func isLegacyLine(line string) bool { + for _, pattern := range legacyPatterns { + if pattern.MatchString(line) { + return true + } + } + return false +} + +// HasEntries reports whether the profile contains a managed block or legacy lines. +func HasEntries(profilePath string) bool { + data, err := os.ReadFile(profilePath) + if err != nil { + return false + } + _, removed, err := clean(string(data), profilePath) + return removed || err != nil +} + // Uninstall removes legacy hook lines and complete installer-managed blocks. // Missing profiles are a no-op; malformed blocks leave the file unchanged. func Uninstall(profilePath string) (removed bool, err error) { @@ -120,21 +186,33 @@ func Uninstall(profilePath string) (removed bool, err error) { if err != nil { return false, fmt.Errorf("reading profile: %w", err) } + cleaned, removed, err := clean(string(data), profilePath) + if err != nil || !removed { + return false, err + } + return true, writeProfile(profilePath, cleaned) +} + +// clean strips managed blocks and legacy lines from profile content. +func clean(data, profilePath string) (cleaned string, removed bool, err error) { + if data == "" { + return "", false, nil + } var kept []string inside := false - for _, line := range strings.Split(string(data), "\n") { + for _, line := range strings.Split(data, "\n") { value := strings.TrimSpace(line) switch value { case BlockStart: if inside { - return false, fmt.Errorf("nested netssh block in %s", profilePath) + return "", false, fmt.Errorf("nested netssh block in %s", profilePath) } inside = true removed = true continue case BlockEnd: if !inside { - return false, fmt.Errorf("unmatched netssh block in %s", profilePath) + return "", false, fmt.Errorf("unmatched netssh block in %s", profilePath) } inside = false continue @@ -142,29 +220,61 @@ func Uninstall(profilePath string) (removed bool, err error) { if inside { continue } - if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) { + if isLegacyLine(value) { removed = true continue } kept = append(kept, line) } if inside { - return false, fmt.Errorf("unclosed netssh block in %s", profilePath) + return "", false, fmt.Errorf("unclosed netssh block in %s", profilePath) } if !removed { - return false, nil + return data, false, nil } - cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n") - target, err := filepath.EvalSymlinks(profilePath) - if err != nil { - return false, err + cleaned = strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n") + // Drop the blank separator line Install puts in front of the block. + if strings.HasSuffix(cleaned, "\n\n") { + cleaned = strings.TrimRight(cleaned, "\n") + "\n" } - info, err := os.Stat(target) - if err != nil { - return false, err + return cleaned, true, nil +} + +// writeProfile replaces the profile atomically, following dotfile symlinks +// and keeping the permissions of an existing file. +func writeProfile(profilePath, content string) error { + target, err := filepath.EvalSymlinks(profilePath) + if os.IsNotExist(err) { + target = profilePath + } else if err != nil { + return err } - if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil { - return false, fmt.Errorf("writing profile: %w", err) + mode := os.FileMode(0o644) + if info, err := os.Stat(target); err == nil { + mode = info.Mode().Perm() } - return true, nil + if err := storage.WriteFile(target, []byte(content), mode); err != nil { + return fmt.Errorf("writing profile: %w", err) + } + return nil +} + +// ProfileCandidates lists every shell profile netssh or install.sh may have +// written to, in the same order as the installer. +func ProfileCandidates() []string { + home := os.Getenv("HOME") + zdot := zshDir() + paths := []string{ + filepath.Join(home, ".bashrc"), + filepath.Join(home, ".bash_profile"), + filepath.Join(home, ".bash_login"), + filepath.Join(home, ".profile"), + filepath.Join(zdot, ".zshrc"), + filepath.Join(zdot, ".zprofile"), + filepath.Join(zdot, ".zshenv"), + } + if zdot != home { + paths = append(paths, filepath.Join(home, ".zshrc"), filepath.Join(home, ".zprofile"), filepath.Join(home, ".zshenv")) + } + return append(paths, filepath.Join(FishConfigDir(), "config.fish")) } diff --git a/internal/hook/hook_test.go b/internal/hook/hook_test.go index 34b84c7..e87b370 100644 --- a/internal/hook/hook_test.go +++ b/internal/hook/hook_test.go @@ -2,6 +2,7 @@ package hook_test import ( "os" + "os/exec" "path/filepath" "strings" "testing" @@ -87,7 +88,7 @@ func TestIsInstalled_MissingFile(t *testing.T) { func TestInstall_Fresh(t *testing.T) { profile := filepath.Join(t.TempDir(), ".bashrc") - installed, err := hook.Install(profile) + installed, err := hook.Install(profile, "bash") if err != nil { t.Fatalf("Install: %v", err) } @@ -102,8 +103,8 @@ func TestInstall_Fresh(t *testing.T) { func TestInstall_Idempotent(t *testing.T) { profile := filepath.Join(t.TempDir(), ".bashrc") - hook.Install(profile) - installed, err := hook.Install(profile) + hook.Install(profile, "bash") + installed, err := hook.Install(profile, "bash") if err != nil { t.Fatalf("second Install: %v", err) } @@ -120,7 +121,7 @@ func TestInstall_Idempotent(t *testing.T) { func TestInstall_CreatesProfileAndParentDirs(t *testing.T) { profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc") - if _, err := hook.Install(profile); err != nil { + if _, err := hook.Install(profile, "bash"); err != nil { t.Fatalf("Install should create missing directories: %v", err) } if _, err := os.Stat(profile); err != nil { @@ -131,7 +132,7 @@ func TestInstall_CreatesProfileAndParentDirs(t *testing.T) { func TestInstall_PreservesExistingContent(t *testing.T) { profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n") - hook.Install(profile) + hook.Install(profile, "bash") data, _ := os.ReadFile(profile) content := string(data) @@ -145,13 +146,13 @@ func TestInstall_PreservesExistingContent(t *testing.T) { func TestInstall_AppendedAtEnd(t *testing.T) { profile := writeProfile(t, "existing line\n") - hook.Install(profile) + hook.Install(profile, "bash") data, _ := os.ReadFile(profile) lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") last := lines[len(lines)-1] - if last != hook.Line { - t.Errorf("hook line should be the last line, got %q", last) + if last != hook.BlockEnd { + t.Errorf("managed block should end the profile, got %q", last) } } @@ -226,7 +227,7 @@ func TestUninstall_CollapsesExtraBlankLines(t *testing.T) { func TestInstallUninstall_Roundtrip(t *testing.T) { profile := writeProfile(t, "existing content\n") - hook.Install(profile) + hook.Install(profile, "bash") if !hook.IsInstalled(profile) { t.Fatal("should be installed after Install") } @@ -265,7 +266,7 @@ func writeProfile(t *testing.T, content string) string { func TestManagedBlockInstallAndUninstall(t *testing.T) { original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n" profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n") - if installed, err := hook.Install(profile); err != nil || installed { + if installed, err := hook.Install(profile, "bash"); err != nil || installed { t.Fatalf("duplicate install: %v, %v", installed, err) } if installed, err := hook.InstallAliasesSource(profile); err != nil || installed { @@ -288,3 +289,69 @@ func TestProfilePathRespectsZdotdir(t *testing.T) { t.Fatalf("zsh path: %s, %v", path, err) } } + +func TestInstallReplacesLegacyEntriesWithBlock(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + profile := writeProfile(t, "export KEEP=yes\nnetssh shell-init\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias j='netssh'\necho 'netssh shell-init'\n") + installed, err := hook.Install(profile, "zsh") + if err != nil || !installed { + t.Fatalf("install: %v, %v", installed, err) + } + data, _ := os.ReadFile(profile) + content := string(data) + for _, gone := range []string{"\nnetssh shell-init\nsource", "eval ", "alias j="} { + if strings.Contains(content, gone) { + t.Fatalf("legacy entry %q kept:\n%s", gone, content) + } + } + for _, kept := range []string{"export KEEP=yes", "echo 'netssh shell-init'", hook.BlockStart, "compdef _netssh netssh", "fpath=("} { + if !strings.Contains(content, kept) { + t.Fatalf("missing %q:\n%s", kept, content) + } + } + if strings.Count(content, hook.BlockStart) != 1 { + t.Fatalf("duplicate blocks:\n%s", content) + } + if removed, err := hook.Uninstall(profile); err != nil || !removed { + t.Fatalf("uninstall: %v, %v", removed, err) + } + data, _ = os.ReadFile(profile) + if string(data) != "export KEEP=yes\necho 'netssh shell-init'\n" { + t.Fatalf("unexpected remainder: %q", data) + } +} + +func TestBlockPerShell(t *testing.T) { + if b := hook.Block("fish"); strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") { + t.Fatalf("fish block: %s", b) + } + if b := hook.Block("bash"); !strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") { + t.Fatalf("bash block: %s", b) + } +} + +func TestZshBlockRegistersCompletionAfterCompinit(t *testing.T) { + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Skip("zsh unavailable") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ZDOTDIR", home) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + if err := os.MkdirAll(hook.ZshFuncDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hook.ZshFuncDir(), "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil { + t.Fatal(err) + } + // compinit runs before the block, as with oh-my-zsh. + rc := "autoload -Uz compinit && compinit -u -d \"$HOME/.zcompdump\"\nnetssh() { :; }\n" + hook.Block("zsh") + if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(rc), 0o644); err != nil { + t.Fatal(err) + } + out, err := exec.Command(zsh, "-i", "-c", `print -r -- $_comps[netssh]`).CombinedOutput() + if err != nil || !strings.Contains(string(out), "_netssh") { + t.Fatalf("completion not registered: %v\n%s", err, out) + } +} diff --git a/internal/hook/zsh.go b/internal/hook/zsh.go new file mode 100644 index 0000000..a9db706 --- /dev/null +++ b/internal/hook/zsh.go @@ -0,0 +1,99 @@ +package hook + +import ( + "fmt" + "os" + "path/filepath" +) + +// ZshCompletionScript replaces Cobra's generated zsh completion. Cobra's script +// spawns a subshell per candidate and passes every host through _describe, +// which takes tens of seconds for thousands of hosts. This script splits the +// candidates with parameter expansion only: entries with a description +// (subcommands, flags) go through _describe, plain hostnames through compadd. +// +// The file works both from fpath (autoloaded as _netssh) and when sourced. +const ZshCompletionScript = `#compdef netssh +compdef _netssh netssh + +# netssh zsh completion — generated by netssh, do not edit. +_netssh() { + local -a lines described plain + local out directive=0 entry ret=1 + local -a nospace + + out=$(netssh __complete "${(@)words[2,CURRENT]}" 2>/dev/null) || return 1 + lines=("${(@f)out}") + if [[ ${lines[-1]} == :<-> ]]; then + directive=${lines[-1]#:} + lines[-1]=() + fi + # Directive 1: error. + (( directive & 1 )) && return 1 + lines=(${lines:#_activeHelp_*}) + lines=(${lines:#}) + # Directive 2: no trailing space. + (( directive & 2 )) && nospace=(-S '') + + # Directive 8: the lines are file extensions to filter by. + if (( directive & 8 )); then + local -a globs + for entry in $lines; do globs+=("*.${entry}"); done + _files -g "(${(j:|:)globs})" + return + fi + # Directive 16: complete directories only. + if (( directive & 16 )); then + _files -/ + return + fi + + described=(${(M)lines:#*$'\t'*}) + plain=(${lines:#*$'\t'*}) + if (( $#described )); then + local -a items + for entry in $described; do + items+=("${${entry%%$'\t'*}//:/\\:}:${entry#*$'\t'}") + done + _describe -t commands 'netssh command' items $nospace && ret=0 + fi + if (( $#plain )); then + # Directive 32: keep the order supplied by netssh. + if (( directive & 32 )); then + compadd -V hosts -X '%Bhosts%b' $nospace -- $plain && ret=0 + else + compadd -J hosts -X '%Bhosts%b' $nospace -- $plain && ret=0 + fi + fi + # Directive 4: no file completion as a fallback. + if (( ! $#lines && ! (directive & 4) )); then + _files && ret=0 + fi + return ret +} + +if [ "$funcstack[1]" = "_netssh" ]; then + _netssh "$@" +fi +` + +// ZshCompletionNote tells the user how zsh picks up a completion file in dir. +func ZshCompletionNote(dir string) string { + dump := filepath.Join(zshDir(), ".zcompdump") + return fmt.Sprintf(`zsh loads it from fpath. The netssh block in your .zshrc adds %s +(netssh hook install). Otherwise add this before compinit: + fpath=(%s $fpath) +Then refresh the completion cache: rm -f %s* && exec zsh`, dir, dir, dump) +} + +func zshDir() string { + if dir := os.Getenv("ZDOTDIR"); dir != "" { + return dir + } + return os.Getenv("HOME") +} + +// ZshFuncDir is the user directory netssh installs zsh completion files into. +func ZshFuncDir() string { + return filepath.Join(os.Getenv("HOME"), ".zfunc") +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index f866312..9b78b26 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" @@ -193,14 +194,9 @@ func RunWizard(cfg *config.Config) error { fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n") return nil } - if _, err := hook.Install(profile); err != nil { + if _, err := hook.Install(profile, shell); err != nil { return err } - if shell != "fish" { - if _, err := hook.InstallAliasesSource(profile); err != nil { - return err - } - } fmt.Fprintln(os.Stderr, hook.ReloadNote(profile)) } return nil @@ -251,10 +247,18 @@ func save(cfg config.Config) error { b.WriteString("netboxes:\n") for _, source := range cfg.NetBoxes { fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion) + if !sshOptionsEmpty(source.SSH) { + b.WriteString(" ssh:\n") + writeSSHOptions(&b, " ", source.SSH) + } } } else { b.WriteString("netbox:\n") fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + if !sshOptionsEmpty(cfg.NetBox.SSH) { + b.WriteString(" ssh:\n") + writeSSHOptions(&b, " ", cfg.NetBox.SSH) + } } b.WriteString("\nresolver:\n") @@ -285,6 +289,23 @@ func save(cfg config.Config) error { if cfg.SSH.DefaultPort > 0 { fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort) } + writeSSHOptions(&b, " ", cfg.SSH.SSHOptions) + if len(cfg.SSH.Rules) > 0 { + b.WriteString(" rules:\n") + for _, rule := range cfg.SSH.Rules { + b.WriteString(" - match:\n") + for _, field := range [][2]string{{"tag", rule.Match.Tag}, {"instance", rule.Match.Instance}, {"kind", rule.Match.Kind}, {"name", rule.Match.Name}} { + if field[1] != "" { + fmt.Fprintf(&b, " %s: %q\n", field[0], field[1]) + } + } + writeSSHOptions(&b, " ", rule.SSHOptions) + } + } + + if cfg.Completion.MaxResults > 0 { + fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) + } if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens { b.WriteString("\nshortcuts:\n") @@ -310,3 +331,33 @@ func save(cfg config.Config) error { fmt.Printf("\nConfig saved → %s\n", path) return nil } + +func sshOptionsEmpty(o config.SSHOptions) bool { + return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 +} + +// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config. +func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) { + for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} { + if field[1] != "" { + fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1]) + } + } + if len(o.ExtraArgs) > 0 { + fmt.Fprintf(b, "%sextra_args:\n", indent) + for _, arg := range o.ExtraArgs { + fmt.Fprintf(b, "%s - %q\n", indent, arg) + } + } + if len(o.Options) > 0 { + keys := make([]string, 0, len(o.Options)) + for key := range o.Options { + keys = append(keys, key) + } + sort.Strings(keys) + fmt.Fprintf(b, "%soptions:\n", indent) + for _, key := range keys { + fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key]) + } + } +} diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 077e5c2..4469300 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -3,6 +3,7 @@ package setup import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -408,7 +409,7 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) { t.Fatalf("instances lost during save: %+v", loaded) } for i, want := range cfg.NetBoxes { - if loaded.NetBoxes[i] != want { + if !reflect.DeepEqual(loaded.NetBoxes[i], want) { t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want) } } @@ -420,3 +421,37 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) { t.Fatal("credentials are not private") } } + +func TestSaveKeepsSSHOptions(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + opts := config.SSHOptions{ + ProxyJump: "jh.example.com", + HostKeyChecking: "accept-new", + Target: "name", + AddressFamily: "inet", + ExtraArgs: []string{"-A"}, + Options: map[string]string{"serveraliveinterval": "60"}, + } + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + Cache: config.CacheConfig{TTL: 3600}, + SSH: config.SSHConfig{ + DefaultUser: "root", + SSHOptions: opts, + Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}}, + }, + Completion: config.CompletionConfig{MaxResults: 100}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion { + t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH) + } +} diff --git a/internal/ssh/options.go b/internal/ssh/options.go new file mode 100644 index 0000000..0542140 --- /dev/null +++ b/internal/ssh/options.go @@ -0,0 +1,200 @@ +package ssh + +import ( + "fmt" + "regexp" + "sort" + "strings" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// WrapperFlags are netssh's own long options. OpenSSH has no long options, +// so these never collide with flags meant for ssh. +type WrapperFlags struct { + DryRun bool + Jump string // --jump HOST overrides the configured jump host + NoJump bool // --no-jump disables any jump host, including ~/.ssh/config +} + +// ExtractWrapperFlags removes netssh's long options that appear before the +// destination. Anything after the destination belongs to the remote command. +func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) { + var flags WrapperFlags + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--dry-run": + flags.DryRun = true + case arg == "--no-jump": + flags.NoJump = true + case arg == "--jump": + if i+1 >= len(args) { + return flags, nil, fmt.Errorf("--jump requires a host") + } + i++ + flags.Jump = args[i] + case strings.HasPrefix(arg, "--jump="): + flags.Jump = strings.TrimPrefix(arg, "--jump=") + case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2: + // Destination or end of options: keep the rest unchanged. + return flags, append(out, args[i:]...), validateJump(flags) + case strings.HasPrefix(arg, "--"): + return flags, nil, fmt.Errorf("unknown option %s", arg) + default: + out = append(out, arg) + // Keep the value of an ssh option such as -l USER together with it. + if takesSeparateValue(arg) && i+1 < len(args) { + i++ + out = append(out, args[i]) + } + } + } + return flags, out, validateJump(flags) +} + +func validateJump(flags WrapperFlags) error { + if flags.Jump == "" && flags.NoJump { + return nil + } + if flags.Jump != "" && flags.NoJump { + return fmt.Errorf("--jump and --no-jump are mutually exclusive") + } + if strings.HasPrefix(flags.Jump, "-") { + return fmt.Errorf("--jump: invalid host %q", flags.Jump) + } + return nil +} + +// takesSeparateValue reports whether a short option cluster ends with a flag +// whose value is the next argument (e.g. "-l" or "-vp"). +func takesSeparateValue(arg string) bool { + for j := 1; j < len(arg); j++ { + if flagsWithArg[arg[j]] { + return j == len(arg)-1 + } + } + return false +} + +// Host is a resolved NetBox host. +type Host struct { + Name string // NetBox name without the instance prefix + IP string +} + +// Result is the final ssh argument list and an optional notice for stderr. +type Result struct { + Args []string + Notice string +} + +var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`) + +// Apply replaces the destination at destIdx and inserts the configured -o +// options directly before it. Explicit options given by the user come first, +// and ssh uses the first -o value it obtains, so the user's choice wins. +func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result { + var inserted []string + add := func(keyword, value string) { + if !hasOption(args[:destIdx], 0, keyword) { + inserted = append(inserted, "-o", keyword+"="+value) + } + } + + dest := host.IP + if opts.Target == "name" || opts.Target == "hostname" { + if plainHostname.MatchString(host.Name) { + dest = host.Name + if opts.Target == "name" { + add("HostName", host.IP) + add("HostKeyAlias", host.Name) + } + } + } + + userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand") + switch { + case userJump: + case flags.NoJump: + inserted = append(inserted, "-o", "ProxyJump=none") + case flags.Jump != "": + inserted = append(inserted, "-J", flags.Jump) + case opts.ProxyJump != "" && opts.ProxyJump != "none": + inserted = append(inserted, "-J", opts.ProxyJump) + } + + var notice string + switch opts.HostKeyChecking { + case "strict": + add("StrictHostKeyChecking", "yes") + case "accept-new": + add("StrictHostKeyChecking", "accept-new") + case "off": + if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") { + add("StrictHostKeyChecking", "no") + add("UserKnownHostsFile", "/dev/null") + add("LogLevel", "ERROR") + notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)" + } + } + + if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') { + add("AddressFamily", opts.AddressFamily) + } + + keys := make([]string, 0, len(opts.Options)) + for key := range opts.Options { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + add(key, opts.Options[key]) + } + + // Extra arguments go first: short flags such as -4 or -A take the last + // value given, so the user's own flags later on the line still win. + replaced := ReplaceHost(args, destIdx, dest) + out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted)) + out = append(out, opts.ExtraArgs...) + out = append(out, replaced[:destIdx]...) + out = append(out, inserted...) + out = append(out, replaced[destIdx:]...) + return Result{Args: out, Notice: notice} +} + +// hasBoolFlag reports whether a flag without a value, such as -4, was given. +func hasBoolFlag(args []string, wanted byte) bool { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" || len(arg) < 2 || arg[0] != '-' { + break + } + for j := 1; j < len(arg); j++ { + if arg[j] == wanted { + return true + } + if flagsWithArg[arg[j]] { + if j == len(arg)-1 { + i++ + } + break + } + } + } + return false +} + +// Quote renders an argument list as a copy-pasteable POSIX shell command. +func Quote(argv []string) string { + quoted := make([]string, len(argv)) + for i, arg := range argv { + if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" { + quoted[i] = arg + continue + } + quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" + } + return strings.Join(quoted, " ") +} diff --git a/internal/ssh/options_test.go b/internal/ssh/options_test.go new file mode 100644 index 0000000..d7ed209 --- /dev/null +++ b/internal/ssh/options_test.go @@ -0,0 +1,130 @@ +package ssh + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestExtractWrapperFlags(t *testing.T) { + for _, tc := range []struct { + args []string + flags WrapperFlags + rest []string + }{ + {[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}}, + {[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}}, + {[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}}, + {[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}}, + } { + flags, rest, err := ExtractWrapperFlags(tc.args) + if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) { + t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err) + } + } + for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} { + if _, _, err := ExtractWrapperFlags(args); err == nil { + t.Errorf("%v accepted", args) + } + } +} + +func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result { + return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags) +} + +func TestApplyDefaultConnectsToIP(t *testing.T) { + got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{}) + if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } +} + +func TestApplyTargetName(t *testing.T) { + got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{}) + want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"} + if !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } + got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{}) + if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } + // Names that are not plain hostnames fall back to the IP address. + got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{}) + if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } +} + +func TestApplyJumpPrecedence(t *testing.T) { + opts := config.SSHOptions{ProxyJump: "jh.example.com"} + for _, tc := range []struct { + args []string + flags WrapperFlags + want string + }{ + {[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"}, + {[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"}, + {[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"}, + {[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"}, + {[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"}, + } { + if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want { + t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want) + } + } + if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 { + t.Fatalf("proxy_jump none still jumps: %v", got.Args) + } +} + +func TestApplyHostKeyChecking(t *testing.T) { + got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{}) + want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10" + if strings.Join(got.Args, " ") != want || got.Notice == "" { + t.Fatalf("got %v, notice %q", got.Args, got.Notice) + } + // An explicit user choice disables the whole "off" bundle. + got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{}) + if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" { + t.Fatalf("user choice overridden: %v", got.Args) + } + got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}) + if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" { + t.Fatalf("got %v", got.Args) + } +} + +func TestApplyOptionsAndExtraArgs(t *testing.T) { + opts := config.SSHOptions{ + AddressFamily: "inet", + ExtraArgs: []string{"-A"}, + Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"}, + } + got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{}) + want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10" + if strings.Join(got.Args, " ") != want { + t.Fatalf("got %q", strings.Join(got.Args, " ")) + } + got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{}) + if strings.Join(got.Args, " ") != "-v6 192.0.2.10" { + t.Fatalf("address family overrides -6: %v", got.Args) + } +} + +func TestApplyKeepsRemoteCommand(t *testing.T) { + got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}) + if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) { + t.Fatalf("got %v", got.Args) + } +} + +func TestQuote(t *testing.T) { + got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"}) + if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want { + t.Fatalf("got %s", got) + } +}