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>
117 lines
5.9 KiB
Markdown
117 lines
5.9 KiB
Markdown
# 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.
|