Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cadea30c7 | ||
|
|
25c03fff9d | ||
|
|
8e4417a6ea | ||
|
|
2fafa0b119 | ||
|
|
b009863c37 | ||
|
|
a1c9210f3c | ||
|
|
15fb57c0ea | ||
|
|
c55aae520f | ||
|
|
88af34a64b | ||
|
|
314905c538 | ||
|
|
ad3f14186d | ||
|
|
4f08a1c92a | ||
|
|
45a2abce90 | ||
|
|
f7fc1b2a37 | ||
|
|
c45ad9f0d1 | ||
|
|
97fc8f5458 | ||
|
|
d24870e4c0 | ||
|
|
053db984be | ||
|
|
6419fbb00d | ||
|
|
b7fa71fe45 | ||
|
|
878701aba7 | ||
|
|
d51c040d9f | ||
|
|
bbc04723b4 | ||
|
|
22c9a3c32b | ||
|
|
b3f40ac3ad | ||
|
|
a80049a7c3 | ||
|
|
238e3a7798 | ||
|
|
0ce7753a9b | ||
|
|
76e27393dd | ||
|
|
a26e96cd32 | ||
|
|
ac713ac97b | ||
|
|
091f5cc518 | ||
|
|
6243514def | ||
|
|
6f00d69b6f | ||
|
|
6711b610f1 | ||
|
|
c2e590bf52 | ||
|
|
786768ec53 | ||
|
|
2b46c4f74d | ||
|
|
129a7201bc | ||
|
|
8fb7633783 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# 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.
|
||||
- Add `netssh sftp` and `netssh scp`: host lookup, shortcuts, instances, default user and port, SSH options and rules, jump host, `--dry-run`, and completion work as for ssh. IPv6 addresses are bracketed for host:path.
|
||||
- Add named port forwards (`ssh.forwards` globally, per instance, and per rule), `netssh tunnel HOST NAME|SPEC...` (ssh -N with ExitOnForwardFailure), and `netssh --forward NAME`. Forwards are editable in `netssh configure` and completed in the shell.
|
||||
- TUI: `Ctrl+O` opens SFTP, `Ctrl+T` starts a tunnel with configured or ad hoc forwards.
|
||||
- Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -29,9 +29,14 @@ 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`
|
||||
- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh
|
||||
- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI
|
||||
- **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 +108,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), `forwards` (`NAME L\|R\|D SPEC` 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 +151,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 +261,114 @@ 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.
|
||||
|
||||
### SFTP and SCP
|
||||
|
||||
`netssh sftp` and `netssh scp` look up the host exactly like a connection does: cache, shortcuts and rewrite rules, `instance/host`, default user and port, SSH options and rules, `--jump`/`--no-jump`, and `--dry-run`.
|
||||
|
||||
```sh
|
||||
netssh sftp fsnbx1 # interactive sftp
|
||||
netssh sftp prod/web01:/var/log # start in a directory
|
||||
netssh scp ./app.tar fsnbx1:/tmp/ # upload
|
||||
netssh scp -r web01:/etc/nginx ./nginx # download
|
||||
netssh scp --dry-run app.tar web01:/tmp/ # print the scp command
|
||||
```
|
||||
|
||||
sftp and scp options are passed through. Mind their differences from ssh: the port is `-P`, and `-l` is a bandwidth limit, so the default user is passed as `-o User=`. `extra_args` and `forwards` contain ssh flags and apply to ssh only. All remote operands of one `scp` call must name the same host; copy between two NetBox hosts in two steps. Tab completion offers `host:` for scp and local files otherwise.
|
||||
|
||||
### Port forwarding
|
||||
|
||||
Plain `-L`, `-R`, and `-D` flags are passed to ssh as usual (`netssh -L 8080:localhost:80 web01`). For tunnels you use often, name them under `ssh.forwards`, globally, per NetBox instance (`netboxes[].ssh.forwards`), or per rule. A more specific layer replaces a forward of the same name.
|
||||
|
||||
```yaml
|
||||
ssh:
|
||||
forwards:
|
||||
web: {local: "8080:localhost:80"} # -L
|
||||
socks: {dynamic: "1080"} # -D, SOCKS proxy
|
||||
back: {remote: "9000:localhost:9000"} # -R
|
||||
rules:
|
||||
- match: {tag: postgres}
|
||||
forwards:
|
||||
db: {local: "5432:localhost:5432"}
|
||||
```
|
||||
|
||||
```sh
|
||||
netssh tunnel fsnbx1 web socks # only forwards: ssh -N, stops with Ctrl+C
|
||||
netssh tunnel prod/db01 db 6379:localhost:6379 # names and ad hoc specs mixed
|
||||
netssh --forward web fsnbx1 # shell plus forward (repeatable)
|
||||
```
|
||||
|
||||
A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 <Tab>`) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards.
|
||||
|
||||
`sftp`, `scp`, and `tunnel` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
|
||||
|
||||
### Default username
|
||||
|
||||
Set `ssh.default_user` in the config to avoid typing a username every time:
|
||||
@@ -279,13 +408,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 +435,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
|
||||
|
||||
@@ -343,6 +486,8 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
|
||||
| `Tab` | autocomplete top result into the search field |
|
||||
| `↑` / `↓` | navigate results |
|
||||
| `Enter` | connect to selected host |
|
||||
| `Ctrl+O` | open an SFTP session to the selected host |
|
||||
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) |
|
||||
| `Ctrl+E` | open inline editor to override user/port before connecting |
|
||||
| `Ctrl+N` | edit local notes for the selected server |
|
||||
| `Ctrl+B` | edit NetBox comments for the selected server |
|
||||
@@ -422,7 +567,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 +610,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 +657,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 +686,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 +715,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 +728,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 +765,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`).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+240
-75
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -39,6 +40,11 @@ var managedSubcommands = map[string]bool{
|
||||
"completion": true,
|
||||
"__complete": true,
|
||||
"help": true,
|
||||
"version": true,
|
||||
"uninstall": true,
|
||||
"sftp": true,
|
||||
"scp": true,
|
||||
"tunnel": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -48,6 +54,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,47 +90,111 @@ func loadConfigOrSetup() *config.Config {
|
||||
|
||||
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
|
||||
func runSSHWrapper(args []string) {
|
||||
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 {
|
||||
flags, args, err := internalssh.ExtractWrapperFlags(args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
runClient(internalssh.SSH, flags, args)
|
||||
}
|
||||
|
||||
// destination is the NetBox host named in the arguments of a client.
|
||||
type destination struct {
|
||||
host string
|
||||
user string
|
||||
dests []int // argument indices naming the host; several only for scp
|
||||
}
|
||||
|
||||
func findDestination(client internalssh.Client, args []string) (*destination, error) {
|
||||
if client.Name == internalssh.SCP.Name {
|
||||
operands, err := client.Operands(args)
|
||||
if err != nil || len(operands) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
dest := &destination{host: operands[0].Host}
|
||||
for _, operand := range operands {
|
||||
dest.dests = append(dest.dests, operand.Idx)
|
||||
if operand.User != "" {
|
||||
dest.user = operand.User
|
||||
}
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
parsed := client.Parse(args)
|
||||
if parsed == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil
|
||||
}
|
||||
|
||||
// runClient resolves the NetBox host in args and execs ssh, sftp, or scp.
|
||||
func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) {
|
||||
cfg := loadConfigOrSetup()
|
||||
|
||||
dest, err := findDestination(client, args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
if dest == nil {
|
||||
if len(flags.Forwards) > 0 {
|
||||
fatalf("--forward needs a destination host")
|
||||
}
|
||||
// No destination found — pass arguments straight through.
|
||||
execClient(client, internalssh.Result{Args: args}, flags)
|
||||
return
|
||||
}
|
||||
|
||||
// Inject the configured default user if none was given on the command line.
|
||||
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
||||
parsed.DestIdx += 2
|
||||
if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) {
|
||||
args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests)
|
||||
}
|
||||
// Inject the configured default port if none was given on the command line.
|
||||
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
|
||||
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
|
||||
parsed.DestIdx += 2
|
||||
if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) {
|
||||
args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests)
|
||||
}
|
||||
|
||||
connectHost(cfg, client, flags, resolveTarget(cfg, dest.host), dest.dests, args)
|
||||
}
|
||||
|
||||
// prependArgs puts extra before args and shifts the destination indices.
|
||||
func prependArgs(extra, args []string, dests []int) []string {
|
||||
for i := range dests {
|
||||
dests[i] += len(extra)
|
||||
}
|
||||
return append(append([]string(nil), extra...), args...)
|
||||
}
|
||||
|
||||
// resolvedHost is a NetBox host with the address to connect to.
|
||||
type resolvedHost struct {
|
||||
Name string // cache/selector name, may carry an instance prefix
|
||||
Kind string
|
||||
IP string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// resolveTarget finds host in the cache (also by shortcut) or NetBox and
|
||||
// resolves its address. It exits on failure.
|
||||
func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
source, _ := netbox.SplitTarget(parsed.Host)
|
||||
source, _ := netbox.SplitTarget(host)
|
||||
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||
used := func(entry cache.Entry) resolvedHost {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags}
|
||||
}
|
||||
|
||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||
c.MarkUsed(parsed.Host)
|
||||
_ = c.Save()
|
||||
connect(entry.IP, parsed, args)
|
||||
return
|
||||
if entry, fresh := c.Get(host); fresh && useCache {
|
||||
return used(entry)
|
||||
}
|
||||
|
||||
// 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
|
||||
lookupHost := host
|
||||
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)
|
||||
@@ -129,10 +203,7 @@ func runSSHWrapper(args []string) {
|
||||
}
|
||||
if found {
|
||||
if fresh {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
connect(entry.IP, parsed, args)
|
||||
return
|
||||
return used(entry)
|
||||
}
|
||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||
lookupHost = entry.Name
|
||||
@@ -149,7 +220,7 @@ func runSSHWrapper(args []string) {
|
||||
|
||||
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
||||
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
||||
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
|
||||
entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false))
|
||||
if cacheErr != nil {
|
||||
fatalf("%v", cacheErr)
|
||||
}
|
||||
@@ -162,10 +233,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)
|
||||
return
|
||||
return used(cached)
|
||||
}
|
||||
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
@@ -175,14 +243,12 @@ func runSSHWrapper(args []string) {
|
||||
|
||||
ip, err := chain.Resolve(ctx, &target, nbClient)
|
||||
if err != nil {
|
||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
||||
fatalf("IP resolution for %q: %v", host, err)
|
||||
}
|
||||
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
|
||||
connect(ip, parsed, args)
|
||||
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
|
||||
c.Upsert(entry)
|
||||
return used(entry)
|
||||
}
|
||||
|
||||
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||
@@ -205,9 +271,43 @@ 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 the client.
|
||||
func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) {
|
||||
opts := hostOptions(cfg, host)
|
||||
if len(flags.Forwards) > 0 {
|
||||
forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
flags.ForwardArgs = forwardArgs
|
||||
}
|
||||
if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) {
|
||||
available := "none configured (ssh.forwards)"
|
||||
if names := forwardNames(opts); len(names) > 0 {
|
||||
available = strings.Join(names, ", ")
|
||||
}
|
||||
fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available)
|
||||
}
|
||||
_, name := netbox.SplitTarget(host.Name)
|
||||
execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.IP}, opts, flags), flags)
|
||||
}
|
||||
|
||||
// hostOptions merges the global, instance, and rule SSH options for host.
|
||||
func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions {
|
||||
instance, name := netbox.SplitTarget(host.Name)
|
||||
return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags})
|
||||
}
|
||||
|
||||
// execClient replaces the process with the client, or prints the command for --dry-run.
|
||||
func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||
if flags.DryRun {
|
||||
fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...)))
|
||||
return
|
||||
}
|
||||
if result.Notice != "" {
|
||||
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
||||
}
|
||||
if err := client.Exec(result.Args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -227,6 +327,9 @@ func runTUI() {
|
||||
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||
m.SetForwards(func(name, kind string, tags []string) []string {
|
||||
return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags}))
|
||||
})
|
||||
p := tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
@@ -243,8 +346,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,36 +369,58 @@ 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)
|
||||
|
||||
var sshArgs []string
|
||||
c.MarkUsed(host.Name)
|
||||
_ = c.Save()
|
||||
|
||||
client, flags, args := tuiCommand(cfg, host)
|
||||
connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args)
|
||||
}
|
||||
|
||||
// tuiCommand builds the client and arguments for the action chosen in the TUI.
|
||||
func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) {
|
||||
client := internalssh.SSH
|
||||
var flags internalssh.WrapperFlags
|
||||
switch host.Action {
|
||||
case tui.ActionSFTP:
|
||||
client = internalssh.SFTP
|
||||
case tui.ActionTunnel:
|
||||
flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true}
|
||||
}
|
||||
|
||||
var args []string
|
||||
// User override from TUI edit mode takes priority, then config default.
|
||||
user := host.User
|
||||
if user == "" {
|
||||
user = cfg.SSH.DefaultUser
|
||||
}
|
||||
if user != "" {
|
||||
sshArgs = append(sshArgs, "-l", user)
|
||||
args = append(args, client.UserArgs(user)...)
|
||||
}
|
||||
|
||||
port := host.Port
|
||||
if port == "" && cfg.SSH.DefaultPort > 0 {
|
||||
port = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||
}
|
||||
if port != "" {
|
||||
sshArgs = append(sshArgs, "-p", port)
|
||||
args = append(args, client.PortArgs(port)...)
|
||||
}
|
||||
// Apply replaces the destination; a bare name cannot be mistaken for
|
||||
// host:path by sftp, unlike an IPv6 address.
|
||||
_, name := netbox.SplitTarget(host.Name)
|
||||
return client, flags, append(args, name)
|
||||
}
|
||||
|
||||
sshArgs = append(sshArgs, host.IP)
|
||||
|
||||
c.MarkUsed(host.Name)
|
||||
_ = c.Save()
|
||||
|
||||
if err := internalssh.Exec(sshArgs); err != nil {
|
||||
fatalf("%v", err)
|
||||
// forwardNames lists the forwards configured for a host, sorted.
|
||||
func forwardNames(opts config.SSHOptions) []string {
|
||||
names := make([]string, 0, len(opts.Forwards))
|
||||
for name := range opts.Forwards {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// --- cobra root + subcommands ---
|
||||
@@ -304,16 +431,17 @@ func rootCmd() *cobra.Command {
|
||||
Short: "SSH wrapper with NetBox hostname resolution",
|
||||
DisableFlagParsing: true,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if len(args) > 0 && args[len(args)-1] == "--forward" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
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))
|
||||
return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
hosts := completeHosts(toComplete)
|
||||
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 +453,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(), sftpCmd(), scpCmd(), tunnelCmd())
|
||||
|
||||
// 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 +508,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 +741,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 +766,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 +796,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 +865,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 +881,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)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||
)
|
||||
|
||||
func sftpCmd() *cobra.Command {
|
||||
return transferCmd(internalssh.SFTP, "sftp [--dry-run] [--jump HOST|--no-jump] [sftp options] HOST[:PATH]",
|
||||
"Open an SFTP session to a NetBox host",
|
||||
`Resolve HOST like netssh does for ssh (cache, shortcuts, instance/host,
|
||||
SSH options and rules, jump host) and run sftp. sftp options are passed
|
||||
through; note that sftp uses -P for the port.
|
||||
|
||||
netssh sftp fsnbx1
|
||||
netssh sftp -P 2222 prod/web01:/var/log`)
|
||||
}
|
||||
|
||||
func scpCmd() *cobra.Command {
|
||||
return transferCmd(internalssh.SCP, "scp [--dry-run] [--jump HOST|--no-jump] [scp options] SOURCE... TARGET",
|
||||
"Copy files to or from a NetBox host",
|
||||
`Resolve the host in HOST:PATH operands like netssh does for ssh and run scp.
|
||||
All remote operands must name the same host.
|
||||
|
||||
netssh scp ./app.tar fsnbx1:/tmp/
|
||||
netssh scp -r prod/web01:/etc/nginx ./nginx`)
|
||||
}
|
||||
|
||||
func transferCmd(client internalssh.Client, use, short, long string) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Long: long,
|
||||
DisableFlagParsing: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||
_ = cmd.Help()
|
||||
return
|
||||
}
|
||||
flags, args, err := client.ExtractWrapperFlags(args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
if len(flags.Forwards) > 0 {
|
||||
fatalf("--forward is only available for ssh and netssh tunnel")
|
||||
}
|
||||
runClient(client, flags, args)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
if client.Name == internalssh.SFTP.Name && client.Parse(args) != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if strings.HasPrefix(toComplete, "-") {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
if client.Name == internalssh.SCP.Name {
|
||||
if strings.Contains(toComplete, ":") {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp // remote paths are not completed
|
||||
}
|
||||
// HOST: for remote operands; without a matching host, local files.
|
||||
hosts := completeHosts(toComplete)
|
||||
if len(hosts) == 0 {
|
||||
return nil, cobra.ShellCompDirectiveDefault
|
||||
}
|
||||
for i := range hosts {
|
||||
hosts[i] += ":"
|
||||
}
|
||||
return hosts, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func tunnelCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "tunnel [--dry-run] [--jump HOST|--no-jump] [ssh options] HOST FORWARD...",
|
||||
Short: "Open port forwards to a NetBox host without a shell",
|
||||
Long: `Run ssh -N with the named forwards configured under ssh.forwards (globally,
|
||||
per NetBox instance, or per rule). A FORWARD that is no configured name but a
|
||||
port spec such as 8080:localhost:80 is used as a local forward (-L).
|
||||
|
||||
netssh tunnel fsnbx1 web socks
|
||||
netssh tunnel prod/db01 5432:localhost:5432`,
|
||||
DisableFlagParsing: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||
_ = cmd.Help()
|
||||
return
|
||||
}
|
||||
flags, args, err := internalssh.SSH.ExtractWrapperFlags(args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
parsed := internalssh.SSH.Parse(args)
|
||||
if parsed == nil {
|
||||
fatalf("usage: netssh tunnel HOST FORWARD...")
|
||||
}
|
||||
// Everything after the host names forwards instead of a remote command.
|
||||
flags.Forwards = append(flags.Forwards, args[parsed.DestIdx+1:]...)
|
||||
flags.Tunnel = true
|
||||
runClient(internalssh.SSH, flags, args[:parsed.DestIdx+1])
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
parsed := internalssh.SSH.Parse(args)
|
||||
if parsed == nil {
|
||||
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
var names []string
|
||||
if entry, ok := cachedHost(cfg, parsed.Host); ok {
|
||||
names = forwardNames(hostOptions(cfg, resolvedHost{Name: entry.Name, Kind: entry.Kind, Tags: entry.Tags}))
|
||||
} else {
|
||||
names = allForwardNames(cfg)
|
||||
}
|
||||
return filterPrefix(names, toComplete, args[parsed.DestIdx+1:]), cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// completeHosts returns the cached hosts for a completion request.
|
||||
func completeHosts(toComplete string) []string {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// cachedHost finds a host for completion by name or shortcut, without NetBox.
|
||||
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return cache.Entry{}, false
|
||||
}
|
||||
if entry, _ := c.Get(host); entry.Name != "" {
|
||||
return entry, true
|
||||
}
|
||||
if !cfg.Shortcuts.Enabled() {
|
||||
return cache.Entry{}, false
|
||||
}
|
||||
entry, found, _, err := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, strings.Contains(host, "/")))
|
||||
return entry, found && err == nil
|
||||
}
|
||||
|
||||
// allForwardNames lists every configured forward name, for when the host is unknown.
|
||||
func allForwardNames(cfg *config.Config) []string {
|
||||
seen := map[string]bool{}
|
||||
add := func(o config.SSHOptions) {
|
||||
for name := range o.Forwards {
|
||||
seen[strings.ToLower(name)] = true
|
||||
}
|
||||
}
|
||||
add(cfg.SSH.SSHOptions)
|
||||
add(cfg.NetBox.SSH)
|
||||
for _, source := range cfg.NetBoxes {
|
||||
add(source.SSH)
|
||||
}
|
||||
for _, rule := range cfg.SSH.Rules {
|
||||
add(rule.SSHOptions)
|
||||
}
|
||||
names := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// filterPrefix keeps the names starting with prefix that were not given yet.
|
||||
func filterPrefix(names []string, prefix string, given []string) []string {
|
||||
used := map[string]bool{}
|
||||
for _, name := range given {
|
||||
used[strings.ToLower(name)] = true
|
||||
}
|
||||
var out []string
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(name, strings.ToLower(prefix)) && !used[name] {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
// TestNetsshHelper runs main() with the arguments in NETSSH_TEST_ARGS.
|
||||
func TestNetsshHelper(t *testing.T) {
|
||||
if args := os.Getenv("NETSSH_TEST_ARGS"); args != "" {
|
||||
os.Args = append([]string{"netssh"}, strings.Split(args, "\x1f")...)
|
||||
main()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// prepareTransferTest writes a config whose only host is cached; NetBox is
|
||||
// unreachable, so every result must come from cache, shortcuts, and options.
|
||||
func prepareTransferTest(t *testing.T) {
|
||||
t.Helper()
|
||||
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:
|
||||
url: http://127.0.0.1:1
|
||||
token: t
|
||||
ssh:
|
||||
default_user: ops
|
||||
default_port: 2222
|
||||
extra_args: ["-A"]
|
||||
forwards:
|
||||
web: {local: "8080:localhost:80"}
|
||||
rules:
|
||||
- match: {tag: dmz}
|
||||
proxy_jump: jh.example.com
|
||||
forwards:
|
||||
socks: {dynamic: "1080"}
|
||||
shortcuts:
|
||||
domains: [.hoster.de]
|
||||
rewrites:
|
||||
- pattern: '^([a-z]+)[0-9]+-'
|
||||
replace: '$1'
|
||||
`
|
||||
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", Tags: []string{"dmz"}})
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func runNetssh(t *testing.T, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestNetsshHelper$")
|
||||
cmd.Env = append(os.Environ(), "NETSSH_TEST_ARGS="+strings.Join(args, "\x1f"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func TestTransferAndTunnelCommands(t *testing.T) {
|
||||
prepareTransferTest(t)
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
// sftp: -P and -o User, rule jump host, path kept, no ssh-only extra_args.
|
||||
{[]string{"sftp", "--dry-run", "fsnbx1:/var/log"}, "sftp -P 2222 -o User=ops -J jh.example.com 10.0.0.9:/var/log"},
|
||||
{[]string{"sftp", "--dry-run", "-P", "22", "root@fsnbx1"}, "sftp -P 22 -J jh.example.com root@10.0.0.9"},
|
||||
{[]string{"scp", "--dry-run", "-r", "a.txt", "fsn1-bx1.hoster.de:/tmp/"}, "scp -P 2222 -o User=ops -r -J jh.example.com a.txt 10.0.0.9:/tmp/"},
|
||||
{[]string{"scp", "--dry-run", "--no-jump", "fsnbx1:/etc/hosts", "."}, "scp -P 2222 -o User=ops -o ProxyJump=none 10.0.0.9:/etc/hosts ."},
|
||||
// Forwards come from the global options and the matching rule.
|
||||
{[]string{"tunnel", "--dry-run", "fsnbx1", "web", "socks", "5432:db:5432"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 -D 1080 -L 5432:db:5432 -o ExitOnForwardFailure=yes -N 10.0.0.9"},
|
||||
{[]string{"--dry-run", "--forward", "web", "fsnbx1"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 10.0.0.9"},
|
||||
} {
|
||||
out, err := runNetssh(t, tc.args...)
|
||||
if err != nil || out != tc.want {
|
||||
t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"tunnel", "--dry-run", "fsnbx1"}, "forwards for fsn1-bx1.hoster.de: socks, web"},
|
||||
{[]string{"tunnel", "--dry-run", "fsnbx1", "db"}, `unknown forward "db"; available: socks, web`},
|
||||
{[]string{"sftp", "--forward", "web", "fsnbx1"}, "only available for ssh"},
|
||||
{[]string{"scp", "fsnbx1:a", "other:b"}, "not supported"},
|
||||
} {
|
||||
out, err := runNetssh(t, tc.args...)
|
||||
if err == nil || !strings.Contains(out, tc.want) {
|
||||
t.Errorf("%v: got %s (%v), want error containing %q", tc.args, out, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferCompletion(t *testing.T) {
|
||||
prepareTransferTest(t)
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"__complete", "sftp", "fsnb"}, "fsnbx1\n"},
|
||||
{[]string{"__complete", "scp", "a.txt", "fsnb"}, "fsnbx1:\n"},
|
||||
{[]string{"__complete", "tunnel", "fsnbx1", ""}, "socks\nweb\n"},
|
||||
{[]string{"__complete", "tunnel", "fsnbx1", "web", ""}, "socks\n"},
|
||||
{[]string{"__complete", "--forward", ""}, "socks\nweb\n"},
|
||||
} {
|
||||
out, err := runNetssh(t, tc.args...)
|
||||
if err != nil || !strings.HasPrefix(out+"\n", tc.want) {
|
||||
t.Errorf("%v: got %q (%v), want prefix %q", tc.args, out, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
||||
|
||||
go 1.27.3
|
||||
go 1.27.4
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.1.1
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
charm.land/lipgloss/v2 v2.1.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/sys v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -43,11 +43,11 @@ require (
|
||||
github.com/sahilm/fuzzy v0.1.3 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/pflag v1.1.0 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v1.2.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
github.com/xo/terminfo v1.3.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.6 // indirect
|
||||
golang.org/x/sync v0.25.0 // indirect
|
||||
golang.org/x/text v0.44.0 // indirect
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU=
|
||||
charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
charm.land/lipgloss/v2 v2.1.0 h1:RLCNLhSQST+NPOmvUSbRWZ3UFOXsf3ZXFmJ5E/2tHEU=
|
||||
charm.land/lipgloss/v2 v2.1.0/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
@@ -87,23 +87,23 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
|
||||
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||
github.com/xo/terminfo v1.3.1 h1:4ZwPCuu4PEFw0PkNp3Hv/x3gqvgLCzYhurszsj101cM=
|
||||
github.com/xo/terminfo v1.3.1/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
|
||||
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
|
||||
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
+22
-1
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,7 @@ type Config struct {
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
@@ -29,6 +30,7 @@ type NetBoxConfig struct {
|
||||
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"`
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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"`
|
||||
// Forwards are named port forwards, selected with --forward or netssh tunnel.
|
||||
Forwards map[string]Forward `mapstructure:"forwards"`
|
||||
}
|
||||
|
||||
// Forward is one port forward; exactly one of the fields is set.
|
||||
type Forward struct {
|
||||
Local string `mapstructure:"local"` // -L [bind:]port:host:hostport
|
||||
Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport
|
||||
Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy)
|
||||
}
|
||||
|
||||
// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80.
|
||||
func (f Forward) Flag() []string {
|
||||
switch {
|
||||
case f.Local != "":
|
||||
return []string{"-L", f.Local}
|
||||
case f.Remote != "":
|
||||
return []string{"-R", f.Remote}
|
||||
case f.Dynamic != "":
|
||||
return []string{"-D", f.Dynamic}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80".
|
||||
func (f Forward) String() string { return strings.Join(f.Flag(), " ") }
|
||||
|
||||
func (f Forward) validate() error {
|
||||
set := 0
|
||||
for _, spec := range []string{f.Local, f.Remote, f.Dynamic} {
|
||||
if spec == "" {
|
||||
continue
|
||||
}
|
||||
set++
|
||||
if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") {
|
||||
return fmt.Errorf("invalid forward %q", spec)
|
||||
}
|
||||
}
|
||||
if set != 1 {
|
||||
return fmt.Errorf("set exactly one of local, remote, dynamic")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
for name, forward := range o.Forwards {
|
||||
if !instanceName.MatchString(name) {
|
||||
return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name)
|
||||
}
|
||||
if err := forward.validate(); err != nil {
|
||||
return fmt.Errorf("%s.forwards.%s: %v", scope, name, err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
// Forwards override per name, so a rule can redefine a global "web" forward.
|
||||
out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards))
|
||||
for name, forward := range o.Forwards {
|
||||
out.Forwards[strings.ToLower(name)] = forward
|
||||
}
|
||||
for name, forward := range override.Forwards {
|
||||
out.Forwards[strings.ToLower(name)] = forward
|
||||
}
|
||||
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)), "-")
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardsLayering(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
netboxes:
|
||||
- name: prod
|
||||
url: https://prod.example
|
||||
token: t
|
||||
ssh:
|
||||
forwards:
|
||||
db: {local: "5432:localhost:5432"}
|
||||
ssh:
|
||||
forwards:
|
||||
web: {local: "8080:localhost:80"}
|
||||
socks: {dynamic: "1080"}
|
||||
rules:
|
||||
- match: {tag: dmz}
|
||||
forwards:
|
||||
web: {local: "8443:localhost:443"}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
|
||||
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
|
||||
t.Fatalf("other instance: %+v", got)
|
||||
}
|
||||
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
|
||||
t.Fatal(f.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardsValidation(t *testing.T) {
|
||||
for _, forwards := range []string{
|
||||
`web: {local: "8080:a:80", dynamic: "1080"}`,
|
||||
`web: {local: "-oProxyCommand=x"}`,
|
||||
`web: {local: "8080 a:80"}`,
|
||||
`"bad name": {local: "8080:a:80"}`,
|
||||
} {
|
||||
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
|
||||
t.Errorf("%s: got %v", forwards, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+136
-26
@@ -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) {
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
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)
|
||||
forwards := formatForwards(o.Forwards)
|
||||
|
||||
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
|
||||
}),
|
||||
huh.NewText().
|
||||
Title("Port forwards").
|
||||
Description("One per line: NAME L|R|D SPEC, e.g. web L 8080:localhost:80 or socks D 1080.\n"+
|
||||
"Use with netssh tunnel HOST NAME or netssh --forward NAME HOST.").
|
||||
Lines(3).
|
||||
Value(&forwards).
|
||||
Validate(func(s string) error {
|
||||
_, err := parseForwards(s)
|
||||
return err
|
||||
}),
|
||||
).Title(title)).Run()
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
|
||||
parsed, _ := parseOptions(options)
|
||||
parsedForwards, _ := parseForwards(forwards)
|
||||
return config.SSHOptions{
|
||||
Forwards: parsedForwards,
|
||||
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
|
||||
}
|
||||
|
||||
// forwardKinds maps the letters used in the form to the ssh flags.
|
||||
var forwardKinds = map[string]func(spec string) config.Forward{
|
||||
"L": func(spec string) config.Forward { return config.Forward{Local: spec} },
|
||||
"R": func(spec string) config.Forward { return config.Forward{Remote: spec} },
|
||||
"D": func(spec string) config.Forward { return config.Forward{Dynamic: spec} },
|
||||
}
|
||||
|
||||
// parseForwards reads "NAME L|R|D SPEC" lines into ssh.forwards.
|
||||
func parseForwards(s string) (map[string]config.Forward, error) {
|
||||
var out map[string]config.Forward
|
||||
for _, line := range parseLines(s) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) != 3 || forwardKinds[strings.ToUpper(fields[1])] == nil {
|
||||
return nil, fmt.Errorf("%q: expected NAME L|R|D SPEC", line)
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string]config.Forward)
|
||||
}
|
||||
out[strings.ToLower(fields[0])] = forwardKinds[strings.ToUpper(fields[1])](fields[2])
|
||||
}
|
||||
if err := (config.SSHOptions{Forwards: out}).Validate("ssh"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func formatForwards(forwards map[string]config.Forward) string {
|
||||
names := make([]string, 0, len(forwards))
|
||||
for name := range forwards {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
lines := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
flag := forwards[name].Flag()
|
||||
if len(flag) == 2 {
|
||||
lines = append(lines, name+" "+strings.TrimPrefix(flag[0], "-")+" "+flag[1])
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
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, " ") + "]"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForwardsRoundtrip(t *testing.T) {
|
||||
text := "Web L 8080:localhost:80\n\nsocks d 1080\nback R 9000:localhost:9000\n"
|
||||
got, err := parseForwards(text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if formatForwards(got) != "back R 9000:localhost:9000\nsocks D 1080\nweb L 8080:localhost:80" {
|
||||
t.Fatalf("format: %q", formatForwards(got))
|
||||
}
|
||||
for _, bad := range []string{"web 8080:localhost:80", "web X 8080", "web L -oFoo", "bad/name L 1:a:2"} {
|
||||
if _, err := parseForwards(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+292
-106
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -16,39 +17,120 @@ 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 {
|
||||
return cancelled(err)
|
||||
}
|
||||
work := prepare(*cfg, sources)
|
||||
if err := runMenu(&work); err != nil {
|
||||
return cancelled(err)
|
||||
}
|
||||
|
||||
if err := save(work); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return fmt.Errorf("updating aliases: %w", err)
|
||||
}
|
||||
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
|
||||
return err
|
||||
}
|
||||
if loaded.Shell.Alias != "" {
|
||||
shell := filepath.Base(os.Getenv("SHELL"))
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
|
||||
return nil
|
||||
}
|
||||
if _, err := hook.Install(profile, shell); 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, forwards", "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)
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Default SSH user").
|
||||
Description("Leave empty to use your system user ($USER).").
|
||||
@@ -68,9 +150,22 @@ func RunWizard(cfg *config.Config) error {
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("SSH defaults"),
|
||||
).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
|
||||
}
|
||||
|
||||
huh.NewGroup(
|
||||
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").
|
||||
@@ -95,9 +190,28 @@ func RunWizard(cfg *config.Config) error {
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("Resolver & cache"),
|
||||
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
|
||||
}
|
||||
|
||||
huh.NewGroup(
|
||||
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'.").
|
||||
@@ -107,103 +221,100 @@ func RunWizard(cfg *config.Config) error {
|
||||
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"),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
).Title("Shortcuts")).Run()
|
||||
if err != nil {
|
||||
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 != "" {
|
||||
cfg.Shortcuts.Domains = nil
|
||||
for _, s := range splitList(shortcutDomains) {
|
||||
if !strings.HasPrefix(s, ".") {
|
||||
s = "." + s
|
||||
}
|
||||
domainList = append(domainList, 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
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
func editCompletion(cfg *config.Config) error {
|
||||
maxResults := ""
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
maxResults = strconv.Itoa(cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
if err := save(out); err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
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
|
||||
}
|
||||
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return fmt.Errorf("updating aliases: %w", err)
|
||||
}
|
||||
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
|
||||
return err
|
||||
}
|
||||
if loaded.Shell.Alias != "" {
|
||||
shell := filepath.Base(os.Getenv("SHELL"))
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
|
||||
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)
|
||||
}
|
||||
if _, err := hook.Install(profile); err != nil {
|
||||
return err
|
||||
out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)})
|
||||
}
|
||||
if shell != "fish" {
|
||||
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||
return err
|
||||
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)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile))
|
||||
}
|
||||
return nil
|
||||
return out
|
||||
}
|
||||
|
||||
var knownStrategies = map[string]bool{
|
||||
@@ -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,49 @@ 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 && len(o.Forwards) == 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])
|
||||
}
|
||||
}
|
||||
if len(o.Forwards) > 0 {
|
||||
names := make([]string, 0, len(o.Forwards))
|
||||
for name := range o.Forwards {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(b, "%sforwards:\n", indent)
|
||||
for _, name := range names {
|
||||
f := o.Forwards[name]
|
||||
for _, field := range [][2]string{{"local", f.Local}, {"remote", f.Remote}, {"dynamic", f.Dynamic}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(b, "%s %s:\n%s %s: %q\n", indent, name, indent, field[0], field[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,150 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveForwardsRoundtrip(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}}
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
SSH: config.SSHConfig{
|
||||
SSHOptions: config.SSHOptions{Forwards: global},
|
||||
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}},
|
||||
},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.SSH.Forwards, global) ||
|
||||
!reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) ||
|
||||
!reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) {
|
||||
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
-30
@@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{
|
||||
|
||||
// ParsedArgs holds the result of parsing SSH arguments.
|
||||
type ParsedArgs struct {
|
||||
Host string // hostname without the user@ prefix
|
||||
Host string // hostname without the user@ prefix or :path
|
||||
User string // empty if not specified
|
||||
Path string // sftp only: the remote path after host:
|
||||
DestIdx int // index in Args where [user@]host sits
|
||||
Args []string
|
||||
}
|
||||
|
||||
// Parse scans SSH arguments and extracts the destination ([user@]host).
|
||||
// Returns nil if no destination is found.
|
||||
func Parse(args []string) *ParsedArgs {
|
||||
func Parse(args []string) *ParsedArgs { return SSH.Parse(args) }
|
||||
|
||||
// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp.
|
||||
func (c Client) Parse(args []string) *ParsedArgs {
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
arg := args[i]
|
||||
@@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs {
|
||||
|
||||
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
||||
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if flagsWithArg[arg[j]] {
|
||||
if j == len(arg)-1 {
|
||||
if c.takesSeparateValue(arg) {
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// first non-flag argument is the destination
|
||||
host, user := splitUserHost(arg)
|
||||
return &ParsedArgs{
|
||||
Host: host,
|
||||
User: user,
|
||||
DestIdx: i,
|
||||
Args: args,
|
||||
}
|
||||
return c.parsed(args, i)
|
||||
}
|
||||
|
||||
// handle arguments after "--"
|
||||
if i < len(args) {
|
||||
host, user := splitUserHost(args[i])
|
||||
return &ParsedArgs{
|
||||
Host: host,
|
||||
User: user,
|
||||
DestIdx: i,
|
||||
Args: args,
|
||||
}
|
||||
return c.parsed(args, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Client) parsed(args []string, i int) *ParsedArgs {
|
||||
dest, path := args[i], ""
|
||||
if c.paths {
|
||||
if d, p, remote := splitRemote(dest); remote {
|
||||
dest, path = d, p
|
||||
}
|
||||
}
|
||||
host, user := splitUserHost(dest)
|
||||
return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args}
|
||||
}
|
||||
|
||||
// ReplaceHost returns a copy of args with the destination replaced by newHost,
|
||||
// preserving any user@ prefix.
|
||||
func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||
return SSH.replaceHost(args, []int{destIdx}, newHost)
|
||||
}
|
||||
|
||||
// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read
|
||||
// host:path, so an IPv6 address must be bracketed.
|
||||
func (c Client) replaceHost(args []string, dests []int, newHost string) []string {
|
||||
result := make([]string, len(args))
|
||||
copy(result, args)
|
||||
|
||||
if c.paths && strings.Contains(newHost, ":") {
|
||||
newHost = "[" + newHost + "]"
|
||||
}
|
||||
for _, destIdx := range dests {
|
||||
original := args[destIdx]
|
||||
prefix, suffix := "", ""
|
||||
if at := strings.Index(original, "@"); at != -1 {
|
||||
result[destIdx] = original[:at+1] + newHost
|
||||
} else {
|
||||
result[destIdx] = newHost
|
||||
prefix, original = original[:at+1], original[at+1:]
|
||||
}
|
||||
if c.paths {
|
||||
if _, path, remote := splitRemote(original); remote {
|
||||
suffix = ":" + path
|
||||
}
|
||||
}
|
||||
result[destIdx] = prefix + newHost + suffix
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasPortFlag reports whether a port was explicitly supplied.
|
||||
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
|
||||
func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
|
||||
|
||||
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
|
||||
func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) }
|
||||
|
||||
// HasPortFlag reports whether a port was explicitly supplied.
|
||||
func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") }
|
||||
|
||||
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||
func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") }
|
||||
|
||||
func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
return SSH.hasOption(args, wanted, keyword)
|
||||
}
|
||||
|
||||
func (c Client) hasOption(args []string, wanted byte, keyword string) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||
@@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
}
|
||||
for j := 1; j < len(arg); j++ {
|
||||
flag := arg[j]
|
||||
if !flagsWithArg[flag] {
|
||||
if !c.withArg[flag] {
|
||||
continue
|
||||
}
|
||||
value := arg[j+1:]
|
||||
@@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
}
|
||||
value = args[i]
|
||||
}
|
||||
if flag == wanted {
|
||||
if wanted != 0 && flag == wanted {
|
||||
return true
|
||||
}
|
||||
if flag == 'o' {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client describes one OpenSSH program netssh can run. The programs share
|
||||
// ssh's connection options but differ in flags and destination syntax.
|
||||
type Client struct {
|
||||
Name string // binary name: ssh, sftp, or scp
|
||||
withArg map[byte]bool // short flags that consume a value
|
||||
port byte // flag for the port: -p (ssh) or -P (sftp, scp)
|
||||
user byte // flag for the login user; 0 when only user@ or -o User work
|
||||
paths bool // destinations are host[:path]
|
||||
}
|
||||
|
||||
var (
|
||||
// SSH is the default client; its flag table is flagsWithArg.
|
||||
SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'}
|
||||
// SFTP follows sftp(1): -l is a bandwidth limit, -P the port.
|
||||
SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true}
|
||||
// SCP follows scp(1); unlike sftp, -B and -R take no value.
|
||||
SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true}
|
||||
)
|
||||
|
||||
func flagSet(flags string) map[byte]bool {
|
||||
set := make(map[byte]bool, len(flags))
|
||||
for i := 0; i < len(flags); i++ {
|
||||
set[flags[i]] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// PortArgs returns the arguments that set port for this client.
|
||||
func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} }
|
||||
|
||||
// UserArgs returns the arguments that set the login user for this client.
|
||||
func (c Client) UserArgs(user string) []string {
|
||||
if c.user == 0 {
|
||||
return []string{"-o", "User=" + user}
|
||||
}
|
||||
return []string{"-" + string(c.user), user}
|
||||
}
|
||||
|
||||
// Operand is a remote scp argument such as user@host:/path.
|
||||
type Operand struct {
|
||||
Idx int
|
||||
User string
|
||||
Host string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Operands returns the remote operands of scp arguments. All remote operands
|
||||
// must name the same host, because one set of options applies per call.
|
||||
func (c Client) Operands(args []string) ([]Operand, error) {
|
||||
start := c.optionsEnd(args)
|
||||
var out []Operand
|
||||
for i := start; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
|
||||
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
|
||||
}
|
||||
dest, path, remote := splitRemote(arg)
|
||||
if !remote {
|
||||
continue
|
||||
}
|
||||
host, user := splitUserHost(dest)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) > 0 && !strings.EqualFold(out[0].Host, host) {
|
||||
return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host)
|
||||
}
|
||||
out = append(out, Operand{Idx: i, User: user, Host: host, Path: path})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// optionsEnd returns the index of the first operand; scp, like ssh, stops
|
||||
// option parsing at the first non-option or after "--".
|
||||
func (c Client) optionsEnd(args []string) int {
|
||||
end := c.insertionPoint(args)
|
||||
if end < len(args) && args[end] == "--" {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// insertionPoint is where added options still count as options: before the
|
||||
// first operand or before "--".
|
||||
func (c Client) insertionPoint(args []string) int {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" {
|
||||
return i
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") || len(arg) < 2 {
|
||||
return i
|
||||
}
|
||||
if c.takesSeparateValue(arg) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return len(args)
|
||||
}
|
||||
|
||||
// splitRemote mirrors scp's colon(): an argument is remote when a ':' comes
|
||||
// before any '/'. A leading "[" starts a bracketed IPv6 address.
|
||||
func splitRemote(arg string) (dest, path string, remote bool) {
|
||||
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
|
||||
return "", "", false
|
||||
}
|
||||
inBracket := false
|
||||
for i := 0; i < len(arg); i++ {
|
||||
switch arg[i] {
|
||||
case '[':
|
||||
inBracket = true
|
||||
case ']':
|
||||
inBracket = false
|
||||
case '/':
|
||||
if !inBracket {
|
||||
return "", "", false
|
||||
}
|
||||
case ':':
|
||||
if !inBracket {
|
||||
return arg[:i], arg[i+1:], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given.
|
||||
func (c Client) HasForwardFlag(args []string) bool {
|
||||
return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward")
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestSFTPParse(t *testing.T) {
|
||||
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
|
||||
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
|
||||
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
|
||||
t.Fatalf("got %+v", parsed)
|
||||
}
|
||||
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
|
||||
t.Fatalf("instance-qualified host: %+v", p)
|
||||
}
|
||||
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
|
||||
t.Fatal("sftp port flag is -P")
|
||||
}
|
||||
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
|
||||
t.Fatal("sftp has no -l user flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCPOperands(t *testing.T) {
|
||||
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
|
||||
if !reflect.DeepEqual(ops, want) {
|
||||
t.Fatalf("got %+v", ops)
|
||||
}
|
||||
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
|
||||
t.Fatalf("local copy has remote operands: %+v", ops)
|
||||
}
|
||||
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
|
||||
t.Fatal("two remote hosts accepted")
|
||||
}
|
||||
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
|
||||
t.Fatal("URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTPApply(t *testing.T) {
|
||||
args := []string{"-P", "2222", "ops@web01:/var/log"}
|
||||
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
|
||||
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
|
||||
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
|
||||
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
|
||||
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
|
||||
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
// sftp needs brackets even without a path; ssh never does.
|
||||
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
|
||||
t.Fatalf("sftp: %v", got)
|
||||
}
|
||||
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
|
||||
t.Fatalf("ssh: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTPTargetName(t *testing.T) {
|
||||
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
|
||||
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUserAndPortArgs(t *testing.T) {
|
||||
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientExtractWrapperFlags(t *testing.T) {
|
||||
// For scp, -X takes a value; for ssh it does not.
|
||||
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
|
||||
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
|
||||
t.Fatalf("got %+v %v %v", flags, rest, err)
|
||||
}
|
||||
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
|
||||
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
|
||||
t.Fatalf("got %+v %v %v", flags, rest, err)
|
||||
}
|
||||
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
|
||||
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||
t.Errorf("%v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardArgs(t *testing.T) {
|
||||
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
|
||||
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
|
||||
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v %v", got, err)
|
||||
}
|
||||
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
|
||||
t.Fatalf("unknown name: %v", err)
|
||||
}
|
||||
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
|
||||
t.Fatalf("no forwards: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyForwardsAndTunnel(t *testing.T) {
|
||||
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
|
||||
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
|
||||
t.Fatalf("forward: %v", got)
|
||||
}
|
||||
flags.Tunnel = true
|
||||
result := apply([]string{"web"}, config.SSHOptions{}, flags)
|
||||
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
|
||||
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
|
||||
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
|
||||
}
|
||||
// The user's own ExitOnForwardFailure wins.
|
||||
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
|
||||
t.Fatalf("overrode user option: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasForwardFlag(t *testing.T) {
|
||||
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
|
||||
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
|
||||
t.Errorf("%q: got %v", args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
|
||||
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
|
||||
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
|
||||
// Exec replaces the current process with the native ssh client via syscall.Exec.
|
||||
// All existing SSH configs, keys, and agent forwarding remain intact.
|
||||
func Exec(args []string) error {
|
||||
sshPath, err := exec.LookPath("ssh")
|
||||
func Exec(args []string) error { return SSH.Exec(args) }
|
||||
|
||||
// Exec replaces the current process with the client's binary.
|
||||
func (c Client) Exec(args []string) error {
|
||||
path, err := exec.LookPath(c.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh not found in PATH: %w", err)
|
||||
return fmt.Errorf("%s not found in PATH: %w", c.Name, err)
|
||||
}
|
||||
argv := append([]string{"ssh"}, args...)
|
||||
return syscall.Exec(sshPath, argv, os.Environ())
|
||||
argv := append([]string{c.Name}, args...)
|
||||
return syscall.Exec(path, argv, os.Environ())
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
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
|
||||
Forwards []string // --forward NAME|SPEC, repeatable (ssh only)
|
||||
// ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them.
|
||||
ForwardArgs []string
|
||||
// Tunnel runs ssh without a remote command (-N), for netssh tunnel.
|
||||
Tunnel bool
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return SSH.ExtractWrapperFlags(args)
|
||||
}
|
||||
|
||||
// ExtractWrapperFlags removes netssh's long options before the destination.
|
||||
func (c Client) 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 == "--forward":
|
||||
if i+1 >= len(args) {
|
||||
return flags, nil, fmt.Errorf("--forward requires a name or spec")
|
||||
}
|
||||
i++
|
||||
flags.Forwards = append(flags.Forwards, args[i])
|
||||
case strings.HasPrefix(arg, "--forward="):
|
||||
flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward="))
|
||||
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 c.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)
|
||||
}
|
||||
for _, forward := range flags.Forwards {
|
||||
if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") {
|
||||
return fmt.Errorf("--forward: invalid value %q", forward)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`)
|
||||
|
||||
// ForwardArgs resolves forward names against the configured forwards. A value
|
||||
// that is no configured name but looks like a port or [bind:]port:host:port
|
||||
// is used as a local forward (-L).
|
||||
func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) {
|
||||
var out []string
|
||||
for _, name := range names {
|
||||
if forward, ok := available[strings.ToLower(name)]; ok {
|
||||
out = append(out, forward.Flag()...)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, ":") && adHocForward.MatchString(name) {
|
||||
out = append(out, "-L", name)
|
||||
continue
|
||||
}
|
||||
known := make([]string, 0, len(available))
|
||||
for key := range available {
|
||||
known = append(known, key)
|
||||
}
|
||||
sort.Strings(known)
|
||||
if len(known) == 0 {
|
||||
return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// takesSeparateValue reports whether a short option cluster ends with a flag
|
||||
// whose value is the next argument (e.g. "-l" or "-vp").
|
||||
func (c Client) takesSeparateValue(arg string) bool {
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if c.withArg[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 {
|
||||
return SSH.Apply(args, []int{destIdx}, host, opts, flags)
|
||||
}
|
||||
|
||||
// Apply replaces the host in every destination (several only for scp) and
|
||||
// inserts the configured options before the first one. extra_args and
|
||||
// forwards are ssh flags and apply to ssh only.
|
||||
func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
destIdx := dests[0]
|
||||
var inserted []string
|
||||
add := func(keyword, value string) {
|
||||
if !c.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 := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.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 != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.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])
|
||||
}
|
||||
|
||||
var extra []string
|
||||
if c.Name == SSH.Name {
|
||||
extra = opts.ExtraArgs
|
||||
inserted = append(inserted, flags.ForwardArgs...)
|
||||
if flags.Tunnel {
|
||||
add("ExitOnForwardFailure", "yes")
|
||||
inserted = append(inserted, "-N")
|
||||
forwards := strings.Join(flags.ForwardArgs, " ")
|
||||
if forwards == "" {
|
||||
forwards = "the given ports"
|
||||
}
|
||||
tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name)
|
||||
if notice == "" {
|
||||
notice = tunnel
|
||||
} else {
|
||||
notice += "\nnetssh: " + tunnel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Options go before the first operand: scp stops reading options there,
|
||||
// and after "--" they would be taken as the destination.
|
||||
at := c.insertionPoint(args)
|
||||
replaced := c.replaceHost(args, dests, dest)
|
||||
out := make([]string, 0, len(extra)+len(replaced)+len(inserted))
|
||||
out = append(out, extra...)
|
||||
out = append(out, replaced[:at]...)
|
||||
out = append(out, inserted...)
|
||||
out = append(out, replaced[at:]...)
|
||||
return Result{Args: out, Notice: notice}
|
||||
}
|
||||
|
||||
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
||||
func (c Client) 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 c.withArg[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, " ")
|
||||
}
|
||||
@@ -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 || !reflect.DeepEqual(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)
|
||||
}
|
||||
}
|
||||
+102
-2
@@ -27,8 +27,17 @@ type SelectedHost struct {
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
Port string // empty = use default port
|
||||
Action string // ActionSSH, ActionSFTP, or ActionTunnel
|
||||
Forwards []string // forward names or specs for ActionTunnel
|
||||
}
|
||||
|
||||
// Actions the user can start on a host.
|
||||
const (
|
||||
ActionSSH = ""
|
||||
ActionSFTP = "sftp"
|
||||
ActionTunnel = "tunnel"
|
||||
)
|
||||
|
||||
// --- bubbletea messages ---
|
||||
|
||||
type debounceMsg struct {
|
||||
@@ -145,6 +154,9 @@ type Model struct {
|
||||
input textinput.Model
|
||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||
editInput textinput.Model // user@host:port inline editor
|
||||
forwardInput textinput.Model // forward names/specs, ctrl+t
|
||||
forwardHost *hostItem
|
||||
forwardsFor func(name, kind string, tags []string) []string
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
@@ -160,6 +172,7 @@ type Model struct {
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // Ctrl+E on the selected item
|
||||
forwardMode bool // Ctrl+T on the selected item
|
||||
}
|
||||
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||
@@ -174,6 +187,10 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
||||
ei := textinput.New()
|
||||
ei.Prompt = ""
|
||||
|
||||
fwd := textinput.New()
|
||||
fwd.Prompt = ""
|
||||
fwd.Placeholder = "web, 8080:localhost:80"
|
||||
|
||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowTitle(false)
|
||||
@@ -194,6 +211,7 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
forwardInput: fwd,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
@@ -281,6 +299,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// --- Forward mode ---
|
||||
if m.forwardMode {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.forwardMode = false
|
||||
m.forwardInput.Blur()
|
||||
m.input.Focus()
|
||||
m.err = nil
|
||||
return m, nil
|
||||
case "enter":
|
||||
item := *m.forwardHost
|
||||
forwards, err := parseForwards(m.forwardInput.Value(), m.availableForwards(item))
|
||||
if err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionTunnel, Forwards: forwards}
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.forwardInput, cmd = m.forwardInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// --- Edit mode ---
|
||||
if m.editMode {
|
||||
switch msg.String() {
|
||||
@@ -355,6 +397,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+o":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionSFTP}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "ctrl+t":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.forwardMode = true
|
||||
m.forwardHost = &item
|
||||
m.err = nil
|
||||
m.input.Blur()
|
||||
m.forwardInput.SetValue("")
|
||||
m.forwardInput.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+n", "ctrl+b":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||
m.noteMode = true
|
||||
@@ -509,13 +568,23 @@ func (m *Model) viewContent() string {
|
||||
sb.WriteString(m.list.View())
|
||||
}
|
||||
|
||||
if m.editMode {
|
||||
if m.forwardMode {
|
||||
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
sb.WriteString("\n" + dim.Render("Forward via "+m.forwardHost.name+": "))
|
||||
sb.WriteString(m.forwardInput.View() + "\n")
|
||||
available := "none configured — enter specs such as 8080:localhost:80"
|
||||
if names := m.availableForwards(*m.forwardHost); len(names) > 0 {
|
||||
available = strings.Join(names, ", ")
|
||||
}
|
||||
sb.WriteString(dim.Render(" forwards: "+available) + "\n")
|
||||
sb.WriteString(dim.Render(" enter start tunnel esc cancel") + "\n")
|
||||
} else if m.editMode {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
||||
sb.WriteString(m.editInput.View() + "\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||
} else {
|
||||
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
@@ -654,3 +723,34 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
}
|
||||
|
||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||
|
||||
// SetForwards provides the forward names configured for a host (ctrl+t).
|
||||
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
|
||||
m.forwardsFor = forwardsFor
|
||||
}
|
||||
|
||||
func (m *Model) availableForwards(item hostItem) []string {
|
||||
if m.forwardsFor == nil {
|
||||
return nil
|
||||
}
|
||||
return m.forwardsFor(item.name, item.kind, item.tags)
|
||||
}
|
||||
|
||||
// parseForwards accepts configured names and specs containing a colon,
|
||||
// separated by commas or spaces.
|
||||
func parseForwards(input string, available []string) ([]string, error) {
|
||||
known := make(map[string]bool, len(available))
|
||||
for _, name := range available {
|
||||
known[strings.ToLower(name)] = true
|
||||
}
|
||||
fields := strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
|
||||
if len(fields) == 0 {
|
||||
return nil, fmt.Errorf("enter a forward name or a spec such as 8080:localhost:80")
|
||||
}
|
||||
for _, field := range fields {
|
||||
if !known[strings.ToLower(field)] && !strings.Contains(field, ":") {
|
||||
return nil, fmt.Errorf("unknown forward %q", field)
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -70,3 +72,53 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||
t.Fatalf("edited host changed: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlOSelectsSFTP(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1"}})
|
||||
m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||
if m.selected == nil || m.selected.Action != ActionSFTP || m.selected.Name != "web01" {
|
||||
t.Fatalf("got %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlTStartsTunnelWithForwards(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.SetForwards(func(name, kind string, tags []string) []string {
|
||||
if name != "web01" || len(tags) != 1 {
|
||||
t.Errorf("forwards asked for %s %v", name, tags)
|
||||
}
|
||||
return []string{"web"}
|
||||
})
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1", Tags: []string{"dmz"}}})
|
||||
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
|
||||
if !m.forwardMode || m.selected != nil {
|
||||
t.Fatal("forward input not opened")
|
||||
}
|
||||
if !strings.Contains(m.viewContent(), "forwards: web") {
|
||||
t.Fatalf("configured forwards not shown:\n%s", m.viewContent())
|
||||
}
|
||||
|
||||
m.forwardInput.SetValue("nope")
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected != nil || m.err == nil {
|
||||
t.Fatal("unknown forward accepted")
|
||||
}
|
||||
|
||||
m.forwardInput.SetValue("WEB, 9000:localhost:9000")
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
want := []string{"WEB", "9000:localhost:9000"}
|
||||
if m.selected == nil || m.selected.Action != ActionTunnel || !reflect.DeepEqual(m.selected.Forwards, want) {
|
||||
t.Fatalf("got %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardInputEscCancels(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "web01"}})
|
||||
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
||||
if m.forwardMode || m.selected != nil {
|
||||
t.Fatal("esc did not cancel")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,19 @@
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"description": "Skip major updates of indirect Go modules: a new major version is a separate module path that no dependency imports, so the update only adds an unused requirement",
|
||||
"matchManagers": [
|
||||
"gomod"
|
||||
],
|
||||
"matchDepTypes": [
|
||||
"indirect"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"major"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Update the Go version declared in go.mod",
|
||||
"matchManagers": [
|
||||
|
||||
Reference in new issue
Block a user