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>
4.6 KiB
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.
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. web1exists in both instances (ambiguity,instance/host).- Docker DNS resolves the
*.hoster.testnames on the mgmt network (hostnamestrategy). - Every target runs sshd on ports 22 and 2222 with users
opsandadmin, 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). |
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:
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.
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 uninstallruns only with--dry-run, so the client keeps its binary.- tmux and zellij run headless inside the client; the checks read their state (
tmux list-panes,zellij action dump-layout) and screen contents, not what a real terminal would render.