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