Compare commits

...
54 Commits
Author SHA1 Message Date
Sebi 6e80b733dc Merge pull request 'v0.0.24' (#40) from release/v0.0.24 into main
Release / release (push) Successful in 6m20s
2026-11-14 12:56:51 +00:00
Sebastian UnterschützandClaude Opus 5.5 d3e7f137c3 docs: changelog for v0.0.24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:56:13 +01:00
Sebi 6493e7c41e Merge pull request 'netssh multi: mehrere Server in einem Fenster (tmux/zellij)' (#37) from feat/multi into main 2026-11-14 12:36:50 +00:00
Sebi a8bf2b4141 Merge pull request 'Docker-Testumgebung + 3 Bugfixes' (#36) from test/docker-env into main 2026-11-14 12:35:36 +00:00
Sebastian UnterschützandClaude Opus 5.5 396d489187 feat(multi): tmux keys to toggle broadcast and exclude single panes
In windows opened by netssh multi, prefix S toggles broadcast for all
panes and prefix e excludes the current pane (a pane-level
synchronize-panes off, shown as [SOLO] in the border) or includes it
again. netssh sync --pane does the same from a shell.

S and e are unbound in a default tmux (E would be "spread evenly").
netssh writes the bindings to a small tmux config and sources it, binds
a key only if the user's tmux.conf does not use it (netssh's own earlier
binding is refreshed), and the bindings act only in windows marked
@netssh. multi.sync_key and multi.solo_key change the keys or turn them
off (none); also in the configurator. zellij syncs whole tabs only, so
excluding a pane is tmux-only.

testenv: the render test presses the keys through the xterm client
(send-keys -K) and checks [SOLO], that broadcast skips the excluded
pane, that S toggles, and that other windows are unaffected. The client
gets GNU grep: BusyBox grep -oF counted only one match per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:32:32 +01:00
Sebastian UnterschützandClaude Opus 5.5 699b9469b6 test: check what a real terminal renders for multi, zellij, and the TUI
tests/16-render.sh runs tmux, zellij, and the TUI in xterm on Xvfb,
takes screenshots, and reads them back with tesseract: remote prompts
in all panes, borders and titles with the hosts, [SYNC] appearing and
disappearing, panes side by side, broadcast output in every pane and
input in one pane only without it, windows split by panes_per_window,
the status bar, and TUI marks. Screenshots stay in testenv/.artifacts.

OCR reads three preprocessing variants (plain, stretched for dim gray,
brightest channel for colored text) and normalizes characters that
DejaVu Sans Mono makes ambiguous (1/l, C/().

The screenshots showed tmux cutting the session name in the status bar
to "[netssh-pv"; netssh multi now widens status-left-length for its
own session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:14:34 +01:00
Sebastian UnterschützandClaude Opus 5.5 a31aab4110 feat: netssh multi — several hosts side by side in tmux or zellij
netssh multi opens one pane per host in a tmux window or zellij tab.
Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME
(NetBox virtualization clusters; devices by default, --kind vm|all),
and groups from the config (@name, nested, with cycle detection).
Every pane runs netssh HOST, so lookup, rules, and jump hosts apply.

- tmux: a new window inside tmux, else a new session that is attached;
  tiled layout, pane borders with the host and a [SYNC] marker.
- zellij: a generated grid layout; a new tab inside zellij, else a new
  session.
- --sync broadcasts typing to all panes; netssh sync toggles it in the
  current tmux window or zellij tab.
- TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together.
- Configurator: multiplexer, panes per window, groups.

NetBox SearchAll gains a cluster filter (name to cluster IDs per
instance), and cache entries keep the host's cluster for completion
and an offline fallback. Panes skip the 24-hour background refresh
(checked once by multi) and get HOME/XDG paths but no NETSSH_* values,
which would expose tokens in the process list.

testenv/tests/15-multi.sh checks real tmux and zellij sessions,
including a broadcast command arriving on all three cluster nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:45:05 +01:00
Sebastian UnterschützandClaude Opus 5.5 dbb99bdded test: add a local Docker test environment for all features
testenv/ starts two real NetBox 4.7 instances (v2 and v1 token), SSH
targets on public, mgmt, and prod networks, a jump host, an IPv6-only
host, a static host, and a client with ssh, sftp, scp, tmux, zellij,
bash, zsh, and fish. A seed script fills NetBox through the REST API.

141 end-to-end checks cover cache and refresh filters, connecting,
all resolver strategies, multiple instances, shortcuts and rewrites,
SSH options and rules, static hosts, sftp/scp incl. IPv6 and jump
hosts, tunnels, local notes and NetBox comments, completion and shell
hooks, the TUI (driven through tmux), the configurator, and uninstall.

Local only: testenv/up.sh, test.sh, shell.sh, down.sh. Not part of
CI/CD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:30:24 +01:00
Sebastian UnterschützandClaude Opus 5.5 55e7a5106b fix: interface_name strategy, instance hosts in sftp/scp, alias rewrites
Found by the new Docker test environment against a real NetBox 4.7:

- The interface_name strategy filtered IP addresses with interface_name
  and vminterface_name, which NetBox ignores, so it returned the first
  address of the host instead of the named interface's. Use the real
  filters interface and vminterface; a missing interface (HTTP 400)
  falls through to the next strategy.
- sftp and scp took instance-qualified hosts (prod/web01:/path) for
  local paths, because a "/" comes before the ":". Such arguments are
  remote when the prefix is a configured NetBox instance.
- Shell aliases of instance-qualified hosts ran the shortcut rules on
  "instance/host", so anchored rewrite rules never matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:30:24 +01:00
Sebi 3c9e54b254 Merge pull request 'Hostname-Strategie und statische Hosts' (#35) from feat/hostname-static-hosts into main
Release / release (push) Successful in 4m31s
2026-11-13 22:49:14 +00:00
Sebastian UnterschützandClaude Opus 5.5 a950a82552 feat: hostname resolver strategy and static hosts
Add the hostname resolver strategy. It connects by the NetBox name
instead of an IP, with resolver.hostname_domain appended to names
without a dot. Its position in the chain decides whether it is always
used or only as a fallback (e.g. [primary_ip, hostname]); names that
are not valid DNS hostnames fall through to the next strategy.

Add static hosts under hosts: for machines that are not in NetBox,
with address, user, port, tags, and their own SSH options. They are
checked before NetBox (by name or shortcut), never expire, survive
cache refresh and clear, and work without any NetBox configured. The
cache overlays them after every load and stores only their last use,
so they appear in completion, aliases, the TUI and its recent list.
SSH options merge global, static host, then rules; rules can match
kind: static. Manage them with netssh hosts list|add|remove and in
netssh configure, which can now also continue without NetBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:46:13 +01:00
Sebi 5821dfbebd Merge pull request 'docs: README ausführlicher' (#34) from docs/readme-details into main 2026-11-13 22:31:12 +00:00
Sebastian UnterschützandClaude Opus 5.5 2be9f19cc2 docs: expand README with reference, recipes, and troubleshooting
Add a table of contents, a quick start, a full configuration reference
with defaults, the environment variables, a table of files and
directories, a command overview, details on rule matching and how
option layers combine (with real --dry-run output), recipes, a
troubleshooting table, and the project layout. Update How it works for
sftp, scp, tunnel, and forwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:30:23 +01:00
Sebi c71cc0ae3b Merge pull request 'chore(renovate): keine Major-Updates indirekter Go-Module' (#33) from chore/renovate-no-indirect-major into main 2026-11-13 22:25:28 +00:00
Sebastian UnterschützandClaude Opus 5.5 2cadea30c7 chore(renovate): skip major updates of indirect Go modules
A new major version of a Go module is a separate module path. For an
indirect dependency nothing imports it, so Renovate only adds an unused
requirement, as in #24 (go.yaml.in/yaml/v3 to v4). Minor and patch
updates of indirect modules stay enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:24:31 +01:00
Sebi 25c03fff9d Merge pull request 'chore(deps): update module go.yaml.in/yaml/v3 to v3.0.6' (#23) from renovate/go.yaml.in-yaml-v3-3.x into main 2026-11-13 22:22:23 +00:00
Sebastian Unterschütz 8e4417a6ea Merge remote-tracking branch 'origin/main' into work-23
# Conflicts:
#	go.mod
#	go.sum
2026-11-13 23:21:53 +01:00
Sebi 2fafa0b119 Merge pull request 'chore(deps): update module github.com/xo/terminfo to v1.3.1' (#22) from renovate/github.com-xo-terminfo-1.x into main 2026-11-13 22:21:34 +00:00
Sebastian Unterschütz b009863c37 Merge remote-tracking branch 'origin/main' into work-22 2026-11-13 23:21:03 +01:00
Sebi a1c9210f3c Merge pull request 'chore(deps): update module github.com/spf13/pflag to v1.1.0' (#25) from renovate/github.com-spf13-pflag-1.x into main 2026-11-13 22:20:26 +00:00
Sebastian Unterschütz 15fb57c0ea Merge remote-tracking branch 'origin/main' into work-25 2026-11-13 23:19:44 +01:00
Sebi c55aae520f Merge pull request 'chore(deps): update module golang.org/x/text to v0.44.0' (#29) from renovate/golang.org-x-text-0.x into main 2026-11-13 22:18:52 +00:00
Sebastian Unterschütz 88af34a64b Merge remote-tracking branch 'origin/main' into work-29
# Conflicts:
#	go.sum
2026-11-13 23:18:04 +01:00
Sebi 314905c538 Merge pull request 'fix(deps): update module golang.org/x/sys to v0.50.0' (#27) from renovate/golang.org-x-sys-0.x into main 2026-11-13 22:17:44 +00:00
Sebastian Unterschütz ad3f14186d Merge remote-tracking branch 'origin/main' into work-27 2026-11-13 23:17:15 +01:00
Sebi 4f08a1c92a Merge pull request 'fix(deps): update module charm.land/lipgloss/v2 to v2.1.0' (#28) from renovate/charm.land-lipgloss-v2-2.x into main 2026-11-13 22:16:38 +00:00
Sebastian Unterschütz 45a2abce90 Merge remote-tracking branch 'origin/main' into work-28 2026-11-13 23:16:05 +01:00
Sebi f7fc1b2a37 Merge pull request 'fix(deps): update module charm.land/bubbletea/v2 to v2.1.1' (#21) from renovate/charm.land-bubbletea-v2-2.x into main 2026-11-13 22:15:25 +00:00
Sebastian Unterschütz c45ad9f0d1 Merge remote-tracking branch 'origin/main' into work-21 2026-11-13 23:14:56 +01:00
Sebi 97fc8f5458 Merge pull request 'chore(deps): update go module directive to v1.27.4' (#30) from renovate/go-1.x into main 2026-11-13 22:13:41 +00:00
Sebastian Unterschütz d24870e4c0 Merge remote-tracking branch 'origin/main' into work-30 2026-11-13 23:13:04 +01:00
Sebi 053db984be Merge pull request 'SFTP, SCP und benannte Port-Forwards' (#32) from feat/sftp-forwarding into main 2026-11-13 22:08:48 +00:00
Sebi 6419fbb00d Merge pull request 'v0.0.23: Configurator für alle Optionen, Regex-Kurznamen' (#31) from feat/configure-all-options into main 2026-11-13 22:07:45 +00:00
Sebastian UnterschützandClaude Opus 5.5 b7fa71fe45 feat: sftp, scp, and named port forwards
Add netssh sftp and netssh scp. Both resolve the host like a connection
does (cache, shortcuts and rewrites, instance/host, default user and
port, SSH options and rules, --jump/--no-jump, --dry-run) and get shell
completion. The per-client flag tables handle the differences from ssh:
the port is -P, -l is a bandwidth limit (the default user goes in as
-o User=), and IPv6 addresses are bracketed in host:path. All remote
scp operands must name the same host.

Add named port forwards under ssh.forwards (global, per instance, per
rule; a more specific layer replaces a forward of the same name),
netssh tunnel HOST NAME|SPEC... (ssh -N with ExitOnForwardFailure) and
netssh --forward NAME. Specs such as 8080:localhost:80 work without
configuration. Forwards are editable in netssh configure and completed
in the shell; the TUI opens SFTP with ctrl+o and a tunnel with ctrl+t.

Host resolution moves out of runSSHWrapper into resolveTarget, shared
by ssh, sftp, scp, tunnel, and the TUI.

Options added by netssh are now inserted before the first operand or
"--": after "--", ssh read them as the destination, and scp stops
reading options at the first operand.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:42:13 +01:00
Sebi bbc04723b4 chore(deps): update go module directive to v1.27.4 2026-11-10 17:45:15 +00:00
Sebi 22c9a3c32b chore(deps): update module golang.org/x/text to v0.44.0 2026-11-09 17:46:12 +00:00
Sebi b3f40ac3ad fix(deps): update module charm.land/lipgloss/v2 to v2.1.0 2026-11-09 13:44:02 +00:00
Sebi a80049a7c3 fix(deps): update module golang.org/x/sys to v0.50.0 2026-11-09 12:45:57 +00:00
Sebi 238e3a7798 chore(deps): update module github.com/spf13/pflag to v1.1.0 2026-11-06 08:45:37 +00:00
Sebi 0ce7753a9b chore(deps): update module github.com/xo/terminfo to v1.3.1 2026-11-04 23:43:38 +00:00
Sebi 76e27393dd chore(deps): update module go.yaml.in/yaml/v3 to v3.0.6 2026-10-31 19:51:04 +00:00
Sebi a26e96cd32 fix(deps): update module charm.land/bubbletea/v2 to v2.1.1 2026-10-28 10:54:26 +00:00
Sebi ac713ac97b Merge pull request 'Fix/instance prefix completion' (#20) from fix/instance-prefix-completion into main
Release / release (push) Successful in 5m43s
Reviewed-on: #20
2026-10-28 10:46:29 +00:00
Sebastian UnterschützandClaude Opus 5.5 091f5cc518 fix(completion): offer unique hostnames without instance prefix
bash (and zsh without a matcher) only accept candidates that start with
the typed text, so "fsn1-bx1<Tab>" found nothing when every candidate
was qualified as "prod/…". Hostnames that exist in only one instance
are now offered bare; names present in several instances stay
qualified. Connecting to a bare name still checks every instance for
ambiguity, so the connection behavior is unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
.git
testenv/.keys
testenv/.state
testenv/.artifacts
+1 -1
View File
@@ -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
+5
View File
@@ -19,3 +19,8 @@ dist/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# local test environment (testenv/up.sh)
testenv/.keys/
testenv/.state/
testenv/.artifacts/
+43
View File
@@ -1,5 +1,48 @@
# Changelog # Changelog
## v0.0.24
- Fix the `interface_name` resolver strategy: it sent filter names that NetBox ignores and therefore returned the host's first IP address instead of the named interface's. It now uses NetBox's `interface` and `vminterface` filters; a missing interface falls through to the next strategy.
- Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths.
- Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`).
- Add `netssh multi` to open several hosts side by side in tmux or zellij: host lists, `tag:`, NetBox virtualization clusters (`cluster:`, devices by default, `--kind vm|all`), and groups from the config (`@name`). `--sync` sends typing to all panes; `netssh sync` toggles it later. In the TUI, `Space` marks hosts and `Enter` opens them together. Configurable in `netssh configure` (multiplexer, panes per window, groups).
- tmux keys for broadcast in `netssh multi` windows: prefix `S` toggles all panes, prefix `e` excludes the current pane (border `[SOLO]`) or includes it again; also `netssh sync --pane`. Both keys are unbound in a default tmux, are not bound if your tmux.conf uses them, act only in netssh windows, and can be changed or turned off (`multi.sync_key`, `multi.solo_key`).
- `netssh multi` widens the status bar of its own tmux session, which tmux otherwise cuts to `[netssh-pv`.
- Cache entries remember the NetBox cluster of a host, for `cluster:` completion and offline use.
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 185 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
## v0.0.23
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving.
- The configurator validates SSH options and rules before writing the config.
- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`.
- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches.
- Add `netssh sftp` and `netssh scp`: host lookup, shortcuts, instances, default user and port, SSH options and rules, jump host, `--dry-run`, and completion work as for ssh. IPv6 addresses are bracketed for host:path.
- Add named port forwards (`ssh.forwards` globally, per instance, and per rule), `netssh tunnel HOST NAME|SPEC...` (ssh -N with ExitOnForwardFailure), and `netssh --forward NAME`. Forwards are editable in `netssh configure` and completed in the shell.
- TUI: `Ctrl+O` opens SFTP, `Ctrl+T` starts a tunnel with configured or ad hoc forwards.
- Add the `hostname` resolver strategy: connect by the NetBox name (plus optional `resolver.hostname_domain`) instead of an IP, always or as a fallback after `primary_ip`.
- Add static hosts (`hosts:`) for machines that are not in NetBox, with user, port, tags, and SSH options, managed with `netssh hosts list|add|remove` and in `netssh configure`. They are found before NetBox, work offline and without any NetBox, and support shortcuts, rules (`kind: static`), completion, aliases, sftp, scp, tunnels, and the TUI.
- Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination.
## v0.0.22
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
## v0.0.21
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
- Add `completion.max_results` to limit the number of hostnames offered per completion.
- Register zsh completion in the managed `.zshrc` block (`fpath` and `compdef`), also when `compinit` already ran, e.g. with oh-my-zsh. The installer reports which completions it set up.
- Add `netssh uninstall [--keep-config] [--dry-run] [--yes]` to remove the binary, profile entries of all versions, completions, cache, and config (with a private backup).
- Write marked `# >>> netssh >>>` blocks from `netssh hook install` and the wizard, replacing unmarked entries of older versions instead of appending.
- Add jump host support: `ssh.proxy_jump` globally, per NetBox instance, and per rule, plus `--jump HOST` and `--no-jump`.
- Add `ssh.host_key_checking` (`strict`, `accept-new`, `off`) for netssh connections only.
- Add `ssh.target: name|hostname` so `~/.ssh/config` host patterns apply and known_hosts entries belong to the device, and `ssh.address_family`.
- Add `ssh.options` and `ssh.extra_args`, and `ssh.rules` that match by tag, instance, kind, or name pattern.
- Add `netssh --version` / `netssh version` and `netssh --dry-run` to print the final ssh command.
## v0.0.20 ## v0.0.20
- Configure multiple named NetBox instances and search them together in parallel. - Configure multiple named NetBox instances and search them together in parallel.
+600 -23
View File
@@ -13,10 +13,26 @@ netssh -p 2222 admin@app-server-03 uptime
`netssh` looks up the host in NetBox, resolves the right IP using a configurable strategy chain, and replaces the process with the native `ssh` binary — so all your existing SSH configs, keys, and agent forwarding work without any changes. `netssh` looks up the host in NetBox, resolves the right IP using a configurable strategy chain, and replaces the process with the native `ssh` binary — so all your existing SSH configs, keys, and agent forwarding work without any changes.
The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the interactive search, shell completion, and generated shell aliases.
## Contents
- [Features](#features)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuration](#configuration) — [wizard](#interactive-wizard), [manual config](#manual-config), [reference](#configuration-reference), [environment variables](#environment-variables), [files](#files-and-directories), [multiple instances](#multiple-netbox-instances)
- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [multiple servers](#multiple-servers-in-one-window), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management)
- [Recipes](#recipes)
- [IP Resolution Strategies](#ip-resolution-strategies)
- [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook)
- [Troubleshooting](#troubleshooting)
- [Uninstall](#uninstall), [Development](#development), [How it works](#how-it-works)
## Features ## Features
- **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options - **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface - **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, named interface, or the hostname itself
- **Static hosts** — add hosts that are not in NetBox (`netssh hosts add router 192.168.1.1`); they work everywhere, also without any NetBox
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments) - **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed - **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm` - **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
@@ -29,9 +45,15 @@ 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`
- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh
- **Several servers in one window** — `netssh multi` opens hosts, a tag, a NetBox cluster, or a saved group side by side in tmux or zellij, with optional typing into all of them
- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
## Installation ## Installation
@@ -92,6 +114,22 @@ cd ssh-netbox-wrapper
go build -o netssh ./cmd/netssh go build -o netssh ./cmd/netssh
``` ```
## Quick start
1. **Install** with the one-liner above and open a new terminal.
2. **Configure** — run `netssh` (or `netssh configure`). The wizard asks for your NetBox URL and an API token; a read-only token is enough unless you want to edit NetBox comments. Everything else has working defaults and can be changed later.
3. **Fill the cache** — `netssh cache refresh` fetches all devices and virtual machines once. Connecting works without it, but completion, shortcuts, aliases, and the recently-used list need cached hosts.
4. **Connect** — `netssh web01`, or just `netssh` for the interactive search.
5. **Optional** — enable completion and aliases with `netssh hook install` (the installer usually did this already), and try `netssh --dry-run web01` to see the exact ssh command.
```sh
netssh configure # NetBox URL + token
netssh cache refresh # fetch all hosts once
netssh web01 # ssh to web01 via its NetBox IP
netssh sftp web01 # sftp to the same host
netssh tunnel web01 8080:localhost:80 # local port 8080 → web01:80
```
## Configuration ## Configuration
### Interactive wizard ### Interactive wizard
@@ -103,8 +141,21 @@ Re-run it at any time to change settings without editing the file manually:
netssh configure netssh configure
``` ```
The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias, The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. Without any instance you can also continue with static hosts only.
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), `forwards` (`NAME L\|R\|D SPEC` per line) |
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
| Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options |
| Multi-session | multiplexer (auto, tmux, zellij), panes per window, tmux keys for broadcast and excluding a pane, groups (`NAME: sel1, sel2` per line) |
| Resolver & cache | strategies, management subnets, interface name, hostname domain, 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 +186,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 +198,79 @@ shortcuts:
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution) strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
``` ```
Single-instance and shared settings 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.; see [Environment variables](#environment-variables)).
netssh reads `netssh.yaml` from the user config directory and, if none exists there, from the current working directory. Unknown keys are ignored; invalid values (an unknown strategy, a bad regex, an invalid SSH option) stop netssh with an error that names the key.
### Configuration reference
Every key, its default, and the section that explains it. Keys marked *layered* can also be set per NetBox instance (`netboxes[].ssh.…`) and per rule (`ssh.rules[].…`).
| Key | Type | Default | Description |
|---|---|---|---|
| `netbox.url` | string | — | NetBox base URL (single instance). Use either `netbox` or `netboxes`. |
| `netbox.token` | string | — | API token; `nbt_…` is v2, anything else v1. |
| `netbox.token_version` | 1 \| 2 | auto | Detected from the token prefix. |
| `netboxes[]` | list | — | Named instances with `name`, `url`, `token`, `token_version`, `ssh` — see [Multiple NetBox instances](#multiple-netbox-instances). |
| `resolver.strategies` | list | `[management_subnet, primary_ip]` | Tried in order, first IP wins — see [IP Resolution Strategies](#ip-resolution-strategies). The wizard suggests `primary_ip`. |
| `resolver.management_subnets` | list of CIDR | `[]` | Used by `management_subnet`. |
| `resolver.interface_name` | string | — | Used by `interface_name`, e.g. `mgmt0`. |
| `resolver.hostname_domain` | string | — | Used by `hostname`: appended to NetBox names without a dot. |
| `cache.ttl` | seconds | `3600` | How long a cached IP is used without asking NetBox; `0` always asks NetBox when connecting. |
| `cache.path` | path | user cache dir `/netssh/hosts.json` | Cache file; named instances use a subdirectory next to it. |
| `ssh.default_user` | string | — | Login user when none is given (`-l` for ssh, `-o User=` for sftp/scp). |
| `ssh.default_port` | int | — | Port when none is given (`-p` for ssh, `-P` for sftp/scp). |
| `ssh.proxy_jump` | string | — | *Layered.* Jump host (`-J`); `none` disables an inherited one. |
| `ssh.host_key_checking` | `strict` \| `accept-new` \| `off` | ssh's own | *Layered.* See [SSH options](#ssh-options). |
| `ssh.target` | `ip` \| `name` \| `hostname` | `ip` | *Layered.* What is passed to ssh as destination. |
| `ssh.address_family` | `any` \| `inet` \| `inet6` | — | *Layered.* `-o AddressFamily=`. |
| `ssh.extra_args` | list | `[]` | *Layered.* Extra ssh arguments; accumulate across layers; ssh only. |
| `ssh.options` | map | `{}` | *Layered.* `-o Key=Value`; per key, the more specific layer wins. |
| `ssh.forwards` | map | `{}` | *Layered.* Named port forwards (`local`, `remote`, `dynamic`) — see [Port forwarding](#port-forwarding). |
| `ssh.rules[]` | list | `[]` | `match` (`tag`, `instance`, `kind`, `name`) plus any layered key. |
| `shortcuts.domains` | list | `[]` | Suffixes stripped for shortcuts and tried when expanding a short name. |
| `shortcuts.strip_hyphens` | bool | `false` | Ignore hyphens when matching shortcuts. |
| `shortcuts.rewrites[]` | list | `[]` | `pattern` (Go regex) and `replace` — see [Host shortcuts](#host-shortcuts). |
| `completion.max_results` | int | `0` | Hostnames offered per `<Tab>`; `0` = unlimited. |
| `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). |
| `hosts[]` | list | `[]` | Static hosts with `name`, `address`, `user`, `port`, `tags`, `ssh` — see [Static hosts](#static-hosts). |
| `groups` | map of lists | `{}` | Named host lists for `netssh multi @NAME`; entries are hosts or selectors. |
| `multi.multiplexer` | `auto` \| `tmux` \| `zellij` | `auto` | Which multiplexer `netssh multi` uses. |
| `multi.panes_per_window` | int | `9` | More hosts open in further tmux windows or zellij tabs. |
| `multi.sync_key`, `multi.solo_key` | tmux key \| `none` | `S`, `e` | Keys after the tmux prefix: broadcast to all panes, exclude/include the current pane. |
### Environment variables
These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`, `groups`) can only be set in the file.
| Variable | Key |
|---|---|
| `NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, `NETSSH_NETBOX_TOKEN_VERSION` | `netbox.*` (single instance only; ignored when the file has only `netboxes:`) |
| `NETSSH_RESOLVER_STRATEGIES`, `NETSSH_RESOLVER_MANAGEMENT_SUBNETS`, `NETSSH_RESOLVER_INTERFACE_NAME`, `NETSSH_RESOLVER_HOSTNAME_DOMAIN` | `resolver.*` |
| `NETSSH_CACHE_TTL`, `NETSSH_CACHE_PATH` | `cache.*` |
| `NETSSH_SSH_DEFAULT_USER`, `NETSSH_SSH_DEFAULT_PORT` | `ssh.default_*` |
| `NETSSH_SSH_PROXY_JUMP`, `NETSSH_SSH_HOST_KEY_CHECKING`, `NETSSH_SSH_TARGET`, `NETSSH_SSH_ADDRESS_FAMILY` | global `ssh.*` options |
| `NETSSH_SHORTCUTS_DOMAINS`, `NETSSH_SHORTCUTS_STRIP_HYPHENS` | `shortcuts.*` |
| `NETSSH_COMPLETION_MAX_RESULTS` | `completion.max_results` |
| `NETSSH_MULTI_MULTIPLEXER`, `NETSSH_MULTI_PANES_PER_WINDOW` | `multi.*` |
| `NETSSH_SHELL_ALIAS` | `shell.alias` |
netssh also respects `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `ZDOTDIR`, and `SHELL` (for `hook install`, `completion install`, and `alias` without `--shell`).
### Files and directories
Paths are shown for Linux, where `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` replace `~/.config` and `~/.cache`. On macOS, config and notes live under `~/Library/Application Support`, the host cache and `aliases.sh` under `~/Library/Caches/netssh/` (unless `XDG_CACHE_HOME` is set for `aliases.sh`); fish and completion files use the paths in the table.
| Path | Content |
|---|---|
| `~/.config/netssh.yaml` | Configuration, including API tokens; written with mode `0600`. |
| `~/.config/netssh/notes/` | Local server notes (`0600`), keyed by NetBox URL, kind, and hostname. |
| `~/.cache/netssh/hosts.json` | Host cache (single instance). |
| `~/.cache/netssh/instances-<hash>/hosts.json` | Host cache for a set of named instances; the hash changes when names or URLs change. |
| `~/.cache/netssh/last_refresh` | Time of the last full refresh, for the 24-hour background refresh. |
| `~/.cache/netssh/aliases.sh` | Generated aliases for bash and zsh. |
| `~/.config/fish/conf.d/netssh.fish` | Generated aliases for fish. |
| `~/.local/share/bash-completion/completions/netssh`, `~/.zfunc/_netssh`, `~/.config/fish/completions/netssh.fish` | Installed completions. |
### Multiple NetBox instances ### Multiple NetBox instances
@@ -228,6 +356,30 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered.
## Usage ## Usage
### Command overview
| Command | Purpose |
|---|---|
| `netssh` | Interactive search (TUI). |
| `netssh [--dry-run] [--jump HOST\|--no-jump] [--forward NAME]... [ssh options] HOST [command]` | Connect with ssh. |
| `netssh sftp [netssh flags] [sftp options] HOST[:PATH]` | SFTP session — see [SFTP and SCP](#sftp-and-scp). |
| `netssh scp [netssh flags] [scp options] SOURCE... TARGET` | Copy files — see [SFTP and SCP](#sftp-and-scp). |
| `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). |
| `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). |
| `netssh multi [--sync] [--kind K] [--tmux\|--zellij] [-l U] [--jump H\|--no-jump] HOST\|tag:T\|cluster:C\|@GROUP...` | Several hosts side by side — see [Multiple servers](#multiple-servers-in-one-window). |
| `netssh sync [--pane]` | Toggle typing into all panes of the current tmux window or zellij tab; `--pane` excludes/includes the current tmux pane. |
| `netssh configure` | Setup wizard. |
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. |
| `netssh search QUERY` | Print matching cached hostnames, one per line. |
| `netssh alias [--shell bash\|zsh\|fish] [--test HOSTNAME]` | Print shell aliases, or test how a name is shortened. |
| `netssh notes show\|set\|append HOST [TEXT] [--netbox] [--kind device\|vm]` | Local notes or NetBox comments. |
| `netssh hook install\|uninstall [--shell S]` | Shell integration (aliases, completion, background refresh). |
| `netssh completion install [--shell S]` / `netssh completion bash\|zsh\|fish` | Install or print completion scripts. |
| `netssh uninstall [--dry-run] [--keep-config] [--yes]` | Remove netssh. |
| `netssh version` / `netssh --version` | Print the version. |
The netssh flags `--dry-run`, `--jump`, `--no-jump`, and `--forward` must come before the host. Everything after the host is passed to ssh as the remote command (for `tunnel`: forward names).
### SSH wrapper mode ### SSH wrapper mode
Pass any SSH flags and a NetBox hostname: Pass any SSH flags and a NetBox hostname:
@@ -240,6 +392,217 @@ 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.
**Rules.** A rule applies when every field set in its `match` fits the host:
| Match field | Compared with | Notes |
|---|---|---|
| `tag` | NetBox tags of the host | Tag name or slug, case-insensitive (`DMZ Hosts` matches `dmz-hosts`). |
| `instance` | Name of the NetBox instance | Only useful with `netboxes:`. |
| `kind` | `device`, `vm`, or `static` | Devices come from DCIM, VMs from Virtualization, `static` from `hosts:`. |
| `name` | Hostname without instance prefix | Shell glob, case-insensitive (`*.dmz.example.com`, `fw-??`). |
Tags and kind are taken from the cache or the NetBox answer, so rules work offline for cached hosts. A rule needs at least one match field. All matching rules apply in file order; a later rule overrides an earlier one.
**How layers combine.** netssh merges global → instance (or the static host's own `ssh`) → matching rules:
- single values (`proxy_jump`, `host_key_checking`, `target`, `address_family`) — the most specific non-empty value wins;
- `options` and `forwards` — merged per key or name, the more specific layer wins;
- `extra_args` — collected from all layers.
Options you type yourself always win: netssh adds its `-o` options after yours, and ssh uses the first value it sees; `-J`, `-4`/`-6`, or your own `StrictHostKeyChecking` suppress the matching setting. Check the result with `--dry-run`:
```sh
$ netssh --dry-run fw01.dmz.example.com
ssh -A -l admin -J dmz-jump.example.com -o StrictHostKeyChecking=accept-new -o serveraliveinterval=60 192.0.2.10
```
### SFTP and SCP
`netssh sftp` and `netssh scp` look up the host exactly like a connection does: cache, shortcuts and rewrite rules, `instance/host`, default user and port, SSH options and rules, `--jump`/`--no-jump`, and `--dry-run`.
```sh
netssh sftp fsnbx1 # interactive sftp
netssh sftp prod/web01:/var/log # start in a directory
netssh scp ./app.tar fsnbx1:/tmp/ # upload
netssh scp -r web01:/etc/nginx ./nginx # download
netssh scp --dry-run app.tar web01:/tmp/ # print the scp command
```
sftp and scp options are passed through. Mind their differences from ssh: the port is `-P`, and `-l` is a bandwidth limit, so the default user is passed as `-o User=`. `extra_args` and `forwards` contain ssh flags and apply to ssh only. All remote operands of one `scp` call must name the same host; copy between two NetBox hosts in two steps. Tab completion offers `host:` for scp and local files otherwise.
### Port forwarding
Plain `-L`, `-R`, and `-D` flags are passed to ssh as usual (`netssh -L 8080:localhost:80 web01`). For tunnels you use often, name them under `ssh.forwards`, globally, per NetBox instance (`netboxes[].ssh.forwards`), or per rule. A more specific layer replaces a forward of the same name.
```yaml
ssh:
forwards:
web: {local: "8080:localhost:80"} # -L
socks: {dynamic: "1080"} # -D, SOCKS proxy
back: {remote: "9000:localhost:9000"} # -R
rules:
- match: {tag: postgres}
forwards:
db: {local: "5432:localhost:5432"}
```
```sh
netssh tunnel fsnbx1 web socks # only forwards: ssh -N, stops with Ctrl+C
netssh tunnel prod/db01 db 6379:localhost:6379 # names and ad hoc specs mixed
netssh --forward web fsnbx1 # shell plus forward (repeatable)
```
A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 <Tab>`) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards.
`sftp`, `scp`, `tunnel`, `hosts`, `multi`, and `sync` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
### Static hosts
Hosts that are not in NetBox — a home router, a cloud VM, a customer machine — can be added to the config. netssh checks them before NetBox, so they work offline and even without any NetBox configured.
```sh
netssh hosts add router 192.168.1.1 --user admin --tag home
netssh hosts add backup backup.example.org --port 2222 --tag offsite
netssh hosts # list
netssh hosts add router 192.168.1.254 --replace # change the address, keep its SSH options
netssh hosts remove backup
```
The same in the config (or in `netssh configure` → *Static hosts*):
```yaml
hosts:
- name: router
address: 192.168.1.1 # IP address or DNS name
user: admin # optional, overrides ssh.default_user
port: 2222 # optional, overrides ssh.default_port
tags: [home] # optional, for ssh.rules
ssh: # optional, same keys as ssh: (proxy_jump, options, forwards, …)
proxy_jump: none
```
Static hosts behave like NetBox hosts everywhere: `netssh router`, `sftp`, `scp`, `tunnel`, shortcuts and rewrite rules, Tab completion, shell aliases, the TUI search and its recently-used list, and `ssh.rules` (match `kind: static` or their tags). Their SSH options are merged like an instance's: global → static host → rules. User and port on the command line still win.
- Names may contain letters, digits, dots, underscores, and hyphens, and are matched case-insensitively. They have no instance prefix.
- A static host wins over a NetBox host of the same name. Give them distinct names to keep both reachable.
- Static hosts never expire and are not touched by `cache refresh` or `cache clear`; removing one from the config removes it everywhere.
- `--netbox` notes and NetBox comments are not available for static hosts; local notes work.
### Multiple servers in one window
`netssh multi` opens one pane per host in tmux or zellij — for example every node of a Proxmox or Kubernetes cluster — and can send your typing to all of them at once.
```sh
netssh multi web01 web02 db01 # hosts, shortcuts, instance/host, static hosts
netssh multi tag:k8s-worker # all hosts with a NetBox tag (and static hosts with it)
netssh multi cluster:pve-fsn1 # the devices (nodes) of a NetBox virtualization cluster
netssh multi --kind vm cluster:pve-fsn1 # the cluster's VMs; --kind all for both
netssh multi @k8s # a saved group
netssh multi --sync @k8s # start with typing sent to every pane
netssh multi --dry-run cluster:pve-fsn1 # show the hosts and tmux/zellij commands
```
Each pane runs `netssh HOST`, so lookup, shortcuts, user and port, jump hosts, and rules work as for a single connection. `-l USER`, `--jump HOST`, and `--no-jump` apply to every pane. A pane whose connection fails stays open with the error until you press Enter.
**Groups** collect hosts and selectors under a name (or use `netssh configure` → *Multi-session*):
```yaml
groups:
k8s: [k8s-master1, "tag:k8s-worker"]
pve: ["cluster:pve-fsn1"]
all-web: ["@k8s", "tag:web", legacy-box]
multi:
multiplexer: auto # auto | tmux | zellij
panes_per_window: 9 # more hosts open in further windows/tabs
sync_key: S # tmux: prefix S = broadcast on/off (none = no key)
solo_key: e # tmux: prefix e = exclude/include this pane
```
**tmux and zellij.** With `auto`, netssh uses the multiplexer you are in; otherwise tmux, then zellij, whichever is installed (`--tmux` / `--zellij` choose explicitly).
| | tmux | zellij |
|---|---|---|
| Inside a session | new window in the current session | new tab in the current session |
| Outside | new session `netssh-NAME`, attached | new session from a generated layout |
| Layout | tiled; pane borders show the host | grid; pane titles show the host |
| Broadcast to all panes | `--sync`; toggle with `Ctrl+b S` or `netssh sync` | `--sync` inside zellij; toggle with `Ctrl+t` then `s` or `netssh sync` |
| Exclude one pane | `Ctrl+b e` (again: include) or `netssh sync --pane` | not possible — zellij syncs whole tabs |
| Shown in the border | `[SYNC]` receives broadcast, `[SOLO]` excluded | `(SYNC)` in the tab name |
**Keys in tmux.** `S` and `e` are free in a default tmux (`E` would be "spread panes evenly"). netssh binds them after your prefix when it opens panes, but only if your own `tmux.conf` does not already use them — then it keeps your binding and says so. They act only in windows opened by `netssh multi`; elsewhere they just show a message. Change or turn them off with `multi.sync_key` / `multi.solo_key` (`none`), e.g. `sync_key: C-s` for `prefix Ctrl+s`.
**In the TUI**, mark hosts with `Space` (or all listed hosts with `Ctrl+A`) and press `Enter` to open them together.
`tag:` and `cluster:` ask NetBox (all instances); if NetBox is unreachable, netssh uses the cached hosts and prints a warning. More than 24 hosts need `--yes`. The 24-hour background cache refresh runs once, not in every pane.
### 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:
@@ -279,13 +642,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.
@@ -293,6 +669,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
@@ -343,6 +720,10 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
| `Tab` | autocomplete top result into the search field | | `Tab` | autocomplete top result into the search field |
| `↑` / `↓` | navigate results | | `↑` / `↓` | navigate results |
| `Enter` | connect to selected host | | `Enter` | connect to selected host |
| `Space` | mark/unmark the host; with marks, `Enter` opens them side by side (`netssh multi`) |
| `Ctrl+A` | mark all listed hosts (again: unmark all) |
| `Ctrl+O` | open an SFTP session to the selected host |
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) |
| `Ctrl+E` | open inline editor to override user/port before connecting | | `Ctrl+E` | open inline editor to override user/port before connecting |
| `Ctrl+N` | edit local notes for the selected server | | `Ctrl+N` | edit local notes for the selected server |
| `Ctrl+B` | edit NetBox comments for the selected server | | `Ctrl+B` | edit NetBox comments for the selected server |
@@ -422,7 +803,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
@@ -436,6 +817,96 @@ The hook is a no-op when the cache is fresh — it adds no measurable delay to y
netssh search app- # prints matching hostnames, one per line netssh search app- # prints matching hostnames, one per line
``` ```
## Recipes
**Everything through a bastion, except the bastion itself**
```yaml
ssh:
proxy_jump: admin@bastion.example.com
rules:
- match: {name: "bastion*"}
proxy_jump: none
```
**A different jump host per site (NetBox instance)**
```yaml
netboxes:
- name: fsn
url: https://netbox.fsn.example.com
token: nbt_…
ssh: {proxy_jump: jump.fsn.example.com}
- name: hel
url: https://netbox.hel.example.com
token: nbt_…
ssh: {proxy_jump: jump.hel.example.com}
```
**Lab machines that are reinstalled every day**
```yaml
ssh:
rules:
- match: {instance: lab}
host_key_checking: off # prints a notice on every connection
```
**Database and web tunnels by tag**
```yaml
ssh:
forwards:
socks: {dynamic: "1080"} # browser via SOCKS on localhost:1080
rules:
- match: {tag: postgres}
forwards: {db: {local: "5432:localhost:5432"}}
- match: {tag: grafana}
forwards: {web: {local: "3000:localhost:3000"}}
```
```sh
netssh tunnel db01 db # psql -h localhost then reaches db01
netssh tunnel grafana01 web socks
```
**Short names for a hosting naming scheme** (`fsn1-bx1.hoster.de` → `fsnbx1`)
```yaml
shortcuts:
domains: [.hoster.de]
rewrites:
- pattern: '^([a-z]+)[0-9]+-'
replace: '$1'
```
```sh
netssh cache refresh && netssh alias --test fsn1-bx1.hoster.de
netssh fsnbx1 # also works as shell alias: fsnbx1
```
**Maintenance on all nodes of a cluster at once**
```sh
netssh multi --sync cluster:pve-fsn1 # every node in one window, typing goes to all
# … run apt upgrade on all nodes; Ctrl+b e on a node that needs something
# different excludes just that one, Ctrl+b S turns broadcast off for all
```
**Deploy a file and restart a service**
```sh
netssh scp ./app.tar prod/app01:/tmp/ && netssh prod/app01 'sudo systemctl restart app'
```
**Use netssh in scripts**
```sh
for host in $(netssh search web-); do
netssh "$host" uptime
done
```
## IP Resolution Strategies ## IP Resolution Strategies
Strategies are tried in the configured order; the first to succeed wins. Strategies are tried in the configured order; the first to succeed wins.
@@ -447,6 +918,22 @@ Cache refresh uses the same strategies as a direct connection. To allow the prim
| `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. | | `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. |
| `management_subnet` | Fetches all IPs for the host and returns the first one matching a configured CIDR. | | `management_subnet` | Fetches all IPs for the host and returns the first one matching a configured CIDR. |
| `interface_name` | Fetches IPs attached to a specific named interface (e.g. `mgmt0`). | | `interface_name` | Fetches IPs attached to a specific named interface (e.g. `mgmt0`). |
| `hostname` | Uses the NetBox name instead of an IP, with `resolver.hostname_domain` appended to names without a dot. DNS or `~/.ssh/config` resolves it when ssh connects. No extra API call. |
**`hostname` strategy.** Its position in the chain decides when it applies:
```yaml
resolver:
strategies: [primary_ip, hostname] # IP if NetBox has one, otherwise the hostname
hostname_domain: example.com # web01 → web01.example.com
```
```yaml
resolver:
strategies: [hostname] # always connect by name, never by IP
```
Names that are not valid DNS hostnames (spaces, underscores) are skipped, so the next strategy is tried. The cache then stores the name instead of an IP, and `--dry-run` shows `ssh web01.example.com`. Compared with `ssh.target: hostname`, the strategy works as a fallback for hosts without an IP in NetBox and can be combined with `target: name`.
## Shell Completion ## Shell Completion
@@ -465,14 +952,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
@@ -502,15 +999,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.
@@ -521,7 +1028,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:
@@ -550,7 +1057,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
@@ -563,6 +1070,50 @@ 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.
## Troubleshooting
**See what netssh would run.** `--dry-run` works for ssh, `sftp`, `scp`, and `tunnel` and prints the final command with the resolved IP and all added options.
| Message / symptom | Cause and fix |
|---|---|
| `host not found` | The name is not in NetBox (devices and VMs are searched). Check the spelling, or configure `shortcuts.domains` if you type short names. |
| `host "x" is not in hosts: and no NetBox is configured` | Only static hosts are configured. Add the host with `netssh hosts add`, or a NetBox with `netssh configure`. |
| `no strategy resolved an IP` | No resolver strategy found an address. Add `primary_ip` or `hostname` as a fallback at the end of `resolver.strategies`. |
| `ambiguous shortcut "x"; use a full hostname` | Several cached hosts shorten to the same name. Use the full name, or check with `netssh alias --test`. |
| `found 2 exact matches` | The name exists in several NetBox instances, or as both device and VM. Prefix the instance (`prod/web01`); for notes and the TUI also `--kind`. |
| Shortcut works for one host but not another | Shortcuts (hyphen folding, rewrites) only match cached hosts. Run `netssh cache refresh`. |
| Connection fails although a NetBox instance is down | Without an instance prefix, every instance must answer to rule out duplicates. Use `instance/host`; a fresh cache entry then needs no NetBox at all. |
| Wrong or outdated IP | Lower `cache.ttl`, run `netssh cache refresh`, and check the [strategy order](#ip-resolution-strategies). |
| `REMOTE HOST IDENTIFICATION HAS CHANGED` | The host was reinstalled or the IP reused. Remove the old key (`ssh-keygen -R <ip>`), consider `target: name`, or for lab hosts `host_key_checking: off`. |
| `unknown forward "x"` | The name is not configured for this host (forwards can be per instance or rule). The message lists the available ones. |
| `bind [127.0.0.1]:8080: Address already in use` | The local port of a forward is taken; `netssh tunnel` then exits instead of hanging. Choose another port. |
| Saving notes with `--netbox` fails with a permission error | The API token is read-only. Local notes work without write permission. |
| `<Tab>` completes files instead of hosts | Run `netssh completion install` and open a new terminal; for zsh also `rm -f ~/.zcompdump*`. |
| Aliases missing in a new shell | `netssh hook install`, then a new terminal. The cache must not be empty. |
| A host is named `sftp`, `scp`, `tunnel`, `hosts`, `multi`, `sync`, or like another subcommand | Use `netssh -- sftp`. |
| `netssh multi needs tmux or zellij` | Install one of them (`apt install tmux`, `brew install tmux`). |
| zellij shows empty boxes in its tab and status bar | Those are Powerline arrows; use a Nerd Font in your terminal, or add `simplified_ui true` to `~/.config/zellij/config.kdl`. |
| `cluster:x matches no hosts` | Clusters select devices by default; VMs need `--kind vm` or `--kind all`. The name is matched case-insensitively. |
| `netssh multi` panes use another config | Inside an existing tmux/zellij session, panes get your `HOME` and `XDG_*` paths, but not `NETSSH_*` variables (they would be visible to other users in the process list). Put such settings in the config file. |
## 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
@@ -574,7 +1125,32 @@ go test ./... # includes offline installer/update tests
go build ./... # build all packages go build ./... # build all packages
``` ```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, setup wizard, shortcut normalization, and shell hook install/uninstall. End-to-end tests run against real services in a local Docker environment — two NetBox instances, SSH targets on separate networks, a jump host, an IPv6 host, tmux, zellij, and bash/zsh/fish. See [testenv/README.md](testenv/README.md). It is not part of CI/CD.
```sh
testenv/up.sh # build and start (first start ~6 min)
testenv/test.sh # 185 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR)
testenv/down.sh
```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, the argument parsers for ssh, sftp, and scp, SSH options and rules, port forwards, config loading, the setup wizard, shortcut normalization and rewrites, completion, the TUI keys, and shell hook install/uninstall. Wrapper tests run the real command (`sftp`, `scp`, `tunnel` with `--dry-run`) in a subprocess against a pre-filled cache, so no NetBox or ssh server is needed.
Project layout:
| Directory | Content |
|---|---|
| `cmd/netssh` | Commands, host resolution, completion. |
| `internal/config` | Config loading, validation, SSH option layering. |
| `internal/netbox` | NetBox API client, multiple instances. |
| `internal/resolver` | IP resolution strategies. |
| `internal/cache` | Host cache. |
| `internal/ssh` | Argument parsing and option injection for ssh, sftp, scp. |
| `internal/multiplex` | tmux and zellij layouts for `netssh multi`. |
| `internal/shortcuts` | Shortcut normalization. |
| `internal/tui` | Interactive search. |
| `internal/setup` | Setup wizard. |
| `internal/hook` | Shell hooks, aliases, completion files. |
| `internal/notes` | Local notes and NetBox comments. |
## Disclaimer ## Disclaimer
@@ -582,9 +1158,10 @@ 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`, `cache`, `sftp`, `scp`, `tunnel`, `multi`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI.
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically. 2. On first run, or when neither a NetBox nor static hosts are configured, 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 removes its own long options (`--dry-run`, `--jump`, `--no-jump`, `--forward`) and parses the remaining arguments with the flag table of the program it will run (ssh, sftp, or scp differ, e.g. in `-l` and `-P`) to find the destination: `[user@]host` for ssh, `[user@]host[:path]` for sftp, and every `host:path` operand for scp.
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 host: first the static hosts from `hosts:` (by name or shortcut), then an exact cache lookup, a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached.
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. It adds the user and port (a static host's own, else the defaults) in the program's syntax unless you gave your own.
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP. 6. It merges the SSH options for the host (global, instance, matching rules), resolves named forwards, and inserts the options before the first operand: jump host, host key checking, `-o` options, forwards, and for `tunnel` `-N` with `ExitOnForwardFailure`.
7. It replaces the hostname with the resolved IP (or the name, with `ssh.target: name`; IPv6 in brackets for sftp/scp) and calls `syscall.Exec` to become `ssh`, `sftp`, or `scp` — or prints the command with `--dry-run`.
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"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/setup"
)
func hostsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "hosts",
Short: "Manage static hosts that are not in NetBox",
Long: `Static hosts are defined under hosts: in the config. They are found before
NetBox, work offline, and support shortcuts, rules (kind: static), aliases,
completion, sftp, scp, and tunnels like NetBox hosts.`,
RunE: func(cmd *cobra.Command, args []string) error { return listHosts() },
}
cmd.AddCommand(hostsListCmd(), hostsAddCmd(), hostsRemoveCmd())
return cmd
}
func hostsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List static hosts",
RunE: func(cmd *cobra.Command, args []string) error { return listHosts() },
}
}
func listHosts() error {
cfg, err := config.Load()
if err != nil {
return err
}
if len(cfg.Hosts) == 0 {
fmt.Println("No static hosts. Add one with: netssh hosts add NAME ADDRESS")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tADDRESS\tUSER\tPORT\tTAGS")
for _, host := range cfg.Hosts {
port := ""
if host.Port > 0 {
port = fmt.Sprint(host.Port)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", host.Name, host.Address, host.User, port, strings.Join(host.Tags, ","))
}
return w.Flush()
}
func hostsAddCmd() *cobra.Command {
var host config.StaticHost
var replace bool
cmd := &cobra.Command{
Use: "add NAME ADDRESS",
Short: "Add a static host (or replace one with --replace)",
Example: ` netssh hosts add router 192.168.1.1 --user admin
netssh hosts add backup backup.example.org --port 2222 --tag offsite`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
host.Name, host.Address = args[0], args[1]
if existing := cfg.StaticHost(host.Name); existing != nil {
if !replace {
return fmt.Errorf("static host %q already exists; use --replace to overwrite it", existing.Name)
}
host.SSH = existing.SSH // keep SSH options set in the config or wizard
*existing = host
} else {
cfg.Hosts = append(cfg.Hosts, host)
}
return saveHosts(cfg)
},
}
cmd.Flags().StringVar(&host.User, "user", "", "Login user (default: ssh.default_user)")
cmd.Flags().IntVar(&host.Port, "port", 0, "SSH port (default: ssh.default_port or 22)")
cmd.Flags().StringSliceVar(&host.Tags, "tag", nil, "Tag for ssh.rules (repeatable)")
cmd.Flags().BoolVar(&replace, "replace", false, "Overwrite an existing static host")
return cmd
}
func hostsRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove NAME",
Aliases: []string{"rm"},
Short: "Remove a static host",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
for i, host := range cfg.Hosts {
if strings.EqualFold(host.Name, args[0]) {
cfg.Hosts = append(cfg.Hosts[:i], cfg.Hosts[i+1:]...)
return saveHosts(cfg)
}
}
return fmt.Errorf("no static host %q", args[0])
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := config.Load()
if err != nil || len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []string
for _, host := range cfg.Hosts {
if strings.HasPrefix(strings.ToLower(host.Name), strings.ToLower(toComplete)) {
names = append(names, host.Name)
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
}
}
// saveHosts writes the config and regenerates the shell aliases.
func saveHosts(cfg *config.Config) error {
if err := setup.Save(*cfg); err != nil {
return err
}
loaded, err := config.Load()
if err != nil {
return err
}
c := newCache(loaded)
_ = c.Load()
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// prepareConfig writes data as the only config, with empty cache and home.
func prepareConfig(t *testing.T, data string) string {
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", "")
path := filepath.Join(dir, "netssh.yaml")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return path
}
const staticOnlyConfig = `ssh:
default_user: ops
rules:
- match: {kind: static, tag: home}
host_key_checking: accept-new
shortcuts:
domains: [.lan]
hosts:
- name: router.lan
address: 192.168.1.1
user: admin
port: 2222
tags: [home]
- name: nas
address: nas.example.org
ssh:
proxy_jump: jh.example.com
`
// Static hosts work without any NetBox and with their own user, port, tags, and options.
func TestStaticHostsWithoutNetBox(t *testing.T) {
prepareConfig(t, staticOnlyConfig)
for _, tc := range []struct {
args []string
want string
}{
{[]string{"--dry-run", "router.lan"}, "ssh -p 2222 -l admin -o StrictHostKeyChecking=accept-new 192.168.1.1"},
{[]string{"--dry-run", "router"}, "ssh -p 2222 -l admin -o StrictHostKeyChecking=accept-new 192.168.1.1"}, // shortcut
{[]string{"--dry-run", "-l", "root", "nas", "uptime"}, "ssh -l root -J jh.example.com nas.example.org uptime"},
{[]string{"sftp", "--dry-run", "router:/etc"}, "sftp -P 2222 -o User=admin -o StrictHostKeyChecking=accept-new 192.168.1.1:/etc"},
{[]string{"scp", "--dry-run", "a.txt", "nas:/tmp/"}, "scp -o User=ops -J jh.example.com a.txt nas.example.org:/tmp/"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || out != tc.want {
t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want)
}
}
if out, err := runNetssh(t, "--dry-run", "unknown"); err == nil || !strings.Contains(out, "no NetBox is configured") {
t.Errorf("unknown host: %s (%v)", out, err)
}
if out, _ := runNetssh(t, "__complete", "rou"); !strings.HasPrefix(out, "router.lan\n") {
t.Errorf("completion: %q", out)
}
if out, _ := runNetssh(t, "alias", "--shell", "bash"); !strings.Contains(out, "alias router='netssh router.lan'") || !strings.Contains(out, "alias nas='netssh nas'") {
t.Errorf("aliases: %s", out)
}
}
func TestHostsCommand(t *testing.T) {
path := prepareConfig(t, "hosts:\n - {name: nas, address: nas.example.org, ssh: {proxy_jump: jh}}\n")
steps := []struct {
args []string
wantErr string
}{
{[]string{"hosts", "add", "router", "192.168.1.1", "--user", "admin", "--port", "2222", "--tag", "home", "--tag", "lab"}, ""},
{[]string{"hosts", "add", "router", "10.0.0.1"}, "already exists"},
{[]string{"hosts", "add", "nas", "10.0.0.5", "--replace"}, ""},
{[]string{"hosts", "add", "bad name", "10.0.0.1"}, "must start with"},
{[]string{"hosts", "remove", "missing"}, "no static host"},
}
for _, step := range steps {
out, err := runNetssh(t, step.args...)
if step.wantErr == "" && err != nil || step.wantErr != "" && !strings.Contains(out, step.wantErr) {
t.Fatalf("%v: %s (%v)", step.args, out, err)
}
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
router, nas := cfg.StaticHost("router"), cfg.StaticHost("nas")
if router == nil || router.User != "admin" || router.Port != 2222 || strings.Join(router.Tags, ",") != "home,lab" {
t.Fatalf("router: %+v", router)
}
if nas == nil || nas.Address != "10.0.0.5" || nas.SSH.ProxyJump != "jh" {
t.Fatalf("--replace must keep SSH options: %+v", nas)
}
out, err := runNetssh(t, "hosts", "list")
if err != nil || !strings.Contains(out, "router 192.168.1.1 admin 2222 home,lab") {
t.Fatalf("list:\n%s (%v)", out, err)
}
if out, err := runNetssh(t, "hosts", "rm", "router"); err != nil {
t.Fatalf("remove: %s (%v)", out, err)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "router") || strings.Contains(string(data), "netbox:") {
t.Fatalf("config after remove:\n%s", data)
}
}
// The hostname strategy passes the NetBox name (plus domain) instead of an IP.
func TestHostnameStrategyConnects(t *testing.T) {
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web01"}, multiHost("db01", "10.0.0.7")}, nil)
prepareConfig(t, fmt.Sprintf("netbox:\n url: %q\n token: t\nresolver:\n strategies: [primary_ip, hostname]\n hostname_domain: example.com\n", srv.URL))
for host, want := range map[string]string{"web01": "ssh web01.example.com", "db01": "ssh 10.0.0.7"} {
if out, err := runNetssh(t, "--dry-run", host); err != nil || out != want {
t.Errorf("%s: got %s (%v), want %s", host, out, err, want)
}
}
}
+92
View File
@@ -1,11 +1,20 @@
package main package main
import ( 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/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
) )
// newCache opens the host cache with the config's static hosts.
func newCache(cfg *config.Config) *cache.Cache {
return cache.New(cfg.CachePath(), cfg.Cache.TTL).WithStatic(cfg.Hosts)
}
func newNetBoxClient(cfg *config.Config) *netbox.Client { func newNetBoxClient(cfg *config.Config) *netbox.Client {
if len(cfg.NetBoxes) > 0 { if len(cfg.NetBoxes) > 0 {
sources := make([]netbox.Source, 0, len(cfg.NetBoxes)) sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
@@ -30,3 +39,86 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
return 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
}
+111
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -206,3 +207,113 @@ func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
} }
} }
} }
func TestHostCompletionsMatchTypedText(t *testing.T) {
entries := []cache.Entry{
{Name: "prod/fsn1-bx1.example.com"},
{Name: "prod/fsn1-bx2.example.com"},
{Name: "stg/fsn1-bx2.example.com"},
{Name: "stg/hel1-tb1.example.com"},
{Name: "prod/HEL1-TB2"},
}
for _, tc := range []struct {
input string
want []string
}{
// Unique names are bare; names in both instances stay qualified.
{"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}},
{"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}},
{"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}},
{"stg/fsn", []string{"stg/fsn1-bx2.example.com"}},
// An instance name typed without the slash completes qualified targets.
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
} {
if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Single-instance caches are unchanged.
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
t.Errorf("single instance: %v", got)
}
}
var fsnRewrite = config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
func TestHostCompletionsOfferShortcuts(t *testing.T) {
entries := []cache.Entry{
{Name: "prod/fsn1-bx1.hoster.de"},
{Name: "prod/fsn1-db1.hoster.de"},
{Name: "stg/fsn1-db1.hoster.de"},
{Name: "prod/web01.hoster.de"},
}
for _, tc := range []struct {
input string
want []string
}{
{"fsnb", []string{"fsnbx1"}},
// Shortcut shared by two instances stays qualified.
{"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}},
{"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}},
{"stg/fsnd", []string{"stg/fsndb1"}},
// A matching full name is not repeated as shortcut.
{"web", []string{"web01.hoster.de"}},
} {
if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
}
}
// Two hosts of the same instance with one shortcut cannot be selected by it.
same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}}
if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 {
t.Errorf("ambiguous shortcut offered: %v", got)
}
}
func TestShortcutTestOutput(t *testing.T) {
var out bytes.Buffer
printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite)
want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n"
if out.String() != want {
t.Fatalf("got:\n%s", out.String())
}
}
// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable,
// so only the cached host can answer.
func TestWrapperConnectsByRewriteShortcut(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n"
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if err != nil || !strings.Contains(string(out), "10.0.0.9\n") {
t.Fatalf("shortcut not resolved from cache: %s, %v", out, err)
}
}
+323 -103
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -39,6 +40,14 @@ var managedSubcommands = map[string]bool{
"completion": true, "completion": true,
"__complete": true, "__complete": true,
"help": true, "help": true,
"version": true,
"uninstall": true,
"sftp": true,
"scp": true,
"tunnel": true,
"hosts": true,
"multi": true,
"sync": true,
} }
func main() { func main() {
@@ -48,6 +57,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 {
@@ -65,7 +78,7 @@ func loadConfigOrSetup() *config.Config {
if err != nil { if err != nil {
fatalf("config: %v", err) fatalf("config: %v", err)
} }
if !cfg.HasNetBox() { if !cfg.HasHosts() {
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)
@@ -80,47 +93,155 @@ 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) {
cfg := loadConfigOrSetup() flags, args, err := internalssh.ExtractWrapperFlags(args)
if err != nil {
parsed := internalssh.Parse(args)
if parsed == nil {
// No destination found — pass arguments straight through to ssh.
if err := internalssh.Exec(args); err != nil {
fatalf("%v", err) fatalf("%v", err)
} }
runClient(internalssh.SSH, flags, args)
}
// destination is the NetBox host named in the arguments of a client.
type destination struct {
host string
user string
dests []int // argument indices naming the host; several only for scp
}
func findDestination(client internalssh.Client, args []string) (*destination, error) {
if client.Name == internalssh.SCP.Name {
operands, err := client.Operands(args)
if err != nil || len(operands) == 0 {
return nil, err
}
dest := &destination{host: operands[0].Host}
for _, operand := range operands {
dest.dests = append(dest.dests, operand.Idx)
if operand.User != "" {
dest.user = operand.User
}
}
return dest, nil
}
parsed := client.Parse(args)
if parsed == nil {
return nil, nil
}
return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil
}
// runClient resolves the NetBox host in args and execs ssh, sftp, or scp.
func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) {
cfg := loadConfigOrSetup()
client = client.WithInstances(instanceNames(cfg))
dest, err := findDestination(client, args)
if err != nil {
fatalf("%v", err)
}
if dest == nil {
if len(flags.Forwards) > 0 {
fatalf("--forward needs a destination host")
}
// No destination found — pass arguments straight through.
execClient(client, internalssh.Result{Args: args}, flags)
return return
} }
// Inject the configured default user if none was given on the command line. host := resolveTarget(cfg, dest.host)
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) { user, port := hostDefaults(cfg, host)
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...) // Inject the default user if none was given on the command line.
parsed.DestIdx += 2 if user != "" && dest.user == "" && !client.HasUserFlag(args) {
args = prependArgs(client.UserArgs(user), args, dest.dests)
} }
// Inject the configured default port if none was given on the command line. // Inject the default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) { if port > 0 && !client.HasPortFlag(args) {
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...) args = prependArgs(client.PortArgs(strconv.Itoa(port)), args, dest.dests)
parsed.DestIdx += 2
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) connectHost(cfg, client, flags, host, dest.dests, args)
}
// hostDefaults returns the login user and port for host: those of a static
// host if set, else ssh.default_user and ssh.default_port.
func hostDefaults(cfg *config.Config, host resolvedHost) (string, int) {
user, port := cfg.SSH.DefaultUser, cfg.SSH.DefaultPort
if host.Kind == config.StaticKind {
if static := cfg.StaticHost(host.Name); static != nil {
if static.User != "" {
user = static.User
}
if static.Port > 0 {
port = static.Port
}
}
}
return user, port
}
// instanceNames lists the named NetBox instances, which may prefix hostnames.
func instanceNames(cfg *config.Config) []string {
names := make([]string, 0, len(cfg.NetBoxes))
for _, source := range cfg.NetBoxes {
names = append(names, source.Name)
}
return names
}
// prependArgs puts extra before args and shifts the destination indices.
func prependArgs(extra, args []string, dests []int) []string {
for i := range dests {
dests[i] += len(extra)
}
return append(append([]string(nil), extra...), args...)
}
// resolvedHost is a NetBox host with the address to connect to.
type resolvedHost struct {
Name string // cache/selector name, may carry an instance prefix
Kind string
IP string
Tags []string
}
// resolveTarget finds host in the cache (also by shortcut) or NetBox and
// resolves its address. It exits on failure.
func resolveTarget(cfg *config.Config, host string) resolvedHost {
c := newCache(cfg)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c) maybeBackgroundRefresh(cfg, c)
source, _ := netbox.SplitTarget(parsed.Host) source, _ := netbox.SplitTarget(host)
useCache := len(cfg.NetBoxes) == 0 || source != "" useCache := len(cfg.NetBoxes) == 0 || source != ""
used := func(entry cache.Entry) resolvedHost {
c.MarkUsed(entry.Name)
_ = c.Save()
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags}
}
// Static hosts from the config win and need no NetBox.
if source == "" {
var normalize func(string) string
if cfg.Shortcuts.Enabled() {
normalize = normalizeTarget(cfg.Shortcuts, false)
}
entry, found, err := c.GetStatic(host, normalize)
if err != nil {
fatalf("%v", err)
}
if found {
return used(entry)
}
}
// 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 && useCache { if entry, fresh := c.Get(host); fresh && useCache {
c.MarkUsed(parsed.Host) return used(entry)
_ = c.Save()
connect(entry.IP, parsed, args)
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 := host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens shortcutsEnabled := cfg.Shortcuts.Enabled()
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
normalize := normalizeTarget(cfg.Shortcuts, source != "") normalize := normalizeTarget(cfg.Shortcuts, source != "")
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
@@ -129,10 +250,7 @@ func runSSHWrapper(args []string) {
} }
if found { if found {
if fresh { if fresh {
c.MarkUsed(entry.Name) return used(entry)
_ = c.Save()
connect(entry.IP, parsed, args)
return
} }
// Stale shortcut match — use canonical name for NetBox re-fetch. // Stale shortcut match — use canonical name for NetBox re-fetch.
lookupHost = entry.Name lookupHost = entry.Name
@@ -140,7 +258,7 @@ func runSSHWrapper(args []string) {
} }
if !cfg.HasNetBox() { if !cfg.HasNetBox() {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)") fatalf("host %q is not in hosts: and no NetBox is configured (netssh configure)", host)
} }
nbClient := newNetBoxClient(cfg) nbClient := newNetBoxClient(cfg)
@@ -149,7 +267,7 @@ func runSSHWrapper(args []string) {
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 { if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false)) entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false))
if cacheErr != nil { if cacheErr != nil {
fatalf("%v", cacheErr) fatalf("%v", cacheErr)
} }
@@ -162,10 +280,7 @@ func runSSHWrapper(args []string) {
} }
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind { if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
c.MarkUsed(target.Target()) return used(cached)
_ = c.Save()
connect(cached.IP, parsed, args)
return
} }
chain, err := resolver.New(cfg.Resolver) chain, err := resolver.New(cfg.Resolver)
@@ -175,14 +290,12 @@ func runSSHWrapper(args []string) {
ip, err := chain.Resolve(ctx, &target, nbClient) ip, err := chain.Resolve(ctx, &target, nbClient)
if err != nil { if err != nil {
fatalf("IP resolution for %q: %v", parsed.Host, err) fatalf("IP resolution for %q: %v", host, err)
} }
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}) entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}
c.MarkUsed(target.Target()) c.Upsert(entry)
_ = c.Save() return used(entry)
connect(ip, parsed, args)
} }
// findSSHHost uses the paginated, ambiguity-checking lookup for connection // findSSHHost uses the paginated, ambiguity-checking lookup for connection
@@ -205,9 +318,43 @@ 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 the client.
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip) func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) {
if err := internalssh.Exec(newArgs); err != nil { opts := hostOptions(cfg, host)
if len(flags.Forwards) > 0 {
forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards)
if err != nil {
fatalf("%v", err)
}
flags.ForwardArgs = forwardArgs
}
if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) {
available := "none configured (ssh.forwards)"
if names := forwardNames(opts); len(names) > 0 {
available = strings.Join(names, ", ")
}
fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available)
}
_, name := netbox.SplitTarget(host.Name)
execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.IP}, opts, flags), flags)
}
// hostOptions merges the global, instance, and rule SSH options for host.
func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions {
instance, name := netbox.SplitTarget(host.Name)
return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags})
}
// execClient replaces the process with the client, or prints the command for --dry-run.
func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) {
if flags.DryRun {
fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...)))
return
}
if result.Notice != "" {
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
}
if err := client.Exec(result.Args); err != nil {
fatalf("%v", err) fatalf("%v", err)
} }
} }
@@ -216,7 +363,7 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
func runTUI() { func runTUI() {
cfg := loadConfigOrSetup() cfg := loadConfigOrSetup()
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c) maybeBackgroundRefresh(cfg, c)
@@ -227,6 +374,9 @@ func runTUI() {
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient)) m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
m.SetForwards(func(name, kind string, tags []string) []string {
return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags}))
})
p := tea.NewProgram(m) p := tea.NewProgram(m)
final, err := p.Run() final, err := p.Run()
if err != nil { if err != nil {
@@ -241,10 +391,18 @@ func runTUI() {
if host == nil { if host == nil {
return return
} }
if host.Action == tui.ActionMulti {
if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil {
fatalf("%v", err)
}
return
}
// 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)
@@ -263,37 +421,60 @@ func runTUI() {
if err != nil { if err != nil {
fatalf("IP resolution: %v", err) fatalf("IP resolution: %v", err)
} }
c.Upsert(cache.Entry{Name: target.Target(), 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, Cluster: target.Cluster})
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)
var sshArgs []string
// User override from TUI edit mode takes priority, then config default.
user := host.User
if user == "" {
user = cfg.SSH.DefaultUser
}
if user != "" {
sshArgs = append(sshArgs, "-l", user)
}
port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort)
}
if port != "" {
sshArgs = append(sshArgs, "-p", port)
}
sshArgs = append(sshArgs, host.IP)
c.MarkUsed(host.Name) c.MarkUsed(host.Name)
_ = c.Save() _ = c.Save()
if err := internalssh.Exec(sshArgs); err != nil { client, flags, args := tuiCommand(cfg, host)
fatalf("%v", err) connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args)
} }
// tuiCommand builds the client and arguments for the action chosen in the TUI.
func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) {
client := internalssh.SSH
var flags internalssh.WrapperFlags
switch host.Action {
case tui.ActionSFTP:
client = internalssh.SFTP
case tui.ActionTunnel:
flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true}
}
var args []string
// User override from TUI edit mode takes priority, then static host and config defaults.
defaultUser, defaultPort := hostDefaults(cfg, resolvedHost{Name: host.Name, Kind: host.Kind})
user := host.User
if user == "" {
user = defaultUser
}
if user != "" {
args = append(args, client.UserArgs(user)...)
}
port := host.Port
if port == "" && defaultPort > 0 {
port = strconv.Itoa(defaultPort)
}
if port != "" {
args = append(args, client.PortArgs(port)...)
}
// Apply replaces the destination; a bare name cannot be mistaken for
// host:path by sftp, unlike an IPv6 address.
_, name := netbox.SplitTarget(host.Name)
return client, flags, append(args, name)
}
// forwardNames lists the forwards configured for a host, sorted.
func forwardNames(opts config.SSHOptions) []string {
names := make([]string, 0, len(opts.Forwards))
for name := range opts.Forwards {
names = append(names, name)
}
sort.Strings(names)
return names
} }
// --- cobra root + subcommands --- // --- cobra root + subcommands ---
@@ -304,16 +485,17 @@ func rootCmd() *cobra.Command {
Short: "SSH wrapper with NetBox hostname resolution", Short: "SSH wrapper with NetBox hostname resolution",
DisableFlagParsing: true, DisableFlagParsing: true,
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 && args[len(args)-1] == "--forward" {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp
_ = c.Load() }
hosts := completeHosts(toComplete)
var completions []cobra.Completion completions := make([]cobra.Completion, 0, len(hosts))
for _, e := range c.Search(toComplete) { for _, host := range hosts {
completions = append(completions, cobra.Completion(e.Name)) 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() {
@@ -325,13 +507,24 @@ func rootCmd() *cobra.Command {
}, },
} }
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd()) root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd())
// 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
} }
} }
@@ -369,12 +562,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")
@@ -440,7 +629,7 @@ func searchCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return err return err
} }
@@ -474,7 +663,7 @@ func cacheListCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return err return err
} }
@@ -500,7 +689,7 @@ func cacheClearCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
c.Clear() c.Clear()
if err := c.Save(); err != nil { if err := c.Save(); err != nil {
return err return err
@@ -542,7 +731,7 @@ func cacheRefreshCmd() *cobra.Command {
return fmt.Errorf("NetBox: %w", err) return fmt.Errorf("NetBox: %w", err)
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
chain, err := resolver.New(cfg.Resolver) chain, err := resolver.New(cfg.Resolver)
@@ -573,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command {
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.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags}) c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster})
written++ written++
} }
@@ -606,13 +795,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)"
@@ -625,10 +816,14 @@ Or use in a script:
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
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"))
} }
@@ -655,9 +850,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 {
@@ -670,7 +892,7 @@ func shellInitCmd() *cobra.Command {
if err != nil { if err != nil {
return nil return nil
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
// Regenerate alias files from the current cache every shell start // Regenerate alias files from the current cache every shell start
@@ -697,9 +919,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 {
@@ -711,17 +935,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)
@@ -732,7 +951,7 @@ 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.HasNetBox() { if cfgErr == nil && cfg.HasNetBox() {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
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 {
@@ -784,7 +1003,8 @@ 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.HasNetBox() { // netssh multi checks once and starts its panes with NETSSH_NO_REFRESH.
if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" {
return return
} }
if !c.NeedsRefresh(24 * time.Hour) { if !c.NeedsRefresh(24 * time.Hour) {
+414
View File
@@ -0,0 +1,414 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"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/multiplex"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// maxHostsWithoutConfirm guards against opening hundreds of panes by accident.
const maxHostsWithoutConfirm = 24
// multiFlags are the options shared by netssh multi and the TUI.
type multiFlags struct {
kind, user, jump, multiplexer string
noJump, sync, dryRun, yes bool
}
func multiCmd() *cobra.Command {
var f multiFlags
var useTmux, useZellij bool
cmd := &cobra.Command{
Use: "multi [flags] HOST|tag:TAG|cluster:NAME|@GROUP...",
Short: "Open several hosts side by side in tmux or zellij",
Long: `Open one pane per host in a tmux window or zellij tab. Hosts are given as
names (shortcuts, instance/host, static hosts) or selectors:
tag:TAG all hosts with this NetBox tag, and static hosts with this tag
cluster:NAME devices of a NetBox virtualization cluster (--kind vm|all for VMs)
@GROUP a group from the config (groups:)
Broadcast typing to all panes of a window with --sync, or toggle it later with
netssh sync. Inside tmux or zellij the panes open in the current session.`,
Example: ` netssh multi web01 web02 db01
netssh multi cluster:pve-fsn1
netssh multi --kind vm cluster:pve-fsn1
netssh multi --sync tag:k8s-worker
netssh multi @k8s`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if useTmux && useZellij {
return errors.New("--tmux and --zellij are mutually exclusive")
}
if useTmux {
f.multiplexer = multiplex.Tmux
} else if useZellij {
f.multiplexer = multiplex.Zellij
}
return runMulti(loadConfigOrSetup(), args, f)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return multiCompletions(cfg, toComplete)
},
}
cmd.Flags().StringVar(&f.kind, "kind", "", "Host kind for tag: and cluster: — device, vm, or all (default: all for tags, device for clusters)")
cmd.Flags().BoolVar(&f.sync, "sync", false, "Start with typing broadcast to all panes")
cmd.Flags().BoolVar(&useTmux, "tmux", false, "Use tmux")
cmd.Flags().BoolVar(&useZellij, "zellij", false, "Use zellij")
cmd.Flags().StringVarP(&f.user, "user", "l", "", "Login user for every host")
cmd.Flags().StringVar(&f.jump, "jump", "", "Jump host for every host")
cmd.Flags().BoolVar(&f.noJump, "no-jump", false, "Disable jump hosts")
cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "Print the hosts and multiplexer commands")
cmd.Flags().BoolVarP(&f.yes, "yes", "y", false, fmt.Sprintf("Open more than %d hosts without asking", maxHostsWithoutConfirm))
_ = cmd.RegisterFlagCompletionFunc("kind", cobra.FixedCompletions([]cobra.Completion{"device", "vm", "all"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func syncCmd() *cobra.Command {
var pane bool
cmd := &cobra.Command{
Use: "sync [--pane]",
Short: "Toggle typing into all panes of the current tmux window or zellij tab",
Long: `Toggle broadcast in the current tmux window or zellij tab. With --pane,
exclude the current tmux pane from broadcast, or include it again.
In windows opened by netssh multi, tmux also has keys for this:
prefix S (all panes) and prefix e (this pane); see multi.sync_key and
multi.solo_key. In zellij, use Ctrl+t then s.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
state, err := multiplex.ToggleSync(multiplex.System{}, pane)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "netssh: "+state)
return nil
},
}
cmd.Flags().BoolVar(&pane, "pane", false, "Exclude the current tmux pane from broadcast, or include it again")
return cmd
}
// runMulti expands selectors and opens one pane per host.
func runMulti(cfg *config.Config, selectors []string, f multiFlags) error {
if f.jump != "" && f.noJump {
return errors.New("--jump and --no-jump are mutually exclusive")
}
switch f.kind {
case "", "device", "vm", "all":
default:
return fmt.Errorf("--kind must be device, vm, or all")
}
c := newCache(cfg)
_ = c.Load()
targets, warnings, err := expandSelectors(cfg, c, selectors, f.kind)
for _, warning := range warnings {
fmt.Fprintln(os.Stderr, "netssh: warning: "+warning)
}
if err != nil {
return err
}
if len(targets) > maxHostsWithoutConfirm && !f.yes && !f.dryRun {
return fmt.Errorf("%d hosts selected; pass --yes to open them all", len(targets))
}
var runner multiplex.Runner = multiplex.System{}
if f.dryRun {
fmt.Printf("# %d hosts: %s\n", len(targets), strings.Join(targets, " "))
runner = &multiplex.DryRun{W: os.Stdout}
}
requested := f.multiplexer
if requested == "" {
requested = cfg.Multi.Multiplexer
}
kind, err := multiplex.Detect(requested, runner)
if err != nil {
return err
}
if !f.dryRun {
// Checked once here; the panes skip it (NETSSH_NO_REFRESH).
maybeBackgroundRefresh(cfg, c)
}
panes := make([]multiplex.Pane, 0, len(targets))
for _, target := range targets {
panes = append(panes, multiplex.Pane{Title: target, Argv: paneArgv(target, f)})
}
layoutDir := filepath.Dir(cfg.Cache.Path)
if err := os.MkdirAll(layoutDir, 0o700); err != nil {
return err
}
syncKey, soloKey := cfg.Multi.Keys()
return multiplex.Open(kind, panes, multiplex.Options{
SyncKey: syncKey,
SoloKey: soloKey,
Label: multiLabel(selectors),
Sync: f.sync,
PerWindow: cfg.Multi.PanesPerWindowOrDefault(),
LayoutDir: layoutDir,
Hint: func(s string) { fmt.Fprintln(os.Stderr, "netssh: "+s) },
}, runner)
}
// paneArgv runs netssh for one host. Panes of an existing tmux or zellij
// session inherit that server's environment, so the paths that select the
// config and cache are passed explicitly; NETSSH_* values are not, because
// arguments are visible to other users (tokens).
func paneArgv(target string, f multiFlags) []string {
self, err := os.Executable()
if err != nil {
self = "netssh"
}
env, err := exec.LookPath("env")
if err != nil {
env = "/usr/bin/env"
}
argv := []string{env, "NETSSH_NO_REFRESH=1"}
for _, key := range []string{"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} {
if value, ok := os.LookupEnv(key); ok {
argv = append(argv, key+"="+value)
}
}
argv = append(argv, self)
switch {
case f.jump != "":
argv = append(argv, "--jump", f.jump)
case f.noJump:
argv = append(argv, "--no-jump")
}
if f.user != "" {
argv = append(argv, "-l", f.user)
}
return append(argv, target)
}
var unsafeLabel = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
// multiLabel names the window, session, or tab after a single selector.
func multiLabel(selectors []string) string {
if len(selectors) != 1 {
return "multi"
}
s := selectors[0]
for _, prefix := range []string{"@", "tag:", "cluster:"} {
s = strings.TrimPrefix(s, prefix)
}
if s = strings.Trim(unsafeLabel.ReplaceAllString(s, "-"), "-"); s == "" {
return "multi"
}
return s
}
// expander resolves selectors into an ordered list of unique host targets.
type expander struct {
cfg *config.Config
cache *cache.Cache
kind string
client *netbox.Client
seen map[string]bool
visiting map[string]bool
targets []string
warnings []string
}
func expandSelectors(cfg *config.Config, c *cache.Cache, selectors []string, kind string) ([]string, []string, error) {
e := &expander{cfg: cfg, cache: c, kind: kind, seen: map[string]bool{}, visiting: map[string]bool{}}
for _, selector := range selectors {
if err := e.expand(selector); err != nil {
return nil, e.warnings, err
}
}
if len(e.targets) == 0 {
return nil, e.warnings, errors.New("no hosts selected")
}
return e.targets, e.warnings, nil
}
func (e *expander) add(target string) {
key := strings.ToLower(target)
if !e.seen[key] {
e.seen[key] = true
e.targets = append(e.targets, target)
}
}
func (e *expander) expand(selector string) error {
switch {
case strings.HasPrefix(selector, "@"):
return e.group(strings.ToLower(strings.TrimPrefix(selector, "@")))
case strings.HasPrefix(selector, "tag:"):
return e.query(selector, netbox.SearchOptions{Tag: strings.TrimPrefix(selector, "tag:")}, "all")
case strings.HasPrefix(selector, "cluster:"):
return e.query(selector, netbox.SearchOptions{Cluster: strings.TrimPrefix(selector, "cluster:")}, "device")
case selector == "":
return nil
}
e.add(selector)
return nil
}
func (e *expander) group(name string) error {
members, ok := e.cfg.Groups[name]
if !ok {
names := make([]string, 0, len(e.cfg.Groups))
for group := range e.cfg.Groups {
names = append(names, "@"+group)
}
sort.Strings(names)
if len(names) == 0 {
return fmt.Errorf("unknown group @%s; no groups are configured (groups:)", name)
}
return fmt.Errorf("unknown group @%s; available: %s", name, strings.Join(names, ", "))
}
if e.visiting[name] {
return fmt.Errorf("group @%s contains itself", name)
}
e.visiting[name] = true
defer delete(e.visiting, name)
for _, member := range members {
if err := e.expand(member); err != nil {
return err
}
}
return nil
}
// query expands tag: and cluster: through NetBox, plus static hosts for tags;
// if NetBox fails, the cache answers with a warning.
func (e *expander) query(selector string, opts netbox.SearchOptions, defaultKind string) error {
kind := e.kind
if kind == "" {
kind = defaultKind
}
if kind != "all" {
opts.Kind = kind
}
before := len(e.targets)
if opts.Tag != "" && (kind == "all" || kind == config.StaticKind) {
for _, host := range e.cfg.Hosts {
if hasTag(host.Tags, opts.Tag) {
e.add(host.Name)
}
}
}
if e.cfg.HasNetBox() {
if e.client == nil {
e.client = newNetBoxClient(e.cfg)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
entries, err := e.client.SearchAll(ctx, "", opts)
cancel()
if err != nil {
e.warnings = append(e.warnings, fmt.Sprintf("%s: NetBox unavailable, using the cache: %v", selector, err))
entries = e.fromCache(opts)
} else {
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
}
for _, entry := range entries {
e.add(entry.Target())
}
}
if len(e.targets) == before {
hint := ""
if opts.Cluster != "" && e.kind == "" {
hint = " (only devices by default; try --kind vm or --kind all)"
}
return fmt.Errorf("%s matches no hosts%s", selector, hint)
}
return nil
}
func (e *expander) fromCache(opts netbox.SearchOptions) []netbox.HostEntry {
var out []netbox.HostEntry
for _, entry := range e.cache.All() {
if entry.Static || opts.Kind != "" && entry.Kind != opts.Kind {
continue
}
if opts.Tag != "" && !hasTag(entry.Tags, opts.Tag) || opts.Cluster != "" && !strings.EqualFold(entry.Cluster, opts.Cluster) {
continue
}
source, name := netbox.SplitTarget(entry.Name)
out = append(out, netbox.HostEntry{Source: source, Name: name, Kind: entry.Kind})
}
sort.Slice(out, func(i, j int) bool { return out[i].Target() < out[j].Target() })
return out
}
// hasTag compares a tag by name or slug, like ssh.rules.
func hasTag(tags []string, want string) bool {
for _, tag := range tags {
if strings.EqualFold(tag, want) || strings.EqualFold(strings.Join(strings.Fields(strings.ToLower(tag)), "-"), want) {
return true
}
}
return false
}
// multiCompletions offers hosts, @groups, and tag:/cluster: values from the cache.
func multiCompletions(cfg *config.Config, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
c := newCache(cfg)
_ = c.Load()
lower := strings.ToLower(toComplete)
var out []string
switch {
case strings.HasPrefix(lower, "tag:"), strings.HasPrefix(lower, "cluster:"):
prefix, value, _ := strings.Cut(lower, ":")
values := map[string]bool{}
for _, entry := range c.All() {
if prefix == "tag" {
for _, tag := range entry.Tags {
values[strings.ToLower(tag)] = true
}
} else if entry.Cluster != "" {
values[entry.Cluster] = true
}
}
for v := range values {
if strings.HasPrefix(strings.ToLower(v), value) {
out = append(out, prefix+":"+v)
}
}
case strings.HasPrefix(lower, "@"):
for group := range cfg.Groups {
if strings.HasPrefix("@"+group, lower) {
out = append(out, "@"+group)
}
}
default:
out = completeHosts(toComplete)
var prefixes []string
for _, p := range []string{"tag:", "cluster:"} {
if toComplete != "" && strings.HasPrefix(p, lower) {
prefixes = append(prefixes, p)
}
}
if len(out) == 0 && len(prefixes) > 0 {
return prefixes, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
for group := range cfg.Groups {
if toComplete == "" {
out = append(out, "@"+group)
}
}
}
sort.Strings(out)
return out, cobra.ShellCompDirectiveNoFileComp
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestExpandSelectors(t *testing.T) {
cfg := &config.Config{
Hosts: []config.StaticHost{{Name: "box", Address: "10.0.0.1", Tags: []string{"Web Servers"}}},
Groups: map[string][]string{
"all": {"@web", "db1", "box"},
"web": {"web1", "tag:web-servers", "web1"},
"loop": {"@loop"},
},
}
c := cache.New(t.TempDir()+"/hosts.json", 60).WithStatic(cfg.Hosts)
got, _, err := expandSelectors(cfg, c, []string{"@all", "WEB1"}, "")
if err != nil || !reflect.DeepEqual(got, []string{"web1", "box", "db1"}) {
t.Fatalf("got %v %v", got, err)
}
if _, _, err := expandSelectors(cfg, c, []string{"@loop"}, ""); err == nil || !strings.Contains(err.Error(), "contains itself") {
t.Fatalf("cycle: %v", err)
}
if _, _, err := expandSelectors(cfg, c, []string{"@nope"}, ""); err == nil || !strings.Contains(err.Error(), "available: @all, @loop, @web") {
t.Fatalf("unknown group: %v", err)
}
if _, _, err := expandSelectors(cfg, c, []string{"tag:web-servers"}, "device"); err == nil {
t.Fatal("--kind device must exclude static hosts")
}
}
// Without a reachable NetBox, tag: and cluster: fall back to the cache.
func TestExpandSelectorsCacheFallback(t *testing.T) {
cfg := &config.Config{NetBox: config.NetBoxConfig{URL: "http://127.0.0.1:1", Token: "t", TokenVersion: 1}}
c := cache.New(t.TempDir()+"/hosts.json", 60)
c.Upsert(cache.Entry{Name: "node2", Kind: "device", Cluster: "pve"})
c.Upsert(cache.Entry{Name: "node1", Kind: "device", Cluster: "pve", Tags: []string{"pve"}})
c.Upsert(cache.Entry{Name: "vm1", Kind: "vm", Cluster: "pve"})
got, warnings, err := expandSelectors(cfg, c, []string{"cluster:PVE"}, "")
if err != nil || !reflect.DeepEqual(got, []string{"node1", "node2"}) || len(warnings) != 1 {
t.Fatalf("devices: %v %v %v", got, warnings, err)
}
if got, _, _ := expandSelectors(cfg, c, []string{"cluster:pve"}, "all"); len(got) != 3 {
t.Fatalf("all: %v", got)
}
if _, _, err := expandSelectors(cfg, c, []string{"cluster:other"}, ""); err == nil || !strings.Contains(err.Error(), "--kind vm") {
t.Fatalf("empty cluster: %v", err)
}
}
func TestMultiLabelAndPaneArgv(t *testing.T) {
for selectors, want := range map[string]string{"@k8s": "k8s", "cluster:pve-fsn1": "pve-fsn1", "tag:a.b": "a-b", "a b": "multi"} {
if got := multiLabel(strings.Fields(selectors)); got != want {
t.Errorf("%q: got %q", selectors, got)
}
}
t.Setenv("XDG_CONFIG_HOME", "/cfg")
t.Setenv("NETSSH_NETBOX_TOKEN", "secret")
argv := strings.Join(paneArgv("prod/web1", multiFlags{user: "ops", jump: "jh"}), " ")
for _, want := range []string{"NETSSH_NO_REFRESH=1", "XDG_CONFIG_HOME=/cfg", "--jump jh -l ops prod/web1"} {
if !strings.Contains(argv, want) {
t.Errorf("argv lacks %q: %s", want, argv)
}
}
if strings.Contains(argv, "secret") {
t.Fatalf("token leaked into the pane arguments: %s", argv)
}
}
func TestMultiDryRun(t *testing.T) {
prepareConfig(t, "hosts:\n - {name: a, address: 10.0.0.1, tags: [web]}\n - {name: b, address: 10.0.0.2, tags: [web]}\ngroups:\n web: [\"tag:web\"]\n")
out, err := runNetssh(t, "multi", "--dry-run", "--tmux", "--sync", "@web")
if err != nil {
t.Fatalf("%s %v", out, err)
}
for _, want := range []string{"# 2 hosts: a b", "tmux new-session -d -x 240 -y 70 -s netssh-web", "synchronize-panes on", "tmux attach-session -t netssh-web"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q:\n%s", want, out)
}
}
if out, err := runNetssh(t, "multi", "--dry-run", "--zellij", "a", "b"); err != nil || !strings.Contains(out, `pane name="b"`) {
t.Errorf("zellij: %s %v", out, err)
}
if out, err := runNetssh(t, "sync"); err == nil || !strings.Contains(out, "inside tmux or zellij") {
t.Errorf("sync outside: %s %v", out, err)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func notesCmd() *cobra.Command {
client = newNetBoxClient(cfg) client = newNetBoxClient(cfg)
} }
host := netbox.HostEntry{Name: args[0], Kind: kind} host := netbox.HostEntry{Name: args[0], Kind: kind}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return err return err
} }
+198
View File
@@ -0,0 +1,198 @@
package main
import (
"sort"
"strings"
"github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
)
func sftpCmd() *cobra.Command {
return transferCmd(internalssh.SFTP, "sftp [--dry-run] [--jump HOST|--no-jump] [sftp options] HOST[:PATH]",
"Open an SFTP session to a NetBox host",
`Resolve HOST like netssh does for ssh (cache, shortcuts, instance/host,
SSH options and rules, jump host) and run sftp. sftp options are passed
through; note that sftp uses -P for the port.
netssh sftp fsnbx1
netssh sftp -P 2222 prod/web01:/var/log`)
}
func scpCmd() *cobra.Command {
return transferCmd(internalssh.SCP, "scp [--dry-run] [--jump HOST|--no-jump] [scp options] SOURCE... TARGET",
"Copy files to or from a NetBox host",
`Resolve the host in HOST:PATH operands like netssh does for ssh and run scp.
All remote operands must name the same host.
netssh scp ./app.tar fsnbx1:/tmp/
netssh scp -r prod/web01:/etc/nginx ./nginx`)
}
func transferCmd(client internalssh.Client, use, short, long string) *cobra.Command {
return &cobra.Command{
Use: use,
Short: short,
Long: long,
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_ = cmd.Help()
return
}
flags, args, err := client.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
if len(flags.Forwards) > 0 {
fatalf("--forward is only available for ssh and netssh tunnel")
}
runClient(client, flags, args)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if client.Name == internalssh.SFTP.Name && client.Parse(args) != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if strings.HasPrefix(toComplete, "-") {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if client.Name == internalssh.SCP.Name {
if strings.Contains(toComplete, ":") {
return nil, cobra.ShellCompDirectiveNoFileComp // remote paths are not completed
}
// HOST: for remote operands; without a matching host, local files.
hosts := completeHosts(toComplete)
if len(hosts) == 0 {
return nil, cobra.ShellCompDirectiveDefault
}
for i := range hosts {
hosts[i] += ":"
}
return hosts, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
},
}
}
func tunnelCmd() *cobra.Command {
return &cobra.Command{
Use: "tunnel [--dry-run] [--jump HOST|--no-jump] [ssh options] HOST FORWARD...",
Short: "Open port forwards to a NetBox host without a shell",
Long: `Run ssh -N with the named forwards configured under ssh.forwards (globally,
per NetBox instance, or per rule). A FORWARD that is no configured name but a
port spec such as 8080:localhost:80 is used as a local forward (-L).
netssh tunnel fsnbx1 web socks
netssh tunnel prod/db01 5432:localhost:5432`,
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_ = cmd.Help()
return
}
flags, args, err := internalssh.SSH.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
parsed := internalssh.SSH.Parse(args)
if parsed == nil {
fatalf("usage: netssh tunnel HOST FORWARD...")
}
// Everything after the host names forwards instead of a remote command.
flags.Forwards = append(flags.Forwards, args[parsed.DestIdx+1:]...)
flags.Tunnel = true
runClient(internalssh.SSH, flags, args[:parsed.DestIdx+1])
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
parsed := internalssh.SSH.Parse(args)
if parsed == nil {
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
}
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
var names []string
if entry, ok := cachedHost(cfg, parsed.Host); ok {
names = forwardNames(hostOptions(cfg, resolvedHost{Name: entry.Name, Kind: entry.Kind, Tags: entry.Tags}))
} else {
names = allForwardNames(cfg)
}
return filterPrefix(names, toComplete, args[parsed.DestIdx+1:]), cobra.ShellCompDirectiveNoFileComp
},
}
}
// completeHosts returns the cached hosts for a completion request.
func completeHosts(toComplete string) []string {
cfg, err := config.Load()
if err != nil {
return nil
}
c := newCache(cfg)
_ = c.Load()
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
hosts = hosts[:limit]
}
return hosts
}
// cachedHost finds a host for completion by name or shortcut, without NetBox.
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) {
c := newCache(cfg)
if err := c.Load(); err != nil {
return cache.Entry{}, false
}
if entry, _ := c.Get(host); entry.Name != "" {
return entry, true
}
if !cfg.Shortcuts.Enabled() {
return cache.Entry{}, false
}
entry, found, _, err := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, strings.Contains(host, "/")))
return entry, found && err == nil
}
// allForwardNames lists every configured forward name, for when the host is unknown.
func allForwardNames(cfg *config.Config) []string {
seen := map[string]bool{}
add := func(o config.SSHOptions) {
for name := range o.Forwards {
seen[strings.ToLower(name)] = true
}
}
add(cfg.SSH.SSHOptions)
add(cfg.NetBox.SSH)
for _, source := range cfg.NetBoxes {
add(source.SSH)
}
for _, rule := range cfg.SSH.Rules {
add(rule.SSHOptions)
}
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
return names
}
// filterPrefix keeps the names starting with prefix that were not given yet.
func filterPrefix(names []string, prefix string, given []string) []string {
used := map[string]bool{}
for _, name := range given {
used[strings.ToLower(name)] = true
}
var out []string
for _, name := range names {
if strings.HasPrefix(name, strings.ToLower(prefix)) && !used[name] {
out = append(out, name)
}
}
return out
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// TestNetsshHelper runs main() with the arguments in NETSSH_TEST_ARGS.
func TestNetsshHelper(t *testing.T) {
if args := os.Getenv("NETSSH_TEST_ARGS"); args != "" {
os.Args = append([]string{"netssh"}, strings.Split(args, "\x1f")...)
main()
os.Exit(0)
}
}
// prepareTransferTest writes a config whose only host is cached; NetBox is
// unreachable, so every result must come from cache, shortcuts, and options.
func prepareTransferTest(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := `netbox:
url: http://127.0.0.1:1
token: t
ssh:
default_user: ops
default_port: 2222
extra_args: ["-A"]
forwards:
web: {local: "8080:localhost:80"}
rules:
- match: {tag: dmz}
proxy_jump: jh.example.com
forwards:
socks: {dynamic: "1080"}
shortcuts:
domains: [.hoster.de]
rewrites:
- pattern: '^([a-z]+)[0-9]+-'
replace: '$1'
`
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), 3600)
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device", Tags: []string{"dmz"}})
if err := c.Save(); err != nil {
t.Fatal(err)
}
}
func runNetssh(t *testing.T, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=^TestNetsshHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_ARGS="+strings.Join(args, "\x1f"))
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func TestTransferAndTunnelCommands(t *testing.T) {
prepareTransferTest(t)
for _, tc := range []struct {
args []string
want string
}{
// sftp: -P and -o User, rule jump host, path kept, no ssh-only extra_args.
{[]string{"sftp", "--dry-run", "fsnbx1:/var/log"}, "sftp -P 2222 -o User=ops -J jh.example.com 10.0.0.9:/var/log"},
{[]string{"sftp", "--dry-run", "-P", "22", "root@fsnbx1"}, "sftp -P 22 -J jh.example.com root@10.0.0.9"},
{[]string{"scp", "--dry-run", "-r", "a.txt", "fsn1-bx1.hoster.de:/tmp/"}, "scp -P 2222 -o User=ops -r -J jh.example.com a.txt 10.0.0.9:/tmp/"},
{[]string{"scp", "--dry-run", "--no-jump", "fsnbx1:/etc/hosts", "."}, "scp -P 2222 -o User=ops -o ProxyJump=none 10.0.0.9:/etc/hosts ."},
// Forwards come from the global options and the matching rule.
{[]string{"tunnel", "--dry-run", "fsnbx1", "web", "socks", "5432:db:5432"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 -D 1080 -L 5432:db:5432 -o ExitOnForwardFailure=yes -N 10.0.0.9"},
{[]string{"--dry-run", "--forward", "web", "fsnbx1"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 10.0.0.9"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || out != tc.want {
t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want)
}
}
for _, tc := range []struct {
args []string
want string
}{
{[]string{"tunnel", "--dry-run", "fsnbx1"}, "forwards for fsn1-bx1.hoster.de: socks, web"},
{[]string{"tunnel", "--dry-run", "fsnbx1", "db"}, `unknown forward "db"; available: socks, web`},
{[]string{"sftp", "--forward", "web", "fsnbx1"}, "only available for ssh"},
{[]string{"scp", "fsnbx1:a", "other:b"}, "not supported"},
} {
out, err := runNetssh(t, tc.args...)
if err == nil || !strings.Contains(out, tc.want) {
t.Errorf("%v: got %s (%v), want error containing %q", tc.args, out, err, tc.want)
}
}
}
func TestTransferCompletion(t *testing.T) {
prepareTransferTest(t)
for _, tc := range []struct {
args []string
want string
}{
{[]string{"__complete", "sftp", "fsnb"}, "fsnbx1\n"},
{[]string{"__complete", "scp", "a.txt", "fsnb"}, "fsnbx1:\n"},
{[]string{"__complete", "tunnel", "fsnbx1", ""}, "socks\nweb\n"},
{[]string{"__complete", "tunnel", "fsnbx1", "web", ""}, "socks\n"},
{[]string{"__complete", "--forward", ""}, "socks\nweb\n"},
} {
out, err := runNetssh(t, tc.args...)
if err != nil || !strings.HasPrefix(out+"\n", tc.want) {
t.Errorf("%v: got %q (%v), want prefix %q", tc.args, out, err, tc.want)
}
}
}
+250
View File
@@ -0,0 +1,250 @@
package main
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
)
// uninstallPlan lists everything netssh has written to this machine.
type uninstallPlan struct {
Profiles []string // shell profiles with a netssh block or legacy lines
Paths []string // files and directories to delete
Config string // config file to back up and delete; empty when kept
Binaries []string
}
func buildUninstallPlan(cfg *config.Config, keepConfig bool) uninstallPlan {
var plan uninstallPlan
seen := make(map[string]bool)
addPath := func(path string) {
if path == "" || seen[path] {
return
}
if _, err := os.Lstat(path); err == nil {
seen[path] = true
plan.Paths = append(plan.Paths, path)
}
}
for _, profile := range hook.ProfileCandidates() {
target, err := filepath.EvalSymlinks(profile)
if err != nil || seen[target] {
continue
}
seen[target] = true
if hook.HasEntries(profile) {
plan.Profiles = append(plan.Profiles, profile)
}
}
aliases := []string{hook.CompletionAliasState()}
if cfg != nil {
aliases = append(aliases, cfg.Shell.Alias)
}
for _, path := range hook.CompletionFiles(aliases...) {
if hook.IsGenerated(path) {
addPath(path)
}
}
// The cache directory holds hosts, aliases, refresh stamps, and instance caches.
cacheDir := filepath.Dir(hook.AliasesPath())
addPath(cacheDir)
if cfg != nil {
for _, path := range []string{cfg.Cache.Path, cfg.CachePath()} {
if path == "" || strings.HasPrefix(path, cacheDir+string(filepath.Separator)) {
continue
}
addPath(path)
addPath(path + ".lock")
addPath(filepath.Join(filepath.Dir(path), "last_refresh"))
if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") {
addPath(filepath.Dir(path))
}
}
}
if !keepConfig {
if _, err := os.Stat(config.Path()); err == nil {
plan.Config = config.Path()
}
// Local server notes live next to the config file.
addPath(filepath.Join(filepath.Dir(config.Path()), "netssh"))
}
plan.Binaries = installedBinaries()
return plan
}
// installedBinaries finds the running binary and any other netssh in PATH.
func installedBinaries() []string {
var out []string
seen := make(map[string]bool)
add := func(path string) {
real, err := filepath.EvalSymlinks(path)
if err != nil || seen[real] {
return
}
if info, err := os.Stat(real); err != nil || info.IsDir() || filepath.Base(real) != "netssh" {
return
}
seen[real] = true
out = append(out, real)
}
if self, err := os.Executable(); err == nil {
add(self)
}
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir != "" {
add(filepath.Join(dir, "netssh"))
}
}
return out
}
func (p uninstallPlan) empty() bool {
return len(p.Profiles) == 0 && len(p.Paths) == 0 && p.Config == "" && len(p.Binaries) == 0
}
func (p uninstallPlan) print(w io.Writer) {
for _, profile := range p.Profiles {
fmt.Fprintf(w, " edit %s (remove netssh entries)\n", profile)
}
for _, path := range p.Paths {
fmt.Fprintf(w, " delete %s\n", path)
}
if p.Config != "" {
fmt.Fprintf(w, " delete %s (a backup is kept)\n", p.Config)
}
for _, binary := range p.Binaries {
fmt.Fprintf(w, " delete %s\n", binary)
}
}
// execute applies the plan. It continues after errors so that one protected
// file does not leave the rest behind, and returns commands that need sudo.
func (p uninstallPlan) execute(w io.Writer) (manual []string, err error) {
var errs []error
for _, profile := range p.Profiles {
if _, err := hook.Uninstall(profile); err != nil {
errs = append(errs, err)
continue
}
fmt.Fprintf(w, "cleaned %s\n", profile)
}
for _, path := range p.Paths {
if err := os.RemoveAll(path); err != nil {
errs = append(errs, err)
continue
}
fmt.Fprintf(w, "removed %s\n", path)
}
if p.Config != "" {
backup, err := backupConfig(p.Config)
if err != nil {
errs = append(errs, fmt.Errorf("config not removed: %w", err))
} else if err := os.Remove(p.Config); err != nil {
errs = append(errs, err)
} else {
fmt.Fprintf(w, "removed %s (backup: %s)\n", p.Config, backup)
}
}
for _, binary := range p.Binaries {
if err := os.Remove(binary); err != nil {
if errors.Is(err, fs.ErrPermission) {
manual = append(manual, "sudo rm -f "+shellQuote(binary))
continue
}
errs = append(errs, err)
continue
}
fmt.Fprintf(w, "removed %s\n", binary)
}
return manual, errors.Join(errs...)
}
// backupConfig keeps a private copy, because the config holds API tokens.
func backupConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
backup := path + ".backup-" + time.Now().Format("20060102-150405")
if err := storage.WriteFile(backup, data, 0o600); err != nil {
return "", err
}
return backup, nil
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func uninstallCmd() *cobra.Command {
var keepConfig, yes, dryRun bool
cmd := &cobra.Command{
Use: "uninstall",
Short: "Remove netssh, its shell integration, cache, and config",
Long: `Removes everything netssh and install.sh have set up:
- netssh blocks and unmarked entries of older versions in bash, zsh, and fish profiles
- shell completion files (bash, zsh, fish, oh-my-zsh cache, command alias)
- the cache directory with hosts, aliases, and per-instance caches
- the config file (a private backup is kept) and local server notes
- the netssh binary
Use --keep-config to keep the config file and notes for a reinstall.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
// A broken config must not block the uninstall.
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
cfg = nil
}
plan := buildUninstallPlan(cfg, keepConfig)
out := cmd.OutOrStdout()
if plan.empty() {
fmt.Fprintln(out, "Nothing to remove.")
return nil
}
fmt.Fprintln(out, "netssh uninstall will:")
plan.print(out)
if dryRun {
return nil
}
if !yes {
fmt.Fprint(out, "Proceed? [y/N] ")
answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
return fmt.Errorf("aborted")
}
}
manual, err := plan.execute(out)
for _, command := range manual {
fmt.Fprintf(out, "Permission denied. Run: %s\n", command)
}
if len(plan.Profiles) > 0 {
fmt.Fprintln(out, "Open a new terminal. For zsh, also run: rm -f ~/.zcompdump*")
}
return err
},
}
cmd.Flags().BoolVar(&keepConfig, "keep-config", false, "Keep the config file and local notes")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Do not ask for confirmation")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would be removed")
return cmd
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
func uninstallFixture(t *testing.T) (home string) {
t.Helper()
home = t.TempDir()
t.Setenv("HOME", home)
t.Setenv("ZDOTDIR", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
t.Setenv("ZSH", "")
t.Setenv("ZSH_CACHE_DIR", "")
t.Setenv("NETSSH_SHELL_ALIAS", "")
t.Setenv("NETSSH_CACHE_PATH", "")
t.Setenv("PATH", filepath.Join(home, "nobin"))
files := map[string]string{
".bashrc": "export KEEP=1\n# >>> netssh >>>\nnetssh shell-init\n# <<< netssh <<<\n",
".zshrc": "plugins=(git)\nnetssh shell-init # netssh cache auto-refresh\nsource ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases\n",
".profile": "export UNRELATED=1\n",
".config/netssh.yaml": "netbox:\n url: \"https://netbox.example.com\"\n token: \"secret\"\nshell:\n alias: \"j\"\n",
".config/netssh/notes/abc.txt": "note",
".cache/netssh/hosts.json": "{}",
".cache/netssh/instances-0011/hosts.json": "{}",
".local/share/bash-completion/completions/netssh": "# bash completion for netssh",
".zfunc/_netssh": hook.ZshCompletionScript,
".zfunc/_j": "#compdef j\n# netssh command alias completion — generated automatically\n",
".zfunc/_other": "#compdef other\n",
}
for name, content := range files {
path := filepath.Join(home, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return home
}
func runUninstall(t *testing.T, args ...string) string {
t.Helper()
cmd := rootCmd()
var out strings.Builder
cmd.SetOut(&out)
cmd.SetArgs(append([]string{"uninstall"}, args...))
if err := cmd.Execute(); err != nil {
t.Fatalf("uninstall: %v\n%s", err, out.String())
}
return out.String()
}
func TestUninstallRemovesEverythingAndBacksUpConfig(t *testing.T) {
home := uninstallFixture(t)
if out := runUninstall(t, "--dry-run"); !strings.Contains(out, ".zshrc") || strings.Contains(out, ".profile") {
t.Fatalf("plan:\n%s", out)
}
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); err != nil {
t.Fatal("dry run removed files")
}
runUninstall(t, "--yes")
for name, want := range map[string]string{".bashrc": "export KEEP=1\n", ".zshrc": "plugins=(git)\n", ".profile": "export UNRELATED=1\n"} {
data, _ := os.ReadFile(filepath.Join(home, name))
if string(data) != want {
t.Errorf("%s: %q", name, data)
}
}
for _, gone := range []string{".cache/netssh", ".config/netssh.yaml", ".config/netssh", ".zfunc/_netssh", ".zfunc/_j", ".local/share/bash-completion/completions/netssh"} {
if _, err := os.Stat(filepath.Join(home, gone)); !os.IsNotExist(err) {
t.Errorf("%s not removed: %v", gone, err)
}
}
if _, err := os.Stat(filepath.Join(home, ".zfunc", "_other")); err != nil {
t.Error("unrelated completion removed")
}
backups, _ := filepath.Glob(filepath.Join(home, ".config", "netssh.yaml.backup-*"))
if len(backups) != 1 {
t.Fatalf("backups: %v", backups)
}
if info, _ := os.Stat(backups[0]); info.Mode().Perm() != 0o600 {
t.Error("backup with token is not private")
}
}
func TestUninstallKeepConfig(t *testing.T) {
home := uninstallFixture(t)
runUninstall(t, "--yes", "--keep-config")
for _, kept := range []string{".config/netssh.yaml", ".config/netssh/notes/abc.txt"} {
if _, err := os.Stat(filepath.Join(home, kept)); err != nil {
t.Errorf("%s removed", kept)
}
}
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); !os.IsNotExist(err) {
t.Error("cache kept")
}
}
func TestUninstallAbortsWithoutConfirmation(t *testing.T) {
home := uninstallFixture(t)
cmd := rootCmd()
cmd.SetOut(&strings.Builder{})
cmd.SetIn(strings.NewReader("n\n"))
cmd.SetArgs([]string{"uninstall"})
if err := cmd.Execute(); err == nil {
t.Fatal("no confirmation required")
}
if _, err := os.Stat(filepath.Join(home, ".config", "netssh.yaml")); err != nil {
t.Fatal("config removed without confirmation")
}
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"fmt"
"runtime/debug"
"github.com/spf13/cobra"
)
// version is set at build time: -ldflags "-X main.version=v1.2.3".
var version = ""
// buildVersion falls back to the module version for `go install` builds.
func buildVersion() string {
if version != "" {
return version
}
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
return "dev"
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the netssh version",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintf(cmd.OutOrStdout(), "netssh %s\n", buildVersion())
},
}
}
+9 -9
View File
@@ -1,15 +1,15 @@
module git.zb-server.de/Sebi/ssh-netbox-wrapper module git.zb-server.de/Sebi/ssh-netbox-wrapper
go 1.27.3 go 1.27.4
require ( require (
charm.land/bubbles/v2 v2.2.1 charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0 charm.land/bubbletea/v2 v2.1.1
charm.land/huh/v2 v2.0.3 charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.6 charm.land/lipgloss/v2 v2.1.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
golang.org/x/sys v0.49.0 golang.org/x/sys v0.50.0
) )
require ( require (
@@ -43,11 +43,11 @@ require (
github.com/sahilm/fuzzy v0.1.3 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.1.0 // indirect
github.com/stretchr/testify v1.12.1 // indirect github.com/stretchr/testify v1.12.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v1.2.0 // indirect github.com/xo/terminfo v1.3.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.6 // indirect
golang.org/x/sync v0.24.0 // indirect golang.org/x/sync v0.25.0 // indirect
golang.org/x/text v0.43.0 // indirect golang.org/x/text v0.44.0 // indirect
) )
+16 -16
View File
@@ -1,11 +1,11 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.1.0 h1:RLCNLhSQST+NPOmvUSbRWZ3UFOXsf3ZXFmJ5E/2tHEU=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= charm.land/lipgloss/v2 v2.1.0/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -87,23 +87,23 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4= github.com/xo/terminfo v1.3.1 h1:4ZwPCuu4PEFw0PkNp3Hv/x3gqvgLCzYhurszsj101cM=
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio= github.com/xo/terminfo v1.3.1/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+22 -1
View File
@@ -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"
+7
View File
@@ -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)
+95 -3
View File
@@ -12,6 +12,7 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
) )
@@ -20,8 +21,12 @@ type Entry struct {
IP string `json:"ip"` IP string `json:"ip"`
Kind string `json:"kind"` Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Cluster string `json:"cluster,omitempty"`
CachedAt time.Time `json:"cached_at"` CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"` LastUsed time.Time `json:"last_used,omitempty"`
// Static marks a host from the config's hosts: list. It never expires;
// it is stored only to remember LastUsed for the recently-used list.
Static bool `json:"static,omitempty"`
} }
type changeKind uint8 type changeKind uint8
@@ -40,6 +45,7 @@ type Cache struct {
path string path string
ttl time.Duration ttl time.Duration
refreshStamp string // path to last_refresh timestamp file refreshStamp string // path to last_refresh timestamp file
static []config.StaticHost
} }
type diskFormat struct { type diskFormat struct {
@@ -98,6 +104,7 @@ func (c *Cache) Load() error {
data, err := os.ReadFile(c.path) data, err := os.ReadFile(c.path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
c.applyStatic()
return nil return nil
} }
if err != nil { if err != nil {
@@ -115,6 +122,7 @@ func (c *Cache) Load() error {
for _, e := range df.Entries { for _, e := range df.Entries {
c.entries[e.Name] = e c.entries[e.Name] = e
} }
c.applyStatic()
return nil return nil
} }
@@ -188,6 +196,15 @@ func (c *Cache) Save() error {
if err := storage.WriteFile(c.path, data, 0o600); err != nil { if err := storage.WriteFile(c.path, data, 0o600); err != nil {
return err return err
} }
// Static hosts come from the config, not the file: keep them in memory.
for name, e := range c.entries {
if e.Static {
if old, ok := merged[name]; ok && old.LastUsed.After(e.LastUsed) {
e.LastUsed = old.LastUsed
}
merged[name] = e
}
}
c.entries = merged c.entries = merged
c.dirty = make(map[string]changeKind) c.dirty = make(map[string]changeKind)
c.cleared = false c.cleared = false
@@ -221,7 +238,10 @@ func (c *Cache) MarkUsed(name string) {
if e, ok := c.entries[name]; ok { if e, ok := c.entries[name]; ok {
e.LastUsed = time.Now() e.LastUsed = time.Now()
c.entries[name] = e c.entries[name] = e
if _, changed := c.dirty[name]; !changed { if e.Static {
// A static host may not be on disk yet; usage alone would not add it.
c.dirty[name] = upsertChange
} else if _, changed := c.dirty[name]; !changed {
c.dirty[name] = usageChange c.dirty[name] = usageChange
} }
} }
@@ -279,7 +299,7 @@ func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (e
entry, found = e, true entry, found = e, true
} }
} }
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil return entry, found, found && (entry.Static || c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl), nil
} }
// Get returns an entry and reports whether it is still within the TTL. // Get returns an entry and reports whether it is still within the TTL.
@@ -291,20 +311,92 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
if !ok { if !ok {
return Entry{}, false return Entry{}, false
} }
if e.Static {
return e, true
}
if c.ttl == 0 { if c.ttl == 0 {
return e, false return e, false
} }
return e, time.Since(e.CachedAt) < c.ttl return e, time.Since(e.CachedAt) < c.ttl
} }
// Clear removes all NetBox entries; static hosts come from the config and stay.
func (c *Cache) Clear() { func (c *Cache) Clear() {
c.mu.Lock() c.mu.Lock()
c.entries = make(map[string]Entry) static := make(map[string]Entry)
for name, e := range c.entries {
if e.Static {
static[name] = e
}
}
c.entries = static
c.dirty = make(map[string]changeKind) c.dirty = make(map[string]changeKind)
c.cleared = true c.cleared = true
c.mu.Unlock() c.mu.Unlock()
} }
// SetStatic adds the config's static hosts. They replace cached entries of the
// same name, keep their recorded LastUsed, and static entries that are no
// longer configured are removed on the next Save.
func (c *Cache) SetStatic(hosts []config.StaticHost) {
c.mu.Lock()
defer c.mu.Unlock()
c.static = hosts
c.applyStatic()
}
// WithStatic sets the static hosts now and after every Load.
func (c *Cache) WithStatic(hosts []config.StaticHost) *Cache {
c.SetStatic(hosts)
return c
}
func (c *Cache) applyStatic() {
hosts := c.static
configured := make(map[string]bool, len(hosts))
for _, host := range hosts {
e := Entry{Name: host.Name, IP: host.Address, Kind: config.StaticKind, Tags: host.Tags, CachedAt: time.Now(), Static: true}
if old, ok := c.entries[host.Name]; ok && old.Static {
e.LastUsed = old.LastUsed
}
c.entries[host.Name] = e
configured[host.Name] = true
}
for name, e := range c.entries {
if e.Static && !configured[name] {
delete(c.entries, name)
c.dirty[name] = deleteChange
}
}
}
// GetStatic finds a static host by name or by shortcut. Static hosts are
// checked before NetBox, so they also work when NetBox is unreachable.
func (c *Cache) GetStatic(name string, normalize func(string) string) (Entry, bool, error) {
c.mu.RLock()
defer c.mu.RUnlock()
var match Entry
found := false
for key, e := range c.entries {
if e.Static && strings.EqualFold(key, name) {
return e, true, nil
}
}
if normalize == nil {
return Entry{}, false, nil
}
short := normalize(name)
for _, e := range c.entries {
if e.Static && normalize(e.Name) == short {
if found {
return Entry{}, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", name)
}
match, found = e, true
}
}
return match, found, nil
}
func (c *Cache) All() []Entry { func (c *Cache) All() []Entry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
+62
View File
@@ -4,8 +4,11 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@@ -495,3 +498,62 @@ func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
} }
} }
} }
func TestStaticHosts(t *testing.T) {
path := filepath.Join(t.TempDir(), "hosts.json")
hosts := []config.StaticHost{{Name: "router", Address: "192.168.1.1", Tags: []string{"home"}}, {Name: "nas", Address: "nas.lan"}}
c := New(path, 1).WithStatic(hosts)
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1", Kind: "device"})
if err := c.Load(); err != nil { // no file yet; static hosts stay
t.Fatal(err)
}
if e, fresh := c.Get("router"); !fresh || e.IP != "192.168.1.1" || e.Kind != config.StaticKind || !e.Static {
t.Fatalf("static get: %+v %v", e, fresh)
}
if e, found, err := c.GetStatic("ROUTER", nil); !found || err != nil || e.Name != "router" {
t.Fatalf("by name: %+v %v %v", e, found, err)
}
normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(s), ".lan") }
if e, found, _ := c.GetStatic("nas.lan", normalize); !found || e.Name != "nas" {
t.Fatalf("by shortcut: %+v", e)
}
if _, found, _ := c.GetStatic("web01", normalize); found {
t.Fatal("NetBox entry returned as static")
}
// Usage of a static host persists; the host itself stays after Save and Clear.
c.MarkUsed("router")
if err := c.Save(); err != nil {
t.Fatal(err)
}
if _, fresh := c.Get("nas"); !fresh {
t.Fatal("static host lost after Save")
}
reloaded := New(path, 1).WithStatic(hosts)
if err := reloaded.Load(); err != nil {
t.Fatal(err)
}
if recent := reloaded.RecentlyUsed(5); len(recent) != 1 || recent[0].Name != "router" {
t.Fatalf("recent: %+v", recent)
}
reloaded.Clear()
if _, fresh := reloaded.Get("router"); !fresh {
t.Fatal("Clear removed a static host")
}
// A host removed from the config disappears, also from the file.
removed := New(path, 1).WithStatic(hosts[1:])
if err := removed.Load(); err != nil {
t.Fatal(err)
}
if e, _ := removed.Get("router"); e.Name != "" {
t.Fatalf("removed static host still present: %+v", e)
}
if err := removed.Save(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "192.168.1.1") {
t.Fatalf("removed static host still on disk:\n%s", data)
}
}
+54 -1
View File
@@ -18,6 +18,11 @@ type Config struct {
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"`
Hosts []StaticHost `mapstructure:"hosts"`
// Groups name lists of hosts and selectors for netssh multi (@name).
Groups map[string][]string `mapstructure:"groups"`
Multi MultiConfig `mapstructure:"multi"`
} }
type ShellConfig struct { type ShellConfig struct {
@@ -29,12 +34,15 @@ type NetBoxConfig struct {
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 {
Strategies []string `mapstructure:"strategies"` Strategies []string `mapstructure:"strategies"`
ManagementSubnets []string `mapstructure:"management_subnets"` ManagementSubnets []string `mapstructure:"management_subnets"`
InterfaceName string `mapstructure:"interface_name"` InterfaceName string `mapstructure:"interface_name"`
// HostnameDomain is appended by the hostname strategy to names without a dot.
HostnameDomain string `mapstructure:"hostname_domain"`
} }
type CacheConfig struct { type CacheConfig struct {
@@ -45,11 +53,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.
@@ -79,7 +120,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", "resolver.hostname_domain", "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", "multi.multiplexer", "multi.panes_per_window"} {
if err := v.BindEnv(key); err != nil { if err := v.BindEnv(key); err != nil {
return nil, err return nil, err
} }
@@ -116,6 +157,18 @@ func Load() (*Config, error) {
if err := cfg.ValidateNetBoxes(); err != nil { if err := cfg.ValidateNetBoxes(); err != nil {
return nil, err return nil, err
} }
if err := cfg.ValidateSSH(); err != nil {
return nil, err
}
if err := cfg.Shortcuts.Validate(); err != nil {
return nil, err
}
if err := cfg.ValidateHosts(); err != nil {
return nil, err
}
if err := cfg.ValidateMulti(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes { for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i] source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name) source.Name = strings.ToLower(source.Name)
+19
View File
@@ -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)
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package config
import (
"fmt"
"regexp"
"strings"
)
// StaticKind is the kind of hosts defined under hosts: instead of NetBox.
const StaticKind = "static"
// StaticHost is a host that is not in NetBox, defined in the config.
type StaticHost struct {
Name string `mapstructure:"name"`
Address string `mapstructure:"address"` // IP address or DNS name
User string `mapstructure:"user"`
Port int `mapstructure:"port"`
Tags []string `mapstructure:"tags"`
SSH SSHOptions `mapstructure:"ssh"`
}
var staticHostName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// ValidateHosts keeps static host names usable as selectors and addresses
// safe to pass to ssh.
func (c *Config) ValidateHosts() error {
seen := make(map[string]bool)
for i, host := range c.Hosts {
scope := fmt.Sprintf("hosts[%d]", i)
if !staticHostName.MatchString(host.Name) {
return fmt.Errorf("%s: name %q must start with a letter or digit and contain only letters, digits, dots, underscores, or hyphens", scope, host.Name)
}
name := strings.ToLower(host.Name)
if seen[name] {
return fmt.Errorf("%s: duplicate host name %q", scope, host.Name)
}
seen[name] = true
if host.Address == "" || strings.HasPrefix(host.Address, "-") || strings.ContainsAny(host.Address, " \t\n\r/@") {
return fmt.Errorf("%s: address %q must be an IP address or hostname", scope, host.Address)
}
if host.Port < 0 || host.Port > 65535 {
return fmt.Errorf("%s: port must be between 1 and 65535", scope)
}
if strings.HasPrefix(host.User, "-") || strings.ContainsAny(host.User, " \t\n\r@") {
return fmt.Errorf("%s: invalid user %q", scope, host.User)
}
if err := host.SSH.Validate(scope + ".ssh"); err != nil {
return err
}
}
return nil
}
// StaticHost returns the static host with the given name, or nil.
func (c *Config) StaticHost(name string) *StaticHost {
for i := range c.Hosts {
if strings.EqualFold(c.Hosts[i].Name, name) {
return &c.Hosts[i]
}
}
return nil
}
+3
View File
@@ -40,6 +40,9 @@ func (c *Config) ValidateNetBoxes() error {
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 } func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
// HasHosts reports whether netssh has anything to connect to: NetBox or static hosts.
func (c *Config) HasHosts() bool { return c.HasNetBox() || len(c.Hosts) > 0 }
// CachePath isolates named sources from legacy caches and from changed endpoints. // CachePath isolates named sources from legacy caches and from changed endpoints.
// Rotating a token or reordering the sources does not discard usage history. // Rotating a token or reordering the sources does not discard usage history.
func (c *Config) CachePath() string { func (c *Config) CachePath() string {
+92
View File
@@ -0,0 +1,92 @@
package config
import (
"fmt"
"regexp"
"sort"
"strings"
)
// MultiConfig controls netssh multi.
type MultiConfig struct {
// Multiplexer is auto, tmux, or zellij; empty means auto.
Multiplexer string `mapstructure:"multiplexer"`
// PanesPerWindow splits larger host lists into more windows or tabs; 0 = 9.
PanesPerWindow int `mapstructure:"panes_per_window"`
// SyncKey and SoloKey are tmux prefix keys for broadcast to all panes and
// for excluding the current pane; empty = default (S, e), "none" = off.
SyncKey string `mapstructure:"sync_key"`
SoloKey string `mapstructure:"solo_key"`
}
// Default tmux keys: unbound in a stock tmux (E would be "spread evenly").
const (
DefaultSyncKey = "S"
DefaultSoloKey = "e"
)
// Keys returns the tmux keys to bind; "" for a disabled one.
func (m MultiConfig) Keys() (sync, solo string) {
pick := func(key, fallback string) string {
switch key {
case "":
return fallback
case "none":
return ""
}
return key
}
return pick(m.SyncKey, DefaultSyncKey), pick(m.SoloKey, DefaultSoloKey)
}
var tmuxKey = regexp.MustCompile(`^([CMS]-)*([A-Za-z0-9]|F[0-9]{1,2}|[-_=+!@#$%^&*,./?<>\[\]]|Tab|Space|Enter|BSpace|Up|Down|Left|Right|Home|End|PPage|NPage)$`)
// DefaultPanesPerWindow keeps panes readable on a normal screen (3×3).
const DefaultPanesPerWindow = 9
// ValidateMulti checks groups and the multi settings.
func (c *Config) ValidateMulti() error {
switch c.Multi.Multiplexer {
case "", "auto", "tmux", "zellij":
default:
return fmt.Errorf("multi.multiplexer must be auto, tmux, or zellij")
}
if c.Multi.PanesPerWindow < 0 || c.Multi.PanesPerWindow > 64 {
return fmt.Errorf("multi.panes_per_window must be between 1 and 64")
}
for name, key := range map[string]string{"sync_key": c.Multi.SyncKey, "solo_key": c.Multi.SoloKey} {
if key != "" && key != "none" && !tmuxKey.MatchString(key) {
return fmt.Errorf("multi.%s %q is not a tmux key (e.g. S, e, C-s, F5) or none", name, key)
}
}
if sync, solo := c.Multi.Keys(); sync != "" && sync == solo {
return fmt.Errorf("multi.sync_key and multi.solo_key must differ")
}
names := make([]string, 0, len(c.Groups))
for name := range c.Groups {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if !instanceName.MatchString(name) {
return fmt.Errorf("groups: name %q must contain only letters, digits, underscores, or hyphens", name)
}
if len(c.Groups[name]) == 0 {
return fmt.Errorf("groups.%s: needs at least one host or selector", name)
}
for _, selector := range c.Groups[name] {
if strings.TrimSpace(selector) == "" || strings.ContainsAny(selector, " \t\n") || strings.HasPrefix(selector, "-") {
return fmt.Errorf("groups.%s: invalid entry %q", name, selector)
}
}
}
return nil
}
// PanesPerWindowOrDefault returns the configured value or the default.
func (m MultiConfig) PanesPerWindowOrDefault() int {
if m.PanesPerWindow > 0 {
return m.PanesPerWindow
}
return DefaultPanesPerWindow
}
+257
View File
@@ -0,0 +1,257 @@
package config
import (
"fmt"
"path"
"regexp"
"strings"
)
// SSHOptions are connection settings that can be set globally, per NetBox
// instance, and per rule. More specific layers override less specific ones.
type SSHOptions struct {
// ProxyJump is passed as -J; "none" disables a jump host set by a broader layer.
ProxyJump string `mapstructure:"proxy_jump"`
// HostKeyChecking is strict, accept-new, or off. Empty keeps ssh's own setting.
HostKeyChecking string `mapstructure:"host_key_checking"`
// Target selects the ssh destination: ip (default), name, or hostname.
Target string `mapstructure:"target"`
// AddressFamily is any, inet, or inet6.
AddressFamily string `mapstructure:"address_family"`
// ExtraArgs are inserted verbatim before the destination.
ExtraArgs []string `mapstructure:"extra_args"`
// Options are passed as -o Key=Value.
Options map[string]string `mapstructure:"options"`
// Forwards are named port forwards, selected with --forward or netssh tunnel.
Forwards map[string]Forward `mapstructure:"forwards"`
}
// Forward is one port forward; exactly one of the fields is set.
type Forward struct {
Local string `mapstructure:"local"` // -L [bind:]port:host:hostport
Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport
Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy)
}
// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80.
func (f Forward) Flag() []string {
switch {
case f.Local != "":
return []string{"-L", f.Local}
case f.Remote != "":
return []string{"-R", f.Remote}
case f.Dynamic != "":
return []string{"-D", f.Dynamic}
}
return nil
}
// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80".
func (f Forward) String() string { return strings.Join(f.Flag(), " ") }
func (f Forward) validate() error {
set := 0
for _, spec := range []string{f.Local, f.Remote, f.Dynamic} {
if spec == "" {
continue
}
set++
if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") {
return fmt.Errorf("invalid forward %q", spec)
}
}
if set != 1 {
return fmt.Errorf("set exactly one of local, remote, dynamic")
}
return nil
}
// SSHRule applies SSH options to hosts that match every non-empty field.
type SSHRule struct {
Match SSHMatch `mapstructure:"match"`
SSHOptions `mapstructure:",squash"`
}
// SSHMatch selects hosts by NetBox attributes available in the cache.
type SSHMatch struct {
Tag string `mapstructure:"tag"`
Instance string `mapstructure:"instance"`
Kind string `mapstructure:"kind"`
Name string `mapstructure:"name"` // shell-style glob, e.g. "*.dmz.example.com"
}
// SSHHost identifies the resolved host that SSH options are computed for.
type SSHHost struct {
Instance string
Name string // NetBox name without the instance prefix
Kind string
Tags []string
}
var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
func (c *Config) ValidateSSH() error {
if err := c.SSH.SSHOptions.Validate("ssh"); err != nil {
return err
}
for i, rule := range c.SSH.Rules {
if err := rule.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
return err
}
}
if err := c.NetBox.SSH.Validate("netbox.ssh"); err != nil {
return err
}
for _, source := range c.NetBoxes {
if err := source.SSH.Validate("netboxes." + source.Name + ".ssh"); err != nil {
return err
}
}
return nil
}
// Validate checks the match and options of a single rule.
func (r SSHRule) Validate(scope string) error {
if err := r.SSHOptions.Validate(scope); err != nil {
return err
}
return r.Match.Validate(scope)
}
// Validate requires at least one selector and a well-formed name pattern.
func (m SSHMatch) Validate(scope string) error {
if m == (SSHMatch{}) {
return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope)
}
if _, err := path.Match(m.Name, ""); err != nil {
return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name)
}
return nil
}
// Validate rejects values that ssh would not accept or would misinterpret.
func (o SSHOptions) Validate(scope string) error {
switch o.HostKeyChecking {
case "", "strict", "accept-new", "off":
default:
return fmt.Errorf("%s.host_key_checking must be strict, accept-new, or off", scope)
}
switch o.Target {
case "", "ip", "name", "hostname":
default:
return fmt.Errorf("%s.target must be ip, name, or hostname", scope)
}
switch o.AddressFamily {
case "", "any", "inet", "inet6":
default:
return fmt.Errorf("%s.address_family must be any, inet, or inet6", scope)
}
if strings.HasPrefix(o.ProxyJump, "-") || strings.ContainsAny(o.ProxyJump, " \t\n") {
return fmt.Errorf("%s.proxy_jump %q is not a valid jump host", scope, o.ProxyJump)
}
for key, value := range o.Options {
if !sshOptionKey.MatchString(key) || strings.ContainsAny(value, "\n\r") {
return fmt.Errorf("%s.options: invalid option %q", scope, key)
}
}
for name, forward := range o.Forwards {
if !instanceName.MatchString(name) {
return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name)
}
if err := forward.validate(); err != nil {
return fmt.Errorf("%s.forwards.%s: %v", scope, name, err)
}
}
return nil
}
// SSHOptionsFor merges global, instance, and matching rule options for host.
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
merged := c.SSH.SSHOptions.merge(SSHOptions{})
if host.Kind == StaticKind {
if static := c.StaticHost(host.Name); static != nil {
merged = merged.merge(static.SSH)
}
} else if host.Instance == "" {
merged = merged.merge(c.NetBox.SSH)
}
for _, source := range c.NetBoxes {
if strings.EqualFold(source.Name, host.Instance) {
merged = merged.merge(source.SSH)
}
}
for _, rule := range c.SSH.Rules {
if rule.Match.matches(host) {
merged = merged.merge(rule.SSHOptions)
}
}
return merged
}
// merge returns o overridden by the non-empty fields of override.
// Extra arguments accumulate; options override per key.
func (o SSHOptions) merge(override SSHOptions) SSHOptions {
out := o
out.ExtraArgs = append(append([]string(nil), o.ExtraArgs...), override.ExtraArgs...)
out.Options = make(map[string]string, len(o.Options)+len(override.Options))
for key, value := range o.Options {
out.Options[strings.ToLower(key)] = value
}
for key, value := range override.Options {
out.Options[strings.ToLower(key)] = value
}
// Forwards override per name, so a rule can redefine a global "web" forward.
out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards))
for name, forward := range o.Forwards {
out.Forwards[strings.ToLower(name)] = forward
}
for name, forward := range override.Forwards {
out.Forwards[strings.ToLower(name)] = forward
}
if override.ProxyJump != "" {
out.ProxyJump = override.ProxyJump
}
if override.HostKeyChecking != "" {
out.HostKeyChecking = override.HostKeyChecking
}
if override.Target != "" {
out.Target = override.Target
}
if override.AddressFamily != "" {
out.AddressFamily = override.AddressFamily
}
return out
}
func (m SSHMatch) matches(host SSHHost) bool {
if m.Instance != "" && !strings.EqualFold(m.Instance, host.Instance) {
return false
}
if m.Kind != "" && !strings.EqualFold(m.Kind, host.Kind) {
return false
}
if m.Name != "" {
if ok, _ := path.Match(strings.ToLower(m.Name), strings.ToLower(host.Name)); !ok {
return false
}
}
if m.Tag != "" {
found := false
for _, tag := range host.Tags {
if strings.EqualFold(tag, m.Tag) || strings.EqualFold(tagSlug(tag), m.Tag) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// tagSlug approximates NetBox's slug so rules may use either the tag name or slug.
func tagSlug(name string) string {
return strings.Join(strings.Fields(strings.ToLower(name)), "-")
}
+212
View File
@@ -0,0 +1,212 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func loadYAML(t *testing.T, data string) (*Config, error) {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("HOME", t.TempDir())
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return Load()
}
func TestSSHOptionsLayering(t *testing.T) {
cfg, err := loadYAML(t, `
netboxes:
- name: prod
url: https://prod.example.com
ssh:
proxy_jump: jh-prod.example.com
host_key_checking: strict
- name: staging
url: https://staging.example.com
ssh:
host_key_checking: off
ssh:
proxy_jump: jh.example.com
extra_args: ["-4"]
options:
ServerAliveInterval: 60
ServerAliveCountMax: 5
rules:
- match: {tag: dmz}
proxy_jump: jh-dmz.example.com
options: {ServerAliveInterval: 10}
- match: {instance: prod, name: "db*"}
proxy_jump: none
completion:
max_results: 200
`)
if err != nil {
t.Fatal(err)
}
if cfg.Completion.MaxResults != 200 {
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
}
for _, tc := range []struct {
host SSHHost
jump, keys, alive string
}{
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
} {
got := cfg.SSHOptionsFor(tc.host)
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
t.Errorf("%+v: got %+v", tc.host, got)
}
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
t.Errorf("extra args: %v", got.ExtraArgs)
}
}
}
func TestSSHOptionsValidation(t *testing.T) {
for _, data := range []string{
"ssh:\n host_key_checking: maybe\n",
"ssh:\n target: dns\n",
"ssh:\n address_family: ipx\n",
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
"ssh:\n options:\n \"bad key\": 1\n",
"ssh:\n rules:\n - proxy_jump: jh\n",
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
} {
if _, err := loadYAML(t, data); err == nil {
t.Errorf("accepted invalid config:\n%s", data)
} else if !strings.Contains(err.Error(), "ssh") {
t.Errorf("unclear error %v", err)
}
}
}
func TestForwardsLayering(t *testing.T) {
cfg, err := loadYAML(t, `
netboxes:
- name: prod
url: https://prod.example
token: t
ssh:
forwards:
db: {local: "5432:localhost:5432"}
ssh:
forwards:
web: {local: "8080:localhost:80"}
socks: {dynamic: "1080"}
rules:
- match: {tag: dmz}
forwards:
web: {local: "8443:localhost:443"}
`)
if err != nil {
t.Fatal(err)
}
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
t.Fatalf("other instance: %+v", got)
}
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
t.Fatal(f.String())
}
}
func TestForwardsValidation(t *testing.T) {
for _, forwards := range []string{
`web: {local: "8080:a:80", dynamic: "1080"}`,
`web: {local: "-oProxyCommand=x"}`,
`web: {local: "8080 a:80"}`,
`"bad name": {local: "8080:a:80"}`,
} {
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
t.Errorf("%s: got %v", forwards, err)
}
}
}
func TestStaticHosts(t *testing.T) {
cfg, err := loadYAML(t, `
resolver:
hostname_domain: example.com
ssh:
proxy_jump: jh
rules:
- match: {kind: static, tag: home}
host_key_checking: off
hosts:
- name: Router
address: 192.168.1.1
user: admin
port: 2222
tags: [home]
ssh:
proxy_jump: none
`)
if err != nil {
t.Fatal(err)
}
if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" {
t.Fatalf("got %+v", cfg)
}
host := cfg.StaticHost("router")
if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 {
t.Fatalf("host: %+v", host)
}
opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}})
if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" {
t.Fatalf("static host options: %+v", opts)
}
// A NetBox host of the same name does not get the static host's options.
if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" {
t.Fatalf("netbox host: %+v", got)
}
}
func TestStaticHostsValidation(t *testing.T) {
for _, hosts := range []string{
"- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}",
"- {name: 'bad name', address: 10.0.0.1}",
"- {name: prod/a, address: 10.0.0.1}",
"- {name: a}",
"- {name: a, address: '-oProxyCommand=x'}",
"- {name: a, address: 10.0.0.1, port: 70000}",
"- {name: a, address: 10.0.0.1, user: 'a b'}",
"- {name: a, address: 10.0.0.1, ssh: {target: nope}}",
} {
if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") {
t.Errorf("%s: got %v", hosts, err)
}
}
}
func TestMultiConfig(t *testing.T) {
cfg, err := loadYAML(t, "groups:\n K8s: [master1, \"tag:worker\"]\nmulti:\n multiplexer: zellij\n")
if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 {
t.Fatalf("%+v %v", cfg, err)
}
if sync, solo := cfg.Multi.Keys(); sync != "S" || solo != "e" {
t.Fatalf("default keys %q %q", sync, solo)
}
custom, err := loadYAML(t, "multi: {sync_key: C-s, solo_key: none}\n")
if sync, solo := custom.Multi.Keys(); err != nil || sync != "C-s" || solo != "" {
t.Fatalf("custom keys %q %q %v", sync, solo, err)
}
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}",
"multi: {sync_key: \"x y\"}", "multi: {sync_key: \"{\"}", "multi: {sync_key: e}"} {
if _, err := loadYAML(t, bad+"\n"); err == nil {
t.Errorf("%s accepted", bad)
}
}
}
+11
View File
@@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) {
t.Fatalf("alias lost source: %+v", entries) t.Fatalf("alias lost source: %+v", entries)
} }
} }
func TestAliasesUseRewriteRules(t *testing.T) {
cfg := config.Config{Shortcuts: config.ShortcutsConfig{
Domains: []string{".hoster.de"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}}
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg)
if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" {
t.Fatalf("entries: %+v", entries)
}
}
+44 -1
View File
@@ -83,7 +83,7 @@ func commandCompletionPath(shell, alias string) string {
case "bash": 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")
}
+38 -2
View File
@@ -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)
}
}
+135 -25
View File
@@ -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")
// Drop the blank separator line Install puts in front of the block.
if strings.HasSuffix(cleaned, "\n\n") {
cleaned = strings.TrimRight(cleaned, "\n") + "\n"
}
return cleaned, true, nil
}
// writeProfile replaces the profile atomically, following dotfile symlinks
// and keeping the permissions of an existing file.
func writeProfile(profilePath, content string) error {
target, err := filepath.EvalSymlinks(profilePath) target, err := filepath.EvalSymlinks(profilePath)
if err != nil { if os.IsNotExist(err) {
return false, err target = profilePath
} else if err != nil {
return err
} }
info, err := os.Stat(target) mode := os.FileMode(0o644)
if err != nil { if info, err := os.Stat(target); err == nil {
return false, err mode = info.Mode().Perm()
} }
if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil { if err := storage.WriteFile(target, []byte(content), mode); err != nil {
return false, fmt.Errorf("writing profile: %w", err) return fmt.Errorf("writing profile: %w", err)
} }
return true, nil return nil
}
// ProfileCandidates lists every shell profile netssh or install.sh may have
// written to, in the same order as the installer.
func ProfileCandidates() []string {
home := os.Getenv("HOME")
zdot := zshDir()
paths := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".bash_profile"),
filepath.Join(home, ".bash_login"),
filepath.Join(home, ".profile"),
filepath.Join(zdot, ".zshrc"),
filepath.Join(zdot, ".zprofile"),
filepath.Join(zdot, ".zshenv"),
}
if zdot != home {
paths = append(paths, filepath.Join(home, ".zshrc"), filepath.Join(home, ".zprofile"), filepath.Join(home, ".zshenv"))
}
return append(paths, filepath.Join(FishConfigDir(), "config.fish"))
} }
+77 -10
View File
@@ -2,6 +2,7 @@ package hook_test
import ( 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)
}
}
+100
View File
@@ -0,0 +1,100 @@
package hook
import (
"fmt"
"os"
"path/filepath"
)
// ZshCompletionScript replaces Cobra's generated zsh completion. Cobra's script
// spawns a subshell per candidate and passes every host through _describe,
// which takes tens of seconds for thousands of hosts. This script splits the
// candidates with parameter expansion only: entries with a description
// (subcommands, flags) go through _describe, plain hostnames through compadd.
//
// The file works both from fpath (autoloaded as _netssh) and when sourced.
const ZshCompletionScript = `#compdef netssh
compdef _netssh netssh
# netssh zsh completion — generated by netssh, do not edit.
_netssh() {
local -a lines described plain
local out directive=0 entry ret=1
local -a nospace
out=$(netssh __complete "${(@)words[2,CURRENT]}" 2>/dev/null) || return 1
lines=("${(@f)out}")
if [[ ${lines[-1]} == :<-> ]]; then
directive=${lines[-1]#:}
lines[-1]=()
fi
# Directive 1: error.
(( directive & 1 )) && return 1
lines=(${lines:#_activeHelp_*})
lines=(${lines:#})
# Directive 2: no trailing space.
(( directive & 2 )) && nospace=(-S '')
# Directive 8: the lines are file extensions to filter by.
if (( directive & 8 )); then
local -a globs
for entry in $lines; do globs+=("*.${entry}"); done
_files -g "(${(j:|:)globs})"
return
fi
# Directive 16: complete directories only.
if (( directive & 16 )); then
_files -/
return
fi
described=(${(M)lines:#*$'\t'*})
plain=(${lines:#*$'\t'*})
if (( $#described )); then
local -a items
for entry in $described; do
items+=("${${entry%%$'\t'*}//:/\\:}:${entry#*$'\t'}")
done
_describe -t commands 'netssh command' items $nospace && ret=0
fi
if (( $#plain )); then
# netssh already filtered the hosts, also by the name after an instance
# prefix: "fsn1" offers "prod/fsn1-…". 'l:|=*' lets zsh accept text
# before the typed word instead of discarding these matches.
local -a group=(-J hosts)
# Directive 32: keep the order supplied by netssh.
(( directive & 32 )) && group=(-V hosts)
compadd $group -X '%Bhosts%b' -M 'l:|=*' $nospace -- $plain && ret=0
fi
# Directive 4: no file completion as a fallback.
if (( ! $#lines && ! (directive & 4) )); then
_files && ret=0
fi
return ret
}
if [ "$funcstack[1]" = "_netssh" ]; then
_netssh "$@"
fi
`
// ZshCompletionNote tells the user how zsh picks up a completion file in dir.
func ZshCompletionNote(dir string) string {
dump := filepath.Join(zshDir(), ".zcompdump")
return fmt.Sprintf(`zsh loads it from fpath. The netssh block in your .zshrc adds %s
(netssh hook install). Otherwise add this before compinit:
fpath=(%s $fpath)
Then refresh the completion cache: rm -f %s* && exec zsh`, dir, dir, dump)
}
func zshDir() string {
if dir := os.Getenv("ZDOTDIR"); dir != "" {
return dir
}
return os.Getenv("HOME")
}
// ZshFuncDir is the user directory netssh installs zsh completion files into.
func ZshFuncDir() string {
return filepath.Join(os.Getenv("HOME"), ".zfunc")
}
+251
View File
@@ -0,0 +1,251 @@
// Package multiplex opens several commands side by side in tmux or zellij.
package multiplex
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
)
// Pane is one command shown in its own pane.
type Pane struct {
Title string
Argv []string
}
// Options control how panes are laid out.
type Options struct {
Label string // window, session, and tab name
Sync bool // type into all panes of a window at once
PerWindow int // panes per tmux window or zellij tab
LayoutDir string // where zellij layout files and tmux key bindings are written
// SyncKey and SoloKey are tmux prefix keys that toggle broadcast for the
// window and exclude the current pane from it; empty means no binding.
SyncKey, SoloKey string
// Hint receives notes for the user before the process is replaced.
Hint func(string)
}
// Runner executes multiplexer commands; tests and --dry-run replace it.
type Runner interface {
// Output runs argv and returns its trimmed standard output.
Output(argv ...string) (string, error)
// Exec replaces the current process with argv.
Exec(argv ...string) error
LookPath(name string) (string, error)
Getenv(key string) string
WriteFile(path string, data []byte) error
}
// Supported multiplexers.
const (
Tmux = "tmux"
Zellij = "zellij"
)
// ErrNone means neither tmux nor zellij is available.
var ErrNone = errors.New("netssh multi needs tmux or zellij; install one of them (e.g. apt install tmux)")
// Detect picks the multiplexer: the requested one, else the one netssh runs
// in, else tmux, else zellij.
func Detect(requested string, r Runner) (string, error) {
switch requested {
case Tmux, Zellij:
if _, err := r.LookPath(requested); err != nil {
return "", fmt.Errorf("%s is not installed", requested)
}
return requested, nil
case "", "auto":
default:
return "", fmt.Errorf("unknown multiplexer %q", requested)
}
if r.Getenv("TMUX") != "" {
return Tmux, nil
}
if r.Getenv("ZELLIJ") != "" {
return Zellij, nil
}
for _, name := range []string{Tmux, Zellij} {
if _, err := r.LookPath(name); err == nil {
return name, nil
}
}
return "", ErrNone
}
// Open shows panes in the multiplexer. Outside of it, Open replaces the
// process with the multiplexer client and only returns on error.
func Open(kind string, panes []Pane, opts Options, r Runner) error {
if len(panes) == 0 {
return errors.New("no hosts to open")
}
if opts.Hint == nil {
opts.Hint = func(string) {}
}
if opts.PerWindow <= 0 {
opts.PerWindow = 9
}
if opts.Label == "" {
opts.Label = "netssh"
}
switch kind {
case Tmux:
return openTmux(panes, opts, r)
case Zellij:
return openZellij(panes, opts, r)
}
return fmt.Errorf("unknown multiplexer %q", kind)
}
// ToggleSync switches broadcast input in the current tmux window or zellij
// tab; with pane it excludes the current tmux pane from it or includes it again.
func ToggleSync(r Runner, pane bool) (string, error) {
if pane {
if r.Getenv("TMUX") == "" {
if r.Getenv("ZELLIJ") != "" {
return "", errors.New("zellij broadcasts to a whole tab; single panes cannot be excluded")
}
return "", errors.New("netssh sync --pane works inside tmux")
}
solo, err := r.Output("tmux", "display-message", "-p", "#{@netssh_solo}")
if err != nil {
return "", err
}
for _, argv := range soloToggle(solo == "1") {
if _, err := r.Output(argv...); err != nil {
return "", err
}
}
if solo == "1" {
return "pane included in broadcast", nil
}
return "pane excluded from broadcast", nil
}
switch {
case r.Getenv("TMUX") != "":
current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes")
if err != nil {
return "", err
}
next := "on"
if current == "on" {
next = "off"
}
if _, err := r.Output("tmux", "set-window-option", "synchronize-panes", next); err != nil {
return "", err
}
return "broadcast " + next, nil
case r.Getenv("ZELLIJ") != "":
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
return "", err
}
return "broadcast toggled", nil
}
return "", errors.New("netssh sync works inside tmux or zellij")
}
// chunks splits panes into groups of at most n.
func chunks(panes []Pane, n int) [][]Pane {
var out [][]Pane
for len(panes) > n {
out = append(out, panes[:n])
panes = panes[n:]
}
return append(out, panes)
}
// windowLabel numbers windows when the panes need more than one.
func windowLabel(label string, i, total int) string {
if total == 1 {
return label
}
return fmt.Sprintf("%s-%d", label, i+1)
}
// System runs real commands.
type System struct{}
func (System) Output(argv ...string) (string, error) {
cmd := exec.Command(argv[0], argv[1:]...)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return "", fmt.Errorf("%s: %s", argv[0], msg)
}
return "", fmt.Errorf("%s: %w", argv[0], err)
}
return strings.TrimSpace(string(out)), nil
}
func (System) Exec(argv ...string) error {
path, err := exec.LookPath(argv[0])
if err != nil {
return err
}
return syscall.Exec(path, argv, os.Environ())
}
func (System) LookPath(name string) (string, error) { return exec.LookPath(name) }
func (System) Getenv(key string) string { return os.Getenv(key) }
func (System) WriteFile(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
// DryRun prints commands instead of running them; Output returns fake IDs.
type DryRun struct {
W io.Writer
Env map[string]string // values for Getenv, e.g. TMUX
n int
}
func (d *DryRun) Output(argv ...string) (string, error) {
fmt.Fprintln(d.W, shellJoin(argv))
d.n++
switch {
case len(argv) > 1 && (argv[1] == "new-window" || argv[1] == "new-session"):
return fmt.Sprintf("@%d %%%d", d.n, d.n), nil
case len(argv) > 1 && argv[1] == "split-window":
return fmt.Sprintf("%%%d", d.n), nil
case len(argv) > 1 && argv[1] == "has-session":
return "", errors.New("no session")
}
return "", nil
}
func (d *DryRun) Exec(argv ...string) error {
fmt.Fprintln(d.W, shellJoin(argv))
return nil
}
// LookPath pretends every multiplexer is installed, so --dry-run shows the
// commands on machines without tmux or zellij.
func (d *DryRun) LookPath(name string) (string, error) { return name, nil }
func (d *DryRun) Getenv(key string) string {
if v, ok := d.Env[key]; ok {
return v
}
return os.Getenv(key)
}
func (d *DryRun) WriteFile(path string, data []byte) error {
fmt.Fprintf(d.W, "# %s\n%s", path, data)
return nil
}
// shellJoin quotes argv for display.
func shellJoin(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, " ")
}
+281
View File
@@ -0,0 +1,281 @@
package multiplex
import (
"errors"
"fmt"
"strings"
"testing"
)
// fake records commands and answers like tmux and zellij would.
type fake struct {
env map[string]string
installed map[string]bool
sessions map[string]bool
calls []string
exec []string
files map[string]string
syncState string
keys map[string]string // existing prefix bindings by key
solo string
n int
}
func newFake(env map[string]string, installed ...string) *fake {
f := &fake{env: env, installed: map[string]bool{}, sessions: map[string]bool{}, files: map[string]string{}, syncState: "off"}
for _, name := range installed {
f.installed[name] = true
}
return f
}
func (f *fake) Output(argv ...string) (string, error) {
f.calls = append(f.calls, strings.Join(argv, " "))
f.n++
switch argv[1] {
case "new-window", "new-session":
return fmt.Sprintf("@%d %%%d", f.n, f.n), nil
case "split-window":
return fmt.Sprintf("%%%d", f.n), nil
case "has-session":
if f.sessions[strings.TrimPrefix(argv[3], "=")] {
return "", nil
}
return "", errors.New("can't find session")
case "show-window-options":
return f.syncState, nil
case "list-keys":
return f.keys[argv[4]], nil
case "display-message":
return f.solo, nil
}
return "", nil
}
func (f *fake) Exec(argv ...string) error { f.exec = argv; return nil }
func (f *fake) LookPath(name string) (string, error) {
if f.installed[name] {
return "/usr/bin/" + name, nil
}
return "", errors.New("not found")
}
func (f *fake) Getenv(key string) string { return f.env[key] }
func (f *fake) WriteFile(path string, data []byte) error {
f.files[path] = string(data)
return nil
}
func panes(names ...string) []Pane {
out := make([]Pane, 0, len(names))
for _, name := range names {
out = append(out, Pane{Title: name, Argv: []string{"/usr/bin/netssh", name}})
}
return out
}
func TestDetect(t *testing.T) {
for _, tc := range []struct {
requested string
env map[string]string
installed []string
want string
}{
{"", map[string]string{"TMUX": "/tmp/tmux"}, []string{"tmux", "zellij"}, Tmux},
{"auto", map[string]string{"ZELLIJ": "0"}, []string{"tmux", "zellij"}, Zellij},
{"", nil, []string{"tmux", "zellij"}, Tmux},
{"", nil, []string{"zellij"}, Zellij},
{"zellij", map[string]string{"TMUX": "x"}, []string{"tmux", "zellij"}, Zellij},
} {
got, err := Detect(tc.requested, newFake(tc.env, tc.installed...))
if err != nil || got != tc.want {
t.Errorf("%+v: got %q %v", tc, got, err)
}
}
if _, err := Detect("", newFake(nil)); !errors.Is(err, ErrNone) {
t.Errorf("nothing installed: %v", err)
}
if _, err := Detect("tmux", newFake(nil, "zellij")); err == nil {
t.Error("missing requested multiplexer accepted")
}
}
func TestTmuxInside(t *testing.T) {
f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux")
if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true, SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh "
want := []string{
"tmux new-window -P -F #{window_id} #{pane_id} -n pve " + script + "a",
"tmux split-window -t @1 -P -F #{pane_id} " + script + "b",
"tmux select-layout -t @1 tiled",
"tmux split-window -t @1 -P -F #{pane_id} " + script + "c",
"tmux select-layout -t @1 tiled",
"tmux set-option -p -t %1 @netssh_host a",
"tmux set-option -p -t %2 @netssh_host b",
"tmux set-option -p -t %4 @netssh_host c",
"tmux set-window-option -t @1 @netssh 1",
"tmux set-window-option -t @1 pane-border-status top",
"tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} ",
"tmux set-window-option -t @1 synchronize-panes on",
"tmux show-options -gv prefix",
"tmux list-keys -T prefix S",
"tmux list-keys -T prefix e",
"tmux source-file /tmp/l/netssh-multi.tmux.conf",
"tmux select-window -t @1",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
if f.exec != nil {
t.Fatal("inside tmux must not attach")
}
}
func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) {
f := newFake(nil, "tmux")
f.sessions["netssh-pve"] = true
var hints []string
err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Hint: func(s string) { hints = append(hints, s) }}, f)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(f.calls, "\n")
for _, want := range []string{
"tmux has-session -t =netssh-pve-2",
"tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ",
"tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ",
"tmux set-option -t netssh-pve-2 status-left-length 40",
} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in\n%s", want, joined)
}
}
if strings.Contains(joined, "synchronize-panes") {
t.Error("sync set without --sync")
}
if strings.Join(f.exec, " ") != "tmux attach-session -t netssh-pve-2" || len(hints) != 1 {
t.Fatalf("exec %v hints %v", f.exec, hints)
}
}
func TestZellijOutsideWritesGridLayout(t *testing.T) {
f := newFake(nil, "zellij")
if err := Open(Zellij, panes("a", "b", "c", `q"x`), Options{Label: "pve", LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
layout := f.files["/tmp/l/netssh-multi.kdl"]
for _, want := range []string{
`plugin location="zellij:tab-bar"`,
`tab name="pve" focus=true {`,
`pane split_direction="vertical" {`,
`pane name="a" command="/usr/bin/netssh" {`,
`args "a"`,
`pane name="q\"x" command="/usr/bin/netssh" {`,
} {
if !strings.Contains(layout, want) {
t.Errorf("layout lacks %q:\n%s", want, layout)
}
}
// 4 panes → 2 columns × 2 rows.
if n := strings.Count(layout, `split_direction="vertical"`); n != 2 {
t.Errorf("%d rows, want 2:\n%s", n, layout)
}
if strings.Join(f.exec, " ") != "zellij --layout /tmp/l/netssh-multi.kdl" {
t.Fatalf("exec %v", f.exec)
}
}
func TestZellijInsideAddsTabs(t *testing.T) {
f := newFake(map[string]string{"ZELLIJ": "0"}, "zellij")
if err := Open(Zellij, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Sync: true, LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
want := []string{
"zellij action new-tab --layout /tmp/l/netssh-multi-1.kdl",
"zellij action toggle-active-sync-tab",
"zellij action new-tab --layout /tmp/l/netssh-multi-2.kdl",
"zellij action toggle-active-sync-tab",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Fatalf("calls:\n%s", strings.Join(f.calls, "\n"))
}
if !strings.Contains(f.files["/tmp/l/netssh-multi-2.kdl"], `tab name="pve-2"`) {
t.Fatalf("second tab: %s", f.files["/tmp/l/netssh-multi-2.kdl"])
}
}
func TestToggleSync(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"})
if state, err := ToggleSync(f, false); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" {
t.Fatalf("%q %v %v", state, err, f.calls)
}
f.syncState = "on"
if state, _ := ToggleSync(f, false); state != "broadcast off" {
t.Fatalf("%q", state)
}
z := newFake(map[string]string{"ZELLIJ": "0"})
if _, err := ToggleSync(z, false); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" {
t.Fatalf("%v %v", err, z.calls)
}
if _, err := ToggleSync(newFake(nil), false); err == nil {
t.Fatal("outside a multiplexer accepted")
}
}
func TestTmuxKeyBindings(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"}, "tmux")
var hint string
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
t.Fatal(err)
}
conf := f.files["/tmp/l/netssh-multi.tmux.conf"]
for _, want := range []string{"bind-key -T prefix S if-shell -F '#{@netssh}'", "bind-key -T prefix e if-shell", "set-option -p synchronize-panes off"} {
if !strings.Contains(conf, want) {
t.Errorf("conf lacks %q:\n%s", want, conf)
}
}
if hint != "keys: C-b S broadcast to all panes, C-b e exclude/include this pane" {
t.Errorf("hint %q", hint)
}
// A key bound in the user's tmux.conf is left alone; netssh's own is replaced.
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
f.keys = map[string]string{"S": "bind-key -T prefix S kill-server", "e": "bind-key -T prefix e if-shell -F #{@netssh} …"}
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
t.Fatal(err)
}
conf = f.files["/tmp/l/netssh-multi.tmux.conf"]
if strings.Contains(conf, "prefix S ") || !strings.Contains(conf, "prefix e ") {
t.Errorf("user binding overwritten or own binding not refreshed:\n%s", conf)
}
if !strings.Contains(hint, "C-b S kept your own binding") {
t.Errorf("hint %q", hint)
}
// No keys configured: nothing is bound.
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
if err := Open(Tmux, panes("a"), Options{LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
if len(f.files) != 0 || strings.Contains(strings.Join(f.calls, "\n"), "source-file") {
t.Errorf("bound keys although disabled: %v", f.calls)
}
}
func TestToggleSyncPane(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"})
if state, err := ToggleSync(f, true); err != nil || state != "pane excluded from broadcast" {
t.Fatalf("%q %v", state, err)
}
if got := strings.Join(f.calls[1:], "; "); got != "tmux set-option -p synchronize-panes off; tmux set-option -p @netssh_solo 1" {
t.Fatalf("exclude: %s", got)
}
f = newFake(map[string]string{"TMUX": "x"})
f.solo = "1"
if state, _ := ToggleSync(f, true); state != "pane included in broadcast" || f.calls[1] != "tmux set-option -pu synchronize-panes" {
t.Fatalf("include: %q %v", state, f.calls)
}
if _, err := ToggleSync(newFake(map[string]string{"ZELLIJ": "0"}), true); err == nil || !strings.Contains(err.Error(), "whole tab") {
t.Fatalf("zellij: %v", err)
}
}
+194
View File
@@ -0,0 +1,194 @@
package multiplex
import (
"fmt"
"path/filepath"
"strconv"
"strings"
)
// paneScript runs the pane's command and keeps a failed one on screen, so an
// unreachable host shows its error instead of the pane vanishing.
const paneScript = `"$@"; status=$?; if [ "$status" -ne 0 ]; then echo; echo "netssh exited with status $status, press Enter to close"; read -r _; fi`
// tmuxArgv runs the pane through sh with argv as arguments: no quoting, and
// independent of the user's default-shell (which may be fish).
func tmuxArgv(p Pane) []string {
return append([]string{"sh", "-c", paneScript, "netssh-pane"}, p.Argv...)
}
// openTmux adds a window per chunk of panes: to the current session inside
// tmux, else to a new detached session that is attached at the end.
func openTmux(panes []Pane, opts Options, r Runner) error {
inside := r.Getenv("TMUX") != ""
session := ""
if !inside {
session = freeSession(r, "netssh-"+opts.Label)
}
groups := chunks(panes, opts.PerWindow)
first := ""
for i, group := range groups {
name := windowLabel(opts.Label, i, len(groups))
format := []string{"-P", "-F", "#{window_id} #{pane_id}", "-n", name}
var create []string
switch {
case inside:
create = append([]string{"tmux", "new-window"}, format...)
case i == 0:
// A large virtual size leaves room for the splits; attaching resizes it.
create = append([]string{"tmux", "new-session", "-d", "-x", "240", "-y", "70", "-s", session}, format...)
default:
create = append([]string{"tmux", "new-window", "-t", session + ":"}, format...)
}
out, err := r.Output(append(create, tmuxArgv(group[0])...)...)
if err != nil {
return err
}
if !inside && i == 0 {
// tmux cuts the status bar's session name at 10 characters
// ("[netssh-pv"); widen it for this session only.
if _, err := r.Output("tmux", "set-option", "-t", session, "status-left-length", "40"); err != nil {
return err
}
}
window, pane, _ := strings.Cut(out, " ")
if first == "" {
first = window
}
paneIDs := []string{pane}
for _, p := range group[1:] {
id, err := r.Output(append([]string{"tmux", "split-window", "-t", window, "-P", "-F", "#{pane_id}"}, tmuxArgv(p)...)...)
if err != nil {
return err
}
paneIDs = append(paneIDs, id)
// Re-tile after every split so the next one still has room.
if _, err := r.Output("tmux", "select-layout", "-t", window, "tiled"); err != nil {
return err
}
}
for j, id := range paneIDs {
// A pane may already be gone (host unreachable and closed); not fatal.
_, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title)
}
windowOptions := [][]string{
{"@netssh", "1"}, // the key bindings act only in these windows
{"pane-border-status", "top"},
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} "},
}
if opts.Sync {
windowOptions = append(windowOptions, []string{"synchronize-panes", "on"})
}
for _, option := range windowOptions {
if _, err := r.Output("tmux", "set-window-option", "-t", window, option[0], option[1]); err != nil {
return err
}
}
}
keys, err := bindKeys(r, opts)
if err != nil {
return err
}
if keys != "" {
opts.Hint(keys)
}
if inside {
_, err := r.Output("tmux", "select-window", "-t", first)
return err
}
return r.Exec("tmux", "attach-session", "-t", session)
}
// soloToggle excludes the current pane from broadcast (a pane-level
// synchronize-panes off) or includes it again by removing that override.
func soloToggle(solo bool) [][]string {
if solo {
return [][]string{
{"tmux", "set-option", "-pu", "synchronize-panes"},
{"tmux", "set-option", "-pu", "@netssh_solo"},
}
}
return [][]string{
{"tmux", "set-option", "-p", "synchronize-panes", "off"},
{"tmux", "set-option", "-p", "@netssh_solo", "1"},
}
}
// keyBindings toggle broadcast in netssh multi windows; elsewhere they only
// show a message, so they do not change what the keys mean to other windows.
const syncBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' {
set-window-option synchronize-panes
display-message 'netssh: broadcast #{?synchronize-panes,on,off}'
} {
display-message 'netssh: not a netssh multi window'
}
`
const soloBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' {
if-shell -F '#{@netssh_solo}' {
set-option -pu synchronize-panes
set-option -pu @netssh_solo
display-message 'netssh: pane included in broadcast'
} {
set-option -p synchronize-panes off
set-option -p @netssh_solo 1
display-message 'netssh: pane excluded from broadcast'
}
} {
display-message 'netssh: not a netssh multi window'
}
`
// bindKeys installs the broadcast keys in the tmux server unless the user's
// own configuration already uses them, and returns a hint naming the keys.
func bindKeys(r Runner, opts Options) (string, error) {
prefix, err := r.Output("tmux", "show-options", "-gv", "prefix")
if err != nil || prefix == "" {
prefix = "C-b"
}
var conf strings.Builder
var hints, taken []string
for _, key := range []struct{ key, binding, what string }{
{opts.SyncKey, syncBinding, "broadcast to all panes"},
{opts.SoloKey, soloBinding, "exclude/include this pane"},
} {
if key.key == "" {
continue
}
// An existing binding is replaced only if netssh made it.
if existing, _ := r.Output("tmux", "list-keys", "-T", "prefix", key.key); existing != "" && !strings.Contains(existing, "@netssh") {
taken = append(taken, prefix+" "+key.key)
continue
}
fmt.Fprintf(&conf, key.binding, key.key)
hints = append(hints, prefix+" "+key.key+" "+key.what)
}
if conf.Len() > 0 {
path := filepath.Join(opts.LayoutDir, "netssh-multi.tmux.conf")
if err := r.WriteFile(path, []byte(conf.String())); err != nil {
return "", err
}
if _, err := r.Output("tmux", "source-file", path); err != nil {
return "", err
}
}
hint := "keys: " + strings.Join(hints, ", ")
if len(hints) == 0 {
hint = "toggle broadcast with: netssh sync, netssh sync --pane"
}
if len(taken) > 0 {
hint += "; " + strings.Join(taken, " and ") + " kept your own binding (use netssh sync)"
}
return hint, nil
}
// freeSession returns name, or name-2, name-3, … if a session already uses it.
func freeSession(r Runner, name string) string {
candidate := name
for n := 2; ; n++ {
if _, err := r.Output("tmux", "has-session", "-t", "="+candidate); err != nil {
return candidate
}
candidate = name + "-" + strconv.Itoa(n)
}
}
+101
View File
@@ -0,0 +1,101 @@
package multiplex
import (
"fmt"
"math"
"path/filepath"
"strings"
)
// openZellij writes a layout with one tab per chunk of panes. Inside zellij
// each tab is added to the current session; outside, a new session starts.
func openZellij(panes []Pane, opts Options, r Runner) error {
groups := chunks(panes, opts.PerWindow)
if r.Getenv("ZELLIJ") != "" {
for i, group := range groups {
path := filepath.Join(opts.LayoutDir, fmt.Sprintf("netssh-multi-%d.kdl", i+1))
tab := zellijTab(windowLabel(opts.Label, i, len(groups)), group, true)
if err := r.WriteFile(path, []byte(zellijLayout(tab))); err != nil {
return err
}
if _, err := r.Output("zellij", "action", "new-tab", "--layout", path); err != nil {
return err
}
if opts.Sync {
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
return err
}
}
}
return nil
}
var tabs strings.Builder
for i, group := range groups {
tabs.WriteString(zellijTab(windowLabel(opts.Label, i, len(groups)), group, i == 0))
}
path := filepath.Join(opts.LayoutDir, "netssh-multi.kdl")
if err := r.WriteFile(path, []byte(zellijLayout(tabs.String()))); err != nil {
return err
}
if opts.Sync {
opts.Hint("zellij starts without broadcast: press Ctrl+t, then s, to type into all panes")
} else {
opts.Hint("toggle broadcast with Ctrl+t, then s (or netssh sync)")
}
return r.Exec("zellij", "--layout", path)
}
// zellijLayout wraps tabs with zellij's tab and status bars.
func zellijLayout(tabs string) string {
return `layout {
default_tab_template {
pane size=1 borderless=true {
plugin location="zellij:tab-bar"
}
children
pane size=2 borderless=true {
plugin location="zellij:status-bar"
}
}
` + tabs + "}\n"
}
// zellijTab arranges panes in a grid: rows of ceil(sqrt(n)) columns.
func zellijTab(name string, panes []Pane, focus bool) string {
cols := int(math.Ceil(math.Sqrt(float64(len(panes)))))
var b strings.Builder
fmt.Fprintf(&b, " tab name=%s", kdlString(name))
if focus {
b.WriteString(" focus=true")
}
b.WriteString(" {\n")
for start := 0; start < len(panes); start += cols {
row := panes[start:min(start+cols, len(panes))]
indent := " "
if len(row) > 1 {
b.WriteString(indent + "pane split_direction=\"vertical\" {\n")
indent += " "
}
for _, p := range row {
fmt.Fprintf(&b, "%spane name=%s command=%s {\n", indent, kdlString(p.Title), kdlString(p.Argv[0]))
if len(p.Argv) > 1 {
args := make([]string, 0, len(p.Argv)-1)
for _, arg := range p.Argv[1:] {
args = append(args, kdlString(arg))
}
fmt.Fprintf(&b, "%s args %s\n", indent, strings.Join(args, " "))
}
b.WriteString(indent + "}\n")
}
if len(row) > 1 {
b.WriteString(" }\n")
}
}
b.WriteString(" }\n")
return b.String()
}
// kdlString quotes s as a KDL string.
func kdlString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + `"`
}
+43 -12
View File
@@ -105,6 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
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)
} }
filters := ""
if opts.Tag != "" {
filters += "&tag=" + url.QueryEscape(opts.Tag)
}
if opts.Cluster != "" {
ids, err := c.clusterIDs(ctx, opts.Cluster)
if err != nil {
return nil, fmt.Errorf("clusters: %w", err)
}
if len(ids) == 0 {
return nil, nil // no such cluster in this NetBox
}
for _, id := range ids {
filters += fmt.Sprintf("&cluster_id=%d", id)
}
}
var ( var (
mu sync.Mutex mu sync.Mutex
results []HostEntry results []HostEntry
@@ -116,7 +132,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
devices, err := c.fetchAllDevices(ctx, query, opts.Tag) devices, err := c.fetchAllDevices(ctx, query, filters)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
@@ -131,7 +147,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
vms, err := c.fetchAllVMs(ctx, query, opts.Tag) vms, err := c.fetchAllVMs(ctx, query, filters)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
@@ -231,15 +247,27 @@ func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry,
return entries, nil return entries, nil
} }
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) { // clusterIDs returns the IDs of the clusters named name (case-insensitive).
func (c *Client) clusterIDs(ctx context.Context, name string) ([]int, error) {
var resp netboxListResponse[netboxCluster]
apiURL := fmt.Sprintf("%s/api/virtualization/clusters/?name__ie=%s&limit=50", c.baseURL, url.QueryEscape(name))
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
ids := make([]int, 0, len(resp.Results))
for _, cluster := range resp.Results {
ids = append(ids, cluster.ID)
}
return ids, nil
}
// fetchAllDevices pages through devices; filters are extra "&key=value" parameters.
func (c *Client) fetchAllDevices(ctx context.Context, query, filters string) ([]HostEntry, error) {
const pageSize = 50 const pageSize = 50
var all []HostEntry var all []HostEntry
for offset := 0; ; offset += pageSize { for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d", apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset) c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice] var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -254,15 +282,12 @@ func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]Host
return all, nil return all, nil
} }
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) { func (c *Client) fetchAllVMs(ctx context.Context, query, filters string) ([]HostEntry, error) {
const pageSize = 50 const pageSize = 50
var all []HostEntry var all []HostEntry
for offset := 0; ; offset += pageSize { for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d", apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset) c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM] var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -343,6 +368,9 @@ func deviceToEntry(d netboxDevice) HostEntry {
for _, t := range d.Tags { for _, t := range d.Tags {
e.Tags = append(e.Tags, t.Name) e.Tags = append(e.Tags, t.Name)
} }
if d.Cluster != nil {
e.Cluster = d.Cluster.Name
}
return e return e
} }
@@ -357,6 +385,9 @@ func vmToEntry(v netboxVM) HostEntry {
for _, t := range v.Tags { for _, t := range v.Tags {
e.Tags = append(e.Tags, t.Name) e.Tags = append(e.Tags, t.Name)
} }
if v.Cluster != nil {
e.Cluster = v.Cluster.Name
}
return e return e
} }
+1 -1
View File
@@ -235,7 +235,7 @@ func TestGetIPsWithFilter(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0") ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface=mgmt0")
if err != nil { if err != nil {
t.Fatalf("GetIPsWithFilter: %v", err) t.Fatalf("GetIPsWithFilter: %v", err)
} }
+51
View File
@@ -0,0 +1,51 @@
package netbox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestSearchAllByCluster(t *testing.T) {
var deviceQuery, vmQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/virtualization/clusters/":
if r.URL.Query().Get("name__ie") == "PVE" {
fmt.Fprint(w, `{"count":2,"results":[{"id":3,"name":"pve"},{"id":4,"name":"pve"}]}`)
return
}
fmt.Fprint(w, `{"count":0,"results":[]}`)
case "/api/dcim/devices/":
deviceQuery = r.URL.RawQuery
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"node1","cluster":{"id":3,"name":"pve"}}]}`)
case "/api/virtualization/virtual-machines/":
vmQuery = r.URL.RawQuery
fmt.Fprint(w, `{"count":1,"results":[{"id":2,"name":"vm1","cluster":{"id":4,"name":"pve"}}]}`)
}
}))
defer srv.Close()
c := NewClient(srv.URL, "t", 1)
hosts, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE", Kind: "device"})
if err != nil || len(hosts) != 1 || hosts[0].Name != "node1" || hosts[0].Cluster != "pve" {
t.Fatalf("got %+v %v", hosts, err)
}
if want := "name__ic=&limit=50&offset=0&cluster_id=3&cluster_id=4"; deviceQuery != want {
t.Errorf("device query %q, want %q", deviceQuery, want)
}
if vmQuery != "" {
t.Error("VMs queried for kind device")
}
all, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE"})
if err != nil || len(all) != 2 {
t.Fatalf("all kinds: %+v %v", all, err)
}
none, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "missing"})
if err != nil || len(none) != 0 {
t.Fatalf("unknown cluster: %+v %v", none, err)
}
}
+11
View File
@@ -7,6 +7,8 @@ import "strings"
type SearchOptions struct { type SearchOptions struct {
Tag string // filter by tag slug; empty = no filter Tag string // filter by tag slug; empty = no filter
Kind string // "device" | "vm" | "" (both) Kind string // "device" | "vm" | "" (both)
// Cluster filters SearchAll by virtualization cluster name (case-insensitive).
Cluster string
} }
// 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.
@@ -18,6 +20,13 @@ type HostEntry struct {
PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped) PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped)
PrimaryIP6 string PrimaryIP6 string
Tags []string Tags []string
Cluster string // virtualization cluster name, if assigned
}
// netboxCluster is the nested cluster of a device or virtual machine.
type netboxCluster struct {
ID int `json:"id"`
Name string `json:"name"`
} }
// netboxIP represents an IP address as returned by the NetBox API. // netboxIP represents an IP address as returned by the NetBox API.
@@ -34,6 +43,7 @@ type netboxDevice struct {
} `json:"tags"` } `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"` PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"` PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
} }
// netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response. // netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response.
@@ -45,6 +55,7 @@ type netboxVM struct {
} `json:"tags"` } `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"` PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"` PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
} }
type netboxListResponse[T any] struct { type netboxListResponse[T any] struct {
+1 -1
View File
@@ -78,7 +78,7 @@ func TestMultiSourceSearchAndRouting(t *testing.T) {
if err != nil || len(ips) != 1 || ips[0] != tc.ip { if err != nil || len(ips) != 1 || ips[0] != tc.ip {
t.Fatalf("wrong IP source: %v, %v", ips, err) t.Fatalf("wrong IP source: %v, %v", ips, err)
} }
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0") ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface=mgmt0")
if err != nil || len(ips) != 1 || ips[0] != tc.ip { if err != nil || len(ips) != 1 || ips[0] != tc.ip {
t.Fatalf("wrong interface source: %v, %v", ips, err) t.Fatalf("wrong interface source: %v, %v", ips, err)
} }
+2
View File
@@ -46,6 +46,8 @@ func newStrategy(name string, cfg config.ResolverConfig) (Strategy, error) {
return nil, err return nil, err
} }
return s, nil return s, nil
case "hostname":
return &HostnameStrategy{domain: cfg.HostnameDomain}, nil
case "interface_name": case "interface_name":
if cfg.InterfaceName == "" { if cfg.InterfaceName == "" {
return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set") return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set")
+31
View File
@@ -0,0 +1,31 @@
package resolver
import (
"context"
"regexp"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
var validHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
// HostnameStrategy connects by the NetBox name instead of an IP, so DNS or
// ~/.ssh/config resolves it. Names without a dot get the optional domain.
// Names that are no valid hostname fall through to the next strategy.
type HostnameStrategy struct {
domain string
}
func (s *HostnameStrategy) Name() string { return "hostname" }
func (s *HostnameStrategy) Resolve(_ context.Context, entry *netbox.HostEntry, _ *netbox.Client) (string, error) {
name := strings.ToLower(entry.Name)
if domain := strings.Trim(s.domain, "."); domain != "" && !strings.Contains(name, ".") {
name += "." + strings.ToLower(domain)
}
if !validHostname.MatchString(name) {
return "", ErrNoIP
}
return name, nil
}
+43
View File
@@ -0,0 +1,43 @@
package resolver
import (
"context"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
func TestHostnameStrategy(t *testing.T) {
for _, tc := range []struct {
name, domain, want string
}{
{"Web01.Example.com", "", "web01.example.com"},
{"web01", "example.com", "web01.example.com"},
{"web01", ".example.com.", "web01.example.com"},
{"web01.lab", "example.com", "web01.lab"}, // already qualified
} {
got, err := (&HostnameStrategy{domain: tc.domain}).Resolve(context.Background(), &netbox.HostEntry{Name: tc.name}, nil)
if err != nil || got != tc.want {
t.Errorf("%q + %q: got %q, %v; want %q", tc.name, tc.domain, got, err, tc.want)
}
}
for _, name := range []string{"Core Switch 1", "-x", "a..b", "web_01"} {
if _, err := (&HostnameStrategy{}).Resolve(context.Background(), &netbox.HostEntry{Name: name}, nil); err != ErrNoIP {
t.Errorf("%q: got %v, want ErrNoIP", name, err)
}
}
}
func TestHostnameAsFallback(t *testing.T) {
chain, err := New(config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"})
if err != nil {
t.Fatal(err)
}
if ip, _ := chain.Resolve(context.Background(), &netbox.HostEntry{Name: "web01", PrimaryIP4: "192.0.2.1"}, nil); ip != "192.0.2.1" {
t.Fatalf("primary IP first: %q", ip)
}
if ip, _ := chain.Resolve(context.Background(), &netbox.HostEntry{Name: "web02"}, nil); ip != "web02.example.com" {
t.Fatalf("hostname fallback: %q", ip)
}
}
+6 -3
View File
@@ -16,13 +16,16 @@ type InterfaceNameStrategy struct {
func (s *InterfaceNameStrategy) Name() string { return "interface_name" } func (s *InterfaceNameStrategy) Name() string { return "interface_name" }
func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) { func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) {
// Build filter parameters for IP addresses attached to the named interface. // NetBox filters IP addresses by interface name with interface= (devices)
// and vminterface= (VMs); unknown parameters would be ignored silently and
// return every address of the host. A missing interface is an HTTP 400,
// which lets the chain try the next strategy.
var filterParam string var filterParam string
switch entry.Kind { switch entry.Kind {
case "device": case "device":
filterParam = fmt.Sprintf("device_id=%d&interface_name=%s", entry.ID, url.QueryEscape(s.name)) filterParam = fmt.Sprintf("device_id=%d&interface=%s", entry.ID, url.QueryEscape(s.name))
case "vm": case "vm":
filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface_name=%s", entry.ID, url.QueryEscape(s.name)) filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface=%s", entry.ID, url.QueryEscape(s.name))
default: default:
return "", fmt.Errorf("unknown kind %q", entry.Kind) return "", fmt.Errorf("unknown kind %q", entry.Kind)
} }
+56
View File
@@ -0,0 +1,56 @@
package resolver
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// NetBox ignores unknown filters, so the strategy must use the real filter
// names; otherwise it silently returns the host's first address.
func TestInterfaceNameStrategyFilters(t *testing.T) {
for kind, want := range map[string]string{
"device": "device_id=7&interface=mgmt0",
"vm": "virtual_machine_id=7&vminterface=mgmt0",
} {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
q.Del("limit")
q.Del("offset")
got = q.Encode()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"count":1,"results":[{"address":"10.0.0.7/24"}]}`))
}))
ip, err := (&InterfaceNameStrategy{name: "mgmt0"}).Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: kind}, netbox.NewClient(srv.URL, "t", 1))
srv.Close()
if err != nil || ip != "10.0.0.7" {
t.Fatalf("%s: %q %v", kind, ip, err)
}
if parsed, _ := urlQuery(want); got != parsed {
t.Errorf("%s: query %q, want %q", kind, got, parsed)
}
}
}
func TestInterfaceNameStrategyMissingInterfaceFallsThrough(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"vminterface":["Select a valid choice."]}`, http.StatusBadRequest)
}))
defer srv.Close()
client := netbox.NewClient(srv.URL, "t", 1)
chain := &Chain{strategies: []Strategy{&InterfaceNameStrategy{name: "mgmt0"}, &PrimaryIPStrategy{}}}
ip, err := chain.Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: "vm", PrimaryIP4: "10.0.0.9"}, client)
if err != nil || ip != "10.0.0.9" {
t.Fatalf("got %q %v", ip, err)
}
}
func urlQuery(s string) (string, error) {
q, err := url.ParseQuery(s)
return q.Encode(), err
}
+140
View File
@@ -0,0 +1,140 @@
package setup
import (
"fmt"
"strconv"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// configureHosts edits a copy of the static hosts; the caller decides whether to keep it.
func configureHosts(hosts []config.StaticHost) ([]config.StaticHost, error) {
hosts = append([]config.StaticHost(nil), hosts...)
for {
action := "done"
var options []huh.Option[string]
for i, host := range hosts {
index := strconv.Itoa(i)
options = append(options,
huh.NewOption("Edit "+host.Name+" ("+host.Address+")", "edit:"+index),
huh.NewOption("SSH options for "+host.Name, "ssh:"+index),
huh.NewOption("Remove "+host.Name, "remove:"+index))
}
options = append(options, huh.NewOption("Add host", "add"), huh.NewOption("Back", "done"))
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("Static hosts").
Description("Hosts that are not in NetBox. They are found before NetBox and work offline.").
Options(options...).
Value(&action))).Run()
if err != nil {
return nil, err
}
if action == "done" {
return hosts, nil
}
if action == "add" {
host, err := editHost(config.StaticHost{}, hosts, -1)
if err != nil {
return nil, err
}
hosts = append(hosts, host)
continue
}
verb, index, _ := strings.Cut(action, ":")
i, _ := strconv.Atoi(index)
switch verb {
case "edit":
host, err := editHost(hosts[i], hosts, i)
if err != nil {
return nil, err
}
hosts[i] = host
case "ssh":
options, err := editSSHOptions("SSH options for "+hosts[i].Name, hosts[i].SSH)
if err != nil {
return nil, err
}
hosts[i].SSH = options
case "remove":
remove := false
if err := huh.NewConfirm().Title("Remove " + hosts[i].Name + "?").Value(&remove).Run(); err != nil {
return nil, err
}
if remove {
hosts = append(hosts[:i], hosts[i+1:]...)
}
}
}
}
func editHost(host config.StaticHost, hosts []config.StaticHost, index int) (config.StaticHost, error) {
name, address, user := host.Name, host.Address, host.User
port := ""
if host.Port > 0 {
port = strconv.Itoa(host.Port)
}
tags := strings.Join(host.Tags, ", ")
candidate := func() config.StaticHost {
out := host
out.Name = strings.TrimSpace(name)
out.Address = strings.TrimSpace(address)
out.User = strings.TrimSpace(user)
out.Port, _ = strconv.Atoi(strings.TrimSpace(port))
out.Tags = splitList(tags)
return out
}
// huh validates the typed value before storing it, so each validator
// checks the host with its own field taken from the argument.
validate := func(set func(*config.StaticHost, string)) func(string) error {
return func(value string) error {
h := candidate()
set(&h, strings.TrimSpace(value))
return validateHostIn(h, hosts, index)
}
}
err := huh.NewForm(huh.NewGroup(
huh.NewInput().Title("Name").Description("What you type: netssh NAME. Letters, digits, dots, hyphens.").Value(&name).
Validate(validate(func(h *config.StaticHost, v string) { h.Name = v })),
huh.NewInput().Title("Address").Description("IP address or DNS name to connect to.").Value(&address).
Validate(validate(func(h *config.StaticHost, v string) {
h.Address = v
if v == "" {
h.Address = "-" // required: reported as invalid address
}
})),
huh.NewInput().Title("User").Description("Leave empty for ssh.default_user.").Value(&user).
Validate(validate(func(h *config.StaticHost, v string) { h.User = v })),
huh.NewInput().Title("Port").Description("Leave empty for ssh.default_port or 22.").Value(&port).Validate(func(s string) error {
if s = strings.TrimSpace(s); s == "" {
return nil
}
if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("must be a port number between 1 and 65535")
}
return nil
}),
huh.NewInput().Title("Tags").Description("Comma-separated, for ssh.rules match: {tag: …}.").Value(&tags),
).Title("Static host")).Run()
if err != nil {
return host, err
}
return candidate(), nil
}
// validateHostIn checks host as entry index of hosts (-1 for a new host).
func validateHostIn(host config.StaticHost, hosts []config.StaticHost, index int) error {
others := make([]config.StaticHost, 0, len(hosts)+1)
for i, other := range hosts {
if i != index {
others = append(others, other)
}
}
if host.Address == "" {
host.Address = "placeholder" // reported once the address field is filled
}
return (&config.Config{Hosts: append(others, host)}).ValidateHosts()
}
+22 -2
View File
@@ -20,6 +20,18 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
} }
for { for {
if len(sources) == 0 { if len(sources) == 0 {
choice := "add"
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("NetBox").
Description("netssh looks up hosts in NetBox. Without NetBox, only static hosts (configured next) are available.").
Options(huh.NewOption("Add a NetBox instance", "add"), huh.NewOption("Continue without NetBox (static hosts only)", "none")).
Value(&choice))).Run()
if err != nil {
return nil, err
}
if choice == "none" {
return nil, nil
}
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "") source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -30,6 +42,7 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
var options []huh.Option[string] var options []huh.Option[string]
for _, source := range sources { for _, source := range sources {
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name)) 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("Remove "+source.Name, "remove:"+source.Name))
} }
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done")) options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
@@ -53,13 +66,20 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
if source.Name != name { if source.Name != name {
continue continue
} }
if verb == "edit" { switch verb {
case "edit":
edited, err := editInstance(source, sources, name) edited, err := editInstance(source, sources, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
sources[i] = edited sources[i] = edited
} else { case "ssh":
options, err := editSSHOptions("SSH options for "+name, source.SSH)
if err != nil {
return nil, err
}
sources[i].SSH = options
default:
remove := false remove := false
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil { if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
return nil, err return nil, err
+128
View File
@@ -0,0 +1,128 @@
package setup
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func editMulti(cfg *config.Config) error {
multiplexer := cfg.Multi.Multiplexer
if multiplexer == "" {
multiplexer = "auto"
}
perWindow := ""
if cfg.Multi.PanesPerWindow > 0 {
perWindow = strconv.Itoa(cfg.Multi.PanesPerWindow)
}
groups := formatGroups(cfg.Groups)
syncKey, soloKey := cfg.Multi.SyncKey, cfg.Multi.SoloKey
validKey := func(field string) func(string) error {
return func(s string) error {
candidate := config.Config{Multi: cfg.Multi}
if field == "sync" {
candidate.Multi.SyncKey = strings.TrimSpace(s)
} else {
candidate.Multi.SoloKey = strings.TrimSpace(s)
}
return candidate.ValidateMulti()
}
}
err := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().
Title("Multiplexer").
Description("netssh multi opens hosts side by side. auto uses the one you are in, else tmux, else zellij.").
Options(huh.NewOption("auto", "auto"), huh.NewOption("tmux", "tmux"), huh.NewOption("zellij", "zellij")).
Value(&multiplexer),
huh.NewInput().
Title("Panes per window").
Description(fmt.Sprintf("More hosts open in further windows or tabs. Leave empty for %d.", config.DefaultPanesPerWindow)).
Value(&perWindow).
Validate(func(s string) error {
if s = strings.TrimSpace(s); s == "" {
return nil
}
if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 64 {
return errors.New("must be a number between 1 and 64")
}
return nil
}),
huh.NewInput().
Title("tmux key: broadcast to all panes").
Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSyncKey)).
Placeholder(config.DefaultSyncKey).
Value(&syncKey).
Validate(validKey("sync")),
huh.NewInput().
Title("tmux key: exclude/include this pane").
Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSoloKey)).
Placeholder(config.DefaultSoloKey).
Value(&soloKey).
Validate(validKey("solo")),
huh.NewText().
Title("Groups").
Description("One per line: NAME: host, tag:TAG, cluster:NAME, @other — open with netssh multi @NAME.").
Lines(4).
Value(&groups).
Validate(func(s string) error {
_, err := parseGroups(s)
return err
}),
).Title("Multi-session")).Run()
if err != nil {
return err
}
cfg.Multi.Multiplexer = multiplexer
if multiplexer == "auto" {
cfg.Multi.Multiplexer = ""
}
cfg.Multi.PanesPerWindow, _ = strconv.Atoi(strings.TrimSpace(perWindow))
cfg.Multi.SyncKey = strings.TrimSpace(syncKey)
cfg.Multi.SoloKey = strings.TrimSpace(soloKey)
cfg.Groups, _ = parseGroups(groups)
return nil
}
// parseGroups reads "NAME: sel1, sel2" lines into groups:.
func parseGroups(s string) (map[string][]string, error) {
var out map[string][]string
for _, line := range parseLines(s) {
name, members, ok := strings.Cut(line, ":")
name = strings.ToLower(strings.TrimSpace(name))
// "tag:x" alone has no group name before the first colon worth keeping.
if !ok || name == "" || name == "tag" || name == "cluster" {
return nil, fmt.Errorf("%q: expected NAME: host, tag:TAG, …", line)
}
var selectors []string
for _, field := range strings.FieldsFunc(members, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) {
selectors = append(selectors, field)
}
if out == nil {
out = make(map[string][]string)
}
out[name] = append(out[name], selectors...)
}
if err := (&config.Config{Groups: out}).ValidateMulti(); err != nil {
return nil, err
}
return out, nil
}
func formatGroups(groups map[string][]string) string {
names := make([]string, 0, len(groups))
for name := range groups {
names = append(names, name)
}
sort.Strings(names)
lines := make([]string, 0, len(names))
for _, name := range names {
lines = append(lines, name+": "+strings.Join(groups[name], ", "))
}
return strings.Join(lines, "\n")
}
+284
View File
@@ -0,0 +1,284 @@
package setup
import (
"fmt"
"sort"
"strconv"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides.
func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") }
// editSSHOptions edits one SSH option layer: global, per instance, or per rule.
func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) {
proxyJump := o.ProxyJump
hostKeyChecking := o.HostKeyChecking
target := o.Target
addressFamily := o.AddressFamily
extraArgs := strings.Join(o.ExtraArgs, "\n")
options := formatOptions(o.Options)
forwards := formatForwards(o.Forwards)
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Jump host").
Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere.").
Value(&proxyJump).
Validate(func(s string) error {
return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh")
}),
huh.NewSelect[string]().
Title("Host key checking").
Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")).
Value(&hostKeyChecking),
huh.NewSelect[string]().
Title("Connection target").
Description("What is passed to ssh as destination.").
Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")).
Value(&target),
huh.NewSelect[string]().
Title("Address family").
Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")).
Value(&addressFamily),
huh.NewText().
Title("Extra arguments").
Description("One argument per line, inserted before the destination, e.g. -A").
Lines(3).
Value(&extraArgs),
huh.NewText().
Title("SSH options").
Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60").
Lines(4).
Value(&options).
Validate(func(s string) error {
_, err := parseOptions(s)
return err
}),
huh.NewText().
Title("Port forwards").
Description("One per line: NAME L|R|D SPEC, e.g. web L 8080:localhost:80 or socks D 1080.\n"+
"Use with netssh tunnel HOST NAME or netssh --forward NAME HOST.").
Lines(3).
Value(&forwards).
Validate(func(s string) error {
_, err := parseForwards(s)
return err
}),
).Title(title)).Run()
if err != nil {
return o, err
}
parsed, _ := parseOptions(options)
parsedForwards, _ := parseForwards(forwards)
return config.SSHOptions{
Forwards: parsedForwards,
ProxyJump: strings.TrimSpace(proxyJump),
HostKeyChecking: hostKeyChecking,
Target: target,
AddressFamily: addressFamily,
ExtraArgs: parseLines(extraArgs),
Options: parsed,
}, nil
}
// parseLines returns the non-empty, trimmed lines of s.
func parseLines(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if line = strings.TrimSpace(line); line != "" {
out = append(out, line)
}
}
return out
}
// parseOptions reads Key=Value lines into the map stored under ssh.options.
func parseOptions(s string) (map[string]string, error) {
var out map[string]string
for _, line := range parseLines(s) {
key, value, ok := strings.Cut(line, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return nil, fmt.Errorf("%q: expected Key=Value", line)
}
if out == nil {
out = make(map[string]string)
}
out[key] = strings.TrimSpace(value)
}
if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil {
return nil, err
}
return out, nil
}
// forwardKinds maps the letters used in the form to the ssh flags.
var forwardKinds = map[string]func(spec string) config.Forward{
"L": func(spec string) config.Forward { return config.Forward{Local: spec} },
"R": func(spec string) config.Forward { return config.Forward{Remote: spec} },
"D": func(spec string) config.Forward { return config.Forward{Dynamic: spec} },
}
// parseForwards reads "NAME L|R|D SPEC" lines into ssh.forwards.
func parseForwards(s string) (map[string]config.Forward, error) {
var out map[string]config.Forward
for _, line := range parseLines(s) {
fields := strings.Fields(line)
if len(fields) != 3 || forwardKinds[strings.ToUpper(fields[1])] == nil {
return nil, fmt.Errorf("%q: expected NAME L|R|D SPEC", line)
}
if out == nil {
out = make(map[string]config.Forward)
}
out[strings.ToLower(fields[0])] = forwardKinds[strings.ToUpper(fields[1])](fields[2])
}
if err := (config.SSHOptions{Forwards: out}).Validate("ssh"); err != nil {
return nil, err
}
return out, nil
}
func formatForwards(forwards map[string]config.Forward) string {
names := make([]string, 0, len(forwards))
for name := range forwards {
names = append(names, name)
}
sort.Strings(names)
lines := make([]string, 0, len(names))
for _, name := range names {
flag := forwards[name].Flag()
if len(flag) == 2 {
lines = append(lines, name+" "+strings.TrimPrefix(flag[0], "-")+" "+flag[1])
}
}
return strings.Join(lines, "\n")
}
func formatOptions(options map[string]string) string {
keys := make([]string, 0, len(options))
for key := range options {
keys = append(keys, key)
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, key := range keys {
lines = append(lines, key+"="+options[key])
}
return strings.Join(lines, "\n")
}
// configureRules edits a copy of the rules; the caller decides whether to keep it.
func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) {
rules = append([]config.SSHRule(nil), rules...)
for {
action := "done"
var options []huh.Option[string]
for i, rule := range rules {
index := strconv.Itoa(i)
options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index))
options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index))
}
options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done"))
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("SSH rules").
Description("Rules apply SSH options to hosts matching every set field. Later rules win.").
Options(options...).
Value(&action))).Run()
if err != nil {
return nil, err
}
if action == "done" {
return rules, nil
}
if action == "add" {
rule, err := editRule(config.SSHRule{}, sources)
if err != nil {
return nil, err
}
rules = append(rules, rule)
continue
}
verb, index, _ := strings.Cut(action, ":")
i, _ := strconv.Atoi(index)
if verb == "edit" {
rule, err := editRule(rules[i], sources)
if err != nil {
return nil, err
}
rules[i] = rule
continue
}
remove := false
if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil {
return nil, err
}
if remove {
rules = append(rules[:i], rules[i+1:]...)
}
}
}
func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) {
match := rule.Match
instances := []huh.Option[string]{huh.NewOption("(any)", "")}
known := false
for _, source := range sources {
instances = append(instances, huh.NewOption(source.Name, source.Name))
known = known || strings.EqualFold(source.Name, match.Instance)
}
if match.Instance != "" && !known {
instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance))
}
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Tag").
Description("NetBox tag name or slug, e.g. dmz").
Value(&match.Tag),
huh.NewSelect[string]().
Title("Instance").
Options(instances...).
Value(&match.Instance),
huh.NewSelect[string]().
Title("Kind").
Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")).
Value(&match.Kind),
huh.NewInput().
Title("Name pattern").
Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com").
Value(&match.Name).
Validate(func(s string) error {
candidate := match
candidate.Tag = strings.TrimSpace(candidate.Tag)
candidate.Name = strings.TrimSpace(s)
return candidate.Validate("rule")
}),
).Title("Rule match")).Run()
if err != nil {
return rule, err
}
match.Tag = strings.TrimSpace(match.Tag)
match.Name = strings.TrimSpace(match.Name)
options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions)
if err != nil {
return rule, err
}
return config.SSHRule{Match: match, SSHOptions: options}, nil
}
func describeMatch(m config.SSHMatch) string {
var parts []string
for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} {
if field[1] != "" {
parts = append(parts, field[0]+"="+field[1])
}
}
return "[" + strings.Join(parts, " ") + "]"
}
+92
View File
@@ -0,0 +1,92 @@
package setup
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestParseLines(t *testing.T) {
got := parseLines(" -A \n\n\t-C\n \n")
if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %q, want %q", got, want)
}
if got := parseLines(" \n "); got != nil {
t.Fatalf("blank input: got %q, want nil", got)
}
}
func TestParseOptionsRoundtrip(t *testing.T) {
text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n"
got, err := parseOptions(text)
if err != nil {
t.Fatal(err)
}
want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
again, err := parseOptions(formatOptions(got))
if err != nil || !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: got %v (%v)", again, err)
}
if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" {
t.Fatalf("formatOptions not sorted: %q", formatOptions(got))
}
}
func TestParseOptionsEmpty(t *testing.T) {
got, err := parseOptions("\n \n")
if err != nil || got != nil {
t.Fatalf("got %v, %v; want nil, nil", got, err)
}
}
func TestParseOptionsInvalid(t *testing.T) {
for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} {
if _, err := parseOptions(text); err == nil {
t.Errorf("%q: expected error", text)
}
}
}
func TestDescribeMatch(t *testing.T) {
got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"})
if got != "[tag=dmz name=*.dmz]" {
t.Fatalf("got %q", got)
}
}
func TestRuleValidation(t *testing.T) {
if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") {
t.Fatalf("empty match: got %v", err)
}
if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil {
t.Fatal("invalid glob accepted")
}
if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil {
t.Fatalf("valid match rejected: %v", err)
}
}
func TestParseForwardsRoundtrip(t *testing.T) {
text := "Web L 8080:localhost:80\n\nsocks d 1080\nback R 9000:localhost:9000\n"
got, err := parseForwards(text)
if err != nil {
t.Fatal(err)
}
want := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if formatForwards(got) != "back R 9000:localhost:9000\nsocks D 1080\nweb L 8080:localhost:80" {
t.Fatalf("format: %q", formatForwards(got))
}
for _, bad := range []string{"web 8080:localhost:80", "web X 8080", "web L -oFoo", "bad/name L 1:a:2"} {
if _, err := parseForwards(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
+376 -109
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -16,39 +17,129 @@ import (
"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 {
sources, err := configureInstances(cfg) sources, err := configureInstances(cfg)
if err != nil { 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).WithStatic(loaded.Hosts)
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) { if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.") fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0) os.Exit(0)
} }
return err return err
} }
// prepare starts from the loaded config so settings without a menu entry survive
// a rewrite, migrates a legacy single instance, and fills in usable defaults.
func prepare(cfg config.Config, sources []config.NetBoxConfig) config.Config {
cfg.NetBox = config.NetBoxConfig{}
cfg.NetBoxes = sources
if len(cfg.Resolver.Strategies) == 0 {
cfg.Resolver.Strategies = []string{"primary_ip"}
}
if cfg.Cache.TTL == 0 {
cfg.Cache.TTL = 3600
}
return cfg
}
func runMenu(cfg *config.Config) error {
for {
action := "save"
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("netssh configuration").
Description("Choose a section to edit, then save.").
Options(
huh.NewOption("SSH defaults — user, port", "defaults"),
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
huh.NewOption(fmt.Sprintf("Multi-session — groups, tmux/zellij (%d groups)", len(cfg.Groups)), "multi"),
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 "hosts":
var hosts []config.StaticHost
if hosts, err = configureHosts(cfg.Hosts); err == nil {
cfg.Hosts = hosts
}
case "multi":
err = editMulti(cfg)
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(). 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).").
@@ -68,12 +159,26 @@ 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
hostnameDomain := cfg.Resolver.HostnameDomain
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, hostname").
Placeholder("primary_ip, management_subnet"). Placeholder("primary_ip, management_subnet").
Value(&strategiesRaw). Value(&strategiesRaw).
Validate(validateStrategies), Validate(validateStrategies),
@@ -86,6 +191,10 @@ func RunWizard(cfg *config.Config) error {
Description("Only used when interface_name strategy is active."). Description("Only used when interface_name strategy is active.").
Placeholder("eth0"). Placeholder("eth0").
Value(&interfaceName), Value(&interfaceName),
huh.NewInput().
Title("Hostname domain").
Description("Only used by the hostname strategy: appended to NetBox names without a dot,\ne.g. example.com turns web01 into web01.example.com.").
Value(&hostnameDomain),
huh.NewInput(). huh.NewInput().
Title("Cache TTL (seconds)"). Title("Cache TTL (seconds)").
Value(&cacheTTL). Value(&cacheTTL).
@@ -95,9 +204,29 @@ 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.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".")
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'.").
@@ -107,109 +236,107 @@ 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) {
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 := ""
NetBoxes: sources, if cfg.Completion.MaxResults > 0 {
SSH: config.SSHConfig{ maxResults = strconv.Itoa(cfg.Completion.MaxResults)
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.CachePath(), 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
} }
if _, err := hook.Install(profile); err != nil {
return err // 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 shell != "fish" { out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)})
if _, err := hook.InstallAliasesSource(profile); err != nil { }
return err if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil {
return nil, err
}
return out, nil
}
func formatRewrites(rewrites []config.ShortcutRewrite) string {
lines := make([]string, 0, len(rewrites))
for _, r := range rewrites {
lines = append(lines, r.Pattern+" => "+r.Replace)
}
return strings.Join(lines, "\n")
}
// splitList returns the non-empty, trimmed entries of a comma-separated list.
func splitList(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
} }
} }
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile)) return out
}
return nil
} }
var knownStrategies = map[string]bool{ var knownStrategies = map[string]bool{
"primary_ip": true, "primary_ip": true,
"management_subnet": true, "management_subnet": true,
"interface_name": true, "interface_name": true,
"hostname": true,
} }
func validateStrategies(s string) error { func validateStrategies(s string) error {
@@ -218,21 +345,16 @@ func validateStrategies(s string) error {
} }
for _, name := range parseStrategies(s) { for _, name := range parseStrategies(s) {
if !knownStrategies[name] { if !knownStrategies[name] {
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name) return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name, hostname", name)
} }
} }
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, ",") { // Save validates cfg and writes it to the config file, replacing the old file.
if name := strings.TrimSpace(part); name != "" { func Save(cfg config.Config) error { return save(cfg) }
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 {
@@ -241,6 +363,18 @@ func save(cfg config.Config) error {
if err := cfg.ValidateNetBoxes(); err != nil { if err := cfg.ValidateNetBoxes(); err != nil {
return err return err
} }
if err := cfg.ValidateSSH(); err != nil {
return err
}
if err := cfg.Shortcuts.Validate(); err != nil {
return err
}
if err := cfg.ValidateHosts(); err != nil {
return err
}
if err := cfg.ValidateMulti(); 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)
@@ -251,10 +385,18 @@ func save(cfg config.Config) error {
b.WriteString("netboxes:\n") b.WriteString("netboxes:\n")
for _, source := range cfg.NetBoxes { 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) 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 { }
} else if cfg.NetBox.URL != "" || len(cfg.Hosts) == 0 {
b.WriteString("netbox:\n") b.WriteString("netbox:\n")
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
if !sshOptionsEmpty(cfg.NetBox.SSH) {
b.WriteString(" ssh:\n")
writeSSHOptions(&b, " ", cfg.NetBox.SSH)
}
} }
b.WriteString("\nresolver:\n") b.WriteString("\nresolver:\n")
@@ -271,6 +413,9 @@ func save(cfg config.Config) error {
if cfg.Resolver.InterfaceName != "" { if cfg.Resolver.InterfaceName != "" {
fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName) fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName)
} }
if cfg.Resolver.HostnameDomain != "" {
fmt.Fprintf(&b, " hostname_domain: %q\n", cfg.Resolver.HostnameDomain)
}
b.WriteString("\ncache:\n") b.WriteString("\ncache:\n")
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL) fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
@@ -285,8 +430,78 @@ 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 len(cfg.Hosts) > 0 {
b.WriteString("\nhosts:\n")
for _, host := range cfg.Hosts {
fmt.Fprintf(&b, " - name: %q\n address: %q\n", host.Name, host.Address)
if host.User != "" {
fmt.Fprintf(&b, " user: %q\n", host.User)
}
if host.Port > 0 {
fmt.Fprintf(&b, " port: %d\n", host.Port)
}
if len(host.Tags) > 0 {
b.WriteString(" tags:\n")
for _, tag := range host.Tags {
fmt.Fprintf(&b, " - %q\n", tag)
}
}
if !sshOptionsEmpty(host.SSH) {
b.WriteString(" ssh:\n")
writeSSHOptions(&b, " ", host.SSH)
}
}
}
if len(cfg.Groups) > 0 {
b.WriteString("\ngroups:\n")
names := make([]string, 0, len(cfg.Groups))
for name := range cfg.Groups {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
quoted := make([]string, 0, len(cfg.Groups[name]))
for _, selector := range cfg.Groups[name] {
quoted = append(quoted, strconv.Quote(selector))
}
fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", "))
}
}
if cfg.Multi != (config.MultiConfig{}) {
b.WriteString("\nmulti:\n")
if cfg.Multi.Multiplexer != "" {
fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer)
}
if cfg.Multi.PanesPerWindow > 0 {
fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow)
}
for _, key := range [][2]string{{"sync_key", cfg.Multi.SyncKey}, {"solo_key", cfg.Multi.SoloKey}} {
if key[1] != "" {
fmt.Fprintf(&b, " %s: %q\n", key[0], key[1])
}
}
}
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")
@@ -297,16 +512,68 @@ 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 != "" {
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias) fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
} }
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil { if err := storage.WriteFile(path, []byte(strings.TrimLeft(b.String(), "\n")), 0o600); err != nil {
return fmt.Errorf("writing config: %w", err) return fmt.Errorf("writing config: %w", err)
} }
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 && len(o.Forwards) == 0
}
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} {
if field[1] != "" {
fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1])
}
}
if len(o.ExtraArgs) > 0 {
fmt.Fprintf(b, "%sextra_args:\n", indent)
for _, arg := range o.ExtraArgs {
fmt.Fprintf(b, "%s - %q\n", indent, arg)
}
}
if len(o.Options) > 0 {
keys := make([]string, 0, len(o.Options))
for key := range o.Options {
keys = append(keys, key)
}
sort.Strings(keys)
fmt.Fprintf(b, "%soptions:\n", indent)
for _, key := range keys {
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
}
}
if len(o.Forwards) > 0 {
names := make([]string, 0, len(o.Forwards))
for name := range o.Forwards {
names = append(names, name)
}
sort.Strings(names)
fmt.Fprintf(b, "%sforwards:\n", indent)
for _, name := range names {
f := o.Forwards[name]
for _, field := range [][2]string{{"local", f.Local}, {"remote", f.Remote}, {"dynamic", f.Dynamic}} {
if field[1] != "" {
fmt.Fprintf(b, "%s %s:\n%s %s: %q\n", indent, name, indent, field[0], field[1])
}
}
}
}
}
+232 -1
View File
@@ -3,6 +3,7 @@ package setup
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -294,6 +295,12 @@ func TestParseStrategies_PreservesOrder(t *testing.T) {
} }
} }
func TestValidateStrategiesHostname(t *testing.T) {
if err := validateStrategies("primary_ip, hostname"); err != nil {
t.Fatal(err)
}
}
func TestValidateStrategies_Valid(t *testing.T) { func TestValidateStrategies_Valid(t *testing.T) {
cases := []string{ cases := []string{
"primary_ip", "primary_ip",
@@ -408,7 +415,7 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
t.Fatalf("instances lost during save: %+v", loaded) t.Fatalf("instances lost during save: %+v", loaded)
} }
for i, want := range cfg.NetBoxes { for i, want := range cfg.NetBoxes {
if loaded.NetBoxes[i] != want { if !reflect.DeepEqual(loaded.NetBoxes[i], want) {
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want) t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
} }
} }
@@ -420,3 +427,227 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
t.Fatal("credentials are not private") t.Fatal("credentials are not private")
} }
} }
func TestSaveKeepsSSHOptions(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
opts := config.SSHOptions{
ProxyJump: "jh.example.com",
HostKeyChecking: "accept-new",
Target: "name",
AddressFamily: "inet",
ExtraArgs: []string{"-A"},
Options: map[string]string{"serveraliveinterval": "60"},
}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Cache: config.CacheConfig{TTL: 3600},
SSH: config.SSHConfig{
DefaultUser: "root",
SSHOptions: opts,
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
},
Completion: config.CompletionConfig{MaxResults: 100},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion {
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
}
}
// Re-running the configurator must keep settings that have no form field of
// their own or were not touched in the menu.
func TestPrepareKeepsUnmanagedSettings(t *testing.T) {
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"},
SSH: config.SSHConfig{
DefaultUser: "root",
SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}},
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
},
Completion: config.CompletionConfig{MaxResults: 50},
Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"},
}
sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}}
got := prepare(cfg, sources)
if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path {
t.Fatalf("settings lost: %+v", got)
}
if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) {
t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes)
}
if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 {
t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache)
}
}
func TestSaveRejectsInvalidSSHOptions(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}},
}
if err := save(cfg); err == nil {
t.Fatal("rule without match was saved")
}
if _, err := os.Stat(config.Path()); !os.IsNotExist(err) {
t.Fatalf("config file written despite error: %v", err)
}
}
func TestParseRewrites(t *testing.T) {
text := `^([a-z]+)[0-9]+- => $1
\.lab$ =>
`
got, err := parseRewrites(text)
if err != nil {
t.Fatal(err)
}
want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %+v", got)
}
if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: %+v", again)
}
for _, bad := range []string{"no arrow", "[a- => x", " => x"} {
if _, err := parseRewrites(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
}
func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Shortcuts: config.ShortcutsConfig{Rewrites: rewrites},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) {
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
}
}
func TestSaveForwardsRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}}
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
SSH: config.SSHConfig{
SSHOptions: config.SSHOptions{Forwards: global},
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}},
},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.SSH.Forwards, global) ||
!reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) ||
!reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) {
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
}
}
func TestSaveStaticHostsRoundtrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
hosts := []config.StaticHost{
{Name: "router", Address: "192.168.1.1", User: "admin", Port: 2222, Tags: []string{"home"}, SSH: config.SSHOptions{ProxyJump: "none"}},
{Name: "nas", Address: "nas.example.org"},
}
cfg := config.Config{
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"},
Hosts: hosts,
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(config.Path())
if strings.Contains(string(data), "netbox:") {
t.Fatalf("static-only config got an empty netbox section:\n%s", data)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Hosts, hosts) || loaded.Resolver.HostnameDomain != "example.com" || loaded.HasNetBox() {
t.Fatalf("got %+v / %+v", loaded.Hosts, loaded.Resolver)
}
}
func TestValidateHostIn(t *testing.T) {
hosts := []config.StaticHost{{Name: "router", Address: "10.0.0.1"}, {Name: "nas", Address: "10.0.0.2"}}
if err := validateHostIn(config.StaticHost{Name: "Router", Address: "10.0.0.9"}, hosts, -1); err == nil {
t.Fatal("duplicate name accepted")
}
if err := validateHostIn(config.StaticHost{Name: "router", Address: "10.0.0.9"}, hosts, 0); err != nil {
t.Fatalf("editing a host in place rejected: %v", err)
}
if err := validateHostIn(config.StaticHost{Name: "new"}, hosts, -1); err != nil {
t.Fatalf("empty address while typing rejected: %v", err)
}
if err := validateHostIn(config.StaticHost{Name: "new", Address: "a b"}, hosts, -1); err == nil {
t.Fatal("invalid address accepted")
}
}
func TestParseGroupsAndSaveMulti(t *testing.T) {
groups, err := parseGroups("K8s: master1, tag:k8s-worker\npve: cluster:pve-fsn1 @k8s\n")
want := map[string][]string{"k8s": {"master1", "tag:k8s-worker"}, "pve": {"cluster:pve-fsn1", "@k8s"}}
if err != nil || !reflect.DeepEqual(groups, want) {
t.Fatalf("got %v %v", groups, err)
}
if again, _ := parseGroups(formatGroups(groups)); !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: %v", again)
}
for _, bad := range []string{"no colon", "tag:x", "bad name: a", "empty:"} {
if _, err := parseGroups(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cfg := config.Config{
Hosts: []config.StaticHost{{Name: "a", Address: "10.0.0.1"}},
Groups: groups,
Multi: config.MultiConfig{Multiplexer: "zellij", PanesPerWindow: 4},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Groups, want) || loaded.Multi != cfg.Multi {
t.Fatalf("got %v %+v", loaded.Groups, loaded.Multi)
}
}
+31 -4
View File
@@ -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,14 @@ 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) // Shorten only the hostname: anchored rewrite rules must not see "instance/".
prefix := ""
if source, host, ok := strings.Cut(name, "/"); ok {
prefix, name = strings.ToLower(source)+"_", host
}
s := prefix + Normalize(name, cfg)
return strings.NewReplacer(".", "_", "/", "_").Replace(s) return strings.NewReplacer(".", "_", "/", "_").Replace(s)
} }
+47
View File
@@ -78,3 +78,50 @@ 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)
}
}
func TestAliasNameRewritesInstanceQualifiedHosts(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".hoster.test"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
if got := shortcuts.AliasName("fsn/fsn1-pve1.hoster.test", cfg); got != "fsn_fsnpve1" {
t.Fatalf("got %q", got)
}
}
+53 -30
View File
@@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{
// ParsedArgs holds the result of parsing SSH arguments. // ParsedArgs holds the result of parsing SSH arguments.
type ParsedArgs struct { type ParsedArgs struct {
Host string // hostname without the user@ prefix Host string // hostname without the user@ prefix or :path
User string // empty if not specified User string // empty if not specified
Path string // sftp only: the remote path after host:
DestIdx int // index in Args where [user@]host sits DestIdx int // index in Args where [user@]host sits
Args []string Args []string
} }
// Parse scans SSH arguments and extracts the destination ([user@]host). // Parse scans SSH arguments and extracts the destination ([user@]host).
// Returns nil if no destination is found. // Returns nil if no destination is found.
func Parse(args []string) *ParsedArgs { func Parse(args []string) *ParsedArgs { return SSH.Parse(args) }
// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp.
func (c Client) Parse(args []string) *ParsedArgs {
i := 0 i := 0
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
@@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs {
if strings.HasPrefix(arg, "-") && len(arg) > 1 { if strings.HasPrefix(arg, "-") && len(arg) > 1 {
// OpenSSH accepts clusters such as -vp 2222 and -vladmin. // OpenSSH accepts clusters such as -vp 2222 and -vladmin.
for j := 1; j < len(arg); j++ { if c.takesSeparateValue(arg) {
if flagsWithArg[arg[j]] {
if j == len(arg)-1 {
i++ i++
} }
break
}
}
i++ i++
continue continue
} }
// first non-flag argument is the destination // first non-flag argument is the destination
host, user := splitUserHost(arg) return c.parsed(args, i)
return &ParsedArgs{
Host: host,
User: user,
DestIdx: i,
Args: args,
}
} }
// handle arguments after "--" // handle arguments after "--"
if i < len(args) { if i < len(args) {
host, user := splitUserHost(args[i]) return c.parsed(args, i)
return &ParsedArgs{
Host: host,
User: user,
DestIdx: i,
Args: args,
}
} }
return nil return nil
} }
func (c Client) parsed(args []string, i int) *ParsedArgs {
dest, path := args[i], ""
if c.paths {
if d, p, remote := c.splitRemote(dest); remote {
dest, path = d, p
}
}
host, user := splitUserHost(dest)
return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args}
}
// ReplaceHost returns a copy of args with the destination replaced by newHost, // ReplaceHost returns a copy of args with the destination replaced by newHost,
// preserving any user@ prefix. // preserving any user@ prefix.
func ReplaceHost(args []string, destIdx int, newHost string) []string { func ReplaceHost(args []string, destIdx int, newHost string) []string {
return SSH.replaceHost(args, []int{destIdx}, newHost)
}
// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read
// host:path, so an IPv6 address must be bracketed.
func (c Client) replaceHost(args []string, dests []int, newHost string) []string {
result := make([]string, len(args)) result := make([]string, len(args))
copy(result, args) copy(result, args)
if c.paths && strings.Contains(newHost, ":") {
newHost = "[" + newHost + "]"
}
for _, destIdx := range dests {
original := args[destIdx] original := args[destIdx]
prefix, suffix := "", ""
if at := strings.Index(original, "@"); at != -1 { if at := strings.Index(original, "@"); at != -1 {
result[destIdx] = original[:at+1] + newHost prefix, original = original[:at+1], original[at+1:]
} else { }
result[destIdx] = newHost if c.paths {
if _, path, remote := c.splitRemote(original); remote {
suffix = ":" + path
}
}
result[destIdx] = prefix + newHost + suffix
} }
return result return result
} }
// HasPortFlag reports whether a port was explicitly supplied. // HasPortFlag reports whether a port was explicitly supplied.
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") } func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
// HasUserFlag reports whether a login user was explicitly supplied. // HasUserFlag reports whether a login user was explicitly supplied.
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") } func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) }
// HasPortFlag reports whether a port was explicitly supplied.
func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") }
// HasUserFlag reports whether a login user was explicitly supplied.
func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") }
func hasOption(args []string, wanted byte, keyword string) bool { func hasOption(args []string, wanted byte, keyword string) bool {
return SSH.hasOption(args, wanted, keyword)
}
func (c Client) hasOption(args []string, wanted byte, keyword string) bool {
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
arg := args[i] arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' { if arg == "--" || len(arg) < 2 || arg[0] != '-' {
@@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
} }
for j := 1; j < len(arg); j++ { for j := 1; j < len(arg); j++ {
flag := arg[j] flag := arg[j]
if !flagsWithArg[flag] { if !c.withArg[flag] {
continue continue
} }
value := arg[j+1:] value := arg[j+1:]
@@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
} }
value = args[i] value = args[i]
} }
if flag == wanted { if wanted != 0 && flag == wanted {
return true return true
} }
if flag == 'o' { if flag == 'o' {
+162
View File
@@ -0,0 +1,162 @@
package ssh
import (
"fmt"
"strings"
)
// Client describes one OpenSSH program netssh can run. The programs share
// ssh's connection options but differ in flags and destination syntax.
type Client struct {
Name string // binary name: ssh, sftp, or scp
withArg map[byte]bool // short flags that consume a value
port byte // flag for the port: -p (ssh) or -P (sftp, scp)
user byte // flag for the login user; 0 when only user@ or -o User work
paths bool // destinations are host[:path]
// instances are the configured NetBox instance names; "inst/host:path"
// is remote only for these, so local paths like "dir/file:x" stay local.
instances map[string]bool
}
// WithInstances returns c aware of the NetBox instance prefixes in host:path.
func (c Client) WithInstances(names []string) Client {
c.instances = make(map[string]bool, len(names))
for _, name := range names {
c.instances[strings.ToLower(name)] = true
}
return c
}
var (
// SSH is the default client; its flag table is flagsWithArg.
SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'}
// SFTP follows sftp(1): -l is a bandwidth limit, -P the port.
SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true}
// SCP follows scp(1); unlike sftp, -B and -R take no value.
SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true}
)
func flagSet(flags string) map[byte]bool {
set := make(map[byte]bool, len(flags))
for i := 0; i < len(flags); i++ {
set[flags[i]] = true
}
return set
}
// PortArgs returns the arguments that set port for this client.
func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} }
// UserArgs returns the arguments that set the login user for this client.
func (c Client) UserArgs(user string) []string {
if c.user == 0 {
return []string{"-o", "User=" + user}
}
return []string{"-" + string(c.user), user}
}
// Operand is a remote scp argument such as user@host:/path.
type Operand struct {
Idx int
User string
Host string
Path string
}
// Operands returns the remote operands of scp arguments. All remote operands
// must name the same host, because one set of options applies per call.
func (c Client) Operands(args []string) ([]Operand, error) {
start := c.optionsEnd(args)
var out []Operand
for i := start; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
}
dest, path, remote := c.splitRemote(arg)
if !remote {
continue
}
host, user := splitUserHost(dest)
if host == "" {
continue
}
if len(out) > 0 && !strings.EqualFold(out[0].Host, host) {
return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host)
}
out = append(out, Operand{Idx: i, User: user, Host: host, Path: path})
}
return out, nil
}
// optionsEnd returns the index of the first operand; scp, like ssh, stops
// option parsing at the first non-option or after "--".
func (c Client) optionsEnd(args []string) int {
end := c.insertionPoint(args)
if end < len(args) && args[end] == "--" {
end++
}
return end
}
// insertionPoint is where added options still count as options: before the
// first operand or before "--".
func (c Client) insertionPoint(args []string) int {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return i
}
if !strings.HasPrefix(arg, "-") || len(arg) < 2 {
return i
}
if c.takesSeparateValue(arg) {
i++
}
}
return len(args)
}
// splitRemote is splitHostPath with netssh's [user@]instance/host:path form.
func (c Client) splitRemote(arg string) (dest, path string, remote bool) {
if head, rest, ok := strings.Cut(arg, "/"); ok {
instance := head[strings.LastIndex(head, "@")+1:]
if c.instances[strings.ToLower(instance)] {
if d, p, r := splitHostPath(rest); r {
return head + "/" + d, p, true
}
}
}
return splitHostPath(arg)
}
// splitHostPath mirrors scp's colon(): an argument is remote when a ':' comes
// before any '/'. A leading "[" starts a bracketed IPv6 address.
func splitHostPath(arg string) (dest, path string, remote bool) {
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
return "", "", false
}
inBracket := false
for i := 0; i < len(arg); i++ {
switch arg[i] {
case '[':
inBracket = true
case ']':
inBracket = false
case '/':
if !inBracket {
return "", "", false
}
case ':':
if !inBracket {
return arg[:i], arg[i+1:], true
}
}
}
return "", "", false
}
// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given.
func (c Client) HasForwardFlag(args []string) bool {
return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward")
}
+180
View File
@@ -0,0 +1,180 @@
package ssh
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSFTPParse(t *testing.T) {
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
t.Fatalf("got %+v", parsed)
}
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
t.Fatalf("instance-qualified host: %+v", p)
}
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
t.Fatal("sftp port flag is -P")
}
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
t.Fatal("sftp has no -l user flag")
}
}
func TestSCPOperands(t *testing.T) {
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
if err != nil {
t.Fatal(err)
}
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
if !reflect.DeepEqual(ops, want) {
t.Fatalf("got %+v", ops)
}
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
t.Fatalf("local copy has remote operands: %+v", ops)
}
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
t.Fatal("two remote hosts accepted")
}
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
t.Fatal("URI accepted")
}
}
func TestSFTPApply(t *testing.T) {
args := []string{"-P", "2222", "ops@web01:/var/log"}
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
}
}
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
// sftp needs brackets even without a path; ssh never does.
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
t.Fatalf("sftp: %v", got)
}
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
t.Fatalf("ssh: %v", got.Args)
}
}
func TestSFTPTargetName(t *testing.T) {
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
}
func TestClientUserAndPortArgs(t *testing.T) {
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
t.Fatal(got)
}
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
t.Fatal(got)
}
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
t.Fatal(got)
}
}
func TestClientExtractWrapperFlags(t *testing.T) {
// For scp, -X takes a value; for ssh it does not.
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
t.Fatalf("got %+v %v %v", flags, rest, err)
}
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
if _, _, err := ExtractWrapperFlags(args); err == nil {
t.Errorf("%v accepted", args)
}
}
}
func TestForwardArgs(t *testing.T) {
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
if err != nil || !reflect.DeepEqual(got, want) {
t.Fatalf("got %v %v", got, err)
}
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
t.Fatalf("unknown name: %v", err)
}
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
t.Fatalf("no forwards: %v", err)
}
}
func TestApplyForwardsAndTunnel(t *testing.T) {
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
t.Fatalf("forward: %v", got)
}
flags.Tunnel = true
result := apply([]string{"web"}, config.SSHOptions{}, flags)
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
}
// The user's own ExitOnForwardFailure wins.
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
t.Fatalf("overrode user option: %v", got)
}
}
func TestHasForwardFlag(t *testing.T) {
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
t.Errorf("%q: got %v", args, got)
}
}
}
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
// Instance-qualified hosts contain "/" before the ":" of host:path; only
// configured instances make such an argument remote.
func TestInstanceQualifiedHostPath(t *testing.T) {
scp := SCP.WithInstances([]string{"prod"})
ops, err := scp.Operands([]string{"a.txt", "ops@prod/web01:/tmp/", "dir/file:x"})
if err != nil {
t.Fatal(err)
}
if want := []Operand{{Idx: 1, User: "ops", Host: "prod/web01", Path: "/tmp/"}}; !reflect.DeepEqual(ops, want) {
t.Fatalf("got %+v", ops)
}
got := scp.Apply([]string{"a.txt", "ops@prod/web01:/tmp/"}, []int{1}, Host{Name: "web01", IP: "192.0.2.1"}, config.SSHOptions{}, WrapperFlags{}).Args
if want := []string{"a.txt", "ops@192.0.2.1:/tmp/"}; !reflect.DeepEqual(got, want) {
t.Fatalf("apply: %v", got)
}
sftp := SFTP.WithInstances([]string{"prod"})
if p := sftp.Parse([]string{"prod/web01:/var/log"}); p == nil || p.Host != "prod/web01" || p.Path != "/var/log" {
t.Fatalf("sftp: %+v", p)
}
if ops, _ := SCP.Operands([]string{"prod/web01:/tmp/", "."}); len(ops) != 0 {
t.Fatalf("unknown instance must stay local: %+v", ops)
}
}
+8 -5
View File
@@ -9,11 +9,14 @@ import (
// Exec replaces the current process with the native ssh client via syscall.Exec. // Exec replaces the current process with the native ssh client via syscall.Exec.
// All existing SSH configs, keys, and agent forwarding remain intact. // All existing SSH configs, keys, and agent forwarding remain intact.
func Exec(args []string) error { func Exec(args []string) error { return SSH.Exec(args) }
sshPath, err := exec.LookPath("ssh")
// Exec replaces the current process with the client's binary.
func (c Client) Exec(args []string) error {
path, err := exec.LookPath(c.Name)
if err != nil { if err != nil {
return fmt.Errorf("ssh not found in PATH: %w", err) return fmt.Errorf("%s not found in PATH: %w", c.Name, err)
} }
argv := append([]string{"ssh"}, args...) argv := append([]string{c.Name}, args...)
return syscall.Exec(sshPath, argv, os.Environ()) return syscall.Exec(path, argv, os.Environ())
} }
+283
View File
@@ -0,0 +1,283 @@
package ssh
import (
"fmt"
"regexp"
"sort"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
// so these never collide with flags meant for ssh.
type WrapperFlags struct {
DryRun bool
Jump string // --jump HOST overrides the configured jump host
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
Forwards []string // --forward NAME|SPEC, repeatable (ssh only)
// ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them.
ForwardArgs []string
// Tunnel runs ssh without a remote command (-N), for netssh tunnel.
Tunnel bool
}
// ExtractWrapperFlags removes netssh's long options that appear before the
// destination. Anything after the destination belongs to the remote command.
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
return SSH.ExtractWrapperFlags(args)
}
// ExtractWrapperFlags removes netssh's long options before the destination.
func (c Client) ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
var flags WrapperFlags
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--dry-run":
flags.DryRun = true
case arg == "--no-jump":
flags.NoJump = true
case arg == "--jump":
if i+1 >= len(args) {
return flags, nil, fmt.Errorf("--jump requires a host")
}
i++
flags.Jump = args[i]
case strings.HasPrefix(arg, "--jump="):
flags.Jump = strings.TrimPrefix(arg, "--jump=")
case arg == "--forward":
if i+1 >= len(args) {
return flags, nil, fmt.Errorf("--forward requires a name or spec")
}
i++
flags.Forwards = append(flags.Forwards, args[i])
case strings.HasPrefix(arg, "--forward="):
flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward="))
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
// Destination or end of options: keep the rest unchanged.
return flags, append(out, args[i:]...), validateJump(flags)
case strings.HasPrefix(arg, "--"):
return flags, nil, fmt.Errorf("unknown option %s", arg)
default:
out = append(out, arg)
// Keep the value of an ssh option such as -l USER together with it.
if c.takesSeparateValue(arg) && i+1 < len(args) {
i++
out = append(out, args[i])
}
}
}
return flags, out, validateJump(flags)
}
func validateJump(flags WrapperFlags) error {
if flags.Jump == "" && flags.NoJump {
return nil
}
if flags.Jump != "" && flags.NoJump {
return fmt.Errorf("--jump and --no-jump are mutually exclusive")
}
if strings.HasPrefix(flags.Jump, "-") {
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
}
for _, forward := range flags.Forwards {
if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") {
return fmt.Errorf("--forward: invalid value %q", forward)
}
}
return nil
}
var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`)
// ForwardArgs resolves forward names against the configured forwards. A value
// that is no configured name but looks like a port or [bind:]port:host:port
// is used as a local forward (-L).
func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) {
var out []string
for _, name := range names {
if forward, ok := available[strings.ToLower(name)]; ok {
out = append(out, forward.Flag()...)
continue
}
if strings.Contains(name, ":") && adHocForward.MatchString(name) {
out = append(out, "-L", name)
continue
}
known := make([]string, 0, len(available))
for key := range available {
known = append(known, key)
}
sort.Strings(known)
if len(known) == 0 {
return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name)
}
return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", "))
}
return out, nil
}
// takesSeparateValue reports whether a short option cluster ends with a flag
// whose value is the next argument (e.g. "-l" or "-vp").
func (c Client) takesSeparateValue(arg string) bool {
for j := 1; j < len(arg); j++ {
if c.withArg[arg[j]] {
return j == len(arg)-1
}
}
return false
}
// Host is a resolved NetBox host.
type Host struct {
Name string // NetBox name without the instance prefix
IP string
}
// Result is the final ssh argument list and an optional notice for stderr.
type Result struct {
Args []string
Notice string
}
var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
// Apply replaces the destination at destIdx and inserts the configured -o
// options directly before it. Explicit options given by the user come first,
// and ssh uses the first -o value it obtains, so the user's choice wins.
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
return SSH.Apply(args, []int{destIdx}, host, opts, flags)
}
// Apply replaces the host in every destination (several only for scp) and
// inserts the configured options before the first one. extra_args and
// forwards are ssh flags and apply to ssh only.
func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
destIdx := dests[0]
var inserted []string
add := func(keyword, value string) {
if !c.hasOption(args[:destIdx], 0, keyword) {
inserted = append(inserted, "-o", keyword+"="+value)
}
}
dest := host.IP
if opts.Target == "name" || opts.Target == "hostname" {
if plainHostname.MatchString(host.Name) {
dest = host.Name
if opts.Target == "name" {
add("HostName", host.IP)
add("HostKeyAlias", host.Name)
}
}
}
userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand")
switch {
case userJump:
case flags.NoJump:
inserted = append(inserted, "-o", "ProxyJump=none")
case flags.Jump != "":
inserted = append(inserted, "-J", flags.Jump)
case opts.ProxyJump != "" && opts.ProxyJump != "none":
inserted = append(inserted, "-J", opts.ProxyJump)
}
var notice string
switch opts.HostKeyChecking {
case "strict":
add("StrictHostKeyChecking", "yes")
case "accept-new":
add("StrictHostKeyChecking", "accept-new")
case "off":
if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") {
add("StrictHostKeyChecking", "no")
add("UserKnownHostsFile", "/dev/null")
add("LogLevel", "ERROR")
notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)"
}
}
if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') {
add("AddressFamily", opts.AddressFamily)
}
keys := make([]string, 0, len(opts.Options))
for key := range opts.Options {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
add(key, opts.Options[key])
}
var extra []string
if c.Name == SSH.Name {
extra = opts.ExtraArgs
inserted = append(inserted, flags.ForwardArgs...)
if flags.Tunnel {
add("ExitOnForwardFailure", "yes")
inserted = append(inserted, "-N")
forwards := strings.Join(flags.ForwardArgs, " ")
if forwards == "" {
forwards = "the given ports"
}
tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name)
if notice == "" {
notice = tunnel
} else {
notice += "\nnetssh: " + tunnel
}
}
}
// Extra arguments go first: short flags such as -4 or -A take the last
// value given, so the user's own flags later on the line still win.
// Options go before the first operand: scp stops reading options there,
// and after "--" they would be taken as the destination.
at := c.insertionPoint(args)
replaced := c.replaceHost(args, dests, dest)
out := make([]string, 0, len(extra)+len(replaced)+len(inserted))
out = append(out, extra...)
out = append(out, replaced[:at]...)
out = append(out, inserted...)
out = append(out, replaced[at:]...)
return Result{Args: out, Notice: notice}
}
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
func (c Client) hasBoolFlag(args []string, wanted byte) bool {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
break
}
for j := 1; j < len(arg); j++ {
if arg[j] == wanted {
return true
}
if c.withArg[arg[j]] {
if j == len(arg)-1 {
i++
}
break
}
}
}
return false
}
// Quote renders an argument list as a copy-pasteable POSIX shell command.
func Quote(argv []string) string {
quoted := make([]string, len(argv))
for i, arg := range argv {
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
quoted[i] = arg
continue
}
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}
return strings.Join(quoted, " ")
}
+130
View File
@@ -0,0 +1,130 @@
package ssh
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestExtractWrapperFlags(t *testing.T) {
for _, tc := range []struct {
args []string
flags WrapperFlags
rest []string
}{
{[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}},
{[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}},
{[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}},
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
} {
flags, rest, err := ExtractWrapperFlags(tc.args)
if err != nil || !reflect.DeepEqual(flags, tc.flags) || !reflect.DeepEqual(rest, tc.rest) {
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
}
}
for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} {
if _, _, err := ExtractWrapperFlags(args); err == nil {
t.Errorf("%v accepted", args)
}
}
}
func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result {
return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags)
}
func TestApplyDefaultConnectsToIP(t *testing.T) {
got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{})
if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) {
t.Fatalf("got %v", got.Args)
}
}
func TestApplyTargetName(t *testing.T) {
got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"}
if !reflect.DeepEqual(got.Args, want) {
t.Fatalf("got %v", got.Args)
}
got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{})
if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) {
t.Fatalf("got %v", got.Args)
}
// Names that are not plain hostnames fall back to the IP address.
got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) {
t.Fatalf("got %v", got.Args)
}
}
func TestApplyJumpPrecedence(t *testing.T) {
opts := config.SSHOptions{ProxyJump: "jh.example.com"}
for _, tc := range []struct {
args []string
flags WrapperFlags
want string
}{
{[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"},
{[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"},
{[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"},
{[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"},
{[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"},
} {
if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want {
t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want)
}
}
if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 {
t.Fatalf("proxy_jump none still jumps: %v", got.Args)
}
}
func TestApplyHostKeyChecking(t *testing.T) {
got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10"
if strings.Join(got.Args, " ") != want || got.Notice == "" {
t.Fatalf("got %v, notice %q", got.Args, got.Notice)
}
// An explicit user choice disables the whole "off" bundle.
got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" {
t.Fatalf("user choice overridden: %v", got.Args)
}
got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{})
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" {
t.Fatalf("got %v", got.Args)
}
}
func TestApplyOptionsAndExtraArgs(t *testing.T) {
opts := config.SSHOptions{
AddressFamily: "inet",
ExtraArgs: []string{"-A"},
Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"},
}
got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{})
want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10"
if strings.Join(got.Args, " ") != want {
t.Fatalf("got %q", strings.Join(got.Args, " "))
}
got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{})
if strings.Join(got.Args, " ") != "-v6 192.0.2.10" {
t.Fatalf("address family overrides -6: %v", got.Args)
}
}
func TestApplyKeepsRemoteCommand(t *testing.T) {
got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{})
if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) {
t.Fatalf("got %v", got.Args)
}
}
func TestQuote(t *testing.T) {
got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"})
if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want {
t.Fatalf("got %s", got)
}
}
+170 -5
View File
@@ -27,8 +27,19 @@ type SelectedHost struct {
IP string IP string
User string // empty = use config default User string // empty = use config default
Port string // empty = use default port Port string // empty = use default port
Action string // ActionSSH, ActionSFTP, ActionTunnel, or ActionMulti
Forwards []string // forward names or specs for ActionTunnel
Targets []string // marked hosts for ActionMulti
} }
// Actions the user can start on a host.
const (
ActionSSH = ""
ActionSFTP = "sftp"
ActionTunnel = "tunnel"
ActionMulti = "multi"
)
// --- bubbletea messages --- // --- bubbletea messages ---
type debounceMsg struct { type debounceMsg struct {
@@ -65,7 +76,8 @@ func (h hostItem) FilterValue() string { return h.name }
// --- compact list delegate --- // --- compact list delegate ---
type compactDelegate struct{} // compactDelegate shares the model's marks so marked hosts show a dot.
type compactDelegate struct{ marked map[string]bool }
func (d compactDelegate) Height() int { return 1 } func (d compactDelegate) Height() int { return 1 }
func (d compactDelegate) Spacing() int { return 0 } func (d compactDelegate) Spacing() int { return 0 }
@@ -75,7 +87,11 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
if !ok { if !ok {
return return
} }
line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip)) mark := " "
if d.marked[h.key()] {
mark = "● "
}
line := fmt.Sprintf(" %s%s %s", mark, h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
if index == m.Index() { if index == m.Index() {
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " ")) line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
} }
@@ -145,6 +161,9 @@ type Model struct {
input textinput.Model input textinput.Model
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
editInput textinput.Model // user@host:port inline editor editInput textinput.Model // user@host:port inline editor
forwardInput textinput.Model // forward names/specs, ctrl+t
forwardHost *hostItem
forwardsFor func(name, kind string, tags []string) []string
list list.Model list list.Model
client *netbox.Client client *netbox.Client
cache *cache.Cache cache *cache.Cache
@@ -160,6 +179,9 @@ type Model struct {
recentMode bool // true when showing recent hosts (empty search, initial state) recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T on the selected item
marked map[string]bool // hostItem.key() of hosts marked with space
markOrder []string // targets in the order they were marked
} }
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model { func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
@@ -174,7 +196,12 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
ei := textinput.New() ei := textinput.New()
ei.Prompt = "" ei.Prompt = ""
l := list.New(nil, compactDelegate{}, 0, 0) fwd := textinput.New()
fwd.Prompt = ""
fwd.Placeholder = "web, 8080:localhost:80"
marked := make(map[string]bool)
l := list.New(nil, compactDelegate{marked: marked}, 0, 0)
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
@@ -194,6 +221,8 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
input: ti, input: ti,
filterInput: fi, filterInput: fi,
editInput: ei, editInput: ei,
forwardInput: fwd,
marked: marked,
list: l, list: l,
client: client, client: client,
cache: c, cache: c,
@@ -281,6 +310,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.noteInput, cmd = m.noteInput.Update(msg) m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd return m, cmd
} }
// --- Forward mode ---
if m.forwardMode {
switch msg.String() {
case "esc":
m.forwardMode = false
m.forwardInput.Blur()
m.input.Focus()
m.err = nil
return m, nil
case "enter":
item := *m.forwardHost
forwards, err := parseForwards(m.forwardInput.Value(), m.availableForwards(item))
if err != nil {
m.err = err
return m, nil
}
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionTunnel, Forwards: forwards}
return m, tea.Quit
}
var cmd tea.Cmd
m.forwardInput, cmd = m.forwardInput.Update(msg)
return m, cmd
}
// --- Edit mode --- // --- Edit mode ---
if m.editMode { if m.editMode {
switch msg.String() { switch msg.String() {
@@ -344,17 +397,49 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case "enter": case "enter":
if len(m.markOrder) > 0 {
m.selected = &SelectedHost{Action: ActionMulti, Targets: append([]string(nil), m.markOrder...)}
return m, tea.Quit
}
if item, ok := m.list.SelectedItem().(hostItem); ok { if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind} m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
return m, tea.Quit return m, tea.Quit
} }
case "space":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.toggleMark(item)
m.list.CursorDown()
return m, nil
}
case "ctrl+a":
m.toggleAllVisible()
return m, nil
case "ctrl+e": case "ctrl+e":
if item, ok := m.list.SelectedItem().(hostItem); ok { if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item) m.openEdit(item)
return m, nil return m, nil
} }
case "ctrl+o":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionSFTP}
return m, tea.Quit
}
case "ctrl+t":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.forwardMode = true
m.forwardHost = &item
m.err = nil
m.input.Blur()
m.forwardInput.SetValue("")
m.forwardInput.Focus()
return m, nil
}
case "ctrl+n", "ctrl+b": case "ctrl+n", "ctrl+b":
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil { if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
m.noteMode = true m.noteMode = true
@@ -509,13 +594,26 @@ func (m *Model) viewContent() string {
sb.WriteString(m.list.View()) sb.WriteString(m.list.View())
} }
if m.editMode { if m.forwardMode {
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
sb.WriteString("\n" + dim.Render("Forward via "+m.forwardHost.name+": "))
sb.WriteString(m.forwardInput.View() + "\n")
available := "none configured — enter specs such as 8080:localhost:80"
if names := m.availableForwards(*m.forwardHost); len(names) > 0 {
available = strings.Join(names, ", ")
}
sb.WriteString(dim.Render(" forwards: "+available) + "\n")
sb.WriteString(dim.Render(" enter start tunnel esc cancel") + "\n")
} else if m.editMode {
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: ")) sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
sb.WriteString(m.editInput.View() + "\n") sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else { } else {
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox" hint := "ctrl+c quit enter connect space mark ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
if n := len(m.markOrder); n > 0 {
hint = fmt.Sprintf("%d marked — enter open side by side space unmark ctrl+a all esc quit", n)
}
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n") sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
} }
@@ -654,3 +752,70 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
} }
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service } func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
func (h hostItem) key() string { return h.name + "\x00" + h.kind }
// toggleMark marks or unmarks a host for opening several side by side.
func (m *Model) toggleMark(item hostItem) {
key := item.key()
if m.marked[key] {
delete(m.marked, key)
for i, target := range m.markOrder {
if target == item.name {
m.markOrder = append(m.markOrder[:i], m.markOrder[i+1:]...)
break
}
}
return
}
m.marked[key] = true
m.markOrder = append(m.markOrder, item.name)
}
// toggleAllVisible marks every listed host, or unmarks them if all are marked.
func (m *Model) toggleAllVisible() {
var items []hostItem
allMarked := true
for _, it := range m.list.Items() {
if item, ok := it.(hostItem); ok {
items = append(items, item)
allMarked = allMarked && m.marked[item.key()]
}
}
for _, item := range items {
if m.marked[item.key()] == allMarked {
m.toggleMark(item)
}
}
}
// SetForwards provides the forward names configured for a host (ctrl+t).
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
m.forwardsFor = forwardsFor
}
func (m *Model) availableForwards(item hostItem) []string {
if m.forwardsFor == nil {
return nil
}
return m.forwardsFor(item.name, item.kind, item.tags)
}
// parseForwards accepts configured names and specs containing a colon,
// separated by commas or spaces.
func parseForwards(input string, available []string) ([]string, error) {
known := make(map[string]bool, len(available))
for _, name := range available {
known[strings.ToLower(name)] = true
}
fields := strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
if len(fields) == 0 {
return nil, fmt.Errorf("enter a forward name or a spec such as 8080:localhost:80")
}
for _, field := range fields {
if !known[strings.ToLower(field)] && !strings.Contains(field, ":") {
return nil, fmt.Errorf("unknown forward %q", field)
}
}
return fields, nil
}
+87
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"reflect"
"strings"
"testing" "testing"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -70,3 +72,88 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
t.Fatalf("edited host changed: %+v", m.selected) t.Fatalf("edited host changed: %+v", m.selected)
} }
} }
func TestCtrlOSelectsSFTP(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1"}})
m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
if m.selected == nil || m.selected.Action != ActionSFTP || m.selected.Name != "web01" {
t.Fatalf("got %+v", m.selected)
}
}
func TestCtrlTStartsTunnelWithForwards(t *testing.T) {
m := New(nil, nil, "")
m.SetForwards(func(name, kind string, tags []string) []string {
if name != "web01" || len(tags) != 1 {
t.Errorf("forwards asked for %s %v", name, tags)
}
return []string{"web"}
})
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1", Tags: []string{"dmz"}}})
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
if !m.forwardMode || m.selected != nil {
t.Fatal("forward input not opened")
}
if !strings.Contains(m.viewContent(), "forwards: web") {
t.Fatalf("configured forwards not shown:\n%s", m.viewContent())
}
m.forwardInput.SetValue("nope")
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected != nil || m.err == nil {
t.Fatal("unknown forward accepted")
}
m.forwardInput.SetValue("WEB, 9000:localhost:9000")
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
want := []string{"WEB", "9000:localhost:9000"}
if m.selected == nil || m.selected.Action != ActionTunnel || !reflect.DeepEqual(m.selected.Forwards, want) {
t.Fatalf("got %+v", m.selected)
}
}
func TestForwardInputEscCancels(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "web01"}})
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if m.forwardMode || m.selected != nil {
t.Fatal("esc did not cancel")
}
}
func TestSpaceMarksHostsAndEnterOpensThem(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "a", Kind: "device"}, {Name: "b", Kind: "device"}, {Name: "c", Kind: "vm"}})
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if len(m.markOrder) != 1 || m.markOrder[0] != "a" || m.list.Index() != 1 {
t.Fatalf("space: marks %v, cursor %d", m.markOrder, m.list.Index())
}
if m.input.Value() != "" {
t.Fatal("space went into the search field")
}
m.list.Select(2)
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if !strings.Contains(m.viewContent(), "2 marked") {
t.Fatalf("no marked count:\n%s", m.viewContent())
}
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected == nil || m.selected.Action != ActionMulti || !reflect.DeepEqual(m.selected.Targets, []string{"a", "c"}) {
t.Fatalf("got %+v", m.selected)
}
}
func TestCtrlATogglesAllVisible(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "a"}, {Name: "b"}})
m.toggleMark(hostItem{name: "a"})
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
if !reflect.DeepEqual(m.markOrder, []string{"a", "b"}) {
t.Fatalf("mark all: %v", m.markOrder)
}
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
if len(m.markOrder) != 0 {
t.Fatalf("unmark all: %v", m.markOrder)
}
}
+13
View File
@@ -25,6 +25,19 @@
], ],
"enabled": true "enabled": true
}, },
{
"description": "Skip major updates of indirect Go modules: a new major version is a separate module path that no dependency imports, so the update only adds an unused requirement",
"matchManagers": [
"gomod"
],
"matchDepTypes": [
"indirect"
],
"matchUpdateTypes": [
"major"
],
"enabled": false
},
{ {
"description": "Update the Go version declared in go.mod", "description": "Update the Go version declared in go.mod",
"matchManagers": [ "matchManagers": [
+116
View File
@@ -0,0 +1,116 @@
# netssh test environment
A local Docker environment for testing every netssh feature against real
services: two NetBox instances, SSH targets on three networks, a jump host,
and a client with ssh, sftp, scp, tmux, zellij, bash, zsh, and fish. It is for
local use only and is not part of CI/CD.
```sh
testenv/up.sh # build, start, seed NetBox (first start ~6 min, then ~1 min)
testenv/test.sh # run all tests; testenv/test.sh tunnel runs only matching files
testenv/shell.sh # interactive shell in the client, netssh configured
testenv/down.sh # stop and delete everything (volumes, generated state)
```
`test.sh` rebuilds the client image first, so the tests always run the current
source. `NO_BUILD=1 testenv/test.sh` skips that. Exit code 1 if any check fails.
Requirements: Docker with Compose v2, about 3 GB RAM, ports 18000/18001 on
127.0.0.1 free.
## Topology
```
public 10.231.0.0/24 + fd00:30::/64 mgmt 10.231.1.0/24 prod 10.231.2.0/24
client .10 ────────────┬─ bastion .30 ───────────────────────────────┬─ (only via bastion)
netbox-fsn .20 │ fsn1-pve1..3 .11-.13 (mgmt0) │ fsn1-pve1..3 .11-.13 (eth0)
netbox-lab .21 │ lab-db1 .41, lab-web1 .42 │ web1 .21, web2 .22
legacy-box .50 client .10 ───────┘
v6-host fd00:30::60
```
| NetBox | Host | Kind | Primary IP | Other | Tags |
|---|---|---|---|---|---|
| fsn | `fsn1-pve1..3.hoster.test` | device, cluster `pve-fsn1` | eth0 = prod (needs bastion) | mgmt0 = mgmt (direct) | `pve` |
| fsn | `bastion.hoster.test` | device | 10.231.0.30 | | `bastion` |
| fsn | `v6-host` | device | fd00:30::60 (IPv6 only) | | `ipv6` |
| fsn | `web1`, `web2` | VM, cluster `pve-fsn1` | prod (needs bastion) | | `web` |
| lab | `lab-db1` | device | 10.231.1.41 | | `db` |
| lab | `web1` | VM, cluster `lab-k8s` | 10.231.1.42 | | `web` |
| — | `legacy-box` (10.231.0.50) | static host | | | |
- **fsn** uses a v2 token (`nbt_…`), **lab** a legacy v1 token; both come from `.state/env`.
- `web1` exists in both instances (ambiguity, `instance/host`).
- Docker DNS resolves the `*.hoster.test` names on the mgmt network (`hostname` strategy).
- Every target runs sshd on ports 22 and 2222 with users `ops` and `admin`, and an HTTP server on its own 127.0.0.1:8081 for tunnel tests ("hello from HOST").
- NetBox UI: http://127.0.0.1:18000 (fsn), http://127.0.0.1:18001 (lab), login `admin` / `admin`.
## Files
| Path | Purpose |
|---|---|
| `compose.yaml` | All services and networks. |
| `client.Dockerfile`, `client-entrypoint.sh` | Client image; builds netssh from the repository. |
| `targets/` | SSH target image (sshd config, entrypoint). |
| `seed/seed.py` | Fills both NetBox instances via the REST API (idempotent), writes `.state/`. |
| `tests/run.sh`, `tests/lib.sh` | Test runner and helpers (`check`, `expect_out`, `expect_eq`, `expect_fail`, `wait_for`). |
| `tests/NN-*.sh` | Test files; each runs in a fresh `HOME`. |
| `tests/drive.py` | Drives `netssh configure` in huh's accessible mode. |
| `.keys/`, `.state/` | Generated test key and seed output (git-ignored). |
| `.artifacts/` | Screenshots from `tests/16-render.sh` (git-ignored). |
## Writing tests
Each `tests/NN-name.sh` is sourced by `run.sh` in a subshell with a new
`HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME`. Write the netssh config with
`config <<'EOF' … EOF` (both NetBox instances plus your YAML) or `config_only`
(no NetBox), then use the helpers:
```sh
config <<'EOF'
ssh: {default_user: ops}
EOF
expect_eq "connect" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_out "dry run" "-J ops@10.231.0.30" netssh --dry-run fsn/web1
expect_fail "ambiguous" "found 2 exact matches" netssh web1
```
ssh always reads `/home/tester/.ssh/config` (OpenSSH ignores `$HOME`): it uses
the test key, `BatchMode`, `StrictHostKeyChecking accept-new`, and
`LogLevel ERROR`. Tests that need their own known_hosts set
`ssh.options.UserKnownHostsFile`. The TUI test runs netssh inside tmux and
reads the screen with `tmux capture-pane`. The multi test (`15-multi.sh`)
opens panes in real tmux and zellij sessions and checks that broadcast typing
reaches every node.
## What a user sees: `16-render.sh`
The render test runs tmux, zellij, and the TUI in a real terminal emulator —
xterm on a virtual X server (Xvfb) — takes screenshots, and reads them back
with OCR (tesseract). It checks what is actually drawn: remote prompts in all
panes, borders and titles with the host names, `[SYNC]` appearing and
disappearing, panes side by side, broadcast output in every pane, input in one
pane only without broadcast, the status bar, and the TUI marks. The broadcast
keys are pressed as a user would: `tmux send-keys -K` delivers them to the
xterm client, so they pass through the prefix and key tables (`prefix e` →
`[SOLO]` and the pane drops out of the broadcast; `prefix S` toggles all panes;
other windows stay unaffected).
```sh
testenv/test.sh render # then look at the screenshots:
ls testenv/.artifacts/ # tmux-1.png, zellij-2.png, tui-3.png, …
```
Helpers in `lib.sh`: `start_screen`/`stop_screen`, `term CMD…` (full-screen
xterm), `shot NAME`, `screen_shows NAME TEXT…`, `screen_count NAME TEXT`.
OCR reads each screenshot in three preprocessing variants (plain, stretched for
dim gray, brightest channel for colored text) and normalizes characters it
confuses in DejaVu Sans Mono (`1`/`l`, `C`/`(`), so checks stay robust without
becoming vague.
## Not covered
- The installer (`install.sh`) downloads release assets from Gitea; it is tested by the Go tests with local fixtures.
- macOS paths and behaviour.
- `netssh uninstall` runs only with `--dry-run`, so the client keeps its binary.
- Rendering is checked in xterm with DejaVu Sans Mono. Other terminals and fonts may differ, e.g. zellij's Powerline arrows need a Nerd Font.
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# ssh reads ~/.ssh from the passwd entry, not $HOME, so the key and ssh config
# live in /home/tester for every test; tests isolate netssh via HOME/XDG_*.
set -eu
mkdir -p /home/tester/.ssh
if [ -f /keys/id_ed25519 ]; then
install -m 600 /keys/id_ed25519 /home/tester/.ssh/id_ed25519
fi
cat >/home/tester/.ssh/config <<'EOF'
Host *
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
BatchMode yes
ConnectTimeout 5
StrictHostKeyChecking accept-new
LogLevel ERROR
EOF
chmod 700 /home/tester/.ssh
chmod 600 /home/tester/.ssh/config
exec "$@"
+26
View File
@@ -0,0 +1,26 @@
# Client for the netssh test environment: netssh built from this repository
# plus everything it drives or integrates with (ssh, sftp, scp, tmux, zellij,
# bash, zsh, fish). Rebuild after code changes: testenv/up.sh or
# docker compose -f testenv/compose.yaml build client
FROM golang:1.27.4-alpine3.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh
FROM alpine:3.23
# xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator,
# take screenshots, and read them back with OCR (tests/16-render.sh).
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
curl python3 jq ncurses coreutils grep \
xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \
&& adduser -D -s /bin/bash tester
COPY --from=build /out/netssh /usr/local/bin/netssh
COPY testenv/client-entrypoint.sh /entrypoint.sh
USER tester
WORKDIR /home/tester
ENV TERM=xterm-256color LANG=C.UTF-8
ENTRYPOINT ["/entrypoint.sh"]
CMD ["sleep", "infinity"]
+211
View File
@@ -0,0 +1,211 @@
# netssh test environment — local only, not used by CI/CD. See testenv/README.md.
#
# public 10.231.0.0/24 + fd00:30::/64 client, NetBox x2, bastion, static host, IPv6 host
# mgmt 10.231.1.0/24 client, Proxmox nodes (mgmt0), lab hosts
# prod 10.231.2.0/24 bastion, Proxmox nodes (eth0), VMs — not reachable from the client
name: netssh-test
x-netbox: &netbox
image: netboxcommunity/netbox:v4.7.4-5.1.1
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/login/ >/dev/null"]
interval: 10s
timeout: 5s
retries: 60
start_period: 60s
x-netbox-env: &netbox-env
DB_HOST: postgres
DB_USER: netbox
DB_PASSWORD: netbox
REDIS_HOST: redis
SECRET_KEY: netssh-test-environment-secret-key-0123456789-abcdefghijklmnopqrstuvwxyz
API_TOKEN_PEPPER_1: netssh-test-environment-pepper-0123456789-abcdefghijklmnop
SUPERUSER_NAME: admin
SUPERUSER_EMAIL: admin@example.com
SUPERUSER_PASSWORD: admin
SUPERUSER_API_KEY: netsshtest01
SUPERUSER_API_TOKEN: 0123456789abcdef0123456789abcdef01234567
SKIP_SUPERUSER: "false"
x-target: &target
build:
context: ..
dockerfile: testenv/targets/target.Dockerfile
image: netssh-test-target
volumes:
- ./.keys/authorized_keys:/keys/authorized_keys:ro
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
POSTGRES_DB: netbox
volumes:
- ./postgres-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- postgres:/var/lib/postgresql
networks: [backend]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netbox"]
interval: 5s
retries: 30
redis:
image: redis:8-alpine
networks: [backend]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 30
netbox-fsn:
<<: *netbox
hostname: netbox-fsn
environment:
<<: *netbox-env
DB_NAME: netbox_fsn
REDIS_DATABASE: "0"
REDIS_CACHE_DATABASE: "1"
ports: ["127.0.0.1:18000:8080"]
networks:
backend: {}
public: {ipv4_address: 10.231.0.20}
netbox-lab:
<<: *netbox
hostname: netbox-lab
environment:
<<: *netbox-env
DB_NAME: netbox_lab
REDIS_DATABASE: "2"
REDIS_CACHE_DATABASE: "3"
ports: ["127.0.0.1:18001:8080"]
networks:
backend: {}
public: {ipv4_address: 10.231.0.21}
# Jump host: the only way from the client into the prod network.
bastion:
<<: *target
hostname: bastion
networks:
public: {ipv4_address: 10.231.0.30, aliases: [bastion.hoster.test]}
prod: {ipv4_address: 10.231.2.30}
fsn1-pve1:
<<: *target
hostname: fsn1-pve1
networks:
mgmt: {ipv4_address: 10.231.1.11, aliases: [fsn1-pve1.hoster.test]}
prod: {ipv4_address: 10.231.2.11}
fsn1-pve2:
<<: *target
hostname: fsn1-pve2
networks:
mgmt: {ipv4_address: 10.231.1.12, aliases: [fsn1-pve2.hoster.test]}
prod: {ipv4_address: 10.231.2.12}
fsn1-pve3:
<<: *target
hostname: fsn1-pve3
networks:
mgmt: {ipv4_address: 10.231.1.13, aliases: [fsn1-pve3.hoster.test]}
prod: {ipv4_address: 10.231.2.13}
# VMs of cluster pve-fsn1: only in prod, so they need the bastion.
web1:
<<: *target
hostname: web1
networks:
prod: {ipv4_address: 10.231.2.21}
web2:
<<: *target
hostname: web2
networks:
prod: {ipv4_address: 10.231.2.22}
# NetBox instance "lab": a device and a VM named web1 like the fsn VM.
lab-db1:
<<: *target
hostname: lab-db1
networks:
mgmt: {ipv4_address: 10.231.1.41}
lab-web1:
<<: *target
hostname: lab-web1
networks:
mgmt: {ipv4_address: 10.231.1.42}
# Not in NetBox: tested as a static host.
legacy-box:
<<: *target
hostname: legacy-box
networks:
public: {ipv4_address: 10.231.0.50}
# In NetBox with an IPv6 primary address only.
v6-host:
<<: *target
hostname: v6-host
networks:
public: {ipv4_address: 10.231.0.60, ipv6_address: "fd00:30::60"}
client:
build:
context: ..
dockerfile: testenv/client.Dockerfile
image: netssh-test-client
hostname: client
init: true
volumes:
- ./.keys:/keys:ro
- ./.state:/state
- ./tests:/tests:ro
- ./.artifacts:/artifacts
networks:
public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"}
mgmt: {ipv4_address: 10.231.1.10}
# Fills both NetBox instances and writes /state/netssh.yaml, then exits.
seed:
image: netssh-test-client
depends_on:
client: {condition: service_started}
netbox-fsn: {condition: service_healthy}
netbox-lab: {condition: service_healthy}
volumes:
- ./.state:/state
- ./seed:/seed:ro
networks:
public: {}
command: ["python3", "/seed/seed.py"]
restart: "no"
# Fixed subnets outside Docker's default address pools (172.17-31.x, 192.168.x),
# so automatically numbered networks of other projects cannot collide.
networks:
backend:
ipam:
config:
- subnet: 10.231.3.0/24
public:
enable_ipv6: true
ipam:
config:
- subnet: 10.231.0.0/24
- subnet: fd00:30::/64
mgmt:
ipam:
config:
- subnet: 10.231.1.0/24
prod:
ipam:
config:
- subnet: 10.231.2.0/24
volumes:
postgres: {}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Stop the netssh test environment and remove its volumes and state.
set -euo pipefail
cd "$(dirname "$0")"
docker compose -f compose.yaml down -v --remove-orphans
rm -rf .state
+3
View File
@@ -0,0 +1,3 @@
-- One Postgres serves both NetBox instances of the test environment.
CREATE DATABASE netbox_fsn OWNER netbox;
CREATE DATABASE netbox_lab OWNER netbox;
+171
View File
@@ -0,0 +1,171 @@
"""Fill both NetBox instances of the netssh test environment.
Idempotent: objects are looked up before they are created, so running the
seed again leaves NetBox unchanged. Writes /state/env (tokens) and
/state/netssh.yaml (base config for the tests).
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
FSN = "http://10.231.0.20:8080"
LAB = "http://10.231.0.21:8080"
# Superuser v2 token from compose.yaml (SUPERUSER_API_KEY / SUPERUSER_API_TOKEN).
V2_TOKEN = "nbt_netsshtest01.0123456789abcdef0123456789abcdef01234567"
STATE = "/state"
class NetBox:
def __init__(self, url):
self.url = url
def call(self, method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(self.url + "/api/" + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + V2_TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as err:
sys.exit(f"{method} {path}: HTTP {err.code}: {err.read().decode(errors='replace')}")
def find(self, path, **filters):
query = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in filters.items())
results = self.call("GET", f"{path}?{query}&limit=2")["results"]
return results[0] if results else None
def ensure(self, path, lookup, body):
"""Return the object matching lookup, creating it from body if missing."""
found = self.find(path, **lookup)
if found:
return found
return self.call("POST", path, body)
def wait(self):
for _ in range(120):
try:
self.call("GET", "status/")
return
except SystemExit:
pass
except (urllib.error.URLError, ConnectionError, TimeoutError):
pass
time.sleep(5)
sys.exit(f"NetBox {self.url} did not become ready")
def base(nb, site, cluster_type, cluster):
for tag in ("pve", "web", "bastion", "db", "ipv6"):
nb.ensure("extras/tags/", {"slug": tag}, {"name": tag, "slug": tag})
site = nb.ensure("dcim/sites/", {"slug": site}, {"name": site, "slug": site, "status": "active"})
mfr = nb.ensure("dcim/manufacturers/", {"slug": "generic"}, {"name": "Generic", "slug": "generic"})
role = nb.ensure("dcim/device-roles/", {"slug": "server"}, {"name": "Server", "slug": "server", "color": "2196f3"})
dtype = nb.ensure("dcim/device-types/", {"slug": "generic-server"},
{"manufacturer": mfr["id"], "model": "Generic Server", "slug": "generic-server"})
ctype = nb.ensure("virtualization/cluster-types/", {"slug": cluster_type},
{"name": cluster_type, "slug": cluster_type})
cluster = nb.ensure("virtualization/clusters/", {"name": cluster},
{"name": cluster, "type": ctype["id"], "status": "active"})
return {"site": site["id"], "role": role["id"], "type": dtype["id"], "cluster": cluster["id"]}
def device(nb, ids, name, ips, tags=(), cluster=False, comments=""):
"""ips: list of (interface, address, primary) with primary in (None, 4, 6)."""
body = {"name": name, "role": ids["role"], "device_type": ids["type"], "site": ids["site"],
"status": "active", "tags": [{"slug": t} for t in tags], "comments": comments}
if cluster:
body["cluster"] = ids["cluster"]
dev = nb.ensure("dcim/devices/", {"name": name}, body)
primary = {}
for ifname, address, family in ips:
iface = nb.ensure("dcim/interfaces/", {"device_id": dev["id"], "name": ifname},
{"device": dev["id"], "name": ifname, "type": "1000base-t"})
ip = nb.ensure("ipam/ip-addresses/", {"address": address},
{"address": address, "assigned_object_type": "dcim.interface", "assigned_object_id": iface["id"]})
if family:
primary[f"primary_ip{family}"] = ip["id"]
if primary:
nb.call("PATCH", f"dcim/devices/{dev['id']}/", primary)
def vm(nb, ids, name, address, tags=()):
machine = nb.ensure("virtualization/virtual-machines/", {"name": name},
{"name": name, "cluster": ids["cluster"], "status": "active",
"tags": [{"slug": t} for t in tags]})
iface = nb.ensure("virtualization/interfaces/", {"virtual_machine_id": machine["id"], "name": "eth0"},
{"virtual_machine": machine["id"], "name": "eth0"})
ip = nb.ensure("ipam/ip-addresses/", {"address": address},
{"address": address, "assigned_object_type": "virtualization.vminterface",
"assigned_object_id": iface["id"]})
nb.call("PATCH", f"virtualization/virtual-machines/{machine['id']}/", {"primary_ip4": ip["id"]})
def v1_token(nb):
"""A legacy v1 token for the lab instance, reused from /state/env if present."""
env = read_env()
if env.get("LAB_TOKEN"):
return env["LAB_TOKEN"]
user = nb.find("users/users/", username="admin")
token = nb.call("POST", "users/tokens/", {"user": user["id"], "version": 1, "description": "netssh v1 test"})
plaintext = token.get("token") or token.get("plaintext")
if not plaintext:
sys.exit(f"v1 token response without plaintext: {token}")
return plaintext
def read_env():
env = {}
try:
with open(os.path.join(STATE, "env")) as f:
for line in f:
key, _, value = line.strip().partition("=")
env[key] = value
except FileNotFoundError:
pass
return env
def main():
fsn, lab = NetBox(FSN), NetBox(LAB)
fsn.wait()
lab.wait()
ids = base(fsn, "fsn1", "proxmox", "pve-fsn1")
for n in (1, 2, 3):
device(fsn, ids, f"fsn1-pve{n}.hoster.test",
[("eth0", f"10.231.2.1{n}/24", 4), ("mgmt0", f"10.231.1.1{n}/24", None)],
tags=("pve",), cluster=True, comments=f"Proxmox node {n}")
device(fsn, ids, "bastion.hoster.test", [("eth0", "10.231.0.30/24", 4)], tags=("bastion",))
device(fsn, ids, "v6-host", [("eth0", "fd00:30::60/64", 6)], tags=("ipv6",))
vm(fsn, ids, "web1", "10.231.2.21/24", tags=("web",))
vm(fsn, ids, "web2", "10.231.2.22/24", tags=("web",))
ids = base(lab, "lab", "kubernetes", "lab-k8s")
device(lab, ids, "lab-db1", [("eth0", "10.231.1.41/24", 4)], tags=("db",))
vm(lab, ids, "web1", "10.231.1.42/24", tags=("web",))
lab_token = v1_token(lab)
os.makedirs(STATE, exist_ok=True)
with open(os.path.join(STATE, "env"), "w") as f:
f.write(f"FSN_URL={FSN}\nLAB_URL={LAB}\nFSN_TOKEN={V2_TOKEN}\nLAB_TOKEN={lab_token}\n")
with open(os.path.join(STATE, "netssh.yaml"), "w") as f:
f.write(f"""netboxes:
- name: fsn
url: "{FSN}"
token: "{V2_TOKEN}"
- name: lab
url: "{LAB}"
token: "{lab_token}"
""")
print("seed: done (fsn: 3 nodes, bastion, v6-host, 2 VMs; lab: lab-db1, web1)")
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Interactive shell in the client with a ready netssh config (fresh home).
set -euo pipefail
cd "$(dirname "$0")"
exec docker compose -f compose.yaml exec -it client bash -c '
. /tests/lib.sh
new_home
base_config >"$XDG_CONFIG_HOME/netssh.yaml" <<EOF
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
host_key_checking: accept-new
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
echo "netssh is configured (HOME=$HOME). Try: netssh cache refresh && netssh"
exec bash -i'
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Prepares host keys and the test users, starts a small HTTP server for the
# tunnel tests (reachable only on the target itself), then runs sshd.
set -eu
[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -q -t ed25519 -N '' -f /etc/ssh/ssh_host_ed25519_key
for user in ops admin; do
home="/home/$user"
mkdir -p "$home/.ssh"
cp /keys/authorized_keys "$home/.ssh/authorized_keys"
chown -R "$user:$user" "$home/.ssh"
chmod 700 "$home/.ssh"
chmod 600 "$home/.ssh/authorized_keys"
done
mkdir -p /srv/www
echo "hello from $(hostname)" > /srv/www/index.html
python3 -m http.server 8081 --bind 127.0.0.1 --directory /srv/www >/dev/null 2>&1 &
exec /usr/sbin/sshd -D -e
+18
View File
@@ -0,0 +1,18 @@
# Test targets accept the generated test key only. Port 2222 tests ssh.default_port.
Port 22
Port 2222
ListenAddress 0.0.0.0
ListenAddress ::
HostKey /etc/ssh/ssh_host_ed25519_key
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
AllowTcpForwarding yes
AllowAgentForwarding yes
GatewayPorts no
PermitTunnel no
X11Forwarding no
PrintMotd no
Subsystem sftp /usr/lib/ssh/sftp-server
+16
View File
@@ -0,0 +1,16 @@
# SSH target for the netssh test environment: every NetBox host, the bastion,
# and the static host run this image with a different hostname.
FROM alpine:3.23
# Alpine locks new accounts with "!"; sshd refuses key logins for locked
# accounts, so the password field is set to "*" (no password, not locked).
RUN apk add --no-cache openssh-server openssh-sftp-server python3 \
&& for user in ops admin; do \
adduser -D -s /bin/sh "$user" && sed -i "s/^$user:!/$user:*/" /etc/shadow; \
done
COPY testenv/targets/sshd_config /etc/ssh/sshd_config
COPY testenv/targets/entrypoint.sh /entrypoint.sh
EXPOSE 22 2222
ENTRYPOINT ["/entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Run the test scripts in the client container. Optional FILTER selects files
# by substring, e.g. testenv/test.sh tunnel. The client is rebuilt first so the
# tests always run the current source.
set -euo pipefail
cd "$(dirname "$0")"
compose=(docker compose -f compose.yaml)
if [ "${NO_BUILD:-}" != 1 ]; then
"${compose[@]}" build -q client
"${compose[@]}" up -d --wait client
fi
exec "${compose[@]}" exec -T client bash /tests/run.sh "${1:-}"
+19
View File
@@ -0,0 +1,19 @@
# Cache refresh across both NetBox instances, filters, list, search, clear.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
EOF
expect_out "refresh both instances" "9 entries written" netssh cache refresh
expect_out "list shows mgmt address" "fsn/fsn1-pve1.hoster.test 10.231.1.11" netssh cache list
expect_out "list shows lab VM" "lab/web1 10.231.1.42 vm" netssh cache list
expect_out "IPv6-only host" "fsn/v6-host fd00:30::60" netssh cache list
expect_out "refresh one instance" "2 entries written" netssh cache refresh --instance lab
expect_out "refresh by tag" "3 entries written" netssh cache refresh --tag pve
expect_out "refresh by kind" "3 entries written" netssh cache refresh --kind vm
expect_eq "search" "fsn/fsn1-pve1.hoster.test
fsn/fsn1-pve2.hoster.test
fsn/fsn1-pve3.hoster.test" bash -c 'netssh search fsn1 | sort'
check "clear" netssh cache clear
expect_eq "empty after clear" "" bash -c 'netssh search fsn'
+29
View File
@@ -0,0 +1,29 @@
# Connecting with the resolved address, default user and port, overrides.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
EOF
expect_eq "connect via NetBox" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "unique name without instance" "fsn1-pve2" netssh fsn1-pve2.hoster.test hostname
expect_eq "default user" "ops" netssh fsn/fsn1-pve1.hoster.test whoami
expect_eq "user@ wins" "admin" netssh admin@fsn/fsn1-pve1.hoster.test whoami
expect_eq "-l wins" "admin" netssh -l admin fsn/fsn1-pve1.hoster.test whoami
expect_eq "remote command with arguments" "a b" netssh fsn/fsn1-pve1.hoster.test echo a b
expect_eq "dry run" "ssh -l ops 10.231.1.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
default_port: 2222
EOF
expect_out "default port" " 2222" netssh fsn/fsn1-pve1.hoster.test 'echo $SSH_CONNECTION'
expect_out "-p wins over default port" " 22" netssh -p 22 fsn/fsn1-pve1.hoster.test 'echo $SSH_CONNECTION'
expect_eq "dry run with port" "ssh -p 2222 -l ops 10.231.1.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
expect_eq "version" "netssh" bash -c 'netssh --version | cut -d" " -f1'
+63
View File
@@ -0,0 +1,63 @@
# Every resolver strategy against real NetBox data. cache.ttl 0 makes every
# connection ask NetBox, so each config change takes effect immediately.
jump=' - match: {instance: fsn}
proxy_jump: ops@10.231.0.30
- match: {name: "bastion*"}
proxy_jump: none'
config <<EOF
cache: {ttl: 0}
resolver: {strategies: [primary_ip]}
ssh:
default_user: ops
rules:
$jump
EOF
expect_eq "primary_ip uses prod address via bastion" "ssh -l ops -J ops@10.231.0.30 10.231.2.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
expect_eq "primary_ip connects through the jump host" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "bastion rule disables the jump" "ssh -l ops 10.231.0.30" netssh --dry-run fsn/bastion.hoster.test
expect_eq "IPv6 primary address" "ssh -l ops -J ops@10.231.0.30 fd00:30::60" netssh --dry-run fsn/v6-host
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh: {default_user: ops}
EOF
expect_eq "management_subnet" "ssh -l ops 10.231.1.12" netssh --dry-run fsn/fsn1-pve2.hoster.test
expect_eq "management_subnet falls back to primary_ip" "ssh -l ops 10.231.2.21" netssh --dry-run fsn/web1
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [interface_name, primary_ip]
interface_name: mgmt0
ssh: {default_user: ops}
EOF
expect_eq "interface_name" "ssh -l ops 10.231.1.13" netssh --dry-run fsn/fsn1-pve3.hoster.test
expect_eq "interface_name connects" "fsn1-pve3" netssh fsn/fsn1-pve3.hoster.test hostname
expect_eq "interface_name falls back for VMs" "ssh -l ops 10.231.2.22" netssh --dry-run fsn/web2
# eth0 holds the prod address, which sorts after mgmt: catches an ignored filter.
sed -i 's/interface_name: mgmt0/interface_name: eth0/' "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "interface_name picks the named interface" "ssh -l ops 10.231.2.13" netssh --dry-run fsn/fsn1-pve3.hoster.test
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [hostname]
hostname_domain: example.org
ssh: {default_user: ops}
EOF
expect_eq "hostname strategy" "ssh -l ops fsn1-pve1.hoster.test" netssh --dry-run fsn/fsn1-pve1.hoster.test
expect_eq "hostname connects via DNS" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "hostname domain for names without a dot" "ssh -l ops lab-db1.example.org" netssh --dry-run lab/lab-db1
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [interface_name]
interface_name: mgmt0
EOF
expect_fail "no strategy finds an address" "no strategy resolved an IP" netssh --dry-run fsn/web1
+20
View File
@@ -0,0 +1,20 @@
# Two NetBox instances: qualified names, ambiguity, an unreachable instance.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
expect_fail "same name in both instances is ambiguous" "found 2 exact matches" netssh --dry-run web1
expect_eq "fsn/web1 through the bastion" "web1" netssh fsn/web1 hostname
expect_eq "lab/web1 directly" "lab-web1" netssh lab/web1 hostname
expect_eq "lab uses its v1 token" "lab-db1" netssh lab/lab-db1 hostname
# lab unreachable: qualified fsn targets still work, unqualified ones need every instance.
sed -i 's#http://10.231.0.21:8080#http://10.231.0.21:9#' "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "qualified target with another instance down" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_fail "unqualified target needs every instance" "10.231.0.21:9" netssh --dry-run fsn1-pve1.hoster.test
+31
View File
@@ -0,0 +1,31 @@
# Domains, hyphen folding, rewrite rules, alias --test, completion, aliases.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh: {default_user: ops}
shortcuts:
domains: [.hoster.test]
rewrites:
- pattern: '^([a-z]+)[0-9]+-'
replace: '$1'
EOF
check "refresh" netssh cache refresh
expect_eq "domain expansion via NetBox" "fsn1-pve1" netssh fsn/fsn1-pve1 hostname
expect_eq "rewrite shortcut, qualified" "fsn1-pve2" netssh fsn/fsnpve2 hostname
expect_eq "rewrite shortcut, unqualified" "fsn1-pve3" netssh fsnpve3 hostname
expect_out "alias --test" "fsn1-pve1.hoster.test → fsnpve1" netssh alias --test fsn1-pve1.hoster.test
expect_out "completion offers shortcuts" "fsnpve1" netssh __complete fsnp
expect_out "aliases use the shortcut" "alias fsn_fsnpve1='netssh " netssh alias --shell bash
expect_fail "unknown shortcut" "host not found" netssh --dry-run fsnpve9
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
shortcuts:
domains: [.hoster.test]
strip_hyphens: true
EOF
check "refresh" netssh cache refresh
expect_eq "hyphen folding" "ssh 10.231.1.12" netssh --dry-run fsn/fsn1pve2
+34
View File
@@ -0,0 +1,34 @@
# Jump hosts, rules, host key checking, connection target, options, extra args.
known="$HOME/known_hosts"
config <<EOF
cache: {ttl: 0}
resolver: {strategies: [primary_ip]}
ssh:
default_user: ops
options:
UserKnownHostsFile: $known
ServerAliveInterval: "30"
extra_args: ["-T"]
rules:
- match: {tag: web}
proxy_jump: ops@10.231.0.30
- match: {tag: pve}
proxy_jump: ops@10.231.0.30
host_key_checking: strict
- match: {name: "fsn1-pve2*"}
host_key_checking: accept-new
- match: {name: "fsn1-pve3*"}
host_key_checking: "off"
target: name
EOF
expect_eq "rule by tag adds jump host" "ssh -T -l ops -J ops@10.231.0.30 -o serveraliveinterval=30 -o userknownhostsfile=$known 10.231.2.21" netssh --dry-run fsn/web1
expect_eq "connect through rule jump" "web1" netssh fsn/web1 hostname
expect_out "--no-jump removes it" "-o ProxyJump=none" netssh --dry-run --no-jump fsn/web1
expect_out "--jump overrides it" "-J ops@10.231.0.30" netssh --dry-run --jump ops@10.231.0.30 fsn/v6-host
expect_fail "strict rejects an unknown host key" "Host key verification failed" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "later rule accept-new wins" "fsn1-pve2" netssh fsn/fsn1-pve2.hoster.test hostname
expect_out "accept-new stored the key" "10.231.2.12" cat "$known"
expect_out "off prints a notice" "host key checking is disabled" netssh fsn/fsn1-pve3.hoster.test hostname
expect_out "target name keeps the address" "-o HostName=10.231.2.13 -o HostKeyAlias=fsn1-pve3.hoster.test" netssh --dry-run fsn/fsn1-pve3.hoster.test
expect_out "target name destination" " fsn1-pve3.hoster.test" netssh --dry-run fsn/fsn1-pve3.hoster.test
expect_out "user -o wins over config" "-o ServerAliveInterval=5" netssh --dry-run -o ServerAliveInterval=5 fsn/web1
+25
View File
@@ -0,0 +1,25 @@
# Static hosts without any NetBox: config, CLI, own user/port/options, rules.
config_only <<'EOF'
ssh:
default_user: ops
rules:
- match: {kind: static, tag: legacy}
options: {ServerAliveInterval: "15"}
hosts:
- name: legacy-box
address: 10.231.0.50
user: admin
tags: [legacy]
EOF
expect_eq "connect to a static host" "legacy-box" netssh legacy-box hostname
expect_eq "static user" "admin" netssh legacy-box whoami
expect_out "rule by kind and tag" "-o serveraliveinterval=15" netssh --dry-run legacy-box
expect_eq "-l still wins" "ops" netssh -l ops legacy-box whoami
check "hosts add" netssh hosts add box2 10.231.0.50 --port 2222 --tag lab
expect_eq "hosts list" "box2 10.231.0.50 2222 lab" bash -c 'netssh hosts list | grep "^box2" | tr -s " "'
expect_out "static port" " 2222" netssh box2 'echo $SSH_CONNECTION'
expect_out "completion" "box2" netssh __complete bo
expect_fail "duplicate" "already exists" netssh hosts add box2 10.0.0.1
check "hosts remove" netssh hosts remove box2
expect_fail "removed host is gone" "no NetBox is configured" netssh --dry-run box2
expect_eq "sftp to a static host" "Remote working directory: /home/admin" bash -c 'echo pwd | netssh sftp -b - legacy-box | grep Remote'
+24
View File
@@ -0,0 +1,24 @@
# sftp and scp: upload, download, jump host, IPv6, errors.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
echo "payload $$" >"$HOME/up.txt"
check "scp upload" netssh scp "$HOME/up.txt" fsn/fsn1-pve1.hoster.test:/tmp/up.txt
expect_eq "uploaded content" "payload $$" netssh fsn/fsn1-pve1.hoster.test cat /tmp/up.txt
check "scp download" netssh scp fsn/fsn1-pve1.hoster.test:/tmp/up.txt "$HOME/down.txt"
expect_eq "downloaded content" "payload $$" cat "$HOME/down.txt"
check "scp to a VM through the bastion" netssh scp "$HOME/up.txt" fsn/web1:/tmp/up.txt
expect_eq "VM content" "payload $$" netssh fsn/web1 cat /tmp/up.txt
check "scp to IPv6 host" netssh scp "$HOME/up.txt" fsn/v6-host:/tmp/v6.txt
expect_eq "IPv6 content" "payload $$" netssh fsn/v6-host cat /tmp/v6.txt
expect_out "sftp starts in the path" "Remote working directory: /tmp" bash -c 'echo pwd | netssh sftp -b - fsn/fsn1-pve2.hoster.test:/tmp'
expect_out "sftp to IPv6 host" "v6.txt" bash -c 'echo "ls /tmp" | netssh sftp -b - fsn/v6-host'
expect_out "sftp through the bastion" "up.txt" bash -c 'echo "ls /tmp" | netssh sftp -b - fsn/web1'
expect_fail "two remote hosts" "not supported" netssh scp fsn/web1:/a fsn/web2:/b
+40
View File
@@ -0,0 +1,40 @@
# Port forwards: named, ad hoc, --forward with a shell, port in use.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
forwards:
web: {local: "18081:127.0.0.1:8081"}
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
tunnel_test() { # NAME PORT WANT ARGS...
local name=$1 port=$2 want=$3 pid out
shift 3
"$@" >"$HOME/tunnel.log" 2>&1 &
pid=$!
if wait_for 15 curl -sf "http://127.0.0.1:$port/"; then
out=$(curl -s "http://127.0.0.1:$port/")
[[ $out == *"$want"* ]] && ok "$name" || bad "$name" "curl: $out"
else
bad "$name" "tunnel did not open: $(cat "$HOME/tunnel.log")"
fi
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
}
tunnel_test "named forward" 18081 "hello from fsn1-pve1" netssh tunnel fsn/fsn1-pve1.hoster.test web
expect_out "tunnel notice" "forwarding -L 18081:127.0.0.1:8081 via fsn1-pve1.hoster.test" cat "$HOME/tunnel.log"
tunnel_test "ad hoc spec through the bastion" 18082 "hello from web1" netssh tunnel fsn/web1 18082:127.0.0.1:8081
tunnel_test "--forward with a remote command" 18081 "hello from fsn1-pve2" netssh --forward web fsn/fsn1-pve2.hoster.test sleep 30
netssh tunnel fsn/fsn1-pve1.hoster.test web >/dev/null 2>&1 &
holder=$!
wait_for 15 curl -sf http://127.0.0.1:18081/
expect_fail "port in use fails instead of hanging" "Address in use" timeout 20 netssh tunnel fsn/fsn1-pve2.hoster.test web
kill "$holder"; wait "$holder" 2>/dev/null
expect_fail "unknown forward lists the configured ones" "available: web" netssh tunnel fsn/fsn1-pve1.hoster.test db
expect_fail "tunnel without forward" "forwards for fsn/fsn1-pve1.hoster.test: web" netssh tunnel fsn/fsn1-pve1.hoster.test
+22
View File
@@ -0,0 +1,22 @@
# Local notes and NetBox comments (v2 token on fsn, v1 token on lab).
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
EOF
check "refresh" netssh cache refresh
host=fsn/fsn1-pve1.hoster.test
check "set local note" netssh notes set $host "first line"
check "append local note" netssh notes append $host "second line"
expect_eq "show local note" "first line
second line" netssh notes show $host
expect_eq "note from stdin" "piped" bash -c "echo piped | netssh notes set $host && netssh notes show $host"
expect_eq "read NetBox comments (v2 token)" "Proxmox node 1" netssh notes show $host --netbox
check "append NetBox comments" netssh notes append $host --netbox "checked by netssh test"
expect_out "NetBox has the new line" "checked by netssh test" curl -s -H "Authorization: Bearer $FSN_TOKEN" "$FSN_URL/api/dcim/devices/?name=fsn1-pve1.hoster.test"
check "restore NetBox comments" netssh notes set $host --netbox "Proxmox node 1"
check "set NetBox comments (v1 token)" netssh notes set lab/lab-db1 --netbox "lab note"
expect_eq "read them back" "lab note" netssh notes show lab/lab-db1 --netbox
check "clear them" netssh notes set lab/lab-db1 --netbox ""
+32
View File
@@ -0,0 +1,32 @@
# Completion, aliases, hook install for bash, zsh, fish, and the command alias.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
forwards:
web: {local: "18081:127.0.0.1:8081"}
shortcuts:
domains: [.hoster.test]
shell:
alias: j
EOF
check "refresh" netssh cache refresh
expect_out "complete hosts" "fsn/fsn1-pve2.hoster.test" netssh __complete fsn/fsn1
expect_out "complete scp host:" "fsn/fsn1-pve1.hoster.test:" netssh __complete scp a.txt fsn/fsn1-pve1
expect_out "complete forwards after the host" "web" netssh __complete tunnel fsn/fsn1-pve1.hoster.test ""
expect_out "complete --forward" "web" netssh __complete --forward ""
expect_out "fish aliases" "alias fsn_fsn1-pve1 " netssh alias --shell fish
check "hook install bash" netssh hook install --shell bash
# Interactive bash without a terminal warns about job control on stderr.
expect_eq "bash loads host aliases" "fsn1-pve1" sh -c "bash -ic 'fsn_fsn1-pve1 hostname' 2>/dev/null </dev/null"
expect_eq "bash command alias" "fsn1-pve2" sh -c "bash -ic 'j fsn/fsn1-pve2.hoster.test hostname' 2>/dev/null </dev/null"
check "hook install zsh" netssh hook install --shell zsh
expect_eq "zsh loads host aliases" "fsn1-pve3" zsh -ic 'fsn_fsn1-pve3 hostname' </dev/null
mkdir -p "$XDG_CONFIG_HOME/fish"
check "hook install fish" netssh hook install --shell fish
expect_eq "fish loads host aliases" "fsn1-pve1" fish -c 'fsn_fsn1-pve1 hostname' </dev/null
check "completion install bash" netssh completion install --shell bash
expect_out "bash completes hosts" "fsn/fsn1-pve2.hoster.test" bash -ic '. ~/.local/share/bash-completion/completions/netssh; COMP_WORDS=(netssh fsn/fsn1-pve2); COMP_CWORD=1; COMP_LINE="netssh fsn/fsn1-pve2"; COMP_POINT=${#COMP_LINE}; __start_netssh netssh; printf "%s\n" "${COMPREPLY[@]}"' </dev/null
+64
View File
@@ -0,0 +1,64 @@
# The interactive TUI, driven through tmux: search, connect, sftp, tunnel, edit.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
forwards:
web: {local: "18085:127.0.0.1:8081"}
EOF
check "refresh" netssh cache refresh
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR"
screen() { tmux capture-pane -p -t tui; }
shows() { screen | grep -qF -- "$1"; }
tui() { # start netssh in a fresh tmux session
tmux kill-server 2>/dev/null
tmux new-session -d -s tui -x 160 -y 40 -e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME" netssh
wait_for 10 shows "Search hostname"
}
step() { # NAME TEXT — wait until the pane shows TEXT
if wait_for 15 shows "$2"; then ok "$1"; else bad "$1" "$(screen)"; fi
}
tui && ok "TUI starts" || bad "TUI starts" "$(screen)"
tmux send-keys -t tui "fsn1-pve2"
step "search shows the host" "fsn/fsn1-pve2.hoster.test"
tmux send-keys -t tui Enter
sleep 1
tmux send-keys -t tui "hostname" Enter
step "enter connects" "fsn1-pve2"
tmux send-keys -t tui "exit" Enter
tui
tmux send-keys -t tui "fsn1-pve3"
wait_for 10 shows "fsn/fsn1-pve3.hoster.test"
tmux send-keys -t tui C-o
step "ctrl+o opens sftp" "sftp>"
tmux send-keys -t tui "pwd" Enter
step "sftp works" "Remote working directory: /home/ops"
tmux send-keys -t tui "bye" Enter
tui
tmux send-keys -t tui "fsn1-pve1"
wait_for 10 shows "fsn/fsn1-pve1.hoster.test"
tmux send-keys -t tui C-t
step "ctrl+t lists configured forwards" "forwards: web"
tmux send-keys -t tui "web" Enter
step "tunnel notice" "forwarding -L 18085:127.0.0.1:8081"
expect_out "tunnel from the TUI works" "hello from fsn1-pve1" curl -s --retry 10 --retry-connrefused --retry-delay 1 http://127.0.0.1:18085/
tmux send-keys -t tui C-c
tui
tmux send-keys -t tui "fsn1-pve1"
wait_for 10 shows "fsn/fsn1-pve1.hoster.test"
tmux send-keys -t tui C-e
step "ctrl+e opens the editor" "Connect as: ops@fsn/fsn1-pve1.hoster.test:22"
tmux send-keys -t tui C-u "admin@fsn/fsn1-pve1.hoster.test:22" Enter
sleep 1
tmux send-keys -t tui "whoami" Enter
step "edited user is used" "admin"
tmux send-keys -t tui "exit" Enter
tmux kill-server 2>/dev/null
+9
View File
@@ -0,0 +1,9 @@
# The configurator in huh's accessible mode: without NetBox, add a static host,
# save, then connect. Menu numbers follow the order in internal/setup.
config_only </dev/null
rm -f "$XDG_CONFIG_HOME/netssh.yaml"
# no NetBox, static hosts, add, name, address, user, port, tags, back, save
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 9)
[[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out"
expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "connect to the configured host" "admin" netssh legacy-box whoami
+12
View File
@@ -0,0 +1,12 @@
# Uninstall in dry-run mode lists everything netssh created.
config <<'EOF'
resolver: {strategies: [primary_ip]}
EOF
check "refresh" netssh cache refresh
check "hook install" netssh hook install --shell bash
expect_out "lists the profile" ".bashrc" netssh uninstall --dry-run
expect_out "lists the config" "netssh.yaml" netssh uninstall --dry-run
expect_out "lists the cache" ".cache/netssh" netssh uninstall --dry-run
out=$(netssh uninstall --dry-run --keep-config 2>&1)
[[ $out != *"netssh.yaml"* ]] && ok "--keep-config keeps the config" || bad "--keep-config keeps the config" "$out"
expect_out "nothing was removed" "netssh shell-init" cat "$HOME/.bashrc"
+74
View File
@@ -0,0 +1,74 @@
# netssh multi with real tmux and zellij: selectors against NetBox, panes,
# broadcast typing reaching every host, netssh sync, zellij layouts and tabs.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
groups:
pve: ["cluster:pve-fsn1"]
mixed: ["@pve", "lab/lab-db1", "tag:web"]
EOF
check "refresh" netssh cache refresh
nodes="fsn/fsn1-pve1.hoster.test fsn/fsn1-pve2.hoster.test fsn/fsn1-pve3.hoster.test"
expect_out "cluster: selects the devices" "# 3 hosts: $nodes" netssh multi --dry-run --tmux cluster:pve-fsn1
expect_out "--kind vm selects the VMs" "# 2 hosts: fsn/web1 fsn/web2" netssh multi --dry-run --tmux --kind vm cluster:pve-fsn1
expect_out "groups, tags, and hosts combined" "# 7 hosts: $nodes lab/lab-db1 fsn/web1 fsn/web2 lab/web1" netssh multi --dry-run --tmux @mixed
expect_fail "unknown cluster" "matches no hosts" netssh multi --dry-run --tmux cluster:nope
expect_out "completion offers clusters" "cluster:pve-fsn1" netssh __complete multi cluster:
expect_out "completion offers groups" "@pve" netssh __complete multi @
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij"
# No first-start tips or release notes on top of the panes.
printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl"
envs=(-e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME")
hosts_of() { tmux list-panes -t "$1" -F '#{@netssh_host}' 2>/dev/null | sort | tr '\n' ' '; }
# wait_for retries a command, so checks must re-evaluate on every call.
panes_in() { [ "$(tmux list-panes -t "$1" 2>/dev/null | wc -l)" = "$2" ]; }
sync_is() { [ "$(tmux show-window-options -t "$1" -v synchronize-panes 2>/dev/null)" = "$2" ]; }
prompts() { [ "$(tmux capture-pane -p -t z | grep -o 'fsn1-pve[123]:~\$' | sort -u | wc -l)" = 3 ]; }
remote_file() { for n in 1 2 3; do netssh "fsn/fsn1-pve$n.hoster.test" test -f "$1" || return 1; done; }
# Inside tmux: a new window in the current session, broadcast from the start.
tmux new-session -d -s outer -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t outer "netssh multi --sync @pve" Enter
if wait_for 20 panes_in outer:pve 3; then ok "tmux: three panes in a new window"; else bad "tmux: three panes in a new window" "$(tmux list-windows -a)"; fi
expect_eq "tmux: panes are labelled" "$nodes " hosts_of outer:pve
expect_eq "tmux: broadcast is on" "on" tmux show-window-options -t outer:pve -v synchronize-panes
sleep 2
marker="/tmp/netssh-multi-$$"
tmux send-keys -t outer:pve "touch $marker" Enter
if wait_for 15 remote_file "$marker"; then ok "tmux: one command reached all three hosts"; else bad "tmux: one command reached all three hosts" "$(tmux capture-pane -p -t outer:pve)"; fi
tmux new-window -t outer -n ctl "${envs[@]}" bash
tmux send-keys -t outer:ctl "netssh sync" Enter
if wait_for 10 sync_is outer:ctl on; then ok "netssh sync toggles broadcast"; else bad "netssh sync toggles broadcast" "$(tmux capture-pane -p -t outer:ctl)"; fi
# Outside tmux: a new session, attached.
tmux send-keys -t outer:ctl "netssh sync; env -u TMUX netssh multi --kind vm cluster:pve-fsn1" Enter
if wait_for 20 panes_in netssh-pve-fsn1 2; then ok "tmux: new session outside tmux"; else bad "tmux: new session outside tmux" "$(tmux list-sessions)"; fi
expect_eq "tmux: VMs through the bastion" "fsn/web1 fsn/web2 " hosts_of netssh-pve-fsn1
tmux kill-server
# zellij outside a session: netssh starts zellij with a grid layout.
tmux new-session -d -s z -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t z "netssh multi --zellij @pve" Enter
zscreen() { tmux capture-pane -p -t z; }
if wait_for 30 prompts; then ok "zellij: three panes with remote shells"; else bad "zellij: three panes with remote shells" "$(zscreen)"; fi
expect_out "zellij: pane titles" "fsn/fsn1-pve2.hoster.test" zscreen
zsession() { zellij list-sessions --short --no-formatting 2>/dev/null | head -1 | grep .; }
if wait_for 10 zsession; then ok "zellij: session is running"; else bad "zellij: session is running" "$(zellij list-sessions 2>&1)"; fi
session=$(zsession)
# zellij inside that session: netssh multi adds a tab.
ZELLIJ_SESSION_NAME=$session ZELLIJ=0 netssh multi --kind vm cluster:pve-fsn1 >/dev/null 2>&1
expect_out "zellij: new tab inside a session" "pve-fsn1" zellij --session "$session" action query-tab-names
expect_out "zellij: tab layout runs netssh for the VMs" "fsn/web2" zellij --session "$session" action dump-layout
zellij kill-all-sessions --yes >/dev/null 2>&1
tmux kill-server 2>/dev/null
true
+102
View File
@@ -0,0 +1,102 @@
# What a user sees: tmux, zellij, and the TUI rendered by a real terminal
# emulator (xterm on Xvfb), checked by OCR of screenshots. The screenshots stay
# in testenv/.artifacts/ for a look by eye.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh: {default_user: ops}
groups:
pve: ["cluster:pve-fsn1"]
EOF
check "refresh" netssh cache refresh
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij"
printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl"
start_screen && ok "X server and xterm" || bad "X server and xterm" "Xvfb did not start"
p1=fsn/fsn1-pve1.hoster.test p2=fsn/fsn1-pve2.hoster.test p3=fsn/fsn1-pve3.hoster.test
prompts() { screen_shows "$1" "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"; }
# Two hosts on one OCR line means their panes sit side by side (tiled grid).
side_by_side() { screen_text "$1" | ocr_norm | grep -F "$(printf %s "$2" | ocr_norm)" | grep -qF "$(printf %s "$3" | ocr_norm)"; }
visible() { # NAME SHOT TEXT... — wait until the screen shows every TEXT
local name=$1 shot=$2
shift 2
if wait_for 30 screen_shows "$shot" "$@"; then ok "$name"; else bad "$name" "$(screen_text "$shot")"; fi
}
# --- tmux, started from a plain terminal: new session, attached in xterm ---
term netssh multi --sync @pve
visible "tmux: three remote shells on screen" tmux-1 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"
visible "tmux: borders show host and [SYNC]" tmux-1 "$p1 [SYNC]" "$p2 [SYNC]" "$p3 [SYNC]"
side_by_side tmux-1 "$p1" "$p2" && ok "tmux: tiled, first two panes side by side" || bad "tmux: tiled, first two panes side by side" "$(screen_text tmux-1)"
tmux send-keys -t netssh-pve "echo typed-once-\$(hostname)" Enter
visible "tmux: broadcast output in every pane" tmux-2 "typed-once-fsn1-pve1" "typed-once-fsn1-pve2" "typed-once-fsn1-pve3"
# Keys as typed in xterm (send-keys -K goes through the client's prefix and
# key tables): prefix e excludes the active pane, prefix S toggles all panes.
client=$(tmux list-clients -t netssh-pve -F '#{client_name}' | head -1)
press() { tmux send-keys -K -c "$client" C-b "$1"; sleep 0.5; }
panes=($(tmux list-panes -t netssh-pve -F '#{pane_id}'))
tmux select-pane -t "${panes[2]}"
press e
visible "tmux: prefix e marks the pane [SOLO]" tmux-solo "$p3 [SOLO]" "$p1 [SYNC]" "$p2 [SYNC]"
tmux send-keys -t "${panes[2]}" "echo solo-only" Enter
tmux send-keys -t "${panes[0]}" "echo two-panes" Enter
sleep 1
shot tmux-solo-2
[ "$(screen_count tmux-solo-2 "solo-only")" = 2 ] && ok "tmux: the excluded pane types alone" || bad "tmux: the excluded pane types alone" "$(screen_text tmux-solo-2)"
[ "$(screen_count tmux-solo-2 "two-panes")" = 4 ] && ok "tmux: broadcast skips the excluded pane" || bad "tmux: broadcast skips the excluded pane" "$(screen_text tmux-solo-2)"
press e
visible "tmux: prefix e again includes it" tmux-solo-3 "$p3 [SYNC]"
press S
sync_is() { [ "$(tmux show-window-options -t netssh-pve -v synchronize-panes 2>/dev/null)" = "$1" ]; }
wait_for 5 sync_is off && ok "tmux: prefix S turns broadcast off" || bad "tmux: prefix S turns broadcast off" "$(tmux show-window-options -t netssh-pve)"
tmux send-keys -t netssh-pve "echo just-this-pane" Enter
sleep 1
shot tmux-3
[ "$(screen_count tmux-3 "just-this-pane")" = 2 ] && ok "tmux: without broadcast only one pane gets input" || bad "tmux: without broadcast only one pane gets input" "$(screen_text tmux-3)"
shows_sync() { screen_shows tmux-3 "[SYNC]"; }
shows_sync && bad "tmux: [SYNC] disappears when broadcast is off" "$(screen_text tmux-3)" || ok "tmux: [SYNC] disappears when broadcast is off"
press S
wait_for 5 sync_is on && ok "tmux: prefix S turns it on again" || bad "tmux: prefix S turns it on again" "$(tmux show-window-options -t netssh-pve)"
tmux kill-server
# --- more hosts than panes_per_window: further windows ---
sed -i 's/^groups:/multi: {panes_per_window: 2}\ngroups:/' "$XDG_CONFIG_HOME/netssh.yaml"
term netssh multi @pve
visible "tmux: hosts split over windows pve-1 and pve-2" tmux-windows "pve-1" "pve-2"
visible "tmux: status bar shows the whole session name" tmux-windows "[netssh-pve]"
tmux kill-server
sed -i '/^multi: {panes_per_window: 2}$/d' "$XDG_CONFIG_HOME/netssh.yaml"
# --- zellij: generated grid layout, broadcast through the sync tab ---
term netssh multi --zellij @pve
visible "zellij: three remote shells on screen" zellij-1 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"
visible "zellij: pane titles show the hosts" zellij-1 "$p1" "$p2" "$p3"
side_by_side zellij-1 "$p1" "$p2" && ok "zellij: grid, first two panes side by side" || bad "zellij: grid, first two panes side by side" "$(screen_text zellij-1)"
session=$(zellij list-sessions --short --no-formatting 2>/dev/null | head -1)
zellij --session "$session" action toggle-active-sync-tab
zellij --session "$session" action write-chars 'echo zellij-$(hostname)'
zellij --session "$session" action write 13
visible "zellij: broadcast output in every pane" zellij-2 "zellij-fsn1-pve1" "zellij-fsn1-pve2" "zellij-fsn1-pve3"
zellij kill-all-sessions --yes >/dev/null 2>&1
# --- TUI: mark hosts, open them together ---
term tmux new-session -s tui netssh
visible "TUI: search screen" tui-1 "Search hostname"
# The netssh keys do nothing outside netssh multi windows.
tmux source-file "$XDG_CACHE_HOME/netssh/netssh-multi.tmux.conf"
client=$(tmux list-clients -t tui -F '#{client_name}' | head -1)
tmux send-keys -K -c "$client" C-b S
sleep 0.5
[ "$(tmux show-window-options -t tui -v synchronize-panes 2>/dev/null)" != on ] && ok "tmux: prefix S leaves other windows alone" || bad "tmux: prefix S leaves other windows alone" "synchronize-panes turned on in a non-netssh window"
tmux send-keys -t tui "pve"
visible "TUI: results listed" tui-2 "$p1" "$p2" "$p3"
tmux send-keys -t tui C-a
visible "TUI: all marked" tui-3 "3 marked"
tmux send-keys -t tui Enter
visible "TUI: marked hosts open side by side" tui-4 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"
tmux kill-server
stop_screen
+32
View File
@@ -0,0 +1,32 @@
"""Drive `netssh configure` in huh's accessible mode (TERM=dumb): one answer
per prompt, sent after a short pause so each form reads its own line."""
import os
import pty
import select
import sys
import time
answers = sys.argv[1:]
pid, fd = pty.fork()
if pid == 0:
os.execvpe("netssh", ["netssh", "configure"], dict(os.environ, TERM="dumb"))
out = b""
def drain(seconds):
global out
end = time.time() + seconds
while time.time() < end:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
out += os.read(fd, 4096)
except OSError:
return
for answer in answers:
drain(0.5)
os.write(fd, (answer + "\n").encode())
drain(2)
sys.stdout.write(out.decode(errors="replace"))
+154
View File
@@ -0,0 +1,154 @@
# Helpers for the netssh test environment. Sourced by run.sh, the tests, and
# shell.sh inside the client container.
# shellcheck disable=SC1091
. /state/env
RESULTS=${RESULTS:-/tmp/netssh-results}
# new_home gives each test file its own netssh config, cache, notes, and rc files.
new_home() {
HOME=$(mktemp -d /tmp/home.XXXXXX)
export HOME XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache" SHELL=/bin/bash
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
unset TMUX ZELLIJ NETSSH_NETBOX_URL NETSSH_NETBOX_TOKEN
cd "$HOME" || exit 1
}
# base_config prints both NetBox instances followed by stdin.
base_config() { cat /state/netssh.yaml -; }
# config writes the netssh config: both NetBox instances plus stdin.
config() { base_config >"$XDG_CONFIG_HOME/netssh.yaml"; }
# config_only writes stdin as the whole config (no NetBox).
config_only() { cat >"$XDG_CONFIG_HOME/netssh.yaml"; }
ok() {
echo "ok $CURRENT: $1" >>"$RESULTS"
printf ' \033[32mok\033[0m %s\n' "$1"
}
bad() {
echo "FAIL $CURRENT: $1" >>"$RESULTS"
printf ' \033[31mFAIL\033[0m %s\n' "$1"
printf '%s\n' "$2" | sed 's/^/ /'
}
# check NAME CMD... — CMD must succeed.
check() {
local name=$1 out
shift
if out=$("$@" 2>&1); then ok "$name"; else bad "$name" "exit $?: $out"; fi
}
# expect_out NAME WANT CMD... — CMD must succeed and print WANT (substring).
expect_out() {
local name=$1 want=$2 out rc
shift 2
out=$("$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" = 0 ] && [[ $out == *"$want"* ]]; then ok "$name"; else bad "$name" "want: $want
got (exit $rc): $out"; fi
}
# expect_eq NAME WANT CMD... — CMD must succeed and print exactly WANT.
expect_eq() {
local name=$1 want=$2 out rc
shift 2
out=$("$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" = 0 ] && [ "$out" = "$want" ]; then ok "$name"; else bad "$name" "want: $want
got (exit $rc): $out"; fi
}
# expect_fail NAME WANT CMD... — CMD must fail and print WANT (substring).
expect_fail() {
local name=$1 want=$2 out rc
shift 2
out=$("$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" != 0 ] && [[ $out == *"$want"* ]]; then ok "$name"; else bad "$name" "want failure with: $want
got (exit $rc): $out"; fi
}
# wait_for TIMEOUT CMD... — retry CMD every 0.2 s until it succeeds.
wait_for() {
local deadline=$((SECONDS + $1))
shift
until "$@" >/dev/null 2>&1; do
[ $SECONDS -ge $deadline ] && return 1
sleep 0.2
done
}
# --- rendering in a real terminal emulator (tests/16-render.sh) ---
# Xvfb provides a screen, xterm renders the terminal, `import` takes a
# screenshot, and tesseract reads it back, so checks see what a user sees.
start_screen() {
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
export DISPLAY=:99
wait_for 10 import -window root -crop 1x1+0+0 null:
}
stop_screen() {
pkill -u "$(id -u)" xterm 2>/dev/null
kill "$XVFB_PID" 2>/dev/null
wait "$XVFB_PID" 2>/dev/null
}
# term CMD... — run CMD in a full-screen xterm (UTF-8, DejaVu Sans Mono).
term() {
pkill -u "$(id -u)" xterm 2>/dev/null
xterm -u8 -geometry 190x52+0+0 -fa 'DejaVu Sans Mono' -fs 11 -bg '#1e1e1e' -fg '#d4d4d4' \
-T netssh -e "$@" >/dev/null 2>&1 &
sleep 1
}
# shot NAME — screenshot to /artifacts/NAME.png.
shot() { import -window root "/artifacts/$1.png"; }
# OCR preprocessing variants: no single one reads everything (dim hint lines,
# red/green borders): plain luminance, stretched dark range for dim gray, and
# the brightest color channel for colored text.
OCR_VARIANTS=("-colorspace gray" "-colorspace gray -level 10%,40%"
"-channel RGB -separate -evaluate-sequence max -level 10%,60%")
# ocr_variant NAME I — OCR of screenshot NAME with preprocessing variant I.
ocr_variant() {
# shellcheck disable=SC2086
magick "/artifacts/$1.png" ${OCR_VARIANTS[$2]} -negate -resize 300% png:- |
tesseract stdin stdout --psm 6 2>/dev/null
}
# screen_text NAME — the text of all variants combined.
screen_text() {
local i
for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done
}
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O,
# typographic dashes for "-") to one form; expected text gets the same mapping,
# so comparisons stay strict about everything else.
ocr_norm() { sed 's/[‐‑‒–—―−]/-/g' | tr 'lI|(O' '111C0'; }
# screen_shows NAME TEXT... — take a screenshot and check that OCR finds every TEXT.
screen_shows() {
local name=$1 text want
shift
shot "$name"
text=$(screen_text "$name" | ocr_norm)
for want in "$@"; do
want=$(printf '%s' "$want" | ocr_norm)
[[ $text == *"$want"* ]] || return 1
done
}
# screen_count NAME TEXT — how often TEXT is on screenshot NAME: the highest
# count of any OCR variant (they read the same screen, so they are not added).
screen_count() {
local want i n max=0
want=$(printf '%s' "$2" | ocr_norm)
for i in "${!OCR_VARIANTS[@]}"; do
n=$(ocr_variant "$1" "$i" | ocr_norm | grep -oF -- "$want" | wc -l)
[ "$n" -gt "$max" ] && max=$n
done
echo "$max"
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Runs every tests/NN-*.sh (or those matching $1) in a fresh netssh home and
# prints a summary. Exit code 1 if any check failed.
set -u
export RESULTS=/tmp/netssh-results
: >"$RESULTS"
filter=${1:-}
for file in /tests/[0-9][0-9]-*.sh; do
name=$(basename "$file" .sh)
[[ -n $filter && $name != *"$filter"* ]] && continue
echo "== $name"
(
export CURRENT=$name
. /tests/lib.sh
new_home
. "$file"
)
done
passed=$(grep -c '^ok ' "$RESULTS" || true)
failed=$(grep -c '^FAIL ' "$RESULTS" || true)
echo
echo "passed: $passed, failed: $failed"
if [ "$failed" -gt 0 ]; then
grep '^FAIL ' "$RESULTS" | sed 's/^/ /'
exit 1
fi
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more