Compare commits

...
Author SHA1 Message Date
Sebastian UnterschützandClaude Opus 5.5 878701aba7 feat(shortcuts): regex rewrite rules for host shortcuts
shortcuts.rewrites applies regex replacements after the domain suffix
is stripped, e.g. ^([a-z]+)[0-9]+- -> $1 turns fsn1-bx1.hoster.de into
fsnbx1. The shortcut works for connecting (cached hosts), shell
aliases, and Tab completion, where it is offered when the full name
does not match the typed text. Rules are editable in netssh configure,
and netssh alias --test HOSTNAME shows a name's shortcut and the cached
hosts it matches.

The shortcut lookup only ran when domains or strip_hyphens were set;
it now also runs for rewrite-only configurations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:42:13 +01:00
Sebastian UnterschützandClaude Opus 5.5 d51c040d9f feat(configure): edit every config option in the configurator
After the NetBox instances, netssh configure now opens a menu with all
sections: SSH defaults, global SSH options (jump host, host key
checking, target, address family, extra args, -o options), SSH rules,
resolver & cache (including the cache file), shortcuts & command alias,
and completion.max_results. SSH options can also be set per instance.

The wizard rebuilt the config from its own fields, so re-running it
dropped global SSH options, ssh.rules and completion.max_results from
the file. It now edits a copy of the loaded config, and save validates
SSH options and rules before writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:42:13 +01:00
Sebi ac713ac97b Merge pull request 'Fix/instance prefix completion' (#20) from fix/instance-prefix-completion into main
Release / release (push) Successful in 5m43s
Reviewed-on: #20
2026-10-28 10:46:29 +00:00
Sebastian UnterschützandClaude Opus 5.5 091f5cc518 fix(completion): offer unique hostnames without instance prefix
bash (and zsh without a matcher) only accept candidates that start with
the typed text, so "fsn1-bx1<Tab>" found nothing when every candidate
was qualified as "prod/…". Hostnames that exist in only one instance
are now offered bare; names present in several instances stay
qualified. Connecting to a bare name still checks every instance for
ambiguity, so the connection behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:44:54 +01:00
Sebastian UnterschützandClaude Opus 5.5 6243514def fix(completion): accept instance-qualified hosts for a bare name in zsh
With several NetBox instances, netssh offers "prod/fsn1-bx1…" for the
typed text "fsn1". compadd discarded these matches because they do not
start with the typed word, so <Tab> found nothing. The matcher
'l:|=*' lets zsh accept text before the typed word; netssh has already
filtered the candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:44:54 +01:00
Sebi 6f00d69b6f Merge pull request 'v0.0.21' (#19) from feat/ssh-options-completion-uninstall into main
Release / release (push) Successful in 5m45s
Reviewed-on: #19
2026-10-28 10:25:20 +00:00
Sebastian UnterschützandClaude Opus 5.5 6711b610f1 docs: document SSH options, uninstall, and zsh completion for v0.0.21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:23:31 +01:00
Sebastian UnterschützandClaude Opus 5.5 c2e590bf52 feat: add netssh uninstall
Remove everything netssh and install.sh set up, also leftovers of
older versions:

- netssh blocks and unmarked legacy lines in bash, zsh, and fish
  profiles
- completion files for bash, zsh (incl. the oh-my-zsh cache), fish,
  and the command alias; files not generated by netssh are kept
- the cache directory with hosts, aliases, and per-instance caches
- the config file and local notes; the config is first copied to a
  private netssh.yaml.backup-<timestamp> because it holds API tokens
- the netssh binary; for protected paths the sudo command is printed

--keep-config keeps config and notes, --dry-run only lists the plan,
and --yes skips the confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:23:28 +01:00
Sebastian UnterschützandClaude Opus 5.5 786768ec53 feat: add netssh version and --version
`netssh --version` used to be passed to ssh and failed with
"unknown option -- -". Release builds now embed the tag via
-X main.version; other builds fall back to the module build info.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:23:19 +01:00
Sebastian UnterschützandClaude Opus 5.5 2b46c4f74d feat(ssh): jump hosts, host key checking, and configurable ssh options
Add SSH settings that apply only to netssh connections, so
~/.ssh/config does not need global changes:

- proxy_jump (passed as -J; "none" disables an inherited jump host)
- host_key_checking: strict | accept-new | off (off prints a notice)
- target: ip (default) | name | hostname; "name" connects to the
  NetBox name with -o HostName=<ip> and -o HostKeyAlias=<name>, so
  `Host *.example.com` rules apply and known_hosts entries follow the
  device instead of a reused IP
- address_family: any | inet | inet6
- options (-o Key=Value) and extra_args

Settings layer global -> netboxes[].ssh -> ssh.rules (match by tag,
instance, kind, or name glob). Options given on the command line
always win: -o values are inserted after the user's own, and
extra_args before them.

New wrapper flags: --jump HOST, --no-jump, and --dry-run, which prints
the final ssh command instead of connecting. The TUI uses the same
options. The setup wizard keeps all new settings when it saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:23:09 +01:00
Sebastian UnterschützandClaude Opus 5.5 129a7201bc feat(hook): write marked profile blocks and register zsh completion
`netssh hook install` and the setup wizard now write the same marked
`# >>> netssh >>>` block as install.sh and replace unmarked entries of
older versions instead of appending duplicates. Uninstall shares the
legacy patterns with the installer (eval/alias/source variants).

For zsh the block adds ~/.zfunc to fpath and calls compdef, so
completion also works when compinit already ran earlier in .zshrc,
e.g. with oh-my-zsh. The installer reports which completion it set up
per shell and how to reset ~/.zcompdump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:22:53 +01:00
Sebastian UnterschützandClaude Opus 5.5 8fb7633783 feat(completion): fast zsh completion for large host caches
Cobra's generated zsh script spawns a subshell per candidate and sends
every host through _describe, so <Tab> hangs for seconds with thousands
of cached hosts. netssh now ships its own script: subcommands and flags
go through _describe, plain hostnames through compadd. 5000 hosts
complete in ~0.08 s instead of ~4 s.

Add completion.max_results to cap the number of offered hostnames and
sort host candidates for a stable order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:22:18 +01:00
30 changed files with 2845 additions and 281 deletions

No files matched your search

+1 -1
View File
@@ -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
+26
View File
@@ -1,5 +1,31 @@
# Changelog
## v0.0.23
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving.
- The configurator validates SSH options and rules before writing the config.
- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`.
- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches.
## v0.0.22
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
## 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.
+155 -16
View File
@@ -29,9 +29,12 @@ netssh -p 2222 admin@app-server-03 uptime
- **Shell completion** — install without sudo via `netssh completion install`
- **Default SSH user** — set a fallback username once in config instead of typing it every time
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`)
- **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
@@ -103,8 +106,19 @@ Re-run it at any time to change settings without editing the file manually:
netssh configure
```
The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias,
then saves to `~/.config/netssh.yaml`.
The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance.
A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**:
| Section | Settings |
|---|---|
| SSH defaults | `ssh.default_user`, `ssh.default_port` |
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line) |
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file |
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
| Completion | `completion.max_results` |
The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing.
### Manual config
@@ -135,6 +149,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 <Tab>; 0 = unlimited
shortcuts:
domains: # strip these suffixes to create short aliases
@@ -240,6 +259,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 <ip>`. With `target: name`,
it calls `ssh -o HostName=<ip> -o HostKeyAlias=<name> <name>`: 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:
@@ -279,13 +366,26 @@ shortcuts:
- .example.com
- .example.de
strip_hyphens: false # optional
rewrites: # optional, applied in order
- pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1
replace: '$1'
```
Resolution order:
1. **Exact match** — the input is looked up as-is in the cache.
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
3. **Shortcut match** — the input and every cached hostname are shortened the same way and compared: lowercase, strip the domain suffix, apply each rewrite rule in order, then remove hyphens if `strip_hyphens` is set (`fsn1web01` → `fsn1-web01.example.com`, `fsnbx1` → `fsn1-bx1.hoster.de`). This only works when the host is already cached; run `netssh cache refresh` first.
**Rewrite rules** use Go regular expressions ([syntax](https://pkg.go.dev/regexp/syntax)); `replace` may refer to groups as `$1` or `${1}`. Every match in the name is replaced. Because the typed shortcut is rewritten too, write rules that leave an already shortened name unchanged: `^([a-z]+)[0-9]+-` matches `fsn1-bx1` but not `fsnbx1`. Shortcuts also become shell aliases and are offered by Tab completion when the full name does not match what you typed (`fsnb<Tab>` → `fsnbx1`).
Check what a hostname is shortened to, and whether the shortcut is unique in the cache:
```sh
$ netssh alias --test fsn1-bx1.hoster.de
fsn1-bx1.hoster.de → fsnbx1
matches fsn1-bx1.hoster.de
```
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
@@ -293,6 +393,7 @@ Domains may be configured with or without a leading dot. If several cached hosts
|-------|--------|-------------|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) |
### Shell aliases
@@ -422,7 +523,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 +566,24 @@ 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 `<Tab>` 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 `<Tab>`.
With several NetBox instances, a hostname that exists in only one instance is
completed without the instance prefix (`web01`); names that exist in several
instances are offered as `prod/web01`, `staging/web01`. In bash, type the
instance prefix for those; zsh and fish offer them for the hostname alone.
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 +613,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 +642,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 +671,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 +684,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-<timestamp>` 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 +721,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`).
+87
View File
@@ -1,6 +1,10 @@
package main
import (
"sort"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
@@ -30,3 +34,86 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
return name
}
}
// hostCompletions returns the cached hosts matching toComplete in a form that
// starts with the typed text, because bash and fish only accept such
// candidates. Without an instance prefix, a hostname that is unique across
// all instances is offered bare ("web01"), so it completes like with a single
// NetBox; connecting to it still checks every instance for ambiguity.
// Names present in several instances stay qualified ("prod/web01").
// Hosts whose full name does not match are offered by their configured
// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com).
func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string {
prefix := strings.ToLower(toComplete)
qualifiedInput := strings.Contains(prefix, "/")
instances := make(map[string]int)
for _, e := range entries {
_, name := netbox.SplitTarget(e.Name)
instances[strings.ToLower(name)]++
}
var unmatched []cache.Entry
var names []string
for _, e := range entries {
source, name := netbox.SplitTarget(e.Name)
target := strings.ToLower(e.Name)
switch {
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
if instances[strings.ToLower(name)] == 1 {
names = append(names, name)
} else {
names = append(names, e.Name)
}
case strings.HasPrefix(target, prefix):
names = append(names, e.Name)
default:
unmatched = append(unmatched, e)
}
}
if sc.Enabled() {
names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...)
}
sort.Strings(names)
return names
}
// shortcutCompletions offers the shortcut of each unmatched host when it starts
// with prefix and selects exactly one host, qualified by instance if needed.
func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string {
bare := make(map[string]int)
qualified := make(map[string]int)
short := func(e cache.Entry) (string, string) {
source, name := netbox.SplitTarget(e.Name)
s := shortcuts.Normalize(name, sc)
if source == "" {
return s, s
}
return s, strings.ToLower(source) + "/" + s
}
for _, e := range all {
b, q := short(e)
bare[b]++
qualified[q]++
}
seen := make(map[string]bool)
var out []string
for _, e := range unmatched {
b, q := short(e)
candidate := ""
switch {
case strings.Contains(prefix, "/"):
if qualified[q] == 1 && strings.HasPrefix(q, prefix) {
candidate = q
}
case !strings.HasPrefix(b, prefix):
case bare[b] == 1:
candidate = b
case q != b && qualified[q] == 1:
candidate = q // like full names: zsh accepts it, bash/fish ignore it
}
if candidate != "" && !seen[candidate] {
seen[candidate] = true
out = append(out, candidate)
}
}
return out
}
+111
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -206,3 +207,113 @@ func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
}
}
}
func TestHostCompletionsMatchTypedText(t *testing.T) {
entries := []cache.Entry{
{Name: "prod/fsn1-bx1.example.com"},
{Name: "prod/fsn1-bx2.example.com"},
{Name: "stg/fsn1-bx2.example.com"},
{Name: "stg/hel1-tb1.example.com"},
{Name: "prod/HEL1-TB2"},
}
for _, tc := range []struct {
input string
want []string
}{
// Unique names are bare; names in both instances stay qualified.
{"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}},
{"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}},
{"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}},
{"stg/fsn", []string{"stg/fsn1-bx2.example.com"}},
// An instance name typed without the slash completes qualified targets.
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
} {
if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Single-instance caches are unchanged.
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
t.Errorf("single instance: %v", got)
}
}
var fsnRewrite = config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
func TestHostCompletionsOfferShortcuts(t *testing.T) {
entries := []cache.Entry{
{Name: "prod/fsn1-bx1.hoster.de"},
{Name: "prod/fsn1-db1.hoster.de"},
{Name: "stg/fsn1-db1.hoster.de"},
{Name: "prod/web01.hoster.de"},
}
for _, tc := range []struct {
input string
want []string
}{
{"fsnb", []string{"fsnbx1"}},
// Shortcut shared by two instances stays qualified.
{"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}},
{"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}},
{"stg/fsnd", []string{"stg/fsndb1"}},
// A matching full name is not repeated as shortcut.
{"web", []string{"web01.hoster.de"}},
} {
if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Two hosts of the same instance with one shortcut cannot be selected by it.
same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}}
if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 {
t.Errorf("ambiguous shortcut offered: %v", got)
}
}
func TestShortcutTestOutput(t *testing.T) {
var out bytes.Buffer
printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite)
want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n"
if out.String() != want {
t.Fatalf("got:\n%s", out.String())
}
}
// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable,
// so only the cached host can answer.
func TestWrapperConnectsByRewriteShortcut(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n"
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "10.0.0.9\n") {
t.Fatalf("shortcut not resolved from cache: %s, %v", out, err)
}
}
+106 -37
View File
@@ -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,14 +125,14 @@ 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
}
// Shortcut cache lookup: scan entries whose normalized name matches the input.
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
shortcutsEnabled := cfg.Shortcuts.Enabled()
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
normalize := normalizeTarget(cfg.Shortcuts, source != "")
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
@@ -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,9 +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) {
completions = append(completions, cobra.Completion(e.Name))
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
hosts = hosts[:limit]
}
completions := make([]cobra.Completion, 0, len(hosts))
for _, host := range hosts {
completions = append(completions, cobra.Completion(host))
}
// Subcommands at the end, after all hostnames.
for _, sub := range cmd.Commands() {
@@ -325,13 +357,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 +412,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")
@@ -606,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
}
func aliasCmd() *cobra.Command {
var shell string
var shell, test string
cmd := &cobra.Command{
Use: "alias",
Short: "Print shell aliases for all cached hosts",
Long: `Print shell alias definitions for all cached hosts.
The alias name is the shortened form derived from the configured shortcuts
(domain suffixes stripped, hyphens optionally stripped).
(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped).
Check a rule with: netssh alias --test fsn1-bx1.example.com
Source the output in your shell profile:
bash/zsh: eval "$(netssh alias)"
@@ -629,6 +670,10 @@ Or use in a script:
if err := c.Load(); err != nil {
return err
}
if test != "" {
printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts)
return nil
}
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
@@ -655,9 +700,36 @@ Or use in a script:
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches")
return cmd
}
// printShortcutTest helps writing shortcut rules: it shows what a name is
// shortened to and which cached hosts share that shortcut.
func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) {
normalize := normalizeTarget(sc, strings.Contains(name, "/"))
short := normalize(name)
fmt.Fprintf(w, "%s → %s\n", name, short)
var matches []string
for _, e := range entries {
if normalize(e.Name) == short {
matches = append(matches, e.Name)
}
}
sort.Strings(matches)
switch len(matches) {
case 0:
fmt.Fprintln(w, "no cached host has this shortcut")
case 1:
fmt.Fprintf(w, "matches %s\n", matches[0])
default:
fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches))
for _, m := range matches {
fmt.Fprintf(w, " %s\n", m)
}
}
}
// shellInitCmd is called at shell startup to update alias files and trigger a
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
func shellInitCmd() *cobra.Command {
@@ -697,9 +769,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 +785,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)
+250
View File
@@ -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
}
+119
View File
@@ -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")
}
}
+33
View File
@@ -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())
},
}
}
+22 -1
View File
@@ -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 <Tab> still shows files, run: rm -f $(quote_sh "$ZSH_DIR")/.zcompdump* && exec zsh"
fi
done
info "Remove netssh again with: netssh uninstall"
+7
View File
@@ -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)
+55 -14
View File
@@ -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,11 +47,44 @@ 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 {
Domains []string `mapstructure:"domains"`
StripHyphens bool `mapstructure:"strip_hyphens"`
Domains []string `mapstructure:"domains"`
StripHyphens bool `mapstructure:"strip_hyphens"`
Rewrites []ShortcutRewrite `mapstructure:"rewrites"`
}
// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with
// Replace, which may reference groups as $1. Applied after domain stripping.
type ShortcutRewrite struct {
Pattern string `mapstructure:"pattern"`
Replace string `mapstructure:"replace"`
}
// Enabled reports whether any shortcut normalization is configured.
func (s ShortcutsConfig) Enabled() bool {
return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0
}
// Validate rejects rewrite rules that cannot be compiled.
func (s ShortcutsConfig) Validate() error {
for i, rewrite := range s.Rewrites {
if rewrite.Pattern == "" {
return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i)
}
if _, err := regexp.Compile(rewrite.Pattern); err != nil {
return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err)
}
}
return nil
}
// Path returns the canonical config file path.
@@ -79,7 +114,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 +151,12 @@ func Load() (*Config, error) {
if err := cfg.ValidateNetBoxes(); err != nil {
return nil, err
}
if err := cfg.ValidateSSH(); err != nil {
return nil, err
}
if err := cfg.Shortcuts.Validate(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name)
+19
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -177,3 +178,21 @@ func TestCommandAliasEnvironmentAndValidation(t *testing.T) {
}
}
}
func TestLoad_ShortcutRewrites(t *testing.T) {
dir := writeConfig(t, "shortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n")
cfg := loadFromDir(t, dir)
want := []ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}
if len(cfg.Shortcuts.Rewrites) != 1 || cfg.Shortcuts.Rewrites[0] != want[0] || !cfg.Shortcuts.Enabled() {
t.Fatalf("rewrites: %+v", cfg.Shortcuts)
}
}
func TestLoad_InvalidShortcutRewrite(t *testing.T) {
for _, rule := range []string{"pattern: '[a-'", "replace: x"} {
t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "shortcuts:\n rewrites:\n - "+rule+"\n"))
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "shortcuts.rewrites[0]") {
t.Errorf("%s: got %v", rule, err)
}
}
}
+195
View File
@@ -0,0 +1,195 @@
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.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
return err
}
}
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
}
// Validate checks the match and options of a single rule.
func (r SSHRule) Validate(scope string) error {
if err := r.SSHOptions.Validate(scope); err != nil {
return err
}
return r.Match.Validate(scope)
}
// Validate requires at least one selector and a well-formed name pattern.
func (m SSHMatch) Validate(scope string) error {
if m == (SSHMatch{}) {
return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope)
}
if _, err := path.Match(m.Name, ""); err != nil {
return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name)
}
return nil
}
// Validate rejects values that ssh would not accept or would misinterpret.
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)), "-")
}
+90
View File
@@ -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)
}
}
}
+11
View File
@@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) {
t.Fatalf("alias lost source: %+v", entries)
}
}
func TestAliasesUseRewriteRules(t *testing.T) {
cfg := config.Config{Shortcuts: config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}}
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg)
if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" {
t.Fatalf("entries: %+v", entries)
}
}
+44 -1
View File
@@ -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")
}
+38 -2
View File
@@ -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 -M l:|=* -- web01 web:02\n"
if string(out) != want {
t.Fatalf("got:\n%s\nwant:\n%s", out, want)
}
}
+137 -27
View File
@@ -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"))
}
+77 -10
View File
@@ -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)
}
}
+100
View File
@@ -0,0 +1,100 @@
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
# netssh already filtered the hosts, also by the name after an instance
# prefix: "fsn1" offers "prod/fsn1-…". 'l:|=*' lets zsh accept text
# before the typed word instead of discarding these matches.
local -a group=(-J hosts)
# Directive 32: keep the order supplied by netssh.
(( directive & 32 )) && group=(-V hosts)
compadd $group -X '%Bhosts%b' -M 'l:|=*' $nospace -- $plain && ret=0
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")
}
+10 -2
View File
@@ -30,6 +30,7 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
var options []huh.Option[string]
for _, source := range sources {
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
options = append(options, huh.NewOption("SSH options for "+source.Name, "ssh:"+source.Name))
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
}
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
@@ -53,13 +54,20 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
if source.Name != name {
continue
}
if verb == "edit" {
switch verb {
case "edit":
edited, err := editInstance(source, sources, name)
if err != nil {
return nil, err
}
sources[i] = edited
} else {
case "ssh":
options, err := editSSHOptions("SSH options for "+name, source.SSH)
if err != nil {
return nil, err
}
sources[i].SSH = options
default:
remove := false
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
return nil, err
+229
View File
@@ -0,0 +1,229 @@
package setup
import (
"fmt"
"sort"
"strconv"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides.
func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") }
// editSSHOptions edits one SSH option layer: global, per instance, or per rule.
func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) {
proxyJump := o.ProxyJump
hostKeyChecking := o.HostKeyChecking
target := o.Target
addressFamily := o.AddressFamily
extraArgs := strings.Join(o.ExtraArgs, "\n")
options := formatOptions(o.Options)
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Jump host").
Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere.").
Value(&proxyJump).
Validate(func(s string) error {
return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh")
}),
huh.NewSelect[string]().
Title("Host key checking").
Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")).
Value(&hostKeyChecking),
huh.NewSelect[string]().
Title("Connection target").
Description("What is passed to ssh as destination.").
Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")).
Value(&target),
huh.NewSelect[string]().
Title("Address family").
Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")).
Value(&addressFamily),
huh.NewText().
Title("Extra arguments").
Description("One argument per line, inserted before the destination, e.g. -A").
Lines(3).
Value(&extraArgs),
huh.NewText().
Title("SSH options").
Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60").
Lines(4).
Value(&options).
Validate(func(s string) error {
_, err := parseOptions(s)
return err
}),
).Title(title)).Run()
if err != nil {
return o, err
}
parsed, _ := parseOptions(options)
return config.SSHOptions{
ProxyJump: strings.TrimSpace(proxyJump),
HostKeyChecking: hostKeyChecking,
Target: target,
AddressFamily: addressFamily,
ExtraArgs: parseLines(extraArgs),
Options: parsed,
}, nil
}
// parseLines returns the non-empty, trimmed lines of s.
func parseLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if line = strings.TrimSpace(line); line != "" {
out = append(out, line)
}
}
return out
}
// parseOptions reads Key=Value lines into the map stored under ssh.options.
func parseOptions(s string) (map[string]string, error) {
var out map[string]string
for _, line := range parseLines(s) {
key, value, ok := strings.Cut(line, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, fmt.Errorf("%q: expected Key=Value", line)
}
if out == nil {
out = make(map[string]string)
}
out[key] = strings.TrimSpace(value)
}
if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil {
return nil, err
}
return out, nil
}
func formatOptions(options map[string]string) string {
keys := make([]string, 0, len(options))
for key := range options {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+options[key])
}
return strings.Join(lines, "\n")
}
// configureRules edits a copy of the rules; the caller decides whether to keep it.
func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) {
rules = append([]config.SSHRule(nil), rules...)
for {
action := "done"
var options []huh.Option[string]
for i, rule := range rules {
index := strconv.Itoa(i)
options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index))
options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index))
}
options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done"))
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("SSH rules").
Description("Rules apply SSH options to hosts matching every set field. Later rules win.").
Options(options...).
Value(&action))).Run()
if err != nil {
return nil, err
}
if action == "done" {
return rules, nil
}
if action == "add" {
rule, err := editRule(config.SSHRule{}, sources)
if err != nil {
return nil, err
}
rules = append(rules, rule)
continue
}
verb, index, _ := strings.Cut(action, ":")
i, _ := strconv.Atoi(index)
if verb == "edit" {
rule, err := editRule(rules[i], sources)
if err != nil {
return nil, err
}
rules[i] = rule
continue
}
remove := false
if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil {
return nil, err
}
if remove {
rules = append(rules[:i], rules[i+1:]...)
}
}
}
func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) {
match := rule.Match
instances := []huh.Option[string]{huh.NewOption("(any)", "")}
known := false
for _, source := range sources {
instances = append(instances, huh.NewOption(source.Name, source.Name))
known = known || strings.EqualFold(source.Name, match.Instance)
}
if match.Instance != "" && !known {
instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance))
}
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Tag").
Description("NetBox tag name or slug, e.g. dmz").
Value(&match.Tag),
huh.NewSelect[string]().
Title("Instance").
Options(instances...).
Value(&match.Instance),
huh.NewSelect[string]().
Title("Kind").
Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")).
Value(&match.Kind),
huh.NewInput().
Title("Name pattern").
Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com").
Value(&match.Name).
Validate(func(s string) error {
candidate := match
candidate.Tag = strings.TrimSpace(candidate.Tag)
candidate.Name = strings.TrimSpace(s)
return candidate.Validate("rule")
}),
).Title("Rule match")).Run()
if err != nil {
return rule, err
}
match.Tag = strings.TrimSpace(match.Tag)
match.Name = strings.TrimSpace(match.Name)
options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions)
if err != nil {
return rule, err
}
return config.SSHRule{Match: match, SSHOptions: options}, nil
}
func describeMatch(m config.SSHMatch) string {
var parts []string
for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} {
if field[1] != "" {
parts = append(parts, field[0]+"="+field[1])
}
}
return "[" + strings.Join(parts, " ") + "]"
}
+72
View File
@@ -0,0 +1,72 @@
package setup
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestParseLines(t *testing.T) {
got := parseLines(" -A \n\n\t-C\n \n")
if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
if got := parseLines(" \n "); got != nil {
t.Fatalf("blank input: got %q, want nil", got)
}
}
func TestParseOptionsRoundtrip(t *testing.T) {
text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n"
got, err := parseOptions(text)
if err != nil {
t.Fatal(err)
}
want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
again, err := parseOptions(formatOptions(got))
if err != nil || !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: got %v (%v)", again, err)
}
if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" {
t.Fatalf("formatOptions not sorted: %q", formatOptions(got))
}
}
func TestParseOptionsEmpty(t *testing.T) {
got, err := parseOptions("\n \n")
if err != nil || got != nil {
t.Fatalf("got %v, %v; want nil, nil", got, err)
}
}
func TestParseOptionsInvalid(t *testing.T) {
for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} {
if _, err := parseOptions(text); err == nil {
t.Errorf("%q: expected error", text)
}
}
}
func TestDescribeMatch(t *testing.T) {
got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"})
if got != "[tag=dmz name=*.dmz]" {
t.Fatalf("got %q", got)
}
}
func TestRuleValidation(t *testing.T) {
if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") {
t.Fatalf("empty match: got %v", err)
}
if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil {
t.Fatal("invalid glob accepted")
}
if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil {
t.Fatalf("valid match rejected: %v", err)
}
}
+336 -166
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -16,163 +17,19 @@ import (
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
// RunWizard runs the interactive configurator, pre-filled with any existing cfg values.
// It saves the result to the config file on success.
func RunWizard(cfg *config.Config) error {
sources, err := configureInstances(cfg)
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
return cancelled(err)
}
defaultUser := cfg.SSH.DefaultUser
defaultPort := ""
if cfg.SSH.DefaultPort > 0 {
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
}
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
stripHyphens := cfg.Shortcuts.StripHyphens
commandAlias := cfg.Shell.Alias
if strategiesRaw == "" {
strategiesRaw = "primary_ip"
}
if cacheTTL == "0" {
cacheTTL = "3600"
work := prepare(*cfg, sources)
if err := runMenu(&work); err != nil {
return cancelled(err)
}
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Default SSH user").
Description("Leave empty to use your system user ($USER).").
Value(&defaultUser),
huh.NewInput().
Title("Default SSH port").
Description("Leave empty to use the standard port (22).").
Placeholder("22").
Value(&defaultPort).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 || n > 65535 {
return errors.New("must be a port number between 1 and 65535")
}
return nil
}),
).Title("SSH defaults"),
huh.NewGroup(
huh.NewInput().
Title("Resolver strategies").
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
Placeholder("primary_ip, management_subnet").
Value(&strategiesRaw).
Validate(validateStrategies),
huh.NewInput().
Title("Management subnets").
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
Value(&subnets),
huh.NewInput().
Title("Interface name").
Description("Only used when interface_name strategy is active.").
Placeholder("eth0").
Value(&interfaceName),
huh.NewInput().
Title("Cache TTL (seconds)").
Value(&cacheTTL).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return errors.New("must be a number")
}
return nil
}),
).Title("Resolver & cache"),
huh.NewGroup(
huh.NewInput().
Title("Domain suffixes").
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
Placeholder(".example.com").
Value(&shortcutDomains),
huh.NewConfirm().
Title("Strip hyphens").
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
Value(&stripHyphens),
huh.NewInput().
Title("Command alias").
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
Placeholder("j").
Value(&commandAlias).
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
).Title("Shortcuts"),
)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
}
strategies := parseStrategies(strategiesRaw)
ttl, _ := strconv.Atoi(cacheTTL)
port := 0
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
port = p
}
var subnetList []string
for _, s := range strings.Split(subnets, ",") {
if s = strings.TrimSpace(s); s != "" {
subnetList = append(subnetList, s)
}
}
var domainList []string
for _, s := range strings.Split(shortcutDomains, ",") {
if s = strings.TrimSpace(s); s != "" {
if !strings.HasPrefix(s, ".") {
s = "." + s
}
domainList = append(domainList, s)
}
}
out := config.Config{
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
NetBoxes: sources,
SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser),
DefaultPort: port,
},
Resolver: config.ResolverConfig{
Strategies: strategies,
ManagementSubnets: subnetList,
InterfaceName: strings.TrimSpace(interfaceName),
},
Cache: config.CacheConfig{
TTL: ttl,
Path: cfg.Cache.Path,
},
Shortcuts: config.ShortcutsConfig{
Domains: domainList,
StripHyphens: stripHyphens,
},
}
if err := save(out); err != nil {
if err := save(work); err != nil {
return err
}
loaded, err := config.Load()
@@ -193,19 +50,273 @@ 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
}
func cancelled(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
}
// prepare starts from the loaded config so settings without a menu entry survive
// a rewrite, migrates a legacy single instance, and fills in usable defaults.
func prepare(cfg config.Config, sources []config.NetBoxConfig) config.Config {
cfg.NetBox = config.NetBoxConfig{}
cfg.NetBoxes = sources
if len(cfg.Resolver.Strategies) == 0 {
cfg.Resolver.Strategies = []string{"primary_ip"}
}
if cfg.Cache.TTL == 0 {
cfg.Cache.TTL = 3600
}
return cfg
}
func runMenu(cfg *config.Config) error {
for {
action := "save"
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("netssh configuration").
Description("Choose a section to edit, then save.").
Options(
huh.NewOption("SSH defaults — user, port", "defaults"),
huh.NewOption("SSH options — jump host, host keys, target, -o", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
huh.NewOption("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"),
huh.NewOption("Completion", "completion"),
huh.NewOption("Save and exit", "save"),
).
Value(&action))).Run()
if err != nil {
return err
}
switch action {
case "defaults":
err = editSSHDefaults(cfg)
case "ssh":
cfg.SSH.SSHOptions, err = editSSHOptions("SSH options (all hosts)", cfg.SSH.SSHOptions)
case "rules":
var rules []config.SSHRule
if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil {
cfg.SSH.Rules = rules
}
case "resolver":
err = editResolverCache(cfg)
case "shortcuts":
err = editShortcuts(cfg)
case "completion":
err = editCompletion(cfg)
case "save":
return nil
}
if err != nil {
return err
}
}
}
func editSSHDefaults(cfg *config.Config) error {
defaultUser := cfg.SSH.DefaultUser
defaultPort := ""
if cfg.SSH.DefaultPort > 0 {
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
}
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Default SSH user").
Description("Leave empty to use your system user ($USER).").
Value(&defaultUser),
huh.NewInput().
Title("Default SSH port").
Description("Leave empty to use the standard port (22).").
Placeholder("22").
Value(&defaultPort).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 || n > 65535 {
return errors.New("must be a port number between 1 and 65535")
}
return nil
}),
).Title("SSH defaults")).Run()
if err != nil {
return err
}
cfg.SSH.DefaultUser = strings.TrimSpace(defaultUser)
cfg.SSH.DefaultPort, _ = strconv.Atoi(strings.TrimSpace(defaultPort))
return nil
}
func editResolverCache(cfg *config.Config) error {
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
cachePath := cfg.Cache.Path
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Resolver strategies").
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
Placeholder("primary_ip, management_subnet").
Value(&strategiesRaw).
Validate(validateStrategies),
huh.NewInput().
Title("Management subnets").
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
Value(&subnets),
huh.NewInput().
Title("Interface name").
Description("Only used when interface_name strategy is active.").
Placeholder("eth0").
Value(&interfaceName),
huh.NewInput().
Title("Cache TTL (seconds)").
Value(&cacheTTL).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return errors.New("must be a number")
}
return nil
}),
huh.NewInput().
Title("Cache file").
Description("Where resolved hosts are stored. Leave empty for the default location.").
Value(&cachePath),
).Title("Resolver & cache")).Run()
if err != nil {
return err
}
cfg.Resolver.Strategies = parseStrategies(strategiesRaw)
cfg.Resolver.ManagementSubnets = splitList(subnets)
cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName)
cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL)
cfg.Cache.Path = strings.TrimSpace(cachePath)
return nil
}
func editShortcuts(cfg *config.Config) error {
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
stripHyphens := cfg.Shortcuts.StripHyphens
rewrites := formatRewrites(cfg.Shortcuts.Rewrites)
commandAlias := cfg.Shell.Alias
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Domain suffixes").
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
Placeholder(".example.com").
Value(&shortcutDomains),
huh.NewConfirm().
Title("Strip hyphens").
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
Value(&stripHyphens),
huh.NewText().
Title("Rewrite rules").
Description("One rule per line: REGEX => REPLACEMENT, applied after the domain suffix is removed.\n"+
"Example: ^([a-z]+)[0-9]+- => $1 turns fsn1-bx1.example.com into fsnbx1.\n"+
"Check rules with: netssh alias --test HOSTNAME").
Lines(3).
Value(&rewrites).
Validate(func(s string) error {
_, err := parseRewrites(s)
return err
}),
huh.NewInput().
Title("Command alias").
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
Placeholder("j").
Value(&commandAlias).
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
).Title("Shortcuts")).Run()
if err != nil {
return err
}
cfg.Shortcuts.Domains = nil
for _, s := range splitList(shortcutDomains) {
if !strings.HasPrefix(s, ".") {
s = "." + s
}
cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s)
}
cfg.Shortcuts.StripHyphens = stripHyphens
cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites)
cfg.Shell.Alias = strings.TrimSpace(commandAlias)
return nil
}
func editCompletion(cfg *config.Config) error {
maxResults := ""
if cfg.Completion.MaxResults > 0 {
maxResults = strconv.Itoa(cfg.Completion.MaxResults)
}
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Maximum completion results").
Description("Limits the hostnames offered per Tab completion. Leave empty for unlimited.").
Value(&maxResults).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
if n, err := strconv.Atoi(strings.TrimSpace(s)); err != nil || n < 0 {
return errors.New("must be a non-negative number")
}
return nil
}),
).Title("Completion")).Run()
if err != nil {
return err
}
cfg.Completion.MaxResults, _ = strconv.Atoi(strings.TrimSpace(maxResults))
return nil
}
// parseRewrites reads "REGEX => REPLACEMENT" lines; the replacement may be empty.
func parseRewrites(s string) ([]config.ShortcutRewrite, error) {
var out []config.ShortcutRewrite
for _, line := range parseLines(s) {
pattern, replace, ok := strings.Cut(line, "=>")
if !ok {
return nil, fmt.Errorf("%q: expected REGEX => REPLACEMENT", line)
}
out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)})
}
if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil {
return nil, err
}
return out, nil
}
func formatRewrites(rewrites []config.ShortcutRewrite) string {
lines := make([]string, 0, len(rewrites))
for _, r := range rewrites {
lines = append(lines, r.Pattern+" => "+r.Replace)
}
return strings.Join(lines, "\n")
}
// splitList returns the non-empty, trimmed entries of a comma-separated list.
func splitList(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
var knownStrategies = map[string]bool{
"primary_ip": true,
"management_subnet": true,
@@ -224,15 +335,7 @@ func validateStrategies(s string) error {
return nil
}
func parseStrategies(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if name := strings.TrimSpace(part); name != "" {
out = append(out, name)
}
}
return out
}
func parseStrategies(s string) []string { return splitList(s) }
func save(cfg config.Config) error {
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
@@ -241,6 +344,12 @@ func save(cfg config.Config) error {
if err := cfg.ValidateNetBoxes(); err != nil {
return err
}
if err := cfg.ValidateSSH(); err != nil {
return err
}
if err := cfg.Shortcuts.Validate(); err != nil {
return err
}
path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err)
@@ -251,10 +360,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,8 +402,25 @@ 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 len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
if cfg.Completion.MaxResults > 0 {
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
}
if cfg.Shortcuts.Enabled() {
b.WriteString("\nshortcuts:\n")
if len(cfg.Shortcuts.Domains) > 0 {
b.WriteString(" domains:\n")
@@ -297,6 +431,12 @@ func save(cfg config.Config) error {
if cfg.Shortcuts.StripHyphens {
b.WriteString(" strip_hyphens: true\n")
}
if len(cfg.Shortcuts.Rewrites) > 0 {
b.WriteString(" rewrites:\n")
for _, r := range cfg.Shortcuts.Rewrites {
fmt.Fprintf(&b, " - pattern: %q\n replace: %q\n", r.Pattern, r.Replace)
}
}
}
if cfg.Shell.Alias != "" {
@@ -310,3 +450,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])
}
}
}
+123 -1
View File
@@ -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,124 @@ 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)
}
}
// Re-running the configurator must keep settings that have no form field of
// their own or were not touched in the menu.
func TestPrepareKeepsUnmanagedSettings(t *testing.T) {
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"},
SSH: config.SSHConfig{
DefaultUser: "root",
SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}},
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
},
Completion: config.CompletionConfig{MaxResults: 50},
Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"},
}
sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}}
got := prepare(cfg, sources)
if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path {
t.Fatalf("settings lost: %+v", got)
}
if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) {
t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes)
}
if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 {
t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache)
}
}
func TestSaveRejectsInvalidSSHOptions(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}},
}
if err := save(cfg); err == nil {
t.Fatal("rule without match was saved")
}
if _, err := os.Stat(config.Path()); !os.IsNotExist(err) {
t.Fatalf("config file written despite error: %v", err)
}
}
func TestParseRewrites(t *testing.T) {
text := `^([a-z]+)[0-9]+- => $1
\.lab$ =>
`
got, err := parseRewrites(text)
if err != nil {
t.Fatal(err)
}
want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: %+v", again)
}
for _, bad := range []string{"no arrow", "[a- => x", " => x"} {
if _, err := parseRewrites(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Shortcuts: config.ShortcutsConfig{Rewrites: rewrites},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) {
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
}
}
+25 -3
View File
@@ -1,13 +1,30 @@
package shortcuts
import (
"regexp"
"strings"
"sync"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
// The result is lowercased for case-insensitive comparison.
// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup.
var compiled sync.Map // pattern → *regexp.Regexp
func rewritePattern(pattern string) *regexp.Regexp {
if re, ok := compiled.Load(pattern); ok {
return re.(*regexp.Regexp)
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil // rejected by config.Load already
}
compiled.Store(pattern, re)
return re
}
// Normalize strips configured domain suffixes, applies rewrite rules in order, and
// optionally strips hyphens. The result is lowercased for case-insensitive comparison.
func Normalize(name string, cfg config.ShortcutsConfig) string {
s := strings.ToLower(name)
for _, domain := range cfg.Domains {
@@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string {
break
}
}
for _, rewrite := range cfg.Rewrites {
if re := rewritePattern(rewrite.Pattern); re != nil {
s = re.ReplaceAllString(s, rewrite.Replace)
}
}
if cfg.StripHyphens {
s = strings.ReplaceAll(s, "-", "")
}
@@ -34,7 +56,7 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
}
// AliasName returns a shell-safe alias name for the given host.
// It normalizes the name (strips configured domains and optionally hyphens) then
// It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then
// replaces any remaining dots with underscores so the result is a valid identifier.
func AliasName(name string, cfg config.ShortcutsConfig) string {
s := Normalize(name, cfg)
+37
View File
@@ -78,3 +78,40 @@ func TestAliasName_NoConfig(t *testing.T) {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
func TestNormalizeRewrites(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
for input, want := range map[string]string{
"fsn1-bx1.hoster.de": "fsnbx1",
"FSN1-BX1.hoster.de": "fsnbx1",
"fsn1-bx1": "fsnbx1",
"fsnbx1": "fsnbx1", // the shortcut itself is left alone
"web01.hoster.de": "web01",
} {
if got := shortcuts.Normalize(input, cfg); got != want {
t.Errorf("Normalize(%q) = %q, want %q", input, got, want)
}
}
if got := shortcuts.AliasName("fsn1-bx1.hoster.de", cfg); got != "fsnbx1" {
t.Errorf("AliasName = %q", got)
}
}
func TestNormalizeRewritesOrder(t *testing.T) {
// Rules run in order after domain stripping, before hyphen stripping.
cfg := config.ShortcutsConfig{
Domains: []string{".hoster.de"},
StripHyphens: true,
Rewrites: []config.ShortcutRewrite{
{Pattern: `\.hoster\.de$`, Replace: "-never"},
{Pattern: `^fsn`, Replace: "f"},
{Pattern: `^f1`, Replace: "falkenstein"},
},
}
if got := shortcuts.Normalize("fsn1-bx-1.hoster.de", cfg); got != "falkensteinbx1" {
t.Fatalf("got %q", got)
}
}
+200
View File
@@ -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, " ")
}
+130
View File
@@ -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)
}
}