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). |
.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:
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).
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 uninstallruns 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.