Files
ssh-netbox-wrapper/testenv
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
..

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.
  • 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:

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 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.