feat: netssh multi — several hosts side by side in tmux or zellij

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>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-14 12:45:05 +01:00
1 parent dbb99bdded
commit a31aab4110
24 files changed
+1754 -40

No files matched your search

+4 -1
View File
@@ -78,10 +78,13 @@ 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`.
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 uninstall` runs 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.
+1 -1
View File
@@ -3,7 +3,7 @@
config_only </dev/null
rm -f "$XDG_CONFIG_HOME/netssh.yaml"
# no NetBox, static hosts, add, name, address, user, port, tags, back, save
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 8)
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 9)
[[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out"
expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "connect to the configured host" "admin" netssh legacy-box whoami
+74
View File
@@ -0,0 +1,74 @@
# netssh multi with real tmux and zellij: selectors against NetBox, panes,
# broadcast typing reaching every host, netssh sync, zellij layouts and tabs.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
groups:
pve: ["cluster:pve-fsn1"]
mixed: ["@pve", "lab/lab-db1", "tag:web"]
EOF
check "refresh" netssh cache refresh
nodes="fsn/fsn1-pve1.hoster.test fsn/fsn1-pve2.hoster.test fsn/fsn1-pve3.hoster.test"
expect_out "cluster: selects the devices" "# 3 hosts: $nodes" netssh multi --dry-run --tmux cluster:pve-fsn1
expect_out "--kind vm selects the VMs" "# 2 hosts: fsn/web1 fsn/web2" netssh multi --dry-run --tmux --kind vm cluster:pve-fsn1
expect_out "groups, tags, and hosts combined" "# 7 hosts: $nodes lab/lab-db1 fsn/web1 fsn/web2 lab/web1" netssh multi --dry-run --tmux @mixed
expect_fail "unknown cluster" "matches no hosts" netssh multi --dry-run --tmux cluster:nope
expect_out "completion offers clusters" "cluster:pve-fsn1" netssh __complete multi cluster:
expect_out "completion offers groups" "@pve" netssh __complete multi @
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij"
# No first-start tips or release notes on top of the panes.
printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl"
envs=(-e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME")
hosts_of() { tmux list-panes -t "$1" -F '#{@netssh_host}' 2>/dev/null | sort | tr '\n' ' '; }
# wait_for retries a command, so checks must re-evaluate on every call.
panes_in() { [ "$(tmux list-panes -t "$1" 2>/dev/null | wc -l)" = "$2" ]; }
sync_is() { [ "$(tmux show-window-options -t "$1" -v synchronize-panes 2>/dev/null)" = "$2" ]; }
prompts() { [ "$(tmux capture-pane -p -t z | grep -o 'fsn1-pve[123]:~\$' | sort -u | wc -l)" = 3 ]; }
remote_file() { for n in 1 2 3; do netssh "fsn/fsn1-pve$n.hoster.test" test -f "$1" || return 1; done; }
# Inside tmux: a new window in the current session, broadcast from the start.
tmux new-session -d -s outer -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t outer "netssh multi --sync @pve" Enter
if wait_for 20 panes_in outer:pve 3; then ok "tmux: three panes in a new window"; else bad "tmux: three panes in a new window" "$(tmux list-windows -a)"; fi
expect_eq "tmux: panes are labelled" "$nodes " hosts_of outer:pve
expect_eq "tmux: broadcast is on" "on" tmux show-window-options -t outer:pve -v synchronize-panes
sleep 2
marker="/tmp/netssh-multi-$$"
tmux send-keys -t outer:pve "touch $marker" Enter
if wait_for 15 remote_file "$marker"; then ok "tmux: one command reached all three hosts"; else bad "tmux: one command reached all three hosts" "$(tmux capture-pane -p -t outer:pve)"; fi
tmux new-window -t outer -n ctl "${envs[@]}" bash
tmux send-keys -t outer:ctl "netssh sync" Enter
if wait_for 10 sync_is outer:ctl on; then ok "netssh sync toggles broadcast"; else bad "netssh sync toggles broadcast" "$(tmux capture-pane -p -t outer:ctl)"; fi
# Outside tmux: a new session, attached.
tmux send-keys -t outer:ctl "netssh sync; env -u TMUX netssh multi --kind vm cluster:pve-fsn1" Enter
if wait_for 20 panes_in netssh-pve-fsn1 2; then ok "tmux: new session outside tmux"; else bad "tmux: new session outside tmux" "$(tmux list-sessions)"; fi
expect_eq "tmux: VMs through the bastion" "fsn/web1 fsn/web2 " hosts_of netssh-pve-fsn1
tmux kill-server
# zellij outside a session: netssh starts zellij with a grid layout.
tmux new-session -d -s z -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t z "netssh multi --zellij @pve" Enter
zscreen() { tmux capture-pane -p -t z; }
if wait_for 30 prompts; then ok "zellij: three panes with remote shells"; else bad "zellij: three panes with remote shells" "$(zscreen)"; fi
expect_out "zellij: pane titles" "fsn/fsn1-pve2.hoster.test" zscreen
zsession() { zellij list-sessions --short --no-formatting 2>/dev/null | head -1 | grep .; }
if wait_for 10 zsession; then ok "zellij: session is running"; else bad "zellij: session is running" "$(zellij list-sessions 2>&1)"; fi
session=$(zsession)
# zellij inside that session: netssh multi adds a tab.
ZELLIJ_SESSION_NAME=$session ZELLIJ=0 netssh multi --kind vm cluster:pve-fsn1 >/dev/null 2>&1
expect_out "zellij: new tab inside a session" "pve-fsn1" zellij --session "$session" action query-tab-names
expect_out "zellij: tab layout runs netssh for the VMs" "fsn/web2" zellij --session "$session" action dump-layout
zellij kill-all-sessions --yes >/dev/null 2>&1
tmux kill-server 2>/dev/null
true