Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
878701aba7 | ||
|
|
d51c040d9f | ||
|
|
ac713ac97b | ||
|
|
091f5cc518 | ||
|
|
6243514def | ||
|
|
6f00d69b6f | ||
|
|
6711b610f1 | ||
|
|
c2e590bf52 | ||
|
|
786768ec53 | ||
|
|
2b46c4f74d | ||
|
|
129a7201bc | ||
|
|
8fb7633783 | ||
|
|
7f53b90266 |
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
|||||||
out="dist/netssh_${GOOS}_${GOARCH}"
|
out="dist/netssh_${GOOS}_${GOARCH}"
|
||||||
echo "→ $out"
|
echo "→ $out"
|
||||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
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
|
-o "$out" ./cmd/netssh
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.0.23
|
||||||
|
|
||||||
|
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
|
||||||
|
- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving.
|
||||||
|
- The configurator validates SSH options and rules before writing the config.
|
||||||
|
- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`.
|
||||||
|
- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches.
|
||||||
|
|
||||||
|
## v0.0.22
|
||||||
|
|
||||||
|
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
|
||||||
|
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
|
||||||
|
|
||||||
|
## v0.0.21
|
||||||
|
|
||||||
|
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
|
||||||
|
- Add `completion.max_results` to limit the number of hostnames offered per completion.
|
||||||
|
- Register zsh completion in the managed `.zshrc` block (`fpath` and `compdef`), also when `compinit` already ran, e.g. with oh-my-zsh. The installer reports which completions it set up.
|
||||||
|
- Add `netssh uninstall [--keep-config] [--dry-run] [--yes]` to remove the binary, profile entries of all versions, completions, cache, and config (with a private backup).
|
||||||
|
- Write marked `# >>> netssh >>>` blocks from `netssh hook install` and the wizard, replacing unmarked entries of older versions instead of appending.
|
||||||
|
- Add jump host support: `ssh.proxy_jump` globally, per NetBox instance, and per rule, plus `--jump HOST` and `--no-jump`.
|
||||||
|
- Add `ssh.host_key_checking` (`strict`, `accept-new`, `off`) for netssh connections only.
|
||||||
|
- Add `ssh.target: name|hostname` so `~/.ssh/config` host patterns apply and known_hosts entries belong to the device, and `ssh.address_family`.
|
||||||
|
- Add `ssh.options` and `ssh.extra_args`, and `ssh.rules` that match by tag, instance, kind, or name pattern.
|
||||||
|
- Add `netssh --version` / `netssh version` and `netssh --dry-run` to print the final ssh command.
|
||||||
|
|
||||||
|
## v0.0.20
|
||||||
|
|
||||||
|
- Configure multiple named NetBox instances and search them together in parallel.
|
||||||
|
- Add, edit, and remove instances in the interactive setup wizard while retaining legacy single-instance configuration support.
|
||||||
|
- Display instance-qualified hostnames and support `instance/hostname` for SSH, completion, aliases, and notes.
|
||||||
|
- Keep credentials, IP resolution, comments, cache entries, and local notes tied to the correct instance.
|
||||||
|
- Preserve partial interactive search results with an outage warning; reject incomplete full refreshes and ambiguous direct connections.
|
||||||
|
- Add `cache refresh --instance NAME` and isolate caches when configured endpoints change.
|
||||||
|
- Add regression tests covering duplicate names and IDs across instances, outages, SSH routing, notes, aliases, and configuration round trips.
|
||||||
|
|
||||||
|
## v0.0.19
|
||||||
|
|
||||||
|
- Update Ultraviolet to commit `a45b7c8`.
|
||||||
|
|
||||||
## v0.0.18
|
## v0.0.18
|
||||||
|
|
||||||
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
|
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ netssh -p 2222 admin@app-server-03 uptime
|
|||||||
- **Shell completion** — install without sudo via `netssh completion install`
|
- **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 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
|
- **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
|
- **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
|
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
||||||
|
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
|
||||||
|
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
|
||||||
|
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -103,8 +106,19 @@ Re-run it at any time to change settings without editing the file manually:
|
|||||||
netssh configure
|
netssh configure
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard walks through NetBox connection, SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance.
|
||||||
then saves to `~/.config/netssh.yaml`.
|
A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**:
|
||||||
|
|
||||||
|
| Section | Settings |
|
||||||
|
|---|---|
|
||||||
|
| SSH defaults | `ssh.default_user`, `ssh.default_port` |
|
||||||
|
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line) |
|
||||||
|
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
|
||||||
|
| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file |
|
||||||
|
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
|
||||||
|
| Completion | `completion.max_results` |
|
||||||
|
|
||||||
|
The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing.
|
||||||
|
|
||||||
### Manual config
|
### Manual config
|
||||||
|
|
||||||
@@ -135,6 +149,11 @@ cache:
|
|||||||
ssh:
|
ssh:
|
||||||
default_user: admin # used when no user is specified on the command line
|
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
|
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:
|
shortcuts:
|
||||||
domains: # strip these suffixes to create short aliases
|
domains: # strip these suffixes to create short aliases
|
||||||
@@ -142,7 +161,44 @@ shortcuts:
|
|||||||
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
||||||
```
|
```
|
||||||
|
|
||||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
|
|
||||||
|
### Multiple NetBox instances
|
||||||
|
|
||||||
|
Use `netssh configure` to manage instances, or replace the `netbox:` section with a `netboxes:` list:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
netboxes:
|
||||||
|
- name: office
|
||||||
|
url: https://netbox.office.example
|
||||||
|
token: nbt_office-token
|
||||||
|
- name: lab
|
||||||
|
url: https://netbox.lab.example
|
||||||
|
token: nbt_lab-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Names must be unique and contain only letters, digits, underscores, or hyphens. Names are normalized to lowercase. Each instance has its own token and optional `token_version`; SSH, resolver, and shortcut settings apply to all instances. Configure either `netbox:` or `netboxes:`. Existing single-instance files continue to work; the wizard migrates them to a named `default` instance when saved.
|
||||||
|
|
||||||
|
The legacy `NETSSH_NETBOX_URL` and `NETSSH_NETBOX_TOKEN` variables apply to single-instance configuration. They are ignored when the file contains only a named `netboxes:` list.
|
||||||
|
|
||||||
|
Each NetBox keeps its own inventory of devices and virtual machines; either inventory may be empty. The interactive search queries all instances in parallel, including when one already has cached matches. Results show the instance as part of the hostname, for example `office/web01`. If an instance is unavailable, available results and cached hosts remain visible with a warning.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh # search all instances interactively
|
||||||
|
netssh office/web01 # choose an instance explicitly
|
||||||
|
netssh admin@lab/web01 uptime
|
||||||
|
netssh web01 # accepted only if the live lookup is unambiguous
|
||||||
|
netssh cache refresh # refresh all instances
|
||||||
|
netssh cache refresh --instance lab # refresh just one instance
|
||||||
|
netssh notes set office/web01 "Office maintenance"
|
||||||
|
netssh notes show lab/web01 --netbox --kind device
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct connections without an instance prefix, every instance must be reachable to verify uniqueness. A qualified target queries only its own instance and can use a fresh cached IP while other instances are offline. Qualified shortcuts such as `office/web01` also support configured domain suffixes and hyphen folding.
|
||||||
|
|
||||||
|
Cache entries and generated aliases retain the instance name (`office_web01` and `lab_web01`). Named-instance caches live in a configuration-specific subdirectory beneath the configured cache path, so changing an endpoint cannot reuse another server's cached IPs. Run `netssh cache refresh` after adding or changing instances. A failed full refresh leaves the cache and its refresh timestamp unchanged; a per-instance refresh preserves the other instances and does not mark the whole cache as refreshed.
|
||||||
|
|
||||||
|
Local notes remain keyed by the NetBox URL, host kind, and canonical hostname. Migrating a single instance or renaming it preserves its notes. Use `instance/hostname` and `--kind` for uncached local notes; these operations work offline. NetBox comments and interface/IP lookups always use the selected instance's credentials.
|
||||||
|
|
||||||
### Short command alias
|
### Short command alias
|
||||||
|
|
||||||
@@ -203,6 +259,74 @@ netssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no db-primary
|
|||||||
|
|
||||||
The process is replaced by `ssh` with the resolved IP — your `~/.ssh/config`, agent, and keys all work as normal.
|
The process is replaced by `ssh` with the resolved IP — your `~/.ssh/config`, agent, and keys all work as normal.
|
||||||
|
|
||||||
|
### Dry run and version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh --dry-run web01 # print the final ssh command instead of connecting
|
||||||
|
netssh --version # or: netssh version
|
||||||
|
```
|
||||||
|
|
||||||
|
Long options (`--dry-run`, `--jump`, `--no-jump`) are netssh's own; OpenSSH has
|
||||||
|
no long options. They must come before the hostname — everything after the
|
||||||
|
hostname is the remote command.
|
||||||
|
|
||||||
|
### SSH options
|
||||||
|
|
||||||
|
All settings below go under `ssh:`. The same keys (except `rules`) can be set
|
||||||
|
per NetBox instance under `netboxes[].ssh` (or `netbox.ssh`), and per rule.
|
||||||
|
More specific settings win: global → instance → matching rules (in order) →
|
||||||
|
command-line flags. Options you pass on the command line always take precedence
|
||||||
|
over the config.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ssh:
|
||||||
|
proxy_jump: "jumphost.example.com" # passed as -J; "none" disables an inherited jump host
|
||||||
|
host_key_checking: accept-new # strict | accept-new | off; unset = ssh's own setting
|
||||||
|
target: ip # ip (default) | name | hostname
|
||||||
|
address_family: inet # any | inet (-4) | inet6 (-6)
|
||||||
|
extra_args: ["-A"] # inserted before your own arguments
|
||||||
|
options: # passed as -o Key=Value
|
||||||
|
ServerAliveInterval: 60
|
||||||
|
ServerAliveCountMax: 5
|
||||||
|
rules: # every set match field must match
|
||||||
|
- match: {tag: dmz} # tag name or slug
|
||||||
|
proxy_jump: "dmz-jump.example.com"
|
||||||
|
- match: {instance: lab, name: "*.lab.example.com", kind: vm}
|
||||||
|
host_key_checking: off
|
||||||
|
|
||||||
|
netboxes:
|
||||||
|
- name: prod
|
||||||
|
url: "https://netbox.example.com"
|
||||||
|
token: "nbt_…"
|
||||||
|
ssh:
|
||||||
|
proxy_jump: "jumphost.example.com"
|
||||||
|
- name: staging
|
||||||
|
url: "https://netbox-staging.example.com"
|
||||||
|
token: "nbt_…"
|
||||||
|
ssh:
|
||||||
|
host_key_checking: off
|
||||||
|
```
|
||||||
|
|
||||||
|
**Jump hosts.** Override the configured jump host for a single call with
|
||||||
|
`--jump other.example.com` (or ssh's own `-J`), or disable all jump hosts —
|
||||||
|
including `ProxyJump` from `~/.ssh/config` — with `--no-jump`.
|
||||||
|
|
||||||
|
**Host key checking.** `strict` sets `StrictHostKeyChecking=yes`, `accept-new`
|
||||||
|
accepts keys of new hosts but rejects changed ones. `off` sets
|
||||||
|
`StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`, and `LogLevel=ERROR`
|
||||||
|
for netssh connections only and prints a short notice. Use it only for hosts
|
||||||
|
that are reinstalled often, such as staging; it disables protection against
|
||||||
|
man-in-the-middle attacks.
|
||||||
|
|
||||||
|
**Connection target.** By default netssh calls `ssh <ip>`. With `target: name`,
|
||||||
|
it calls `ssh -o HostName=<ip> -o HostKeyAlias=<name> <name>`: the resolved IP
|
||||||
|
is still used, but `Host *.example.com` rules in `~/.ssh/config` apply, and
|
||||||
|
known_hosts entries belong to the device instead of an IP that may be reused.
|
||||||
|
With `target: hostname`, ssh resolves the NetBox name itself (DNS or
|
||||||
|
`~/.ssh/config`). Names that are not valid hostnames fall back to the IP.
|
||||||
|
Switching to `name` stores new known_hosts entries under the device name, so
|
||||||
|
ssh asks once to confirm each host key.
|
||||||
|
|
||||||
### Default username
|
### Default username
|
||||||
|
|
||||||
Set `ssh.default_user` in the config to avoid typing a username every time:
|
Set `ssh.default_user` in the config to avoid typing a username every time:
|
||||||
@@ -242,13 +366,26 @@ shortcuts:
|
|||||||
- .example.com
|
- .example.com
|
||||||
- .example.de
|
- .example.de
|
||||||
strip_hyphens: false # optional
|
strip_hyphens: false # optional
|
||||||
|
rewrites: # optional, applied in order
|
||||||
|
- pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1
|
||||||
|
replace: '$1'
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
|
|
||||||
1. **Exact match** — the input is looked up as-is in the cache.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
@@ -256,6 +393,7 @@ Domains may be configured with or without a leading dot. If several cached hosts
|
|||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
||||||
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
| `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
|
### Shell aliases
|
||||||
|
|
||||||
@@ -385,7 +523,7 @@ netssh hook install --shell zsh
|
|||||||
netssh hook install --shell fish
|
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
|
```sh
|
||||||
netssh hook uninstall
|
netssh hook uninstall
|
||||||
@@ -428,14 +566,24 @@ netssh completion install --shell fish
|
|||||||
| zsh | `~/.zfunc/_netssh` |
|
| zsh | `~/.zfunc/_netssh` |
|
||||||
| fish | `~/.config/fish/completions/netssh.fish` |
|
| 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
|
```zsh
|
||||||
fpath=(~/.zfunc $fpath)
|
rm -f ~/.zcompdump* && exec zsh
|
||||||
autoload -Uz compinit && compinit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
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
|
## Shell Hook
|
||||||
|
|
||||||
@@ -465,15 +613,25 @@ Reload with: source /home/user/.bashrc
|
|||||||
|
|
||||||
What gets written to the profile:
|
What gets written to the profile:
|
||||||
|
|
||||||
**bash / zsh** (`~/.bashrc` or `~/.zshrc`):
|
**bash** (`~/.bashrc`):
|
||||||
```bash
|
```bash
|
||||||
netssh shell-init # netssh cache auto-refresh
|
# >>> netssh >>>
|
||||||
source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases
|
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** (`~/.config/fish/config.fish`):
|
||||||
```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.
|
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
|
||||||
@@ -484,7 +642,7 @@ Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically a
|
|||||||
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
|
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
|
||||||
| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` |
|
| 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:
|
Reload your profile after installation to activate immediately:
|
||||||
|
|
||||||
@@ -513,7 +671,7 @@ netssh hook uninstall # auto-detects $SHELL
|
|||||||
netssh hook uninstall --shell zsh
|
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
|
### Relation to the connect-time auto-refresh
|
||||||
|
|
||||||
@@ -526,6 +684,24 @@ The shell hook and the connect-time trigger (which fires on each `netssh` invoca
|
|||||||
|
|
||||||
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
|
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
|
## Development
|
||||||
|
|
||||||
Renovate monitors the release workflow's actions, the Go version in `go.mod`, and
|
Renovate monitors the release workflow's actions, the Go version in `go.mod`, and
|
||||||
@@ -545,9 +721,9 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
|
|||||||
|
|
||||||
## How it works
|
## 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.
|
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`, …).
|
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.
|
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.
|
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`).
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newNetBoxClient(cfg *config.Config) *netbox.Client {
|
||||||
|
if len(cfg.NetBoxes) > 0 {
|
||||||
|
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
|
||||||
|
for _, source := range cfg.NetBoxes {
|
||||||
|
sources = append(sources, netbox.Source{Name: source.Name, URL: source.URL, Token: source.Token, TokenVersion: source.TokenVersion})
|
||||||
|
}
|
||||||
|
return netbox.NewMultiClient(sources)
|
||||||
|
}
|
||||||
|
if cfg.NetBox.URL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) string {
|
||||||
|
return func(target string) string {
|
||||||
|
source, name := netbox.SplitTarget(target)
|
||||||
|
name = shortcuts.Normalize(name, cfg)
|
||||||
|
if qualified && source != "" {
|
||||||
|
return source + "/" + name
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareInstancesTest(t *testing.T, officeURL, labURL string) *config.Config {
|
||||||
|
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 := fmt.Sprintf("netboxes:\n - name: office\n url: %q\n token: office-token\n - name: lab\n url: %q\n token: lab-token\nresolver:\n strategies: [primary_ip]\n", officeURL, labURL)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiHost(name, ip string) connectionTestHost {
|
||||||
|
return connectionTestHost{ID: 1, Name: name, PrimaryIP4: &struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}{Address: ip + "/24"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshStoresSameHostnameFromBothInstances(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, ip := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2"} {
|
||||||
|
got, fresh := c.Get(name)
|
||||||
|
if !fresh || got.IP != ip {
|
||||||
|
t.Fatalf("cache source collision: %+v, fresh=%v", got, fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.All()) != 2 || c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("full refresh is incomplete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshFailureAndPerInstanceRefresh(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) }))
|
||||||
|
defer failed.Close()
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, failed.URL)
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
c.Upsert(cache.Entry{Name: "lab/old", Kind: "device", IP: "10.0.0.2"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err == nil {
|
||||||
|
t.Fatal("failed instance marked as full refresh")
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 1 || !c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("partial refresh changed cache or full-refresh stamp")
|
||||||
|
}
|
||||||
|
cmd = rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh", "--instance", "office"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 2 || !c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("per-instance refresh lost other source or updated full stamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalNotesCLIKeepsInstancesSeparate(t *testing.T) {
|
||||||
|
cfg := prepareInstancesTest(t, "https://office.example", "https://lab.example")
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
for _, name := range []string{"office/web", "lab/web"} {
|
||||||
|
c.Upsert(cache.Entry{Name: name, Kind: "device", IP: "10.0.0.1"})
|
||||||
|
}
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run := func(args ...string) (string, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs(append([]string{"notes"}, args...))
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
if _, err := run("set", source+"/web", source+" note"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
got, err := run("show", source+"/web")
|
||||||
|
if err != nil || got != source+" note" {
|
||||||
|
t.Fatalf("CLI notes crossed sources: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := run("set", "web", "wrong note"); err == nil {
|
||||||
|
t.Fatal("ambiguous note target accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiInstanceWrapperHelper(t *testing.T) {
|
||||||
|
if target := os.Getenv("NETSSH_TEST_MULTI_TARGET"); target != "" {
|
||||||
|
runSSHWrapper([]string{target})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapperRoutesQualifiedTargetsAndRejectsAmbiguity(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
c := cache.New(cfg.CachePath(), 3600)
|
||||||
|
if err := c.SetRefreshed(); 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)
|
||||||
|
}
|
||||||
|
for target, want := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2", "web": "ambiguous"} {
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
|
||||||
|
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET="+target, "PATH="+bin+":"+os.Getenv("PATH"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if want == "ambiguous" {
|
||||||
|
if err == nil || !strings.Contains(string(out), "2 exact matches") {
|
||||||
|
t.Fatalf("ambiguous connection accepted: %s, %v", out, err)
|
||||||
|
}
|
||||||
|
} else if err != nil || !strings.Contains(string(out), want+"\n") {
|
||||||
|
t.Fatalf("wrong SSH destination for %s: %s, %v", target, out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQualifiedShortcutDoesNotMatchOtherInstance(t *testing.T) {
|
||||||
|
cfg := config.ShortcutsConfig{Domains: []string{".example.com"}, StripHyphens: true}
|
||||||
|
normalize := normalizeTarget(cfg, true)
|
||||||
|
if normalize("office/web-01.example.com") != normalize("office/web01") {
|
||||||
|
t.Fatal("qualified shortcut does not normalize")
|
||||||
|
}
|
||||||
|
if normalize("office/web01") == normalize("lab/web01") {
|
||||||
|
t.Fatal("qualified shortcut lost instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("router", "10.1.0.1")}, []connectionTestHost{multiHost("app", "10.1.0.2")})
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("switch", "10.2.0.1")}, []connectionTestHost{multiHost("test-vm", "10.2.0.2")})
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]struct{ kind, ip string }{
|
||||||
|
"office/router": {"device", "10.1.0.1"}, "office/app": {"vm", "10.1.0.2"},
|
||||||
|
"lab/switch": {"device", "10.2.0.1"}, "lab/test-vm": {"vm", "10.2.0.2"},
|
||||||
|
}
|
||||||
|
if len(c.All()) != len(want) {
|
||||||
|
t.Fatalf("missing inventory entries: %+v", c.All())
|
||||||
|
}
|
||||||
|
for name, entry := range want {
|
||||||
|
got, fresh := c.Get(name)
|
||||||
|
if !fresh || got.Kind != entry.kind || got.IP != entry.ip {
|
||||||
|
t.Fatalf("wrong cached host %s: %+v", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
-73
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -23,7 +24,6 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
|
||||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -40,6 +40,8 @@ var managedSubcommands = map[string]bool{
|
|||||||
"completion": true,
|
"completion": true,
|
||||||
"__complete": true,
|
"__complete": true,
|
||||||
"help": true,
|
"help": true,
|
||||||
|
"version": true,
|
||||||
|
"uninstall": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -49,6 +51,10 @@ func main() {
|
|||||||
runTUI()
|
runTUI()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if args[0] == "--version" {
|
||||||
|
fmt.Printf("netssh %s\n", buildVersion())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if managedSubcommands[args[0]] {
|
if managedSubcommands[args[0]] {
|
||||||
if err := rootCmd().Execute(); err != nil {
|
if err := rootCmd().Execute(); err != nil {
|
||||||
@@ -66,7 +72,7 @@ func loadConfigOrSetup() *config.Config {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("config: %v", err)
|
fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
||||||
if err := setup.RunWizard(cfg); err != nil {
|
if err := setup.RunWizard(cfg); err != nil {
|
||||||
fatalf("setup: %v", err)
|
fatalf("setup: %v", err)
|
||||||
@@ -81,16 +87,21 @@ func loadConfigOrSetup() *config.Config {
|
|||||||
|
|
||||||
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
|
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
|
||||||
func runSSHWrapper(args []string) {
|
func runSSHWrapper(args []string) {
|
||||||
|
flags, args, err := internalssh.ExtractWrapperFlags(args)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
cfg := loadConfigOrSetup()
|
cfg := loadConfigOrSetup()
|
||||||
|
|
||||||
parsed := internalssh.Parse(args)
|
parsed := internalssh.Parse(args)
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
// No destination found — pass arguments straight through to ssh.
|
// No destination found — pass arguments straight through to ssh.
|
||||||
if err := internalssh.Exec(args); err != nil {
|
execSSH(internalssh.Result{Args: args}, flags)
|
||||||
fatalf("%v", err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
connect := func(name, kind, ip string, tags []string) {
|
||||||
|
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
|
||||||
|
}
|
||||||
|
|
||||||
// Inject the configured default user if none was given on the command line.
|
// Inject the configured default user if none was given on the command line.
|
||||||
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
||||||
@@ -103,24 +114,27 @@ func runSSHWrapper(args []string) {
|
|||||||
parsed.DestIdx += 2
|
parsed.DestIdx += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
|
source, _ := netbox.SplitTarget(parsed.Host)
|
||||||
|
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||||
|
|
||||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||||
c.MarkUsed(parsed.Host)
|
c.MarkUsed(parsed.Host)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
connect(entry.IP, parsed, args)
|
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
// 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.
|
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||||
lookupHost := parsed.Host
|
lookupHost := parsed.Host
|
||||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
shortcutsEnabled := cfg.Shortcuts.Enabled()
|
||||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" {
|
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||||
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
|
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||||
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
@@ -129,7 +143,7 @@ func runSSHWrapper(args []string) {
|
|||||||
if fresh {
|
if fresh {
|
||||||
c.MarkUsed(entry.Name)
|
c.MarkUsed(entry.Name)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
connect(entry.IP, parsed, args)
|
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||||
@@ -137,19 +151,35 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||||
}
|
}
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := newNetBoxClient(cfg)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
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))
|
||||||
|
if cacheErr != nil {
|
||||||
|
fatalf("%v", cacheErr)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("NetBox: %v", err)
|
fatalf("NetBox: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||||
|
c.MarkUsed(target.Target())
|
||||||
|
_ = c.Save()
|
||||||
|
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("resolver: %v", err)
|
fatalf("resolver: %v", err)
|
||||||
@@ -160,11 +190,11 @@ func runSSHWrapper(args []string) {
|
|||||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
|
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||||
c.MarkUsed(target.Name)
|
c.MarkUsed(target.Target())
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
|
|
||||||
connect(ip, parsed, args)
|
connect(target.Target(), target.Kind, ip, target.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||||
@@ -187,9 +217,24 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain
|
|||||||
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
// connectHost applies the SSH options configured for the host and execs ssh.
|
||||||
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip)
|
// target is the cache/selector name, which may carry an instance prefix.
|
||||||
if err := internalssh.Exec(newArgs); err != nil {
|
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
|
||||||
|
instance, name := netbox.SplitTarget(target)
|
||||||
|
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
|
||||||
|
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execSSH replaces the process with ssh, or prints the command for --dry-run.
|
||||||
|
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||||
|
if flags.DryRun {
|
||||||
|
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Notice != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
||||||
|
}
|
||||||
|
if err := internalssh.Exec(result.Args); err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,13 +243,13 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
|||||||
func runTUI() {
|
func runTUI() {
|
||||||
cfg := loadConfigOrSetup()
|
cfg := loadConfigOrSetup()
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
var nbClient *netbox.Client
|
var nbClient *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.HasNetBox() {
|
||||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient = newNetBoxClient(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||||
@@ -225,8 +270,10 @@ func runTUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
// 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 {
|
if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind {
|
||||||
host.IP = cached.IP
|
host.IP = cached.IP
|
||||||
|
tags = cached.Tags
|
||||||
} else {
|
} else {
|
||||||
if nbClient == nil {
|
if nbClient == nil {
|
||||||
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
|
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
|
||||||
@@ -245,7 +292,8 @@ func runTUI() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("IP resolution: %v", err)
|
fatalf("IP resolution: %v", err)
|
||||||
}
|
}
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
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)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
@@ -273,9 +321,7 @@ func runTUI() {
|
|||||||
c.MarkUsed(host.Name)
|
c.MarkUsed(host.Name)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
|
|
||||||
if err := internalssh.Exec(sshArgs); err != nil {
|
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
|
||||||
fatalf("%v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- cobra root + subcommands ---
|
// --- cobra root + subcommands ---
|
||||||
@@ -290,12 +336,16 @@ func rootCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveError
|
return nil, cobra.ShellCompDirectiveError
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
var completions []cobra.Completion
|
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||||
for _, e := range c.Search(toComplete) {
|
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||||
completions = append(completions, cobra.Completion(e.Name))
|
hosts = hosts[:limit]
|
||||||
|
}
|
||||||
|
completions := make([]cobra.Completion, 0, len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
completions = append(completions, cobra.Completion(host))
|
||||||
}
|
}
|
||||||
// Subcommands at the end, after all hostnames.
|
// Subcommands at the end, after all hostnames.
|
||||||
for _, sub := range cmd.Commands() {
|
for _, sub := range cmd.Commands() {
|
||||||
@@ -307,13 +357,24 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
|
||||||
|
|
||||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||||
root.InitDefaultCompletionCmd()
|
root.InitDefaultCompletionCmd()
|
||||||
for _, cmd := range root.Commands() {
|
for _, cmd := range root.Commands() {
|
||||||
if cmd.Name() == "completion" {
|
if cmd.Name() == "completion" {
|
||||||
cmd.AddCommand(completionInstallCmd(root))
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,12 +412,8 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
case "zsh":
|
case "zsh":
|
||||||
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
|
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
|
||||||
file = filepath.Join(dir, "_netssh")
|
file = filepath.Join(dir, "_netssh")
|
||||||
gen = func() ([]byte, error) {
|
gen = func() ([]byte, error) { return []byte(hook.ZshCompletionScript), nil }
|
||||||
var buf strings.Builder
|
note = hook.ZshCompletionNote(dir)
|
||||||
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"
|
|
||||||
case "fish":
|
case "fish":
|
||||||
dir = filepath.Join(hook.FishConfigDir(), "completions")
|
dir = filepath.Join(hook.FishConfigDir(), "completions")
|
||||||
file = filepath.Join(dir, "netssh.fish")
|
file = filepath.Join(dir, "netssh.fish")
|
||||||
@@ -403,9 +460,9 @@ func configureCmd() *cobra.Command {
|
|||||||
Use: "configure",
|
Use: "configure",
|
||||||
Short: "Interactively configure netssh",
|
Short: "Interactively configure netssh",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, _ := config.Load()
|
cfg, err := config.Load()
|
||||||
if cfg == nil {
|
if err != nil {
|
||||||
cfg = &config.Config{}
|
return err
|
||||||
}
|
}
|
||||||
return setup.RunWizard(cfg)
|
return setup.RunWizard(cfg)
|
||||||
},
|
},
|
||||||
@@ -422,7 +479,7 @@ func searchCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -456,7 +513,7 @@ func cacheListCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -482,7 +539,7 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
c.Clear()
|
c.Clear()
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -494,7 +551,7 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cacheRefreshCmd() *cobra.Command {
|
func cacheRefreshCmd() *cobra.Command {
|
||||||
var filterTag, filterKind string
|
var filterTag, filterKind, filterInstance string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "refresh",
|
Use: "refresh",
|
||||||
Short: "Re-fetch all known hosts from NetBox",
|
Short: "Re-fetch all known hosts from NetBox",
|
||||||
@@ -503,21 +560,28 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
return fmt.Errorf("netbox.url is not configured")
|
return fmt.Errorf("netbox.url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := newNetBoxClient(cfg)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
||||||
entries, err := nbClient.SearchAll(ctx, "", opts)
|
query := ""
|
||||||
|
if filterInstance != "" {
|
||||||
|
if !nbClient.MultipleSources() {
|
||||||
|
return fmt.Errorf("--instance requires named netboxes configuration")
|
||||||
|
}
|
||||||
|
query = filterInstance + "/"
|
||||||
|
}
|
||||||
|
entries, err := nbClient.SearchAll(ctx, query, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("NetBox: %w", err)
|
return fmt.Errorf("NetBox: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
@@ -527,12 +591,12 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
written := 0
|
written := 0
|
||||||
nameCounts := make(map[string]int)
|
nameCounts := make(map[string]int)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
nameCounts[strings.ToLower(e.Name)]++
|
nameCounts[strings.ToLower(e.Target())]++
|
||||||
}
|
}
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
e := &entries[i]
|
e := &entries[i]
|
||||||
if nameCounts[strings.ToLower(e.Name)] > 1 {
|
if nameCounts[strings.ToLower(e.Target())] > 1 {
|
||||||
c.Delete(e.Name)
|
c.Delete(e.Target())
|
||||||
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
|
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -544,18 +608,18 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
c.Delete(e.Name)
|
c.Delete(e.Target())
|
||||||
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||||
written++
|
written++
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if filterTag == "" && filterKind == "" {
|
if filterTag == "" && filterKind == "" && filterInstance == "" {
|
||||||
if err := c.SetRefreshed(); err != nil {
|
if err := c.SetRefreshed(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -571,6 +635,7 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
||||||
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
||||||
|
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func aliasCmd() *cobra.Command {
|
func aliasCmd() *cobra.Command {
|
||||||
var shell string
|
var shell, test string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "alias",
|
Use: "alias",
|
||||||
Short: "Print shell aliases for all cached hosts",
|
Short: "Print shell aliases for all cached hosts",
|
||||||
Long: `Print shell alias definitions 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
|
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:
|
Source the output in your shell profile:
|
||||||
bash/zsh: eval "$(netssh alias)"
|
bash/zsh: eval "$(netssh alias)"
|
||||||
@@ -599,10 +666,14 @@ Or use in a script:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if test != "" {
|
||||||
|
printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = filepath.Base(os.Getenv("SHELL"))
|
shell = filepath.Base(os.Getenv("SHELL"))
|
||||||
}
|
}
|
||||||
@@ -629,9 +700,36 @@ Or use in a script:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
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
|
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
|
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||||
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||||
func shellInitCmd() *cobra.Command {
|
func shellInitCmd() *cobra.Command {
|
||||||
@@ -644,7 +742,7 @@ func shellInitCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
// Regenerate alias files from the current cache every shell start
|
// Regenerate alias files from the current cache every shell start
|
||||||
@@ -671,9 +769,11 @@ func hookInstallCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "install",
|
Use: "install",
|
||||||
Short: "Add netssh shell-init to your shell profile",
|
Short: "Add netssh shell-init to your shell profile",
|
||||||
Long: `Appends a single line to your shell profile that runs 'netssh shell-init'
|
Long: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell
|
||||||
on every new shell session. shell-init checks whether the cache is older
|
profile that runs 'netssh shell-init' on every new shell session and, for zsh,
|
||||||
than 24 hours and, if so, starts a background refresh — no delay to your prompt.
|
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.`,
|
After installation, reload your profile or open a new shell.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -685,17 +785,12 @@ After installation, reload your profile or open a new shell.`,
|
|||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if !installed {
|
||||||
fmt.Printf("Hook already installed in %s\n", profile)
|
fmt.Printf("Hook already installed in %s\n", profile)
|
||||||
@@ -705,8 +800,8 @@ After installation, reload your profile or open a new shell.`,
|
|||||||
|
|
||||||
// Generate the aliases file immediately so it is ready on the next shell start.
|
// Generate the aliases file immediately so it is ready on the next shell start.
|
||||||
cfg, cfgErr := config.Load()
|
cfg, cfgErr := config.Load()
|
||||||
if cfgErr == nil && cfg.NetBox.URL != "" {
|
if cfgErr == nil && cfg.HasNetBox() {
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if _ = c.Load(); true {
|
if _ = c.Load(); true {
|
||||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||||
@@ -758,7 +853,7 @@ func hookUninstallCmd() *cobra.Command {
|
|||||||
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
||||||
// has not been fully refreshed within the last 24 hours.
|
// has not been fully refreshed within the last 24 hours.
|
||||||
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !c.NeedsRefresh(24 * time.Hour) {
|
if !c.NeedsRefresh(24 * time.Hour) {
|
||||||
|
|||||||
+17
-7
@@ -34,11 +34,11 @@ func notesCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var client *netbox.Client
|
var client *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.HasNetBox() {
|
||||||
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
client = newNetBoxClient(cfg)
|
||||||
}
|
}
|
||||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -46,13 +46,23 @@ func notesCmd() *cobra.Command {
|
|||||||
if !remote {
|
if !remote {
|
||||||
cached = c.All()
|
cached = c.All()
|
||||||
}
|
}
|
||||||
|
query := host.Name
|
||||||
|
source, rawName := netbox.SplitTarget(query)
|
||||||
|
var matches []cache.Entry
|
||||||
for _, entry := range cached {
|
for _, entry := range cached {
|
||||||
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
|
_, entryName := netbox.SplitTarget(entry.Name)
|
||||||
host.Name = entry.Name
|
if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
|
||||||
host.Kind = entry.Kind
|
matches = append(matches, entry)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return fmt.Errorf("ambiguous hostname %q; use instance/hostname", query)
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
host.Name = matches[0].Name
|
||||||
|
host.Kind = matches[0].Kind
|
||||||
|
}
|
||||||
|
|
||||||
if !remote && host.Kind == "" {
|
if !remote && host.Kind == "" {
|
||||||
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -168,6 +168,11 @@ plan_profile() {
|
|||||||
printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")"
|
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")"
|
printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")"
|
||||||
fi
|
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'
|
printf '# <<< netssh <<<\n'
|
||||||
} >> "$data"
|
} >> "$data"
|
||||||
fi
|
fi
|
||||||
@@ -231,10 +236,26 @@ for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do
|
|||||||
mv -f "$staged" "$target"
|
mv -f "$staged" "$target"
|
||||||
info "updated : ${PROFILE_PATHS[$index]}"
|
info "updated : ${PROFILE_PATHS[$index]}"
|
||||||
done
|
done
|
||||||
|
COMPLETIONS=()
|
||||||
for kind in "${SHELLS[@]}"; do
|
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"
|
info "warning: could not update $kind completion"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY"
|
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."
|
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)
|
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)
|
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'"} {
|
for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} {
|
||||||
if !strings.Contains(string(data), kept) {
|
if !strings.Contains(string(data), kept) {
|
||||||
t.Fatalf("unrelated line lost: %s", kept)
|
t.Fatalf("unrelated line lost: %s", kept)
|
||||||
|
|||||||
Vendored
+2
-1
@@ -258,7 +258,8 @@ func (c *Cache) Search(prefix string) []Entry {
|
|||||||
prefix = strings.ToLower(prefix)
|
prefix = strings.ToLower(prefix)
|
||||||
var out []Entry
|
var out []Entry
|
||||||
for name, e := range c.entries {
|
for name, e := range c.entries {
|
||||||
if strings.HasPrefix(strings.ToLower(name), prefix) {
|
_, hostname, qualified := strings.Cut(strings.ToLower(name), "/")
|
||||||
|
if strings.HasPrefix(strings.ToLower(name), prefix) || (qualified && !strings.Contains(prefix, "/") && strings.HasPrefix(hostname, prefix)) {
|
||||||
out = append(out, e)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+12
@@ -483,3 +483,15 @@ func TestUpsertPreservesUsageHistory(t *testing.T) {
|
|||||||
t.Fatalf("history lost: %+v", after)
|
t.Fatalf("history lost: %+v", after)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
|
||||||
|
c := New("", 3600)
|
||||||
|
c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"})
|
||||||
|
c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"})
|
||||||
|
c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"})
|
||||||
|
for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} {
|
||||||
|
if got := len(c.Search(query)); got != want {
|
||||||
|
t.Errorf("Search(%q)=%d, want %d", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Shell ShellConfig `mapstructure:"shell"`
|
Shell ShellConfig `mapstructure:"shell"`
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
|
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
SSH SSHConfig `mapstructure:"ssh"`
|
SSH SSHConfig `mapstructure:"ssh"`
|
||||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||||
|
Completion CompletionConfig `mapstructure:"completion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShellConfig struct {
|
type ShellConfig struct {
|
||||||
@@ -24,9 +26,11 @@ type ShellConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
TokenVersion int `mapstructure:"token_version"`
|
TokenVersion int `mapstructure:"token_version"`
|
||||||
|
SSH SSHOptions `mapstructure:"ssh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResolverConfig struct {
|
type ResolverConfig struct {
|
||||||
@@ -43,11 +47,44 @@ type CacheConfig struct {
|
|||||||
type SSHConfig struct {
|
type SSHConfig struct {
|
||||||
DefaultUser string `mapstructure:"default_user"`
|
DefaultUser string `mapstructure:"default_user"`
|
||||||
DefaultPort int `mapstructure:"default_port"`
|
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 {
|
type ShortcutsConfig struct {
|
||||||
Domains []string `mapstructure:"domains"`
|
Domains []string `mapstructure:"domains"`
|
||||||
StripHyphens bool `mapstructure:"strip_hyphens"`
|
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.
|
// Path returns the canonical config file path.
|
||||||
@@ -77,7 +114,7 @@ func Load() (*Config, error) {
|
|||||||
v.SetEnvPrefix("NETSSH")
|
v.SetEnvPrefix("NETSSH")
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
v.AutomaticEnv()
|
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 {
|
if err := v.BindEnv(key); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -93,6 +130,11 @@ func Load() (*Config, error) {
|
|||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
|
// Legacy environment variables must not invent an additional unnamed source
|
||||||
|
// when a file explicitly configures named instances (including wizard migration).
|
||||||
|
if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") {
|
||||||
|
cfg.NetBox = NetBoxConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -106,6 +148,26 @@ 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)
|
||||||
|
if source.TokenVersion == 0 && source.Token != "" {
|
||||||
|
source.TokenVersion = 1
|
||||||
|
if strings.HasPrefix(source.Token, "nbt_") {
|
||||||
|
source.TokenVersion = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Cache.Path == "" {
|
if cfg.Cache.Path == "" {
|
||||||
cacheDir, err := os.UserCacheDir()
|
cacheDir, err := os.UserCacheDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var instanceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||||
|
|
||||||
|
// ValidateNetBoxes keeps source names usable as instance/hostname selectors.
|
||||||
|
func (c *Config) ValidateNetBoxes() error {
|
||||||
|
if len(c.NetBoxes) > 0 && c.NetBox.URL != "" {
|
||||||
|
return fmt.Errorf("configure either netbox or netboxes, not both")
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, source := range c.NetBoxes {
|
||||||
|
if !instanceName.MatchString(source.Name) {
|
||||||
|
return fmt.Errorf("netboxes.name %q must contain only letters, digits, underscores, or hyphens", source.Name)
|
||||||
|
}
|
||||||
|
name := strings.ToLower(source.Name)
|
||||||
|
if seen[name] {
|
||||||
|
return fmt.Errorf("duplicate NetBox instance name %q", source.Name)
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
u, err := url.Parse(source.URL)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
|
||||||
|
return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name)
|
||||||
|
}
|
||||||
|
if source.TokenVersion < 0 || source.TokenVersion > 2 {
|
||||||
|
return fmt.Errorf("NetBox %q: token_version must be 0, 1, or 2", source.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
|
||||||
|
|
||||||
|
// CachePath isolates named sources from legacy caches and from changed endpoints.
|
||||||
|
// Rotating a token or reordering the sources does not discard usage history.
|
||||||
|
func (c *Config) CachePath() string {
|
||||||
|
if len(c.NetBoxes) == 0 {
|
||||||
|
return c.Cache.Path
|
||||||
|
}
|
||||||
|
var identities []string
|
||||||
|
for _, source := range c.NetBoxes {
|
||||||
|
identities = append(identities, strings.ToLower(source.Name)+"\x00"+strings.TrimRight(source.URL, "/"))
|
||||||
|
}
|
||||||
|
sort.Strings(identities)
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(identities, "\x00")))
|
||||||
|
return filepath.Join(filepath.Dir(c.Cache.Path), fmt.Sprintf("instances-%x", sum[:8]), filepath.Base(c.Cache.Path))
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadNamedNetBoxes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "https://legacy-env.example")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "legacy-env-token")
|
||||||
|
data := `netboxes:
|
||||||
|
- name: Office
|
||||||
|
url: https://office.example/netbox
|
||||||
|
token: nbt_office
|
||||||
|
- name: lab
|
||||||
|
url: https://lab.example
|
||||||
|
token: legacy-token
|
||||||
|
cache:
|
||||||
|
path: /tmp/netssh-tests/hosts.json
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !cfg.HasNetBox() || len(cfg.NetBoxes) != 2 || cfg.NetBoxes[0].Name != "office" || cfg.NetBoxes[0].TokenVersion != 2 || cfg.NetBoxes[1].TokenVersion != 1 {
|
||||||
|
t.Fatalf("bad instances: %+v", cfg.NetBoxes)
|
||||||
|
}
|
||||||
|
if cfg.NetBox.URL != "" {
|
||||||
|
t.Fatal("legacy environment variables added an unnamed instance")
|
||||||
|
}
|
||||||
|
first := cfg.CachePath()
|
||||||
|
if first == cfg.Cache.Path {
|
||||||
|
t.Fatal("named sources reuse legacy cache")
|
||||||
|
}
|
||||||
|
cfg.NetBoxes[0], cfg.NetBoxes[1] = cfg.NetBoxes[1], cfg.NetBoxes[0]
|
||||||
|
cfg.NetBoxes[0].Token = "rotated"
|
||||||
|
if cfg.CachePath() != first {
|
||||||
|
t.Fatal("reordering/rotating credentials discarded cache")
|
||||||
|
}
|
||||||
|
cfg.NetBoxes[0].URL = "https://other.example"
|
||||||
|
if cfg.CachePath() == first {
|
||||||
|
t.Fatal("changed endpoint reuses old hosts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNetBoxes(t *testing.T) {
|
||||||
|
good := NetBoxConfig{Name: "office", URL: "https://office.example"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cfg Config
|
||||||
|
}{
|
||||||
|
{"duplicate", Config{NetBoxes: []NetBoxConfig{good, {Name: "OFFICE", URL: "https://other.example"}}}},
|
||||||
|
{"empty name", Config{NetBoxes: []NetBoxConfig{{URL: "https://office.example"}}}},
|
||||||
|
{"invalid name", Config{NetBoxes: []NetBoxConfig{{Name: "a/b", URL: "https://office.example"}}}},
|
||||||
|
{"missing URL", Config{NetBoxes: []NetBoxConfig{{Name: "office"}}}},
|
||||||
|
{"invalid URL", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: "file:///tmp/netbox"}}}},
|
||||||
|
{"conflicting config", Config{NetBox: good, NetBoxes: []NetBoxConfig{good}}},
|
||||||
|
{"invalid token version", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: good.URL, TokenVersion: 3}}}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if err := tc.cfg.ValidateNetBoxes(); err == nil {
|
||||||
|
t.Fatal("invalid configuration accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
legacy := Config{NetBox: good, Cache: CacheConfig{Path: "hosts.json"}}
|
||||||
|
if err := legacy.ValidateNetBoxes(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !legacy.HasNetBox() || legacy.CachePath() != "hosts.json" {
|
||||||
|
t.Fatal("legacy behavior changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHOptions are connection settings that can be set globally, per NetBox
|
||||||
|
// instance, and per rule. More specific layers override less specific ones.
|
||||||
|
type SSHOptions struct {
|
||||||
|
// ProxyJump is passed as -J; "none" disables a jump host set by a broader layer.
|
||||||
|
ProxyJump string `mapstructure:"proxy_jump"`
|
||||||
|
// HostKeyChecking is strict, accept-new, or off. Empty keeps ssh's own setting.
|
||||||
|
HostKeyChecking string `mapstructure:"host_key_checking"`
|
||||||
|
// Target selects the ssh destination: ip (default), name, or hostname.
|
||||||
|
Target string `mapstructure:"target"`
|
||||||
|
// AddressFamily is any, inet, or inet6.
|
||||||
|
AddressFamily string `mapstructure:"address_family"`
|
||||||
|
// ExtraArgs are inserted verbatim before the destination.
|
||||||
|
ExtraArgs []string `mapstructure:"extra_args"`
|
||||||
|
// Options are passed as -o Key=Value.
|
||||||
|
Options map[string]string `mapstructure:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHRule applies SSH options to hosts that match every non-empty field.
|
||||||
|
type SSHRule struct {
|
||||||
|
Match SSHMatch `mapstructure:"match"`
|
||||||
|
SSHOptions `mapstructure:",squash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHMatch selects hosts by NetBox attributes available in the cache.
|
||||||
|
type SSHMatch struct {
|
||||||
|
Tag string `mapstructure:"tag"`
|
||||||
|
Instance string `mapstructure:"instance"`
|
||||||
|
Kind string `mapstructure:"kind"`
|
||||||
|
Name string `mapstructure:"name"` // shell-style glob, e.g. "*.dmz.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHHost identifies the resolved host that SSH options are computed for.
|
||||||
|
type SSHHost struct {
|
||||||
|
Instance string
|
||||||
|
Name string // NetBox name without the instance prefix
|
||||||
|
Kind string
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
|
||||||
|
|
||||||
|
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
|
||||||
|
func (c *Config) ValidateSSH() error {
|
||||||
|
if err := c.SSH.SSHOptions.Validate("ssh"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, rule := range c.SSH.Rules {
|
||||||
|
if err := rule.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.NetBox.SSH.Validate("netbox.ssh"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, source := range c.NetBoxes {
|
||||||
|
if err := source.SSH.Validate("netboxes." + source.Name + ".ssh"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the match and options of a single rule.
|
||||||
|
func (r SSHRule) Validate(scope string) error {
|
||||||
|
if err := r.SSHOptions.Validate(scope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.Match.Validate(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate requires at least one selector and a well-formed name pattern.
|
||||||
|
func (m SSHMatch) Validate(scope string) error {
|
||||||
|
if m == (SSHMatch{}) {
|
||||||
|
return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope)
|
||||||
|
}
|
||||||
|
if _, err := path.Match(m.Name, ""); err != nil {
|
||||||
|
return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rejects values that ssh would not accept or would misinterpret.
|
||||||
|
func (o SSHOptions) Validate(scope string) error {
|
||||||
|
switch o.HostKeyChecking {
|
||||||
|
case "", "strict", "accept-new", "off":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s.host_key_checking must be strict, accept-new, or off", scope)
|
||||||
|
}
|
||||||
|
switch o.Target {
|
||||||
|
case "", "ip", "name", "hostname":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s.target must be ip, name, or hostname", scope)
|
||||||
|
}
|
||||||
|
switch o.AddressFamily {
|
||||||
|
case "", "any", "inet", "inet6":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s.address_family must be any, inet, or inet6", scope)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(o.ProxyJump, "-") || strings.ContainsAny(o.ProxyJump, " \t\n") {
|
||||||
|
return fmt.Errorf("%s.proxy_jump %q is not a valid jump host", scope, o.ProxyJump)
|
||||||
|
}
|
||||||
|
for key, value := range o.Options {
|
||||||
|
if !sshOptionKey.MatchString(key) || strings.ContainsAny(value, "\n\r") {
|
||||||
|
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHOptionsFor merges global, instance, and matching rule options for host.
|
||||||
|
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
|
||||||
|
merged := c.SSH.SSHOptions.merge(SSHOptions{})
|
||||||
|
if host.Instance == "" {
|
||||||
|
merged = merged.merge(c.NetBox.SSH)
|
||||||
|
}
|
||||||
|
for _, source := range c.NetBoxes {
|
||||||
|
if strings.EqualFold(source.Name, host.Instance) {
|
||||||
|
merged = merged.merge(source.SSH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rule := range c.SSH.Rules {
|
||||||
|
if rule.Match.matches(host) {
|
||||||
|
merged = merged.merge(rule.SSHOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge returns o overridden by the non-empty fields of override.
|
||||||
|
// Extra arguments accumulate; options override per key.
|
||||||
|
func (o SSHOptions) merge(override SSHOptions) SSHOptions {
|
||||||
|
out := o
|
||||||
|
out.ExtraArgs = append(append([]string(nil), o.ExtraArgs...), override.ExtraArgs...)
|
||||||
|
out.Options = make(map[string]string, len(o.Options)+len(override.Options))
|
||||||
|
for key, value := range o.Options {
|
||||||
|
out.Options[strings.ToLower(key)] = value
|
||||||
|
}
|
||||||
|
for key, value := range override.Options {
|
||||||
|
out.Options[strings.ToLower(key)] = value
|
||||||
|
}
|
||||||
|
if override.ProxyJump != "" {
|
||||||
|
out.ProxyJump = override.ProxyJump
|
||||||
|
}
|
||||||
|
if override.HostKeyChecking != "" {
|
||||||
|
out.HostKeyChecking = override.HostKeyChecking
|
||||||
|
}
|
||||||
|
if override.Target != "" {
|
||||||
|
out.Target = override.Target
|
||||||
|
}
|
||||||
|
if override.AddressFamily != "" {
|
||||||
|
out.AddressFamily = override.AddressFamily
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m SSHMatch) matches(host SSHHost) bool {
|
||||||
|
if m.Instance != "" && !strings.EqualFold(m.Instance, host.Instance) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.Kind != "" && !strings.EqualFold(m.Kind, host.Kind) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.Name != "" {
|
||||||
|
if ok, _ := path.Match(strings.ToLower(m.Name), strings.ToLower(host.Name)); !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Tag != "" {
|
||||||
|
found := false
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
if strings.EqualFold(tag, m.Tag) || strings.EqualFold(tagSlug(tag), m.Tag) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagSlug approximates NetBox's slug so rules may use either the tag name or slug.
|
||||||
|
func tagSlug(name string) string {
|
||||||
|
return strings.Join(strings.Fields(strings.ToLower(name)), "-")
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadYAML(t *testing.T, data string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHOptionsLayering(t *testing.T) {
|
||||||
|
cfg, err := loadYAML(t, `
|
||||||
|
netboxes:
|
||||||
|
- name: prod
|
||||||
|
url: https://prod.example.com
|
||||||
|
ssh:
|
||||||
|
proxy_jump: jh-prod.example.com
|
||||||
|
host_key_checking: strict
|
||||||
|
- name: staging
|
||||||
|
url: https://staging.example.com
|
||||||
|
ssh:
|
||||||
|
host_key_checking: off
|
||||||
|
ssh:
|
||||||
|
proxy_jump: jh.example.com
|
||||||
|
extra_args: ["-4"]
|
||||||
|
options:
|
||||||
|
ServerAliveInterval: 60
|
||||||
|
ServerAliveCountMax: 5
|
||||||
|
rules:
|
||||||
|
- match: {tag: dmz}
|
||||||
|
proxy_jump: jh-dmz.example.com
|
||||||
|
options: {ServerAliveInterval: 10}
|
||||||
|
- match: {instance: prod, name: "db*"}
|
||||||
|
proxy_jump: none
|
||||||
|
completion:
|
||||||
|
max_results: 200
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Completion.MaxResults != 200 {
|
||||||
|
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
host SSHHost
|
||||||
|
jump, keys, alive string
|
||||||
|
}{
|
||||||
|
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
|
||||||
|
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
|
||||||
|
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
|
||||||
|
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
|
||||||
|
} {
|
||||||
|
got := cfg.SSHOptionsFor(tc.host)
|
||||||
|
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
|
||||||
|
t.Errorf("%+v: got %+v", tc.host, got)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
|
||||||
|
t.Errorf("extra args: %v", got.ExtraArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHOptionsValidation(t *testing.T) {
|
||||||
|
for _, data := range []string{
|
||||||
|
"ssh:\n host_key_checking: maybe\n",
|
||||||
|
"ssh:\n target: dns\n",
|
||||||
|
"ssh:\n address_family: ipx\n",
|
||||||
|
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
|
||||||
|
"ssh:\n options:\n \"bad key\": 1\n",
|
||||||
|
"ssh:\n rules:\n - proxy_jump: jh\n",
|
||||||
|
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
|
||||||
|
} {
|
||||||
|
if _, err := loadYAML(t, data); err == nil {
|
||||||
|
t.Errorf("accepted invalid config:\n%s", data)
|
||||||
|
} else if !strings.Contains(err.Error(), "ssh") {
|
||||||
|
t.Errorf("unclear error %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -227,3 +227,29 @@ func TestCommandAliasGenerationAndCollision(t *testing.T) {
|
|||||||
t.Fatal(got)
|
t.Fatal(got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAliasesIncludeInstanceName(t *testing.T) {
|
||||||
|
cfg := config.Config{Shortcuts: config.ShortcutsConfig{Domains: []string{".example.com"}}}
|
||||||
|
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "office/web01.example.com"}, {Name: "lab/web01.example.com"}}, cfg)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("missing qualified aliases: %+v", entries)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
got[entry.Name] = entry.Host
|
||||||
|
}
|
||||||
|
if got["office_web01"] != "office/web01.example.com" || got["lab_web01"] != "lab/web01.example.com" {
|
||||||
|
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":
|
case "bash":
|
||||||
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
||||||
case "zsh":
|
case "zsh":
|
||||||
return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias)
|
return filepath.Join(ZshFuncDir(), "_"+alias)
|
||||||
default:
|
default:
|
||||||
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
||||||
}
|
}
|
||||||
@@ -114,3 +114,46 @@ complete --command %s --wraps netssh
|
|||||||
`, completionMarker, alias)
|
`, 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":
|
case "bash":
|
||||||
err = root.GenBashCompletionV2(&canonical, true)
|
err = root.GenBashCompletionV2(&canonical, true)
|
||||||
case "zsh":
|
case "zsh":
|
||||||
err = root.GenZshCompletion(&canonical)
|
_, err = canonical.WriteString(hook.ZshCompletionScript)
|
||||||
case "fish":
|
case "fish":
|
||||||
err = root.GenFishCompletion(&canonical, true)
|
err = root.GenFishCompletion(&canonical, true)
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,8 @@ compinit -u -D
|
|||||||
alias j=netssh
|
alias j=netssh
|
||||||
autoload -Uz _j
|
autoload -Uz _j
|
||||||
# Capture matches without requiring an interactive ZLE completion widget.
|
# Capture matches without requiring an interactive ZLE completion widget.
|
||||||
_describe() { print -rl -- "${completions[@]}"; }
|
_describe() { print -rl -- "${(P)4}"; }
|
||||||
|
compadd() { print -rl -- "$@"; }
|
||||||
words=(j ser)
|
words=(j ser)
|
||||||
CURRENT=2
|
CURRENT=2
|
||||||
_j
|
_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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install appends the hook line to the profile if it is not already present.
|
// Install writes the managed netssh block into the profile. Entries written by
|
||||||
// Returns (true, nil) when newly installed, (false, nil) when already present.
|
// 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.
|
// Creates the profile file (and any parent directories) if they do not exist.
|
||||||
func Install(profilePath string) (installed bool, err error) {
|
func Install(profilePath, shell string) (installed bool, err error) {
|
||||||
if IsInstalled(profilePath) {
|
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
|
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 {
|
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||||
return false, fmt.Errorf("creating directory: %w", err)
|
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 := writeProfile(profilePath, cleaned); err != nil {
|
||||||
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 {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return true, nil
|
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.
|
// 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).
|
// 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.
|
// 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 hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`)
|
||||||
var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\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.
|
// Uninstall removes legacy hook lines and complete installer-managed blocks.
|
||||||
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
|
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
|
||||||
func Uninstall(profilePath string) (removed bool, err error) {
|
func Uninstall(profilePath string) (removed bool, err error) {
|
||||||
@@ -120,21 +186,33 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("reading profile: %w", err)
|
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
|
var kept []string
|
||||||
inside := false
|
inside := false
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(data, "\n") {
|
||||||
value := strings.TrimSpace(line)
|
value := strings.TrimSpace(line)
|
||||||
switch value {
|
switch value {
|
||||||
case BlockStart:
|
case BlockStart:
|
||||||
if inside {
|
if inside {
|
||||||
return false, fmt.Errorf("nested netssh block in %s", profilePath)
|
return "", false, fmt.Errorf("nested netssh block in %s", profilePath)
|
||||||
}
|
}
|
||||||
inside = true
|
inside = true
|
||||||
removed = true
|
removed = true
|
||||||
continue
|
continue
|
||||||
case BlockEnd:
|
case BlockEnd:
|
||||||
if !inside {
|
if !inside {
|
||||||
return false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
return "", false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
||||||
}
|
}
|
||||||
inside = false
|
inside = false
|
||||||
continue
|
continue
|
||||||
@@ -142,29 +220,61 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
|||||||
if inside {
|
if inside {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) {
|
if isLegacyLine(value) {
|
||||||
removed = true
|
removed = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, line)
|
kept = append(kept, line)
|
||||||
}
|
}
|
||||||
if inside {
|
if inside {
|
||||||
return false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
return "", false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
||||||
}
|
}
|
||||||
if !removed {
|
if !removed {
|
||||||
return false, nil
|
return data, false, nil
|
||||||
}
|
}
|
||||||
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
cleaned = strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||||
target, err := filepath.EvalSymlinks(profilePath)
|
// Drop the blank separator line Install puts in front of the block.
|
||||||
if err != nil {
|
if strings.HasSuffix(cleaned, "\n\n") {
|
||||||
return false, err
|
cleaned = strings.TrimRight(cleaned, "\n") + "\n"
|
||||||
}
|
}
|
||||||
info, err := os.Stat(target)
|
return cleaned, true, nil
|
||||||
if err != nil {
|
}
|
||||||
return false, err
|
|
||||||
|
// 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 {
|
mode := os.FileMode(0o644)
|
||||||
return false, fmt.Errorf("writing profile: %w", err)
|
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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -87,7 +88,7 @@ func TestIsInstalled_MissingFile(t *testing.T) {
|
|||||||
func TestInstall_Fresh(t *testing.T) {
|
func TestInstall_Fresh(t *testing.T) {
|
||||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||||
|
|
||||||
installed, err := hook.Install(profile)
|
installed, err := hook.Install(profile, "bash")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Install: %v", err)
|
t.Fatalf("Install: %v", err)
|
||||||
}
|
}
|
||||||
@@ -102,8 +103,8 @@ func TestInstall_Fresh(t *testing.T) {
|
|||||||
func TestInstall_Idempotent(t *testing.T) {
|
func TestInstall_Idempotent(t *testing.T) {
|
||||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||||
|
|
||||||
hook.Install(profile)
|
hook.Install(profile, "bash")
|
||||||
installed, err := hook.Install(profile)
|
installed, err := hook.Install(profile, "bash")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second Install: %v", err)
|
t.Fatalf("second Install: %v", err)
|
||||||
}
|
}
|
||||||
@@ -120,7 +121,7 @@ func TestInstall_Idempotent(t *testing.T) {
|
|||||||
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
||||||
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
|
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)
|
t.Fatalf("Install should create missing directories: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(profile); err != nil {
|
if _, err := os.Stat(profile); err != nil {
|
||||||
@@ -131,7 +132,7 @@ func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
|||||||
func TestInstall_PreservesExistingContent(t *testing.T) {
|
func TestInstall_PreservesExistingContent(t *testing.T) {
|
||||||
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
|
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
|
||||||
|
|
||||||
hook.Install(profile)
|
hook.Install(profile, "bash")
|
||||||
|
|
||||||
data, _ := os.ReadFile(profile)
|
data, _ := os.ReadFile(profile)
|
||||||
content := string(data)
|
content := string(data)
|
||||||
@@ -145,13 +146,13 @@ func TestInstall_PreservesExistingContent(t *testing.T) {
|
|||||||
|
|
||||||
func TestInstall_AppendedAtEnd(t *testing.T) {
|
func TestInstall_AppendedAtEnd(t *testing.T) {
|
||||||
profile := writeProfile(t, "existing line\n")
|
profile := writeProfile(t, "existing line\n")
|
||||||
hook.Install(profile)
|
hook.Install(profile, "bash")
|
||||||
|
|
||||||
data, _ := os.ReadFile(profile)
|
data, _ := os.ReadFile(profile)
|
||||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||||
last := lines[len(lines)-1]
|
last := lines[len(lines)-1]
|
||||||
if last != hook.Line {
|
if last != hook.BlockEnd {
|
||||||
t.Errorf("hook line should be the last line, got %q", last)
|
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) {
|
func TestInstallUninstall_Roundtrip(t *testing.T) {
|
||||||
profile := writeProfile(t, "existing content\n")
|
profile := writeProfile(t, "existing content\n")
|
||||||
|
|
||||||
hook.Install(profile)
|
hook.Install(profile, "bash")
|
||||||
if !hook.IsInstalled(profile) {
|
if !hook.IsInstalled(profile) {
|
||||||
t.Fatal("should be installed after Install")
|
t.Fatal("should be installed after Install")
|
||||||
}
|
}
|
||||||
@@ -265,7 +266,7 @@ func writeProfile(t *testing.T, content string) string {
|
|||||||
func TestManagedBlockInstallAndUninstall(t *testing.T) {
|
func TestManagedBlockInstallAndUninstall(t *testing.T) {
|
||||||
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
|
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")
|
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)
|
t.Fatalf("duplicate install: %v, %v", installed, err)
|
||||||
}
|
}
|
||||||
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
|
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)
|
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")
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
sources map[string]*Client
|
||||||
baseURL string
|
baseURL string
|
||||||
token string
|
token string
|
||||||
tokenVersion int
|
tokenVersion int
|
||||||
@@ -38,6 +39,9 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
|||||||
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||||
// Use SearchOptions to restrict by kind or tag.
|
// Use SearchOptions to restrict by kind or tag.
|
||||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
return c.searchSources(ctx, query, opts, false)
|
||||||
|
}
|
||||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||||
}
|
}
|
||||||
@@ -95,6 +99,9 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
|
|||||||
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||||
// Intended for cache refresh; use Search for interactive queries.
|
// Intended for cache refresh; use Search for interactive queries.
|
||||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
return c.searchSources(ctx, query, opts, true)
|
||||||
|
}
|
||||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||||
}
|
}
|
||||||
@@ -146,6 +153,13 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
|||||||
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
||||||
// that need more than just the primary IP.
|
// that need more than just the primary IP.
|
||||||
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
source, host, err := c.sourceForHost(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return source.GetIPs(ctx, host)
|
||||||
|
}
|
||||||
var apiURL string
|
var apiURL string
|
||||||
switch entry.Kind {
|
switch entry.Kind {
|
||||||
case "device":
|
case "device":
|
||||||
@@ -170,6 +184,9 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error)
|
|||||||
|
|
||||||
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
||||||
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost")
|
||||||
|
}
|
||||||
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
||||||
var resp netboxIPListResponse
|
var resp netboxIPListResponse
|
||||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
@@ -360,12 +377,13 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er
|
|||||||
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||||
}
|
}
|
||||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||||
|
_, hostname := SplitTarget(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HostEntry{}, err
|
return HostEntry{}, err
|
||||||
}
|
}
|
||||||
var matches []HostEntry
|
var matches []HostEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.EqualFold(e.Name, name) {
|
if strings.EqualFold(e.Name, hostname) {
|
||||||
matches = append(matches, e)
|
matches = append(matches, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,6 +391,9 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er
|
|||||||
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
|
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
|
||||||
}
|
}
|
||||||
if len(matches) != 1 {
|
if len(matches) != 1 {
|
||||||
|
if c.sources != nil {
|
||||||
|
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use instance/hostname and --kind to select a unique host", name, len(matches))
|
||||||
|
}
|
||||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||||
}
|
}
|
||||||
return matches[0], nil
|
return matches[0], nil
|
||||||
@@ -395,6 +416,13 @@ func (c *Client) hostURL(host HostEntry) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||||
|
if c.sources != nil {
|
||||||
|
source, host, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return source.GetComments(ctx, host)
|
||||||
|
}
|
||||||
u, err := c.hostURL(host)
|
u, err := c.hostURL(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
@@ -407,6 +435,13 @@ func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||||
|
if c.sources != nil {
|
||||||
|
source, host, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return source.SetComments(ctx, host, comments, etag)
|
||||||
|
}
|
||||||
u, err := c.hostURL(host)
|
u, err := c.hostURL(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package netbox
|
package netbox
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// SearchOptions filters a Search or SearchAll query.
|
// SearchOptions filters a Search or SearchAll query.
|
||||||
// Zero value means no filtering (return all kinds, no tag filter).
|
// Zero value means no filtering (return all kinds, no tag filter).
|
||||||
type SearchOptions struct {
|
type SearchOptions struct {
|
||||||
@@ -9,6 +11,7 @@ type SearchOptions struct {
|
|||||||
|
|
||||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||||
type HostEntry struct {
|
type HostEntry struct {
|
||||||
|
Source string // named NetBox instance; empty for the legacy single-instance config
|
||||||
ID int
|
ID int
|
||||||
Name string
|
Name string
|
||||||
Kind string // "device" | "vm"
|
Kind string // "device" | "vm"
|
||||||
@@ -58,3 +61,19 @@ type netboxIPListResponse struct {
|
|||||||
} `json:"assigned_object"`
|
} `json:"assigned_object"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Target returns an unambiguous selector for cache, completion, and SSH.
|
||||||
|
func (h HostEntry) Target() string {
|
||||||
|
if h.Source == "" {
|
||||||
|
return h.Name
|
||||||
|
}
|
||||||
|
return h.Source + "/" + h.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitTarget separates an optional instance prefix from a hostname.
|
||||||
|
func SplitTarget(target string) (source, name string) {
|
||||||
|
if source, name, ok := strings.Cut(target, "/"); ok {
|
||||||
|
return strings.ToLower(source), name
|
||||||
|
}
|
||||||
|
return "", target
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source binds a name to its own API client and credentials.
|
||||||
|
type Source struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Token string
|
||||||
|
TokenVersion int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMultiClient(sources []Source) *Client {
|
||||||
|
c := &Client{sources: make(map[string]*Client)}
|
||||||
|
for _, source := range sources {
|
||||||
|
c.sources[strings.ToLower(source.Name)] = NewClient(source.URL, source.Token, source.TokenVersion)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) searchSources(ctx context.Context, query string, opts SearchOptions, all bool) ([]HostEntry, error) {
|
||||||
|
requested, query := SplitTarget(query)
|
||||||
|
if requested != "" {
|
||||||
|
if _, ok := c.sources[requested]; !ok {
|
||||||
|
return nil, fmt.Errorf("unknown NetBox instance %q", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var entries []HostEntry
|
||||||
|
var errs []error
|
||||||
|
started := 0
|
||||||
|
for name, client := range c.sources {
|
||||||
|
if requested != "" && name != requested {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
started++
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string, client *Client) {
|
||||||
|
defer wg.Done()
|
||||||
|
var found []HostEntry
|
||||||
|
var err error
|
||||||
|
if all {
|
||||||
|
found, err = client.SearchAll(ctx, query, opts)
|
||||||
|
} else {
|
||||||
|
found, err = client.Search(ctx, query, opts)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("NetBox %s: %w", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range found {
|
||||||
|
found[i].Source = name
|
||||||
|
}
|
||||||
|
entries = append(entries, found...)
|
||||||
|
}(name, client)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
// Full lookups must see every source to rule out ambiguous hostnames.
|
||||||
|
if len(errs) > 0 && (all || len(errs) == started) {
|
||||||
|
return nil, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||||
|
return entries, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sourceForHost(host HostEntry) (*Client, HostEntry, error) {
|
||||||
|
prefix, name := SplitTarget(host.Name)
|
||||||
|
if prefix != "" {
|
||||||
|
if host.Source != "" && !strings.EqualFold(prefix, host.Source) {
|
||||||
|
return nil, host, fmt.Errorf("conflicting NetBox instance for %q", host.Name)
|
||||||
|
}
|
||||||
|
host.Source, host.Name = prefix, name
|
||||||
|
}
|
||||||
|
if c.sources == nil {
|
||||||
|
return c, host, nil
|
||||||
|
}
|
||||||
|
source, ok := c.sources[strings.ToLower(host.Source)]
|
||||||
|
if !ok {
|
||||||
|
return nil, host, fmt.Errorf("specify a configured NetBox instance as instance/hostname (got %q)", host.Target())
|
||||||
|
}
|
||||||
|
host.Source = strings.ToLower(host.Source)
|
||||||
|
return source, host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostScope identifies a local note by its original NetBox URL and canonical
|
||||||
|
// hostname, so renaming an instance does not move or mix its notes.
|
||||||
|
func (c *Client) HostScope(host HostEntry) (HostEntry, string, error) {
|
||||||
|
source, host, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return host, "", err
|
||||||
|
}
|
||||||
|
return host, source.baseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetIPsWithFilterForHost(ctx context.Context, host HostEntry, filters string) ([]string, error) {
|
||||||
|
source, _, err := c.sourceForHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return source.GetIPsWithFilter(ctx, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MultipleSources() bool { return c.sources != nil }
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Token "+token {
|
||||||
|
t.Errorf("credentials crossed sources: %q, want %q", got, token)
|
||||||
|
http.Error(w, "bad token", 401)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/dcim/devices/":
|
||||||
|
json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}}))
|
||||||
|
case "/api/virtualization/virtual-machines/":
|
||||||
|
json.NewEncoder(w).Encode(vmListResponse())
|
||||||
|
case "/api/ipam/ip-addresses/":
|
||||||
|
if r.URL.Query().Get("device_id") != "1" {
|
||||||
|
t.Errorf("wrong host ID: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(ipListResponse(ip + "/24"))
|
||||||
|
case "/api/dcim/devices/1/":
|
||||||
|
if r.Method == http.MethodPatch {
|
||||||
|
var data map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if data["comments"] != comment+" updated" {
|
||||||
|
t.Errorf("comment write crossed sources: %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"comments": comment})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourceSearchAndRouting(t *testing.T) {
|
||||||
|
office := sourceTestServer(t, "office-token", "10.0.0.1", "office note")
|
||||||
|
lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note")
|
||||||
|
c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}})
|
||||||
|
ctx := context.Background()
|
||||||
|
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||||
|
if err != nil || len(entries) != 2 {
|
||||||
|
t.Fatalf("combined search: %+v, %v", entries, err)
|
||||||
|
}
|
||||||
|
if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" {
|
||||||
|
t.Fatalf("source lost: %+v", entries)
|
||||||
|
}
|
||||||
|
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||||
|
t.Fatal("ambiguous unqualified hostname accepted")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
host, err := c.FindHost(ctx, tc.name+"/web", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if host.Source != tc.name || host.Name != "web" || host.ID != 1 {
|
||||||
|
t.Fatalf("wrong host identity: %+v", host)
|
||||||
|
}
|
||||||
|
ips, err := c.GetIPs(ctx, host)
|
||||||
|
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||||
|
t.Fatalf("wrong IP source: %v, %v", ips, err)
|
||||||
|
}
|
||||||
|
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0")
|
||||||
|
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||||
|
t.Fatalf("wrong interface source: %v, %v", ips, err)
|
||||||
|
}
|
||||||
|
text, _, err := c.GetComments(ctx, host)
|
||||||
|
if err != nil || text != tc.note {
|
||||||
|
t.Fatalf("wrong comment source: %q, %v", text, err)
|
||||||
|
}
|
||||||
|
// TUI cache items carry a qualified name rather than a separate source.
|
||||||
|
qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"}
|
||||||
|
if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil {
|
||||||
|
t.Fatal("unknown source accepted")
|
||||||
|
}
|
||||||
|
if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil {
|
||||||
|
t.Fatal("unqualified note namespace accepted")
|
||||||
|
}
|
||||||
|
if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil {
|
||||||
|
t.Fatal("IP filter without instance accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourceSearchRunsConcurrently(t *testing.T) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
makeServer := func() *httptest.Server {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wg.Done()
|
||||||
|
both := make(chan struct{})
|
||||||
|
go func() { wg.Wait(); close(both) }()
|
||||||
|
select {
|
||||||
|
case <-both:
|
||||||
|
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||||
|
case <-r.Context().Done():
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
a, b := makeServer(), makeServer()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}})
|
||||||
|
if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil {
|
||||||
|
t.Fatalf("sources did not run concurrently: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourcePartialFailure(t *testing.T) {
|
||||||
|
good := sourceTestServer(t, "good-token", "10.0.0.1", "note")
|
||||||
|
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) }))
|
||||||
|
defer failed.Close()
|
||||||
|
c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}})
|
||||||
|
ctx := context.Background()
|
||||||
|
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||||
|
if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") {
|
||||||
|
t.Fatalf("partial search lost results or warning: %+v, %v", entries, err)
|
||||||
|
}
|
||||||
|
if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil {
|
||||||
|
t.Fatal("incomplete full search succeeded")
|
||||||
|
}
|
||||||
|
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||||
|
t.Fatal("incomplete search claimed unique host")
|
||||||
|
}
|
||||||
|
if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" {
|
||||||
|
t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) {
|
||||||
|
office := newTestServer(t, map[string]any{
|
||||||
|
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}),
|
||||||
|
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}),
|
||||||
|
})
|
||||||
|
defer office.Close()
|
||||||
|
lab := newTestServer(t, map[string]any{
|
||||||
|
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}),
|
||||||
|
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}),
|
||||||
|
})
|
||||||
|
defer lab.Close()
|
||||||
|
client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||||
|
entries, err := client.SearchAll(context.Background(), "", SearchOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"}
|
||||||
|
if len(entries) != len(want) {
|
||||||
|
t.Fatalf("independent inventories missing: %+v", entries)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if want[entry.Target()] != entry.Kind {
|
||||||
|
t.Fatalf("inventory crossed source or kind: %+v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||||
|
if err != nil || len(vms) != 2 {
|
||||||
|
t.Fatalf("VM filter across instances: %+v, %v", vms, err)
|
||||||
|
}
|
||||||
|
for _, host := range vms {
|
||||||
|
if host.Kind != "vm" {
|
||||||
|
t.Fatalf("device leaked into VM filter: %+v", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm")
|
||||||
|
if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" {
|
||||||
|
t.Fatalf("wrong VM selected: %+v, %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-2
@@ -52,7 +52,11 @@ func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool)
|
|||||||
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||||
return note, err
|
return note, err
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(s.path(host))
|
path, err := s.localPath(host)
|
||||||
|
if err != nil {
|
||||||
|
return note, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return note, nil
|
return note, nil
|
||||||
}
|
}
|
||||||
@@ -67,5 +71,22 @@ func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
|||||||
}
|
}
|
||||||
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||||
}
|
}
|
||||||
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600)
|
path, err := s.localPath(note.Host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return storage.WriteFile(path, []byte(note.Text), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) localPath(host netbox.HostEntry) (string, error) {
|
||||||
|
if s.Client == nil {
|
||||||
|
return s.path(host), nil
|
||||||
|
}
|
||||||
|
host, namespace, err := s.Client.HostScope(host)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scoped := *s
|
||||||
|
scoped.Namespace = namespace
|
||||||
|
return scoped.path(host), nil
|
||||||
}
|
}
|
||||||
@@ -50,3 +50,35 @@ func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
|||||||
t.Fatal("expected read error")
|
t.Fatal("expected read error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocalNotesSeparateInstancesAndSurviveRename(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: "https://office.example"}, {Name: "lab", URL: "https://lab.example"}})
|
||||||
|
service := &Service{Dir: t.TempDir(), Client: client}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
note := Note{Host: netbox.HostEntry{Name: source + "/web", Kind: "device"}, Text: source + " private note"}
|
||||||
|
if err := service.Write(ctx, note, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
note, err := service.Read(ctx, netbox.HostEntry{Name: source + "/web", Kind: "device"}, false)
|
||||||
|
if err != nil || note.Text != source+" private note" {
|
||||||
|
t.Fatalf("notes crossed instances: %+v, %v", note, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.Client = netbox.NewMultiClient([]netbox.Source{{Name: "renamed", URL: "https://office.example"}})
|
||||||
|
note, err := service.Read(ctx, netbox.HostEntry{Name: "renamed/web", Kind: "device"}, false)
|
||||||
|
if err != nil || note.Text != "office private note" {
|
||||||
|
t.Fatalf("renaming source lost note: %+v, %v", note, err)
|
||||||
|
}
|
||||||
|
// This is also the namespace used before migrating a legacy single NetBox.
|
||||||
|
legacy := &Service{Dir: service.Dir, Namespace: "https://office.example"}
|
||||||
|
note, err = legacy.Read(ctx, netbox.HostEntry{Name: "web", Kind: "device"}, false)
|
||||||
|
if err != nil || note.Text != "office private note" {
|
||||||
|
t.Fatalf("legacy note namespace changed: %+v, %v", note, err)
|
||||||
|
}
|
||||||
|
if err := service.Write(ctx, Note{Host: netbox.HostEntry{Name: "web", Kind: "device"}}, false); err == nil {
|
||||||
|
t.Fatal("unqualified notes accepted for multiple sources")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVMResolversStayWithinTheirNetBoxInstance(t *testing.T) {
|
||||||
|
sources := make([]netbox.Source, 0, 2)
|
||||||
|
for i, name := range []string{"office", "lab"} {
|
||||||
|
ip := fmt.Sprintf("10.0.0.%d", i+1)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("virtual_machine_id") != "7" {
|
||||||
|
t.Errorf("wrong VM filter: %s", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Token "+name {
|
||||||
|
t.Error("VM lookup used another instance's credentials")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"count":1,"results":[{"address":%q}]}`, ip+"/24")
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
sources = append(sources, netbox.Source{Name: name, URL: srv.URL, Token: name})
|
||||||
|
}
|
||||||
|
client := netbox.NewMultiClient(sources)
|
||||||
|
management, err := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, source := range sources {
|
||||||
|
host := &netbox.HostEntry{Source: source.Name, Name: "vm", Kind: "vm", ID: 7}
|
||||||
|
for _, strategy := range []Strategy{management, &InterfaceNameStrategy{name: "mgmt0"}} {
|
||||||
|
ip, err := strategy.Resolve(context.Background(), host, client)
|
||||||
|
if want := fmt.Sprintf("10.0.0.%d", i+1); err != nil || ip != want {
|
||||||
|
t.Fatalf("%s VM resolved through wrong instance: %q, %v", strategy.Name(), ip, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE
|
|||||||
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, err := client.GetIPsWithFilter(ctx, filterParam)
|
ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configureInstances edits a copy; nothing is written until the whole wizard succeeds.
|
||||||
|
func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
||||||
|
sources := append([]config.NetBoxConfig(nil), cfg.NetBoxes...)
|
||||||
|
if len(sources) == 0 && cfg.NetBox.URL != "" {
|
||||||
|
source := cfg.NetBox
|
||||||
|
source.Name = "default"
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
action := "done"
|
||||||
|
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"))
|
||||||
|
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("NetBox instances").Description("All configured instances are searched together.").Options(options...).Value(&action))).Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if action == "done" {
|
||||||
|
return sources, nil
|
||||||
|
}
|
||||||
|
if action == "add" {
|
||||||
|
source, err := editInstance(config.NetBoxConfig{}, sources, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources = append(sources, source)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
verb, name, _ := strings.Cut(action, ":")
|
||||||
|
for i, source := range sources {
|
||||||
|
if source.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch verb {
|
||||||
|
case "edit":
|
||||||
|
edited, err := editInstance(source, sources, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources[i] = edited
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
sources = append(sources[:i], sources[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editInstance(source config.NetBoxConfig, sources []config.NetBoxConfig, original string) (config.NetBoxConfig, error) {
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().Title("Instance name").Description("Used in selectors such as office/web01.").Value(&source.Name).Validate(func(name string) error {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
candidate := config.Config{NetBoxes: []config.NetBoxConfig{{Name: name, URL: "https://example.invalid"}}}
|
||||||
|
if err := candidate.ValidateNetBoxes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, existing := range sources {
|
||||||
|
if strings.EqualFold(existing.Name, name) && !strings.EqualFold(existing.Name, original) {
|
||||||
|
return fmt.Errorf("instance name %q is already used", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewInput().Title("NetBox URL").Placeholder("https://netbox.example.com").Value(&source.URL).Validate(func(value string) error {
|
||||||
|
return (&config.Config{NetBoxes: []config.NetBoxConfig{{Name: "instance", URL: strings.TrimSpace(value)}}}).ValidateNetBoxes()
|
||||||
|
}),
|
||||||
|
huh.NewInput().Title("NetBox API token").EchoMode(huh.EchoModePassword).Value(&source.Token).Validate(func(value string) error {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
).Title("NetBox connection")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return source, err
|
||||||
|
}
|
||||||
|
source.Name = strings.ToLower(strings.TrimSpace(source.Name))
|
||||||
|
source.URL = strings.TrimRight(strings.TrimSpace(source.URL), "/")
|
||||||
|
source.Token = strings.TrimSpace(source.Token)
|
||||||
|
source.TokenVersion = netbox.TokenVersion(source.Token)
|
||||||
|
if source.TokenVersion == 1 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Note: NetBox %s uses a legacy v1 token. A v2 token starts with nbt_.\n", source.Name)
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides.
|
||||||
|
func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") }
|
||||||
|
|
||||||
|
// editSSHOptions edits one SSH option layer: global, per instance, or per rule.
|
||||||
|
func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) {
|
||||||
|
proxyJump := o.ProxyJump
|
||||||
|
hostKeyChecking := o.HostKeyChecking
|
||||||
|
target := o.Target
|
||||||
|
addressFamily := o.AddressFamily
|
||||||
|
extraArgs := strings.Join(o.ExtraArgs, "\n")
|
||||||
|
options := formatOptions(o.Options)
|
||||||
|
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Jump host").
|
||||||
|
Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere.").
|
||||||
|
Value(&proxyJump).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh")
|
||||||
|
}),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Host key checking").
|
||||||
|
Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")).
|
||||||
|
Value(&hostKeyChecking),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Connection target").
|
||||||
|
Description("What is passed to ssh as destination.").
|
||||||
|
Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")).
|
||||||
|
Value(&target),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Address family").
|
||||||
|
Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")).
|
||||||
|
Value(&addressFamily),
|
||||||
|
huh.NewText().
|
||||||
|
Title("Extra arguments").
|
||||||
|
Description("One argument per line, inserted before the destination, e.g. -A").
|
||||||
|
Lines(3).
|
||||||
|
Value(&extraArgs),
|
||||||
|
huh.NewText().
|
||||||
|
Title("SSH options").
|
||||||
|
Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60").
|
||||||
|
Lines(4).
|
||||||
|
Value(&options).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
_, err := parseOptions(s)
|
||||||
|
return err
|
||||||
|
}),
|
||||||
|
).Title(title)).Run()
|
||||||
|
if err != nil {
|
||||||
|
return o, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, _ := parseOptions(options)
|
||||||
|
return config.SSHOptions{
|
||||||
|
ProxyJump: strings.TrimSpace(proxyJump),
|
||||||
|
HostKeyChecking: hostKeyChecking,
|
||||||
|
Target: target,
|
||||||
|
AddressFamily: addressFamily,
|
||||||
|
ExtraArgs: parseLines(extraArgs),
|
||||||
|
Options: parsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLines returns the non-empty, trimmed lines of s.
|
||||||
|
func parseLines(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
if line = strings.TrimSpace(line); line != "" {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOptions reads Key=Value lines into the map stored under ssh.options.
|
||||||
|
func parseOptions(s string) (map[string]string, error) {
|
||||||
|
var out map[string]string
|
||||||
|
for _, line := range parseLines(s) {
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !ok || key == "" {
|
||||||
|
return nil, fmt.Errorf("%q: expected Key=Value", line)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = make(map[string]string)
|
||||||
|
}
|
||||||
|
out[key] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOptions(options map[string]string) string {
|
||||||
|
keys := make([]string, 0, len(options))
|
||||||
|
for key := range options {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
lines := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
lines = append(lines, key+"="+options[key])
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureRules edits a copy of the rules; the caller decides whether to keep it.
|
||||||
|
func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) {
|
||||||
|
rules = append([]config.SSHRule(nil), rules...)
|
||||||
|
for {
|
||||||
|
action := "done"
|
||||||
|
var options []huh.Option[string]
|
||||||
|
for i, rule := range rules {
|
||||||
|
index := strconv.Itoa(i)
|
||||||
|
options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index))
|
||||||
|
options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index))
|
||||||
|
}
|
||||||
|
options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done"))
|
||||||
|
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
|
||||||
|
Title("SSH rules").
|
||||||
|
Description("Rules apply SSH options to hosts matching every set field. Later rules win.").
|
||||||
|
Options(options...).
|
||||||
|
Value(&action))).Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if action == "done" {
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
if action == "add" {
|
||||||
|
rule, err := editRule(config.SSHRule{}, sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
verb, index, _ := strings.Cut(action, ":")
|
||||||
|
i, _ := strconv.Atoi(index)
|
||||||
|
if verb == "edit" {
|
||||||
|
rule, err := editRule(rules[i], sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules[i] = rule
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remove := false
|
||||||
|
if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
rules = append(rules[:i], rules[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) {
|
||||||
|
match := rule.Match
|
||||||
|
instances := []huh.Option[string]{huh.NewOption("(any)", "")}
|
||||||
|
known := false
|
||||||
|
for _, source := range sources {
|
||||||
|
instances = append(instances, huh.NewOption(source.Name, source.Name))
|
||||||
|
known = known || strings.EqualFold(source.Name, match.Instance)
|
||||||
|
}
|
||||||
|
if match.Instance != "" && !known {
|
||||||
|
instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Tag").
|
||||||
|
Description("NetBox tag name or slug, e.g. dmz").
|
||||||
|
Value(&match.Tag),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Instance").
|
||||||
|
Options(instances...).
|
||||||
|
Value(&match.Instance),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Kind").
|
||||||
|
Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")).
|
||||||
|
Value(&match.Kind),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Name pattern").
|
||||||
|
Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com").
|
||||||
|
Value(&match.Name).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
candidate := match
|
||||||
|
candidate.Tag = strings.TrimSpace(candidate.Tag)
|
||||||
|
candidate.Name = strings.TrimSpace(s)
|
||||||
|
return candidate.Validate("rule")
|
||||||
|
}),
|
||||||
|
).Title("Rule match")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return rule, err
|
||||||
|
}
|
||||||
|
match.Tag = strings.TrimSpace(match.Tag)
|
||||||
|
match.Name = strings.TrimSpace(match.Name)
|
||||||
|
|
||||||
|
options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions)
|
||||||
|
if err != nil {
|
||||||
|
return rule, err
|
||||||
|
}
|
||||||
|
return config.SSHRule{Match: match, SSHOptions: options}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeMatch(m config.SSHMatch) string {
|
||||||
|
var parts []string
|
||||||
|
for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} {
|
||||||
|
if field[1] != "" {
|
||||||
|
parts = append(parts, field[0]+"="+field[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(parts, " ") + "]"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLines(t *testing.T) {
|
||||||
|
got := parseLines(" -A \n\n\t-C\n \n")
|
||||||
|
if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := parseLines(" \n "); got != nil {
|
||||||
|
t.Fatalf("blank input: got %q, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsRoundtrip(t *testing.T) {
|
||||||
|
text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n"
|
||||||
|
got, err := parseOptions(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
again, err := parseOptions(formatOptions(got))
|
||||||
|
if err != nil || !reflect.DeepEqual(again, want) {
|
||||||
|
t.Fatalf("roundtrip: got %v (%v)", again, err)
|
||||||
|
}
|
||||||
|
if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" {
|
||||||
|
t.Fatalf("formatOptions not sorted: %q", formatOptions(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsEmpty(t *testing.T) {
|
||||||
|
got, err := parseOptions("\n \n")
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("got %v, %v; want nil, nil", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsInvalid(t *testing.T) {
|
||||||
|
for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} {
|
||||||
|
if _, err := parseOptions(text); err == nil {
|
||||||
|
t.Errorf("%q: expected error", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeMatch(t *testing.T) {
|
||||||
|
got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"})
|
||||||
|
if got != "[tag=dmz name=*.dmz]" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleValidation(t *testing.T) {
|
||||||
|
if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") {
|
||||||
|
t.Fatalf("empty match: got %v", err)
|
||||||
|
}
|
||||||
|
if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil {
|
||||||
|
t.Fatal("invalid glob accepted")
|
||||||
|
}
|
||||||
|
if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil {
|
||||||
|
t.Fatalf("valid match rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+295
-145
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -13,61 +14,123 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
"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/config"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
"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.
|
// It saves the result to the config file on success.
|
||||||
func RunWizard(cfg *config.Config) error {
|
func RunWizard(cfg *config.Config) error {
|
||||||
url := cfg.NetBox.URL
|
sources, err := configureInstances(cfg)
|
||||||
token := cfg.NetBox.Token
|
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", "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
|
defaultUser := cfg.SSH.DefaultUser
|
||||||
defaultPort := ""
|
defaultPort := ""
|
||||||
if cfg.SSH.DefaultPort > 0 {
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||||
}
|
}
|
||||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
err := huh.NewForm(huh.NewGroup(
|
||||||
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(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("NetBox URL").
|
|
||||||
Description("e.g. https://netbox.example.com").
|
|
||||||
Placeholder("https://").
|
|
||||||
Value(&url).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return errors.New("required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("NetBox API token").
|
|
||||||
EchoMode(huh.EchoModePassword).
|
|
||||||
Value(&token).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return errors.New("required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
).Title("NetBox connection"),
|
|
||||||
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Default SSH user").
|
Title("Default SSH user").
|
||||||
Description("Leave empty to use your system user ($USER).").
|
Description("Leave empty to use your system user ($USER).").
|
||||||
@@ -87,9 +150,22 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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().
|
huh.NewInput().
|
||||||
Title("Resolver strategies").
|
Title("Resolver strategies").
|
||||||
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
||||||
@@ -114,9 +190,28 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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().
|
huh.NewInput().
|
||||||
Title("Domain suffixes").
|
Title("Domain suffixes").
|
||||||
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
||||||
@@ -126,112 +221,100 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Title("Strip hyphens").
|
Title("Strip hyphens").
|
||||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||||
Value(&stripHyphens),
|
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().
|
huh.NewInput().
|
||||||
Title("Command alias").
|
Title("Command alias").
|
||||||
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
||||||
Placeholder("j").
|
Placeholder("j").
|
||||||
Value(&commandAlias).
|
Value(&commandAlias).
|
||||||
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
||||||
).Title("Shortcuts"),
|
).Title("Shortcuts")).Run()
|
||||||
)
|
if err != nil {
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
if errors.Is(err, huh.ErrUserAborted) {
|
|
||||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
cfg.Shortcuts.Domains = nil
|
||||||
strategies := parseStrategies(strategiesRaw)
|
for _, s := range splitList(shortcutDomains) {
|
||||||
tokenVersion := netbox.TokenVersion(token)
|
|
||||||
if tokenVersion == 1 {
|
|
||||||
fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.")
|
|
||||||
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl, _ := strconv.Atoi(cacheTTL)
|
|
||||||
|
|
||||||
port := 0
|
|
||||||
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
|
|
||||||
port = p
|
|
||||||
}
|
|
||||||
|
|
||||||
var subnetList []string
|
|
||||||
for _, s := range strings.Split(subnets, ",") {
|
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
|
||||||
subnetList = append(subnetList, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainList []string
|
|
||||||
for _, s := range strings.Split(shortcutDomains, ",") {
|
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
|
||||||
if !strings.HasPrefix(s, ".") {
|
if !strings.HasPrefix(s, ".") {
|
||||||
s = "." + 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{
|
func editCompletion(cfg *config.Config) error {
|
||||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
maxResults := ""
|
||||||
NetBox: config.NetBoxConfig{
|
if cfg.Completion.MaxResults > 0 {
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
maxResults = strconv.Itoa(cfg.Completion.MaxResults)
|
||||||
Token: strings.TrimSpace(token),
|
|
||||||
TokenVersion: tokenVersion,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
if err := save(out); err != nil {
|
huh.NewInput().
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL)
|
cfg.Completion.MaxResults, _ = strconv.Atoi(strings.TrimSpace(maxResults))
|
||||||
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
|
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 {
|
out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)})
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if shell != "fish" {
|
if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil {
|
||||||
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
return nil, err
|
||||||
return 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 out
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownStrategies = map[string]bool{
|
var knownStrategies = map[string]bool{
|
||||||
@@ -252,30 +335,44 @@ func validateStrategies(s string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseStrategies(s string) []string {
|
func parseStrategies(s string) []string { return splitList(s) }
|
||||||
var out []string
|
|
||||||
for _, part := range strings.Split(s, ",") {
|
|
||||||
if name := strings.TrimSpace(part); name != "" {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func save(cfg config.Config) error {
|
func save(cfg config.Config) error {
|
||||||
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
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()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
if len(cfg.NetBoxes) > 0 {
|
||||||
|
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")
|
b.WriteString("netbox:\n")
|
||||||
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
|
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
|
if !sshOptionsEmpty(cfg.NetBox.SSH) {
|
||||||
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
|
b.WriteString(" ssh:\n")
|
||||||
|
writeSSHOptions(&b, " ", cfg.NetBox.SSH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("\nresolver:\n")
|
b.WriteString("\nresolver:\n")
|
||||||
b.WriteString(" strategies:\n")
|
b.WriteString(" strategies:\n")
|
||||||
@@ -305,8 +402,25 @@ func save(cfg config.Config) error {
|
|||||||
if cfg.SSH.DefaultPort > 0 {
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
|
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")
|
b.WriteString("\nshortcuts:\n")
|
||||||
if len(cfg.Shortcuts.Domains) > 0 {
|
if len(cfg.Shortcuts.Domains) > 0 {
|
||||||
b.WriteString(" domains:\n")
|
b.WriteString(" domains:\n")
|
||||||
@@ -317,6 +431,12 @@ func save(cfg config.Config) error {
|
|||||||
if cfg.Shortcuts.StripHyphens {
|
if cfg.Shortcuts.StripHyphens {
|
||||||
b.WriteString(" strip_hyphens: true\n")
|
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 != "" {
|
if cfg.Shell.Alias != "" {
|
||||||
@@ -330,3 +450,33 @@ func save(cfg config.Config) error {
|
|||||||
fmt.Printf("\nConfig saved → %s\n", path)
|
fmt.Printf("\nConfig saved → %s\n", path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sshOptionsEmpty(o config.SSHOptions) bool {
|
||||||
|
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
||||||
|
func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
|
||||||
|
for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} {
|
||||||
|
if field[1] != "" {
|
||||||
|
fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(o.ExtraArgs) > 0 {
|
||||||
|
fmt.Fprintf(b, "%sextra_args:\n", indent)
|
||||||
|
for _, arg := range o.ExtraArgs {
|
||||||
|
fmt.Fprintf(b, "%s - %q\n", indent, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(o.Options) > 0 {
|
||||||
|
keys := make([]string, 0, len(o.Options))
|
||||||
|
for key := range o.Options {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
fmt.Fprintf(b, "%soptions:\n", indent)
|
||||||
|
for _, key := range keys {
|
||||||
|
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package setup
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -386,3 +387,158 @@ func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
|||||||
t.Fatalf("disable: %+v, %v", loaded, err)
|
t.Fatalf("disable: %+v, %v", loaded, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBoxes: []config.NetBoxConfig{
|
||||||
|
{Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2},
|
||||||
|
{Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1},
|
||||||
|
},
|
||||||
|
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||||
|
Cache: config.CacheConfig{TTL: 3600},
|
||||||
|
}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" {
|
||||||
|
t.Fatalf("instances lost during save: %+v", loaded)
|
||||||
|
}
|
||||||
|
for i, want := range cfg.NetBoxes {
|
||||||
|
if !reflect.DeepEqual(loaded.NetBoxes[i], want) {
|
||||||
|
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := os.Stat(config.Path())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
package shortcuts
|
package shortcuts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
|
// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup.
|
||||||
// The result is lowercased for case-insensitive comparison.
|
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 {
|
func Normalize(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := strings.ToLower(name)
|
s := strings.ToLower(name)
|
||||||
for _, domain := range cfg.Domains {
|
for _, domain := range cfg.Domains {
|
||||||
@@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, rewrite := range cfg.Rewrites {
|
||||||
|
if re := rewritePattern(rewrite.Pattern); re != nil {
|
||||||
|
s = re.ReplaceAllString(s, rewrite.Replace)
|
||||||
|
}
|
||||||
|
}
|
||||||
if cfg.StripHyphens {
|
if cfg.StripHyphens {
|
||||||
s = strings.ReplaceAll(s, "-", "")
|
s = strings.ReplaceAll(s, "-", "")
|
||||||
}
|
}
|
||||||
@@ -34,9 +56,9 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AliasName returns a shell-safe alias name for the given host.
|
// 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.
|
// replaces any remaining dots with underscores so the result is a valid identifier.
|
||||||
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := Normalize(name, cfg)
|
s := Normalize(name, cfg)
|
||||||
return strings.ReplaceAll(s, ".", "_")
|
return strings.NewReplacer(".", "_", "/", "_").Replace(s)
|
||||||
}
|
}
|
||||||
@@ -78,3 +78,40 @@ func TestAliasName_NoConfig(t *testing.T) {
|
|||||||
t.Errorf("AliasName = %q, want %q", got, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
|
||||||
|
// so these never collide with flags meant for ssh.
|
||||||
|
type WrapperFlags struct {
|
||||||
|
DryRun bool
|
||||||
|
Jump string // --jump HOST overrides the configured jump host
|
||||||
|
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractWrapperFlags removes netssh's long options that appear before the
|
||||||
|
// destination. Anything after the destination belongs to the remote command.
|
||||||
|
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||||
|
var flags WrapperFlags
|
||||||
|
out := make([]string, 0, len(args))
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
switch {
|
||||||
|
case arg == "--dry-run":
|
||||||
|
flags.DryRun = true
|
||||||
|
case arg == "--no-jump":
|
||||||
|
flags.NoJump = true
|
||||||
|
case arg == "--jump":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return flags, nil, fmt.Errorf("--jump requires a host")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
flags.Jump = args[i]
|
||||||
|
case strings.HasPrefix(arg, "--jump="):
|
||||||
|
flags.Jump = strings.TrimPrefix(arg, "--jump=")
|
||||||
|
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
|
||||||
|
// Destination or end of options: keep the rest unchanged.
|
||||||
|
return flags, append(out, args[i:]...), validateJump(flags)
|
||||||
|
case strings.HasPrefix(arg, "--"):
|
||||||
|
return flags, nil, fmt.Errorf("unknown option %s", arg)
|
||||||
|
default:
|
||||||
|
out = append(out, arg)
|
||||||
|
// Keep the value of an ssh option such as -l USER together with it.
|
||||||
|
if takesSeparateValue(arg) && i+1 < len(args) {
|
||||||
|
i++
|
||||||
|
out = append(out, args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags, out, validateJump(flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJump(flags WrapperFlags) error {
|
||||||
|
if flags.Jump == "" && flags.NoJump {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if flags.Jump != "" && flags.NoJump {
|
||||||
|
return fmt.Errorf("--jump and --no-jump are mutually exclusive")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(flags.Jump, "-") {
|
||||||
|
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// takesSeparateValue reports whether a short option cluster ends with a flag
|
||||||
|
// whose value is the next argument (e.g. "-l" or "-vp").
|
||||||
|
func takesSeparateValue(arg string) bool {
|
||||||
|
for j := 1; j < len(arg); j++ {
|
||||||
|
if flagsWithArg[arg[j]] {
|
||||||
|
return j == len(arg)-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host is a resolved NetBox host.
|
||||||
|
type Host struct {
|
||||||
|
Name string // NetBox name without the instance prefix
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the final ssh argument list and an optional notice for stderr.
|
||||||
|
type Result struct {
|
||||||
|
Args []string
|
||||||
|
Notice string
|
||||||
|
}
|
||||||
|
|
||||||
|
var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
|
||||||
|
|
||||||
|
// Apply replaces the destination at destIdx and inserts the configured -o
|
||||||
|
// options directly before it. Explicit options given by the user come first,
|
||||||
|
// and ssh uses the first -o value it obtains, so the user's choice wins.
|
||||||
|
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||||
|
var inserted []string
|
||||||
|
add := func(keyword, value string) {
|
||||||
|
if !hasOption(args[:destIdx], 0, keyword) {
|
||||||
|
inserted = append(inserted, "-o", keyword+"="+value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := host.IP
|
||||||
|
if opts.Target == "name" || opts.Target == "hostname" {
|
||||||
|
if plainHostname.MatchString(host.Name) {
|
||||||
|
dest = host.Name
|
||||||
|
if opts.Target == "name" {
|
||||||
|
add("HostName", host.IP)
|
||||||
|
add("HostKeyAlias", host.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
|
||||||
|
switch {
|
||||||
|
case userJump:
|
||||||
|
case flags.NoJump:
|
||||||
|
inserted = append(inserted, "-o", "ProxyJump=none")
|
||||||
|
case flags.Jump != "":
|
||||||
|
inserted = append(inserted, "-J", flags.Jump)
|
||||||
|
case opts.ProxyJump != "" && opts.ProxyJump != "none":
|
||||||
|
inserted = append(inserted, "-J", opts.ProxyJump)
|
||||||
|
}
|
||||||
|
|
||||||
|
var notice string
|
||||||
|
switch opts.HostKeyChecking {
|
||||||
|
case "strict":
|
||||||
|
add("StrictHostKeyChecking", "yes")
|
||||||
|
case "accept-new":
|
||||||
|
add("StrictHostKeyChecking", "accept-new")
|
||||||
|
case "off":
|
||||||
|
if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") {
|
||||||
|
add("StrictHostKeyChecking", "no")
|
||||||
|
add("UserKnownHostsFile", "/dev/null")
|
||||||
|
add("LogLevel", "ERROR")
|
||||||
|
notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
|
||||||
|
add("AddressFamily", opts.AddressFamily)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(opts.Options))
|
||||||
|
for key := range opts.Options {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
add(key, opts.Options[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra arguments go first: short flags such as -4 or -A take the last
|
||||||
|
// value given, so the user's own flags later on the line still win.
|
||||||
|
replaced := ReplaceHost(args, destIdx, dest)
|
||||||
|
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
|
||||||
|
out = append(out, opts.ExtraArgs...)
|
||||||
|
out = append(out, replaced[:destIdx]...)
|
||||||
|
out = append(out, inserted...)
|
||||||
|
out = append(out, replaced[destIdx:]...)
|
||||||
|
return Result{Args: out, Notice: notice}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
||||||
|
func hasBoolFlag(args []string, wanted byte) bool {
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for j := 1; j < len(arg); j++ {
|
||||||
|
if arg[j] == wanted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if flagsWithArg[arg[j]] {
|
||||||
|
if j == len(arg)-1 {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quote renders an argument list as a copy-pasteable POSIX shell command.
|
||||||
|
func Quote(argv []string) string {
|
||||||
|
quoted := make([]string, len(argv))
|
||||||
|
for i, arg := range argv {
|
||||||
|
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
|
||||||
|
quoted[i] = arg
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
return strings.Join(quoted, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractWrapperFlags(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
flags WrapperFlags
|
||||||
|
rest []string
|
||||||
|
}{
|
||||||
|
{[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}},
|
||||||
|
{[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}},
|
||||||
|
{[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}},
|
||||||
|
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
||||||
|
} {
|
||||||
|
flags, rest, err := ExtractWrapperFlags(tc.args)
|
||||||
|
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
|
||||||
|
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} {
|
||||||
|
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||||
|
t.Errorf("%v accepted", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||||
|
return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDefaultConnectsToIP(t *testing.T) {
|
||||||
|
got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{})
|
||||||
|
if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) {
|
||||||
|
t.Fatalf("got %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyTargetName(t *testing.T) {
|
||||||
|
got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||||
|
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"}
|
||||||
|
if !reflect.DeepEqual(got.Args, want) {
|
||||||
|
t.Fatalf("got %v", got.Args)
|
||||||
|
}
|
||||||
|
got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{})
|
||||||
|
if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) {
|
||||||
|
t.Fatalf("got %v", got.Args)
|
||||||
|
}
|
||||||
|
// Names that are not plain hostnames fall back to the IP address.
|
||||||
|
got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||||
|
if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) {
|
||||||
|
t.Fatalf("got %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyJumpPrecedence(t *testing.T) {
|
||||||
|
opts := config.SSHOptions{ProxyJump: "jh.example.com"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
flags WrapperFlags
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"},
|
||||||
|
{[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"},
|
||||||
|
{[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"},
|
||||||
|
{[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"},
|
||||||
|
{[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"},
|
||||||
|
} {
|
||||||
|
if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want {
|
||||||
|
t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 {
|
||||||
|
t.Fatalf("proxy_jump none still jumps: %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyHostKeyChecking(t *testing.T) {
|
||||||
|
got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||||
|
want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10"
|
||||||
|
if strings.Join(got.Args, " ") != want || got.Notice == "" {
|
||||||
|
t.Fatalf("got %v, notice %q", got.Args, got.Notice)
|
||||||
|
}
|
||||||
|
// An explicit user choice disables the whole "off" bundle.
|
||||||
|
got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||||
|
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" {
|
||||||
|
t.Fatalf("user choice overridden: %v", got.Args)
|
||||||
|
}
|
||||||
|
got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{})
|
||||||
|
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" {
|
||||||
|
t.Fatalf("got %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyOptionsAndExtraArgs(t *testing.T) {
|
||||||
|
opts := config.SSHOptions{
|
||||||
|
AddressFamily: "inet",
|
||||||
|
ExtraArgs: []string{"-A"},
|
||||||
|
Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"},
|
||||||
|
}
|
||||||
|
got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{})
|
||||||
|
want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10"
|
||||||
|
if strings.Join(got.Args, " ") != want {
|
||||||
|
t.Fatalf("got %q", strings.Join(got.Args, " "))
|
||||||
|
}
|
||||||
|
got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{})
|
||||||
|
if strings.Join(got.Args, " ") != "-v6 192.0.2.10" {
|
||||||
|
t.Fatalf("address family overrides -6: %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyKeepsRemoteCommand(t *testing.T) {
|
||||||
|
got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{})
|
||||||
|
if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) {
|
||||||
|
t.Fatalf("got %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuote(t *testing.T) {
|
||||||
|
got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"})
|
||||||
|
if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want {
|
||||||
|
t.Fatalf("got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tuiSourceServer(t *testing.T, fail bool) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if fail {
|
||||||
|
http.Error(w, "offline", 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/api/dcim/devices/" {
|
||||||
|
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"web","primary_ip4":{"address":"10.0.0.1/24"}}]}`)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombinedSearchDoesNotStopAtFirstCachedInstance(t *testing.T) {
|
||||||
|
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, false)
|
||||||
|
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||||
|
c := cache.New("", 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "office/web", Kind: "device", IP: "10.0.0.9"})
|
||||||
|
m := New(client, c, "")
|
||||||
|
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||||
|
if msg.err != nil || len(msg.entries) != 2 {
|
||||||
|
t.Fatalf("cache hid another instance: %+v, %v", msg.entries, msg.err)
|
||||||
|
}
|
||||||
|
m.input.SetValue("web")
|
||||||
|
m.seq = 1
|
||||||
|
m.Update(msg)
|
||||||
|
if len(m.list.Items()) != 2 {
|
||||||
|
t.Fatal("combined results not displayed")
|
||||||
|
}
|
||||||
|
for _, item := range m.list.Items() {
|
||||||
|
if !strings.Contains(item.(hostItem).name, "/") {
|
||||||
|
t.Fatal("instance not visible in results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.list.Select(0)
|
||||||
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||||
|
if m.selected == nil || m.selected.Name != "lab/web" {
|
||||||
|
t.Fatalf("selection lost source: %+v", m.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPartialOutageKeepsLiveAndCachedResultsVisible(t *testing.T) {
|
||||||
|
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, true)
|
||||||
|
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||||
|
c := cache.New("", 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "lab/web", Kind: "device", IP: "10.0.0.2"})
|
||||||
|
m := New(client, c, "")
|
||||||
|
m.input.SetValue("web")
|
||||||
|
m.seq = 1
|
||||||
|
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||||
|
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||||
|
if len(msg.entries) != 2 || msg.err == nil {
|
||||||
|
t.Fatalf("partial outage lost cache or warning: %+v, %v", msg.entries, msg.err)
|
||||||
|
}
|
||||||
|
m.Update(msg)
|
||||||
|
view := m.View().Content
|
||||||
|
if !strings.Contains(view, "warning:") || !strings.Contains(view, "lab/web") || !strings.Contains(view, "office/web") {
|
||||||
|
t.Fatalf("partial results hidden: %s", view)
|
||||||
|
}
|
||||||
|
if rows := strings.Count(view, "\n"); rows > 24 {
|
||||||
|
t.Fatalf("outage warning overflowed view: %d", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-19
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -412,13 +413,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil // discard stale result
|
return m, nil // discard stale result
|
||||||
}
|
}
|
||||||
m.loading = false
|
m.loading = false
|
||||||
if msg.err != nil {
|
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.lastResults = msg.entries
|
m.lastResults = msg.entries
|
||||||
m.updateListItems(msg.entries)
|
m.updateListItems(msg.entries)
|
||||||
m.err = nil
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,9 +492,15 @@ func (m *Model) viewContent() string {
|
|||||||
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
||||||
} else if m.err != nil {
|
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
|
||||||
} else {
|
} else {
|
||||||
|
if m.err != nil {
|
||||||
|
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
|
||||||
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||||
|
if m.width > 0 {
|
||||||
|
style = style.MaxWidth(m.width)
|
||||||
|
}
|
||||||
|
sb.WriteString(style.Render(warning) + "\n")
|
||||||
|
}
|
||||||
if m.recentMode && m.input.Value() == "" {
|
if m.recentMode && m.input.Value() == "" {
|
||||||
if len(m.list.Items()) > 0 {
|
if len(m.list.Items()) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||||
@@ -534,25 +537,30 @@ func (m *Model) startDebounce() tea.Cmd {
|
|||||||
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||||
opts := m.netboxSearchOpts()
|
opts := m.netboxSearchOpts()
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Return cache hits immediately without a network round-trip.
|
var cached []netbox.HostEntry
|
||||||
if m.cache != nil {
|
if m.cache != nil {
|
||||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
for _, entry := range m.cache.Search(query) {
|
||||||
entries := make([]netbox.HostEntry, len(cached))
|
source, name := netbox.SplitTarget(entry.Name)
|
||||||
for i, c := range cached {
|
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
||||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
|
||||||
}
|
|
||||||
return searchResultMsg{query: query, seq: seq, entries: entries}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
||||||
if m.client == nil {
|
return searchResultMsg{query: query, seq: seq, entries: cached}
|
||||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := m.client.Search(ctx, query, opts)
|
entries, err := m.client.Search(ctx, query, opts)
|
||||||
|
// A cached hit in one instance must not hide live hosts in other instances.
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
seen[e.Target()+"\x00"+e.Kind] = true
|
||||||
|
}
|
||||||
|
for _, e := range cached {
|
||||||
|
if !seen[e.Target()+"\x00"+e.Kind] {
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,7 +585,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = e.PrimaryIP6
|
ip = e.PrimaryIP6
|
||||||
}
|
}
|
||||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
|
||||||
}
|
}
|
||||||
m.list.SetItems(items)
|
m.list.SetItems(items)
|
||||||
m.resizeList()
|
m.resizeList()
|
||||||
@@ -589,6 +597,9 @@ func (m *Model) resizeList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
extraRows := 5 // title, blank line, search, footer separator, footer
|
extraRows := 5 // title, blank line, search, footer separator, footer
|
||||||
|
if m.err != nil {
|
||||||
|
extraRows++
|
||||||
|
}
|
||||||
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||||
extraRows++
|
extraRows++
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user