From a31aab411098af9cf8df7fb2bb253fa5ff6f98a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sat, 14 Nov 2026 12:45:05 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20netssh=20multi=20=E2=80=94=20severa?= =?UTF-8?q?l=20hosts=20side=20by=20side=20in=20tmux=20or=20zellij?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 +- README.md | 75 ++++- cmd/netssh/main.go | 19 +- cmd/netssh/multi.go | 402 +++++++++++++++++++++++++++ cmd/netssh/multi_test.go | 92 ++++++ internal/cache/cache.go | 1 + internal/config/config.go | 8 +- internal/config/multi.go | 57 ++++ internal/config/ssh_test.go | 12 + internal/multiplex/multiplex.go | 226 +++++++++++++++ internal/multiplex/multiplex_test.go | 211 ++++++++++++++ internal/multiplex/tmux.go | 95 +++++++ internal/multiplex/zellij.go | 101 +++++++ internal/netbox/client.go | 55 +++- internal/netbox/cluster_test.go | 51 ++++ internal/netbox/models.go | 19 +- internal/setup/multi.go | 102 +++++++ internal/setup/wizard.go | 31 +++ internal/setup/wizard_test.go | 34 +++ internal/tui/model.go | 83 +++++- internal/tui/model_test.go | 35 +++ testenv/README.md | 5 +- testenv/tests/13-configure.sh | 2 +- testenv/tests/15-multi.sh | 74 +++++ 24 files changed, 1754 insertions(+), 40 deletions(-) create mode 100644 cmd/netssh/multi.go create mode 100644 cmd/netssh/multi_test.go create mode 100644 internal/config/multi.go create mode 100644 internal/multiplex/multiplex.go create mode 100644 internal/multiplex/multiplex_test.go create mode 100644 internal/multiplex/tmux.go create mode 100644 internal/multiplex/zellij.go create mode 100644 internal/netbox/cluster_test.go create mode 100644 internal/setup/multi.go create mode 100644 testenv/tests/15-multi.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ebdab..60db109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ - Fix the `interface_name` resolver strategy: it sent filter names that NetBox ignores and therefore returned the host's first IP address instead of the named interface's. It now uses NetBox's `interface` and `vminterface` filters; a missing interface falls through to the next strategy. - Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths. - Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`). -- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 141 end-to-end checks of all features (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD. +- Add `netssh multi` to open several hosts side by side in tmux or zellij: host lists, `tag:`, NetBox virtualization clusters (`cluster:`, devices by default, `--kind vm|all`), and groups from the config (`@name`). `--sync` sends typing to all panes; `netssh sync` toggles it later. In the TUI, `Space` marks hosts and `Enter` opens them together. Configurable in `netssh configure` (multiplexer, panes per window, groups). +- Cache entries remember the NetBox cluster of a host, for `cluster:` completion and offline use. +- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 160 end-to-end checks of all features, including `netssh multi` with real tmux and zellij (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD. ## v0.0.23 diff --git a/README.md b/README.md index a8d859d..d77ff38 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int - [Installation](#installation) - [Quick start](#quick-start) - [Configuration](#configuration) — [wizard](#interactive-wizard), [manual config](#manual-config), [reference](#configuration-reference), [environment variables](#environment-variables), [files](#files-and-directories), [multiple instances](#multiple-netbox-instances) -- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management) +- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [multiple servers](#multiple-servers-in-one-window), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management) - [Recipes](#recipes) - [IP Resolution Strategies](#ip-resolution-strategies) - [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook) @@ -50,6 +50,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int - **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours - **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config` - **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh +- **Several servers in one window** — `netssh multi` opens hosts, a tag, a NetBox cluster, or a saved group side by side in tmux or zellij, with optional typing into all of them - **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI - **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting - **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config @@ -149,6 +150,7 @@ A menu then gives access to every other setting; open only the sections you want | SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) | | SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options | | Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options | +| Multi-session | multiplexer (auto, tmux, zellij), panes per window, groups (`NAME: sel1, sel2` per line) | | Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file | | Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` | | Completion | `completion.max_results` | @@ -232,10 +234,13 @@ Every key, its default, and the section that explains it. Keys marked *layered* | `completion.max_results` | int | `0` | Hostnames offered per ``; `0` = unlimited. | | `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). | | `hosts[]` | list | `[]` | Static hosts with `name`, `address`, `user`, `port`, `tags`, `ssh` — see [Static hosts](#static-hosts). | +| `groups` | map of lists | `{}` | Named host lists for `netssh multi @NAME`; entries are hosts or selectors. | +| `multi.multiplexer` | `auto` \| `tmux` \| `zellij` | `auto` | Which multiplexer `netssh multi` uses. | +| `multi.panes_per_window` | int | `9` | More hosts open in further tmux windows or zellij tabs. | ### Environment variables -These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`) can only be set in the file. +These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`, `groups`) can only be set in the file. | Variable | Key | |---|---| @@ -246,6 +251,7 @@ These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_ | `NETSSH_SSH_PROXY_JUMP`, `NETSSH_SSH_HOST_KEY_CHECKING`, `NETSSH_SSH_TARGET`, `NETSSH_SSH_ADDRESS_FAMILY` | global `ssh.*` options | | `NETSSH_SHORTCUTS_DOMAINS`, `NETSSH_SHORTCUTS_STRIP_HYPHENS` | `shortcuts.*` | | `NETSSH_COMPLETION_MAX_RESULTS` | `completion.max_results` | +| `NETSSH_MULTI_MULTIPLEXER`, `NETSSH_MULTI_PANES_PER_WINDOW` | `multi.*` | | `NETSSH_SHELL_ALIAS` | `shell.alias` | netssh also respects `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `ZDOTDIR`, and `SHELL` (for `hook install`, `completion install`, and `alias` without `--shell`). @@ -359,6 +365,8 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered. | `netssh scp [netssh flags] [scp options] SOURCE... TARGET` | Copy files — see [SFTP and SCP](#sftp-and-scp). | | `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). | | `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). | +| `netssh multi [--sync] [--kind K] [--tmux\|--zellij] [-l U] [--jump H\|--no-jump] HOST\|tag:T\|cluster:C\|@GROUP...` | Several hosts side by side — see [Multiple servers](#multiple-servers-in-one-window). | +| `netssh sync` | Toggle typing into all panes of the current tmux window or zellij tab. | | `netssh configure` | Setup wizard. | | `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. | | `netssh search QUERY` | Print matching cached hostnames, one per line. | @@ -513,7 +521,7 @@ netssh --forward web fsnbx1 # shell plus forward (repeatable) A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 `) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards. -`sftp`, `scp`, `tunnel`, and `hosts` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`. +`sftp`, `scp`, `tunnel`, `hosts`, `multi`, and `sync` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`. ### Static hosts @@ -547,6 +555,47 @@ Static hosts behave like NetBox hosts everywhere: `netssh router`, `sftp`, `scp` - Static hosts never expire and are not touched by `cache refresh` or `cache clear`; removing one from the config removes it everywhere. - `--netbox` notes and NetBox comments are not available for static hosts; local notes work. +### Multiple servers in one window + +`netssh multi` opens one pane per host in tmux or zellij — for example every node of a Proxmox or Kubernetes cluster — and can send your typing to all of them at once. + +```sh +netssh multi web01 web02 db01 # hosts, shortcuts, instance/host, static hosts +netssh multi tag:k8s-worker # all hosts with a NetBox tag (and static hosts with it) +netssh multi cluster:pve-fsn1 # the devices (nodes) of a NetBox virtualization cluster +netssh multi --kind vm cluster:pve-fsn1 # the cluster's VMs; --kind all for both +netssh multi @k8s # a saved group +netssh multi --sync @k8s # start with typing sent to every pane +netssh multi --dry-run cluster:pve-fsn1 # show the hosts and tmux/zellij commands +``` + +Each pane runs `netssh HOST`, so lookup, shortcuts, user and port, jump hosts, and rules work as for a single connection. `-l USER`, `--jump HOST`, and `--no-jump` apply to every pane. A pane whose connection fails stays open with the error until you press Enter. + +**Groups** collect hosts and selectors under a name (or use `netssh configure` → *Multi-session*): + +```yaml +groups: + k8s: [k8s-master1, "tag:k8s-worker"] + pve: ["cluster:pve-fsn1"] + all-web: ["@k8s", "tag:web", legacy-box] +multi: + multiplexer: auto # auto | tmux | zellij + panes_per_window: 9 # more hosts open in further windows/tabs +``` + +**tmux and zellij.** With `auto`, netssh uses the multiplexer you are in; otherwise tmux, then zellij, whichever is installed (`--tmux` / `--zellij` choose explicitly). + +| | tmux | zellij | +|---|---|---| +| Inside a session | new window in the current session | new tab in the current session | +| Outside | new session `netssh-NAME`, attached | new session from a generated layout | +| Layout | tiled; pane borders show the host | grid; pane titles show the host | +| Broadcast | `--sync`, or `netssh sync` / `Ctrl+b :setw synchronize-panes` to toggle; borders show `[SYNC]` | `--sync` inside zellij, or `Ctrl+t` then `s` (also `netssh sync`) | + +**In the TUI**, mark hosts with `Space` (or all listed hosts with `Ctrl+A`) and press `Enter` to open them together. + +`tag:` and `cluster:` ask NetBox (all instances); if NetBox is unreachable, netssh uses the cached hosts and prints a warning. More than 24 hosts need `--yes`. The 24-hour background cache refresh runs once, not in every pane. + ### Default username Set `ssh.default_user` in the config to avoid typing a username every time: @@ -664,6 +713,8 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search | `Tab` | autocomplete top result into the search field | | `↑` / `↓` | navigate results | | `Enter` | connect to selected host | +| `Space` | mark/unmark the host; with marks, `Enter` opens them side by side (`netssh multi`) | +| `Ctrl+A` | mark all listed hosts (again: unmark all) | | `Ctrl+O` | open an SFTP session to the selected host | | `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) | | `Ctrl+E` | open inline editor to override user/port before connecting | @@ -827,6 +878,14 @@ netssh cache refresh && netssh alias --test fsn1-bx1.hoster.de netssh fsnbx1 # also works as shell alias: fsnbx1 ``` +**Maintenance on all nodes of a cluster at once** + +```sh +netssh multi --sync cluster:pve-fsn1 # every node in one window, typing goes to all +# … run apt upgrade on all nodes, then turn broadcast off for per-node work: +netssh sync # from another pane, or Ctrl+b :setw synchronize-panes +``` + **Deploy a file and restart a service** ```sh @@ -1024,7 +1083,10 @@ Both are non-blocking — the refresh runs as a background process and never del | Saving notes with `--netbox` fails with a permission error | The API token is read-only. Local notes work without write permission. | | `` completes files instead of hosts | Run `netssh completion install` and open a new terminal; for zsh also `rm -f ~/.zcompdump*`. | | Aliases missing in a new shell | `netssh hook install`, then a new terminal. The cache must not be empty. | -| A host is named `sftp`, `scp`, `tunnel`, `hosts`, or like another subcommand | Use `netssh -- sftp`. | +| A host is named `sftp`, `scp`, `tunnel`, `hosts`, `multi`, `sync`, or like another subcommand | Use `netssh -- sftp`. | +| `netssh multi needs tmux or zellij` | Install one of them (`apt install tmux`, `brew install tmux`). | +| `cluster:x matches no hosts` | Clusters select devices by default; VMs need `--kind vm` or `--kind all`. The name is matched case-insensitively. | +| `netssh multi` panes use another config | Inside an existing tmux/zellij session, panes get your `HOME` and `XDG_*` paths, but not `NETSSH_*` variables (they would be visible to other users in the process list). Put such settings in the config file. | ## Uninstall @@ -1059,7 +1121,7 @@ End-to-end tests run against real services in a local Docker environment — two ```sh testenv/up.sh # build and start (first start ~6 min) -testenv/test.sh # 141 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator +testenv/test.sh # 160 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij testenv/down.sh ``` @@ -1075,6 +1137,7 @@ Project layout: | `internal/resolver` | IP resolution strategies. | | `internal/cache` | Host cache. | | `internal/ssh` | Argument parsing and option injection for ssh, sftp, scp. | +| `internal/multiplex` | tmux and zellij layouts for `netssh multi`. | | `internal/shortcuts` | Shortcut normalization. | | `internal/tui` | Interactive search. | | `internal/setup` | Setup wizard. | @@ -1087,7 +1150,7 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen ## How it works -1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI. +1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `multi`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI. 2. On first run, or when neither a NetBox nor static hosts are configured, the interactive setup wizard starts automatically. 3. It removes its own long options (`--dry-run`, `--jump`, `--no-jump`, `--forward`) and parses the remaining arguments with the flag table of the program it will run (ssh, sftp, or scp differ, e.g. in `-l` and `-P`) to find the destination: `[user@]host` for ssh, `[user@]host[:path]` for sftp, and every `host:path` operand for scp. 4. It resolves the host: first the static hosts from `hosts:` (by name or shortcut), then an exact cache lookup, a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached. diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index cff6248..fd233bf 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -46,6 +46,8 @@ var managedSubcommands = map[string]bool{ "scp": true, "tunnel": true, "hosts": true, + "multi": true, + "sync": true, } func main() { @@ -291,7 +293,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost { fatalf("IP resolution for %q: %v", host, err) } - entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags} + entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster} c.Upsert(entry) return used(entry) } @@ -389,6 +391,12 @@ func runTUI() { if host == nil { return } + if host.Action == tui.ActionMulti { + if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil { + fatalf("%v", err) + } + return + } // Revalidate stale or live TUI results through the configured resolver chain. var tags []string @@ -413,7 +421,7 @@ func runTUI() { if err != nil { fatalf("IP resolution: %v", err) } - c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags}) + c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}) tags = target.Tags } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) @@ -499,7 +507,7 @@ func rootCmd() *cobra.Command { }, } - root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd()) + root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd()) // cobra builds the "completion" command lazily; force init so we can extend it. root.InitDefaultCompletionCmd() @@ -754,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command { fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err) continue } - c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags}) + c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster}) written++ } @@ -995,7 +1003,8 @@ func hookUninstallCmd() *cobra.Command { // maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache // has not been fully refreshed within the last 24 hours. func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { - if !cfg.HasNetBox() { + // netssh multi checks once and starts its panes with NETSSH_NO_REFRESH. + if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" { return } if !c.NeedsRefresh(24 * time.Hour) { diff --git a/cmd/netssh/multi.go b/cmd/netssh/multi.go new file mode 100644 index 0000000..ba1c2fc --- /dev/null +++ b/cmd/netssh/multi.go @@ -0,0 +1,402 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/multiplex" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" +) + +// maxHostsWithoutConfirm guards against opening hundreds of panes by accident. +const maxHostsWithoutConfirm = 24 + +// multiFlags are the options shared by netssh multi and the TUI. +type multiFlags struct { + kind, user, jump, multiplexer string + noJump, sync, dryRun, yes bool +} + +func multiCmd() *cobra.Command { + var f multiFlags + var useTmux, useZellij bool + cmd := &cobra.Command{ + Use: "multi [flags] HOST|tag:TAG|cluster:NAME|@GROUP...", + Short: "Open several hosts side by side in tmux or zellij", + Long: `Open one pane per host in a tmux window or zellij tab. Hosts are given as +names (shortcuts, instance/host, static hosts) or selectors: + + tag:TAG all hosts with this NetBox tag, and static hosts with this tag + cluster:NAME devices of a NetBox virtualization cluster (--kind vm|all for VMs) + @GROUP a group from the config (groups:) + +Broadcast typing to all panes of a window with --sync, or toggle it later with +netssh sync. Inside tmux or zellij the panes open in the current session.`, + Example: ` netssh multi web01 web02 db01 + netssh multi cluster:pve-fsn1 + netssh multi --kind vm cluster:pve-fsn1 + netssh multi --sync tag:k8s-worker + netssh multi @k8s`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if useTmux && useZellij { + return errors.New("--tmux and --zellij are mutually exclusive") + } + if useTmux { + f.multiplexer = multiplex.Tmux + } else if useZellij { + f.multiplexer = multiplex.Zellij + } + return runMulti(loadConfigOrSetup(), args, f) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + cfg, err := config.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + return multiCompletions(cfg, toComplete) + }, + } + cmd.Flags().StringVar(&f.kind, "kind", "", "Host kind for tag: and cluster: — device, vm, or all (default: all for tags, device for clusters)") + cmd.Flags().BoolVar(&f.sync, "sync", false, "Start with typing broadcast to all panes") + cmd.Flags().BoolVar(&useTmux, "tmux", false, "Use tmux") + cmd.Flags().BoolVar(&useZellij, "zellij", false, "Use zellij") + cmd.Flags().StringVarP(&f.user, "user", "l", "", "Login user for every host") + cmd.Flags().StringVar(&f.jump, "jump", "", "Jump host for every host") + cmd.Flags().BoolVar(&f.noJump, "no-jump", false, "Disable jump hosts") + cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "Print the hosts and multiplexer commands") + cmd.Flags().BoolVarP(&f.yes, "yes", "y", false, fmt.Sprintf("Open more than %d hosts without asking", maxHostsWithoutConfirm)) + _ = cmd.RegisterFlagCompletionFunc("kind", cobra.FixedCompletions([]cobra.Completion{"device", "vm", "all"}, cobra.ShellCompDirectiveNoFileComp)) + return cmd +} + +func syncCmd() *cobra.Command { + return &cobra.Command{ + Use: "sync", + Short: "Toggle typing into all panes of the current tmux window or zellij tab", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + state, err := multiplex.ToggleSync(multiplex.System{}) + if err != nil { + return err + } + fmt.Fprintln(os.Stderr, "netssh: "+state) + return nil + }, + } +} + +// runMulti expands selectors and opens one pane per host. +func runMulti(cfg *config.Config, selectors []string, f multiFlags) error { + if f.jump != "" && f.noJump { + return errors.New("--jump and --no-jump are mutually exclusive") + } + switch f.kind { + case "", "device", "vm", "all": + default: + return fmt.Errorf("--kind must be device, vm, or all") + } + c := newCache(cfg) + _ = c.Load() + targets, warnings, err := expandSelectors(cfg, c, selectors, f.kind) + for _, warning := range warnings { + fmt.Fprintln(os.Stderr, "netssh: warning: "+warning) + } + if err != nil { + return err + } + if len(targets) > maxHostsWithoutConfirm && !f.yes && !f.dryRun { + return fmt.Errorf("%d hosts selected; pass --yes to open them all", len(targets)) + } + + var runner multiplex.Runner = multiplex.System{} + if f.dryRun { + fmt.Printf("# %d hosts: %s\n", len(targets), strings.Join(targets, " ")) + runner = &multiplex.DryRun{W: os.Stdout} + } + requested := f.multiplexer + if requested == "" { + requested = cfg.Multi.Multiplexer + } + kind, err := multiplex.Detect(requested, runner) + if err != nil { + return err + } + if !f.dryRun { + // Checked once here; the panes skip it (NETSSH_NO_REFRESH). + maybeBackgroundRefresh(cfg, c) + } + + panes := make([]multiplex.Pane, 0, len(targets)) + for _, target := range targets { + panes = append(panes, multiplex.Pane{Title: target, Argv: paneArgv(target, f)}) + } + layoutDir := filepath.Dir(cfg.Cache.Path) + if err := os.MkdirAll(layoutDir, 0o700); err != nil { + return err + } + return multiplex.Open(kind, panes, multiplex.Options{ + Label: multiLabel(selectors), + Sync: f.sync, + PerWindow: cfg.Multi.PanesPerWindowOrDefault(), + LayoutDir: layoutDir, + Hint: func(s string) { fmt.Fprintln(os.Stderr, "netssh: "+s) }, + }, runner) +} + +// paneArgv runs netssh for one host. Panes of an existing tmux or zellij +// session inherit that server's environment, so the paths that select the +// config and cache are passed explicitly; NETSSH_* values are not, because +// arguments are visible to other users (tokens). +func paneArgv(target string, f multiFlags) []string { + self, err := os.Executable() + if err != nil { + self = "netssh" + } + env, err := exec.LookPath("env") + if err != nil { + env = "/usr/bin/env" + } + argv := []string{env, "NETSSH_NO_REFRESH=1"} + for _, key := range []string{"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} { + if value, ok := os.LookupEnv(key); ok { + argv = append(argv, key+"="+value) + } + } + argv = append(argv, self) + switch { + case f.jump != "": + argv = append(argv, "--jump", f.jump) + case f.noJump: + argv = append(argv, "--no-jump") + } + if f.user != "" { + argv = append(argv, "-l", f.user) + } + return append(argv, target) +} + +var unsafeLabel = regexp.MustCompile(`[^A-Za-z0-9_-]+`) + +// multiLabel names the window, session, or tab after a single selector. +func multiLabel(selectors []string) string { + if len(selectors) != 1 { + return "multi" + } + s := selectors[0] + for _, prefix := range []string{"@", "tag:", "cluster:"} { + s = strings.TrimPrefix(s, prefix) + } + if s = strings.Trim(unsafeLabel.ReplaceAllString(s, "-"), "-"); s == "" { + return "multi" + } + return s +} + +// expander resolves selectors into an ordered list of unique host targets. +type expander struct { + cfg *config.Config + cache *cache.Cache + kind string + client *netbox.Client + seen map[string]bool + visiting map[string]bool + targets []string + warnings []string +} + +func expandSelectors(cfg *config.Config, c *cache.Cache, selectors []string, kind string) ([]string, []string, error) { + e := &expander{cfg: cfg, cache: c, kind: kind, seen: map[string]bool{}, visiting: map[string]bool{}} + for _, selector := range selectors { + if err := e.expand(selector); err != nil { + return nil, e.warnings, err + } + } + if len(e.targets) == 0 { + return nil, e.warnings, errors.New("no hosts selected") + } + return e.targets, e.warnings, nil +} + +func (e *expander) add(target string) { + key := strings.ToLower(target) + if !e.seen[key] { + e.seen[key] = true + e.targets = append(e.targets, target) + } +} + +func (e *expander) expand(selector string) error { + switch { + case strings.HasPrefix(selector, "@"): + return e.group(strings.ToLower(strings.TrimPrefix(selector, "@"))) + case strings.HasPrefix(selector, "tag:"): + return e.query(selector, netbox.SearchOptions{Tag: strings.TrimPrefix(selector, "tag:")}, "all") + case strings.HasPrefix(selector, "cluster:"): + return e.query(selector, netbox.SearchOptions{Cluster: strings.TrimPrefix(selector, "cluster:")}, "device") + case selector == "": + return nil + } + e.add(selector) + return nil +} + +func (e *expander) group(name string) error { + members, ok := e.cfg.Groups[name] + if !ok { + names := make([]string, 0, len(e.cfg.Groups)) + for group := range e.cfg.Groups { + names = append(names, "@"+group) + } + sort.Strings(names) + if len(names) == 0 { + return fmt.Errorf("unknown group @%s; no groups are configured (groups:)", name) + } + return fmt.Errorf("unknown group @%s; available: %s", name, strings.Join(names, ", ")) + } + if e.visiting[name] { + return fmt.Errorf("group @%s contains itself", name) + } + e.visiting[name] = true + defer delete(e.visiting, name) + for _, member := range members { + if err := e.expand(member); err != nil { + return err + } + } + return nil +} + +// query expands tag: and cluster: through NetBox, plus static hosts for tags; +// if NetBox fails, the cache answers with a warning. +func (e *expander) query(selector string, opts netbox.SearchOptions, defaultKind string) error { + kind := e.kind + if kind == "" { + kind = defaultKind + } + if kind != "all" { + opts.Kind = kind + } + before := len(e.targets) + if opts.Tag != "" && (kind == "all" || kind == config.StaticKind) { + for _, host := range e.cfg.Hosts { + if hasTag(host.Tags, opts.Tag) { + e.add(host.Name) + } + } + } + if e.cfg.HasNetBox() { + if e.client == nil { + e.client = newNetBoxClient(e.cfg) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + entries, err := e.client.SearchAll(ctx, "", opts) + cancel() + if err != nil { + e.warnings = append(e.warnings, fmt.Sprintf("%s: NetBox unavailable, using the cache: %v", selector, err)) + entries = e.fromCache(opts) + } else { + sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() }) + } + for _, entry := range entries { + e.add(entry.Target()) + } + } + if len(e.targets) == before { + hint := "" + if opts.Cluster != "" && e.kind == "" { + hint = " (only devices by default; try --kind vm or --kind all)" + } + return fmt.Errorf("%s matches no hosts%s", selector, hint) + } + return nil +} + +func (e *expander) fromCache(opts netbox.SearchOptions) []netbox.HostEntry { + var out []netbox.HostEntry + for _, entry := range e.cache.All() { + if entry.Static || opts.Kind != "" && entry.Kind != opts.Kind { + continue + } + if opts.Tag != "" && !hasTag(entry.Tags, opts.Tag) || opts.Cluster != "" && !strings.EqualFold(entry.Cluster, opts.Cluster) { + continue + } + source, name := netbox.SplitTarget(entry.Name) + out = append(out, netbox.HostEntry{Source: source, Name: name, Kind: entry.Kind}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Target() < out[j].Target() }) + return out +} + +// hasTag compares a tag by name or slug, like ssh.rules. +func hasTag(tags []string, want string) bool { + for _, tag := range tags { + if strings.EqualFold(tag, want) || strings.EqualFold(strings.Join(strings.Fields(strings.ToLower(tag)), "-"), want) { + return true + } + } + return false +} + +// multiCompletions offers hosts, @groups, and tag:/cluster: values from the cache. +func multiCompletions(cfg *config.Config, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + c := newCache(cfg) + _ = c.Load() + lower := strings.ToLower(toComplete) + var out []string + switch { + case strings.HasPrefix(lower, "tag:"), strings.HasPrefix(lower, "cluster:"): + prefix, value, _ := strings.Cut(lower, ":") + values := map[string]bool{} + for _, entry := range c.All() { + if prefix == "tag" { + for _, tag := range entry.Tags { + values[strings.ToLower(tag)] = true + } + } else if entry.Cluster != "" { + values[entry.Cluster] = true + } + } + for v := range values { + if strings.HasPrefix(strings.ToLower(v), value) { + out = append(out, prefix+":"+v) + } + } + case strings.HasPrefix(lower, "@"): + for group := range cfg.Groups { + if strings.HasPrefix("@"+group, lower) { + out = append(out, "@"+group) + } + } + default: + out = completeHosts(toComplete) + var prefixes []string + for _, p := range []string{"tag:", "cluster:"} { + if toComplete != "" && strings.HasPrefix(p, lower) { + prefixes = append(prefixes, p) + } + } + if len(out) == 0 && len(prefixes) > 0 { + return prefixes, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + for group := range cfg.Groups { + if toComplete == "" { + out = append(out, "@"+group) + } + } + } + sort.Strings(out) + return out, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/netssh/multi_test.go b/cmd/netssh/multi_test.go new file mode 100644 index 0000000..2d5961f --- /dev/null +++ b/cmd/netssh/multi_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestExpandSelectors(t *testing.T) { + cfg := &config.Config{ + Hosts: []config.StaticHost{{Name: "box", Address: "10.0.0.1", Tags: []string{"Web Servers"}}}, + Groups: map[string][]string{ + "all": {"@web", "db1", "box"}, + "web": {"web1", "tag:web-servers", "web1"}, + "loop": {"@loop"}, + }, + } + c := cache.New(t.TempDir()+"/hosts.json", 60).WithStatic(cfg.Hosts) + got, _, err := expandSelectors(cfg, c, []string{"@all", "WEB1"}, "") + if err != nil || !reflect.DeepEqual(got, []string{"web1", "box", "db1"}) { + t.Fatalf("got %v %v", got, err) + } + if _, _, err := expandSelectors(cfg, c, []string{"@loop"}, ""); err == nil || !strings.Contains(err.Error(), "contains itself") { + t.Fatalf("cycle: %v", err) + } + if _, _, err := expandSelectors(cfg, c, []string{"@nope"}, ""); err == nil || !strings.Contains(err.Error(), "available: @all, @loop, @web") { + t.Fatalf("unknown group: %v", err) + } + if _, _, err := expandSelectors(cfg, c, []string{"tag:web-servers"}, "device"); err == nil { + t.Fatal("--kind device must exclude static hosts") + } +} + +// Without a reachable NetBox, tag: and cluster: fall back to the cache. +func TestExpandSelectorsCacheFallback(t *testing.T) { + cfg := &config.Config{NetBox: config.NetBoxConfig{URL: "http://127.0.0.1:1", Token: "t", TokenVersion: 1}} + c := cache.New(t.TempDir()+"/hosts.json", 60) + c.Upsert(cache.Entry{Name: "node2", Kind: "device", Cluster: "pve"}) + c.Upsert(cache.Entry{Name: "node1", Kind: "device", Cluster: "pve", Tags: []string{"pve"}}) + c.Upsert(cache.Entry{Name: "vm1", Kind: "vm", Cluster: "pve"}) + got, warnings, err := expandSelectors(cfg, c, []string{"cluster:PVE"}, "") + if err != nil || !reflect.DeepEqual(got, []string{"node1", "node2"}) || len(warnings) != 1 { + t.Fatalf("devices: %v %v %v", got, warnings, err) + } + if got, _, _ := expandSelectors(cfg, c, []string{"cluster:pve"}, "all"); len(got) != 3 { + t.Fatalf("all: %v", got) + } + if _, _, err := expandSelectors(cfg, c, []string{"cluster:other"}, ""); err == nil || !strings.Contains(err.Error(), "--kind vm") { + t.Fatalf("empty cluster: %v", err) + } +} + +func TestMultiLabelAndPaneArgv(t *testing.T) { + for selectors, want := range map[string]string{"@k8s": "k8s", "cluster:pve-fsn1": "pve-fsn1", "tag:a.b": "a-b", "a b": "multi"} { + if got := multiLabel(strings.Fields(selectors)); got != want { + t.Errorf("%q: got %q", selectors, got) + } + } + t.Setenv("XDG_CONFIG_HOME", "/cfg") + t.Setenv("NETSSH_NETBOX_TOKEN", "secret") + argv := strings.Join(paneArgv("prod/web1", multiFlags{user: "ops", jump: "jh"}), " ") + for _, want := range []string{"NETSSH_NO_REFRESH=1", "XDG_CONFIG_HOME=/cfg", "--jump jh -l ops prod/web1"} { + if !strings.Contains(argv, want) { + t.Errorf("argv lacks %q: %s", want, argv) + } + } + if strings.Contains(argv, "secret") { + t.Fatalf("token leaked into the pane arguments: %s", argv) + } +} + +func TestMultiDryRun(t *testing.T) { + prepareConfig(t, "hosts:\n - {name: a, address: 10.0.0.1, tags: [web]}\n - {name: b, address: 10.0.0.2, tags: [web]}\ngroups:\n web: [\"tag:web\"]\n") + out, err := runNetssh(t, "multi", "--dry-run", "--tmux", "--sync", "@web") + if err != nil { + t.Fatalf("%s %v", out, err) + } + for _, want := range []string{"# 2 hosts: a b", "tmux new-session -d -x 240 -y 70 -s netssh-web", "synchronize-panes on", "tmux attach-session -t netssh-web"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q:\n%s", want, out) + } + } + if out, err := runNetssh(t, "multi", "--dry-run", "--zellij", "a", "b"); err != nil || !strings.Contains(out, `pane name="b"`) { + t.Errorf("zellij: %s %v", out, err) + } + if out, err := runNetssh(t, "sync"); err == nil || !strings.Contains(out, "inside tmux or zellij") { + t.Errorf("sync outside: %s %v", out, err) + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index fdeda5c..e53581a 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -21,6 +21,7 @@ type Entry struct { IP string `json:"ip"` Kind string `json:"kind"` Tags []string `json:"tags,omitempty"` + Cluster string `json:"cluster,omitempty"` CachedAt time.Time `json:"cached_at"` LastUsed time.Time `json:"last_used,omitempty"` // Static marks a host from the config's hosts: list. It never expires; diff --git a/internal/config/config.go b/internal/config/config.go index 8e6dbeb..e8ee0ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,9 @@ type Config struct { Shortcuts ShortcutsConfig `mapstructure:"shortcuts"` Completion CompletionConfig `mapstructure:"completion"` Hosts []StaticHost `mapstructure:"hosts"` + // Groups name lists of hosts and selectors for netssh multi (@name). + Groups map[string][]string `mapstructure:"groups"` + Multi MultiConfig `mapstructure:"multi"` } type ShellConfig struct { @@ -117,7 +120,7 @@ func Load() (*Config, error) { v.SetEnvPrefix("NETSSH") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() - for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} { + for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results", "multi.multiplexer", "multi.panes_per_window"} { if err := v.BindEnv(key); err != nil { return nil, err } @@ -163,6 +166,9 @@ func Load() (*Config, error) { if err := cfg.ValidateHosts(); err != nil { return nil, err } + if err := cfg.ValidateMulti(); err != nil { + return nil, err + } for i := range cfg.NetBoxes { source := &cfg.NetBoxes[i] source.Name = strings.ToLower(source.Name) diff --git a/internal/config/multi.go b/internal/config/multi.go new file mode 100644 index 0000000..a313ebf --- /dev/null +++ b/internal/config/multi.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "sort" + "strings" +) + +// MultiConfig controls netssh multi. +type MultiConfig struct { + // Multiplexer is auto, tmux, or zellij; empty means auto. + Multiplexer string `mapstructure:"multiplexer"` + // PanesPerWindow splits larger host lists into more windows or tabs; 0 = 9. + PanesPerWindow int `mapstructure:"panes_per_window"` +} + +// DefaultPanesPerWindow keeps panes readable on a normal screen (3×3). +const DefaultPanesPerWindow = 9 + +// ValidateMulti checks groups and the multi settings. +func (c *Config) ValidateMulti() error { + switch c.Multi.Multiplexer { + case "", "auto", "tmux", "zellij": + default: + return fmt.Errorf("multi.multiplexer must be auto, tmux, or zellij") + } + if c.Multi.PanesPerWindow < 0 || c.Multi.PanesPerWindow > 64 { + return fmt.Errorf("multi.panes_per_window must be between 1 and 64") + } + names := make([]string, 0, len(c.Groups)) + for name := range c.Groups { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if !instanceName.MatchString(name) { + return fmt.Errorf("groups: name %q must contain only letters, digits, underscores, or hyphens", name) + } + if len(c.Groups[name]) == 0 { + return fmt.Errorf("groups.%s: needs at least one host or selector", name) + } + for _, selector := range c.Groups[name] { + if strings.TrimSpace(selector) == "" || strings.ContainsAny(selector, " \t\n") || strings.HasPrefix(selector, "-") { + return fmt.Errorf("groups.%s: invalid entry %q", name, selector) + } + } + } + return nil +} + +// PanesPerWindowOrDefault returns the configured value or the default. +func (m MultiConfig) PanesPerWindowOrDefault() int { + if m.PanesPerWindow > 0 { + return m.PanesPerWindow + } + return DefaultPanesPerWindow +} diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index 79743da..87c5ded 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -190,3 +190,15 @@ func TestStaticHostsValidation(t *testing.T) { } } } + +func TestMultiConfig(t *testing.T) { + cfg, err := loadYAML(t, "groups:\n K8s: [master1, \"tag:worker\"]\nmulti:\n multiplexer: zellij\n") + if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 { + t.Fatalf("%+v %v", cfg, err) + } + for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}"} { + if _, err := loadYAML(t, bad+"\n"); err == nil { + t.Errorf("%s accepted", bad) + } + } +} diff --git a/internal/multiplex/multiplex.go b/internal/multiplex/multiplex.go new file mode 100644 index 0000000..82e46be --- /dev/null +++ b/internal/multiplex/multiplex.go @@ -0,0 +1,226 @@ +// Package multiplex opens several commands side by side in tmux or zellij. +package multiplex + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" +) + +// Pane is one command shown in its own pane. +type Pane struct { + Title string + Argv []string +} + +// Options control how panes are laid out. +type Options struct { + Label string // window, session, and tab name + Sync bool // type into all panes of a window at once + PerWindow int // panes per tmux window or zellij tab + LayoutDir string // where zellij layout files are written + // Hint receives notes for the user before the process is replaced. + Hint func(string) +} + +// Runner executes multiplexer commands; tests and --dry-run replace it. +type Runner interface { + // Output runs argv and returns its trimmed standard output. + Output(argv ...string) (string, error) + // Exec replaces the current process with argv. + Exec(argv ...string) error + LookPath(name string) (string, error) + Getenv(key string) string + WriteFile(path string, data []byte) error +} + +// Supported multiplexers. +const ( + Tmux = "tmux" + Zellij = "zellij" +) + +// ErrNone means neither tmux nor zellij is available. +var ErrNone = errors.New("netssh multi needs tmux or zellij; install one of them (e.g. apt install tmux)") + +// Detect picks the multiplexer: the requested one, else the one netssh runs +// in, else tmux, else zellij. +func Detect(requested string, r Runner) (string, error) { + switch requested { + case Tmux, Zellij: + if _, err := r.LookPath(requested); err != nil { + return "", fmt.Errorf("%s is not installed", requested) + } + return requested, nil + case "", "auto": + default: + return "", fmt.Errorf("unknown multiplexer %q", requested) + } + if r.Getenv("TMUX") != "" { + return Tmux, nil + } + if r.Getenv("ZELLIJ") != "" { + return Zellij, nil + } + for _, name := range []string{Tmux, Zellij} { + if _, err := r.LookPath(name); err == nil { + return name, nil + } + } + return "", ErrNone +} + +// Open shows panes in the multiplexer. Outside of it, Open replaces the +// process with the multiplexer client and only returns on error. +func Open(kind string, panes []Pane, opts Options, r Runner) error { + if len(panes) == 0 { + return errors.New("no hosts to open") + } + if opts.Hint == nil { + opts.Hint = func(string) {} + } + if opts.PerWindow <= 0 { + opts.PerWindow = 9 + } + if opts.Label == "" { + opts.Label = "netssh" + } + switch kind { + case Tmux: + return openTmux(panes, opts, r) + case Zellij: + return openZellij(panes, opts, r) + } + return fmt.Errorf("unknown multiplexer %q", kind) +} + +// ToggleSync switches broadcast input in the current tmux window or zellij tab. +func ToggleSync(r Runner) (string, error) { + switch { + case r.Getenv("TMUX") != "": + current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes") + if err != nil { + return "", err + } + next := "on" + if current == "on" { + next = "off" + } + if _, err := r.Output("tmux", "set-window-option", "synchronize-panes", next); err != nil { + return "", err + } + return "broadcast " + next, nil + case r.Getenv("ZELLIJ") != "": + if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil { + return "", err + } + return "broadcast toggled", nil + } + return "", errors.New("netssh sync works inside tmux or zellij") +} + +// chunks splits panes into groups of at most n. +func chunks(panes []Pane, n int) [][]Pane { + var out [][]Pane + for len(panes) > n { + out = append(out, panes[:n]) + panes = panes[n:] + } + return append(out, panes) +} + +// windowLabel numbers windows when the panes need more than one. +func windowLabel(label string, i, total int) string { + if total == 1 { + return label + } + return fmt.Sprintf("%s-%d", label, i+1) +} + +// System runs real commands. +type System struct{} + +func (System) Output(argv ...string) (string, error) { + cmd := exec.Command(argv[0], argv[1:]...) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return "", fmt.Errorf("%s: %s", argv[0], msg) + } + return "", fmt.Errorf("%s: %w", argv[0], err) + } + return strings.TrimSpace(string(out)), nil +} + +func (System) Exec(argv ...string) error { + path, err := exec.LookPath(argv[0]) + if err != nil { + return err + } + return syscall.Exec(path, argv, os.Environ()) +} + +func (System) LookPath(name string) (string, error) { return exec.LookPath(name) } +func (System) Getenv(key string) string { return os.Getenv(key) } +func (System) WriteFile(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} + +// DryRun prints commands instead of running them; Output returns fake IDs. +type DryRun struct { + W io.Writer + Env map[string]string // values for Getenv, e.g. TMUX + n int +} + +func (d *DryRun) Output(argv ...string) (string, error) { + fmt.Fprintln(d.W, shellJoin(argv)) + d.n++ + switch { + case len(argv) > 1 && (argv[1] == "new-window" || argv[1] == "new-session"): + return fmt.Sprintf("@%d %%%d", d.n, d.n), nil + case len(argv) > 1 && argv[1] == "split-window": + return fmt.Sprintf("%%%d", d.n), nil + case len(argv) > 1 && argv[1] == "has-session": + return "", errors.New("no session") + } + return "", nil +} + +func (d *DryRun) Exec(argv ...string) error { + fmt.Fprintln(d.W, shellJoin(argv)) + return nil +} + +// LookPath pretends every multiplexer is installed, so --dry-run shows the +// commands on machines without tmux or zellij. +func (d *DryRun) LookPath(name string) (string, error) { return name, nil } +func (d *DryRun) Getenv(key string) string { + if v, ok := d.Env[key]; ok { + return v + } + return os.Getenv(key) +} +func (d *DryRun) WriteFile(path string, data []byte) error { + fmt.Fprintf(d.W, "# %s\n%s", path, data) + return nil +} + +// shellJoin quotes argv for display. +func shellJoin(argv []string) string { + quoted := make([]string, len(argv)) + for i, arg := range argv { + if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" { + quoted[i] = arg + continue + } + quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" + } + return strings.Join(quoted, " ") +} diff --git a/internal/multiplex/multiplex_test.go b/internal/multiplex/multiplex_test.go new file mode 100644 index 0000000..ab24f2f --- /dev/null +++ b/internal/multiplex/multiplex_test.go @@ -0,0 +1,211 @@ +package multiplex + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// fake records commands and answers like tmux and zellij would. +type fake struct { + env map[string]string + installed map[string]bool + sessions map[string]bool + calls []string + exec []string + files map[string]string + syncState string + n int +} + +func newFake(env map[string]string, installed ...string) *fake { + f := &fake{env: env, installed: map[string]bool{}, sessions: map[string]bool{}, files: map[string]string{}, syncState: "off"} + for _, name := range installed { + f.installed[name] = true + } + return f +} + +func (f *fake) Output(argv ...string) (string, error) { + f.calls = append(f.calls, strings.Join(argv, " ")) + f.n++ + switch argv[1] { + case "new-window", "new-session": + return fmt.Sprintf("@%d %%%d", f.n, f.n), nil + case "split-window": + return fmt.Sprintf("%%%d", f.n), nil + case "has-session": + if f.sessions[strings.TrimPrefix(argv[3], "=")] { + return "", nil + } + return "", errors.New("can't find session") + case "show-window-options": + return f.syncState, nil + } + return "", nil +} +func (f *fake) Exec(argv ...string) error { f.exec = argv; return nil } +func (f *fake) LookPath(name string) (string, error) { + if f.installed[name] { + return "/usr/bin/" + name, nil + } + return "", errors.New("not found") +} +func (f *fake) Getenv(key string) string { return f.env[key] } +func (f *fake) WriteFile(path string, data []byte) error { + f.files[path] = string(data) + return nil +} + +func panes(names ...string) []Pane { + out := make([]Pane, 0, len(names)) + for _, name := range names { + out = append(out, Pane{Title: name, Argv: []string{"/usr/bin/netssh", name}}) + } + return out +} + +func TestDetect(t *testing.T) { + for _, tc := range []struct { + requested string + env map[string]string + installed []string + want string + }{ + {"", map[string]string{"TMUX": "/tmp/tmux"}, []string{"tmux", "zellij"}, Tmux}, + {"auto", map[string]string{"ZELLIJ": "0"}, []string{"tmux", "zellij"}, Zellij}, + {"", nil, []string{"tmux", "zellij"}, Tmux}, + {"", nil, []string{"zellij"}, Zellij}, + {"zellij", map[string]string{"TMUX": "x"}, []string{"tmux", "zellij"}, Zellij}, + } { + got, err := Detect(tc.requested, newFake(tc.env, tc.installed...)) + if err != nil || got != tc.want { + t.Errorf("%+v: got %q %v", tc, got, err) + } + } + if _, err := Detect("", newFake(nil)); !errors.Is(err, ErrNone) { + t.Errorf("nothing installed: %v", err) + } + if _, err := Detect("tmux", newFake(nil, "zellij")); err == nil { + t.Error("missing requested multiplexer accepted") + } +} + +func TestTmuxInside(t *testing.T) { + f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux") + if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true}, f); err != nil { + t.Fatal(err) + } + script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh " + want := []string{ + "tmux new-window -P -F #{window_id} #{pane_id} -n pve " + script + "a", + "tmux split-window -t @1 -P -F #{pane_id} " + script + "b", + "tmux select-layout -t @1 tiled", + "tmux split-window -t @1 -P -F #{pane_id} " + script + "c", + "tmux select-layout -t @1 tiled", + "tmux set-option -p -t %1 @netssh_host a", + "tmux set-option -p -t %2 @netssh_host b", + "tmux set-option -p -t %4 @netssh_host c", + "tmux set-window-option -t @1 pane-border-status top", + "tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],} ", + "tmux set-window-option -t @1 synchronize-panes on", + "tmux select-window -t @1", + } + if strings.Join(f.calls, "\n") != strings.Join(want, "\n") { + t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n")) + } + if f.exec != nil { + t.Fatal("inside tmux must not attach") + } +} + +func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) { + f := newFake(nil, "tmux") + f.sessions["netssh-pve"] = true + var hints []string + err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Hint: func(s string) { hints = append(hints, s) }}, f) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(f.calls, "\n") + for _, want := range []string{ + "tmux has-session -t =netssh-pve-2", + "tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ", + "tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ", + } { + if !strings.Contains(joined, want) { + t.Errorf("missing %q in\n%s", want, joined) + } + } + if strings.Contains(joined, "synchronize-panes") { + t.Error("sync set without --sync") + } + if strings.Join(f.exec, " ") != "tmux attach-session -t netssh-pve-2" || len(hints) != 1 { + t.Fatalf("exec %v hints %v", f.exec, hints) + } +} + +func TestZellijOutsideWritesGridLayout(t *testing.T) { + f := newFake(nil, "zellij") + if err := Open(Zellij, panes("a", "b", "c", `q"x`), Options{Label: "pve", LayoutDir: "/tmp/l"}, f); err != nil { + t.Fatal(err) + } + layout := f.files["/tmp/l/netssh-multi.kdl"] + for _, want := range []string{ + `plugin location="zellij:tab-bar"`, + `tab name="pve" focus=true {`, + `pane split_direction="vertical" {`, + `pane name="a" command="/usr/bin/netssh" {`, + `args "a"`, + `pane name="q\"x" command="/usr/bin/netssh" {`, + } { + if !strings.Contains(layout, want) { + t.Errorf("layout lacks %q:\n%s", want, layout) + } + } + // 4 panes → 2 columns × 2 rows. + if n := strings.Count(layout, `split_direction="vertical"`); n != 2 { + t.Errorf("%d rows, want 2:\n%s", n, layout) + } + if strings.Join(f.exec, " ") != "zellij --layout /tmp/l/netssh-multi.kdl" { + t.Fatalf("exec %v", f.exec) + } +} + +func TestZellijInsideAddsTabs(t *testing.T) { + f := newFake(map[string]string{"ZELLIJ": "0"}, "zellij") + if err := Open(Zellij, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Sync: true, LayoutDir: "/tmp/l"}, f); err != nil { + t.Fatal(err) + } + want := []string{ + "zellij action new-tab --layout /tmp/l/netssh-multi-1.kdl", + "zellij action toggle-active-sync-tab", + "zellij action new-tab --layout /tmp/l/netssh-multi-2.kdl", + "zellij action toggle-active-sync-tab", + } + if strings.Join(f.calls, "\n") != strings.Join(want, "\n") { + t.Fatalf("calls:\n%s", strings.Join(f.calls, "\n")) + } + if !strings.Contains(f.files["/tmp/l/netssh-multi-2.kdl"], `tab name="pve-2"`) { + t.Fatalf("second tab: %s", f.files["/tmp/l/netssh-multi-2.kdl"]) + } +} + +func TestToggleSync(t *testing.T) { + f := newFake(map[string]string{"TMUX": "x"}) + if state, err := ToggleSync(f); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" { + t.Fatalf("%q %v %v", state, err, f.calls) + } + f.syncState = "on" + if state, _ := ToggleSync(f); state != "broadcast off" { + t.Fatalf("%q", state) + } + z := newFake(map[string]string{"ZELLIJ": "0"}) + if _, err := ToggleSync(z); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" { + t.Fatalf("%v %v", err, z.calls) + } + if _, err := ToggleSync(newFake(nil)); err == nil { + t.Fatal("outside a multiplexer accepted") + } +} diff --git a/internal/multiplex/tmux.go b/internal/multiplex/tmux.go new file mode 100644 index 0000000..bf60987 --- /dev/null +++ b/internal/multiplex/tmux.go @@ -0,0 +1,95 @@ +package multiplex + +import ( + "strconv" + "strings" +) + +// paneScript runs the pane's command and keeps a failed one on screen, so an +// unreachable host shows its error instead of the pane vanishing. +const paneScript = `"$@"; status=$?; if [ "$status" -ne 0 ]; then echo; echo "netssh exited with status $status, press Enter to close"; read -r _; fi` + +// tmuxArgv runs the pane through sh with argv as arguments: no quoting, and +// independent of the user's default-shell (which may be fish). +func tmuxArgv(p Pane) []string { + return append([]string{"sh", "-c", paneScript, "netssh-pane"}, p.Argv...) +} + +// openTmux adds a window per chunk of panes: to the current session inside +// tmux, else to a new detached session that is attached at the end. +func openTmux(panes []Pane, opts Options, r Runner) error { + inside := r.Getenv("TMUX") != "" + session := "" + if !inside { + session = freeSession(r, "netssh-"+opts.Label) + } + groups := chunks(panes, opts.PerWindow) + first := "" + for i, group := range groups { + name := windowLabel(opts.Label, i, len(groups)) + format := []string{"-P", "-F", "#{window_id} #{pane_id}", "-n", name} + var create []string + switch { + case inside: + create = append([]string{"tmux", "new-window"}, format...) + case i == 0: + // A large virtual size leaves room for the splits; attaching resizes it. + create = append([]string{"tmux", "new-session", "-d", "-x", "240", "-y", "70", "-s", session}, format...) + default: + create = append([]string{"tmux", "new-window", "-t", session + ":"}, format...) + } + out, err := r.Output(append(create, tmuxArgv(group[0])...)...) + if err != nil { + return err + } + window, pane, _ := strings.Cut(out, " ") + if first == "" { + first = window + } + paneIDs := []string{pane} + for _, p := range group[1:] { + id, err := r.Output(append([]string{"tmux", "split-window", "-t", window, "-P", "-F", "#{pane_id}"}, tmuxArgv(p)...)...) + if err != nil { + return err + } + paneIDs = append(paneIDs, id) + // Re-tile after every split so the next one still has room. + if _, err := r.Output("tmux", "select-layout", "-t", window, "tiled"); err != nil { + return err + } + } + for j, id := range paneIDs { + // A pane may already be gone (host unreachable and closed); not fatal. + _, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title) + } + windowOptions := [][]string{ + {"pane-border-status", "top"}, + {"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],} "}, + } + if opts.Sync { + windowOptions = append(windowOptions, []string{"synchronize-panes", "on"}) + } + for _, option := range windowOptions { + if _, err := r.Output("tmux", "set-window-option", "-t", window, option[0], option[1]); err != nil { + return err + } + } + } + if inside { + _, err := r.Output("tmux", "select-window", "-t", first) + return err + } + opts.Hint("toggle broadcast with: netssh sync (or Ctrl+b :setw synchronize-panes)") + return r.Exec("tmux", "attach-session", "-t", session) +} + +// freeSession returns name, or name-2, name-3, … if a session already uses it. +func freeSession(r Runner, name string) string { + candidate := name + for n := 2; ; n++ { + if _, err := r.Output("tmux", "has-session", "-t", "="+candidate); err != nil { + return candidate + } + candidate = name + "-" + strconv.Itoa(n) + } +} diff --git a/internal/multiplex/zellij.go b/internal/multiplex/zellij.go new file mode 100644 index 0000000..8b52398 --- /dev/null +++ b/internal/multiplex/zellij.go @@ -0,0 +1,101 @@ +package multiplex + +import ( + "fmt" + "math" + "path/filepath" + "strings" +) + +// openZellij writes a layout with one tab per chunk of panes. Inside zellij +// each tab is added to the current session; outside, a new session starts. +func openZellij(panes []Pane, opts Options, r Runner) error { + groups := chunks(panes, opts.PerWindow) + if r.Getenv("ZELLIJ") != "" { + for i, group := range groups { + path := filepath.Join(opts.LayoutDir, fmt.Sprintf("netssh-multi-%d.kdl", i+1)) + tab := zellijTab(windowLabel(opts.Label, i, len(groups)), group, true) + if err := r.WriteFile(path, []byte(zellijLayout(tab))); err != nil { + return err + } + if _, err := r.Output("zellij", "action", "new-tab", "--layout", path); err != nil { + return err + } + if opts.Sync { + if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil { + return err + } + } + } + return nil + } + var tabs strings.Builder + for i, group := range groups { + tabs.WriteString(zellijTab(windowLabel(opts.Label, i, len(groups)), group, i == 0)) + } + path := filepath.Join(opts.LayoutDir, "netssh-multi.kdl") + if err := r.WriteFile(path, []byte(zellijLayout(tabs.String()))); err != nil { + return err + } + if opts.Sync { + opts.Hint("zellij starts without broadcast: press Ctrl+t, then s, to type into all panes") + } else { + opts.Hint("toggle broadcast with Ctrl+t, then s (or netssh sync)") + } + return r.Exec("zellij", "--layout", path) +} + +// zellijLayout wraps tabs with zellij's tab and status bars. +func zellijLayout(tabs string) string { + return `layout { + default_tab_template { + pane size=1 borderless=true { + plugin location="zellij:tab-bar" + } + children + pane size=2 borderless=true { + plugin location="zellij:status-bar" + } + } +` + tabs + "}\n" +} + +// zellijTab arranges panes in a grid: rows of ceil(sqrt(n)) columns. +func zellijTab(name string, panes []Pane, focus bool) string { + cols := int(math.Ceil(math.Sqrt(float64(len(panes))))) + var b strings.Builder + fmt.Fprintf(&b, " tab name=%s", kdlString(name)) + if focus { + b.WriteString(" focus=true") + } + b.WriteString(" {\n") + for start := 0; start < len(panes); start += cols { + row := panes[start:min(start+cols, len(panes))] + indent := " " + if len(row) > 1 { + b.WriteString(indent + "pane split_direction=\"vertical\" {\n") + indent += " " + } + for _, p := range row { + fmt.Fprintf(&b, "%spane name=%s command=%s {\n", indent, kdlString(p.Title), kdlString(p.Argv[0])) + if len(p.Argv) > 1 { + args := make([]string, 0, len(p.Argv)-1) + for _, arg := range p.Argv[1:] { + args = append(args, kdlString(arg)) + } + fmt.Fprintf(&b, "%s args %s\n", indent, strings.Join(args, " ")) + } + b.WriteString(indent + "}\n") + } + if len(row) > 1 { + b.WriteString(" }\n") + } + } + b.WriteString(" }\n") + return b.String() +} + +// kdlString quotes s as a KDL string. +func kdlString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + `"` +} diff --git a/internal/netbox/client.go b/internal/netbox/client.go index dd868ed..c1653f8 100644 --- a/internal/netbox/client.go +++ b/internal/netbox/client.go @@ -105,6 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { return nil, fmt.Errorf("invalid host kind %q", opts.Kind) } + filters := "" + if opts.Tag != "" { + filters += "&tag=" + url.QueryEscape(opts.Tag) + } + if opts.Cluster != "" { + ids, err := c.clusterIDs(ctx, opts.Cluster) + if err != nil { + return nil, fmt.Errorf("clusters: %w", err) + } + if len(ids) == 0 { + return nil, nil // no such cluster in this NetBox + } + for _, id := range ids { + filters += fmt.Sprintf("&cluster_id=%d", id) + } + } var ( mu sync.Mutex results []HostEntry @@ -116,7 +132,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions wg.Add(1) go func() { defer wg.Done() - devices, err := c.fetchAllDevices(ctx, query, opts.Tag) + devices, err := c.fetchAllDevices(ctx, query, filters) mu.Lock() defer mu.Unlock() if err != nil { @@ -131,7 +147,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions wg.Add(1) go func() { defer wg.Done() - vms, err := c.fetchAllVMs(ctx, query, opts.Tag) + vms, err := c.fetchAllVMs(ctx, query, filters) mu.Lock() defer mu.Unlock() if err != nil { @@ -231,15 +247,27 @@ func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry, return entries, nil } -func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) { +// clusterIDs returns the IDs of the clusters named name (case-insensitive). +func (c *Client) clusterIDs(ctx context.Context, name string) ([]int, error) { + var resp netboxListResponse[netboxCluster] + apiURL := fmt.Sprintf("%s/api/virtualization/clusters/?name__ie=%s&limit=50", c.baseURL, url.QueryEscape(name)) + if err := c.get(ctx, apiURL, &resp); err != nil { + return nil, err + } + ids := make([]int, 0, len(resp.Results)) + for _, cluster := range resp.Results { + ids = append(ids, cluster.ID) + } + return ids, nil +} + +// fetchAllDevices pages through devices; filters are extra "&key=value" parameters. +func (c *Client) fetchAllDevices(ctx context.Context, query, filters string) ([]HostEntry, error) { const pageSize = 50 var all []HostEntry for offset := 0; ; offset += pageSize { apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d", - c.baseURL, url.QueryEscape(query), pageSize, offset) - if tag != "" { - apiURL += "&tag=" + url.QueryEscape(tag) - } + c.baseURL, url.QueryEscape(query), pageSize, offset) + filters var resp netboxListResponse[netboxDevice] if err := c.get(ctx, apiURL, &resp); err != nil { return nil, err @@ -254,15 +282,12 @@ func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]Host return all, nil } -func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) { +func (c *Client) fetchAllVMs(ctx context.Context, query, filters string) ([]HostEntry, error) { const pageSize = 50 var all []HostEntry for offset := 0; ; offset += pageSize { apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d", - c.baseURL, url.QueryEscape(query), pageSize, offset) - if tag != "" { - apiURL += "&tag=" + url.QueryEscape(tag) - } + c.baseURL, url.QueryEscape(query), pageSize, offset) + filters var resp netboxListResponse[netboxVM] if err := c.get(ctx, apiURL, &resp); err != nil { return nil, err @@ -343,6 +368,9 @@ func deviceToEntry(d netboxDevice) HostEntry { for _, t := range d.Tags { e.Tags = append(e.Tags, t.Name) } + if d.Cluster != nil { + e.Cluster = d.Cluster.Name + } return e } @@ -357,6 +385,9 @@ func vmToEntry(v netboxVM) HostEntry { for _, t := range v.Tags { e.Tags = append(e.Tags, t.Name) } + if v.Cluster != nil { + e.Cluster = v.Cluster.Name + } return e } diff --git a/internal/netbox/cluster_test.go b/internal/netbox/cluster_test.go new file mode 100644 index 0000000..b745f5a --- /dev/null +++ b/internal/netbox/cluster_test.go @@ -0,0 +1,51 @@ +package netbox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSearchAllByCluster(t *testing.T) { + var deviceQuery, vmQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/virtualization/clusters/": + if r.URL.Query().Get("name__ie") == "PVE" { + fmt.Fprint(w, `{"count":2,"results":[{"id":3,"name":"pve"},{"id":4,"name":"pve"}]}`) + return + } + fmt.Fprint(w, `{"count":0,"results":[]}`) + case "/api/dcim/devices/": + deviceQuery = r.URL.RawQuery + fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"node1","cluster":{"id":3,"name":"pve"}}]}`) + case "/api/virtualization/virtual-machines/": + vmQuery = r.URL.RawQuery + fmt.Fprint(w, `{"count":1,"results":[{"id":2,"name":"vm1","cluster":{"id":4,"name":"pve"}}]}`) + } + })) + defer srv.Close() + c := NewClient(srv.URL, "t", 1) + + hosts, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE", Kind: "device"}) + if err != nil || len(hosts) != 1 || hosts[0].Name != "node1" || hosts[0].Cluster != "pve" { + t.Fatalf("got %+v %v", hosts, err) + } + if want := "name__ic=&limit=50&offset=0&cluster_id=3&cluster_id=4"; deviceQuery != want { + t.Errorf("device query %q, want %q", deviceQuery, want) + } + if vmQuery != "" { + t.Error("VMs queried for kind device") + } + all, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE"}) + if err != nil || len(all) != 2 { + t.Fatalf("all kinds: %+v %v", all, err) + } + none, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "missing"}) + if err != nil || len(none) != 0 { + t.Fatalf("unknown cluster: %+v %v", none, err) + } +} diff --git a/internal/netbox/models.go b/internal/netbox/models.go index e2341fd..7f958e3 100644 --- a/internal/netbox/models.go +++ b/internal/netbox/models.go @@ -7,6 +7,8 @@ import "strings" type SearchOptions struct { Tag string // filter by tag slug; empty = no filter Kind string // "device" | "vm" | "" (both) + // Cluster filters SearchAll by virtualization cluster name (case-insensitive). + Cluster string } // HostEntry is a unified model for both devices and virtual machines from NetBox. @@ -18,6 +20,13 @@ type HostEntry struct { PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped) PrimaryIP6 string Tags []string + Cluster string // virtualization cluster name, if assigned +} + +// netboxCluster is the nested cluster of a device or virtual machine. +type netboxCluster struct { + ID int `json:"id"` + Name string `json:"name"` } // netboxIP represents an IP address as returned by the NetBox API. @@ -32,8 +41,9 @@ type netboxDevice struct { Tags []struct { Name string `json:"name"` } `json:"tags"` - PrimaryIP4 *netboxIP `json:"primary_ip4"` - PrimaryIP6 *netboxIP `json:"primary_ip6"` + PrimaryIP4 *netboxIP `json:"primary_ip4"` + PrimaryIP6 *netboxIP `json:"primary_ip6"` + Cluster *netboxCluster `json:"cluster"` } // netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response. @@ -43,8 +53,9 @@ type netboxVM struct { Tags []struct { Name string `json:"name"` } `json:"tags"` - PrimaryIP4 *netboxIP `json:"primary_ip4"` - PrimaryIP6 *netboxIP `json:"primary_ip6"` + PrimaryIP4 *netboxIP `json:"primary_ip4"` + PrimaryIP6 *netboxIP `json:"primary_ip6"` + Cluster *netboxCluster `json:"cluster"` } type netboxListResponse[T any] struct { diff --git a/internal/setup/multi.go b/internal/setup/multi.go new file mode 100644 index 0000000..9c804eb --- /dev/null +++ b/internal/setup/multi.go @@ -0,0 +1,102 @@ +package setup + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "charm.land/huh/v2" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func editMulti(cfg *config.Config) error { + multiplexer := cfg.Multi.Multiplexer + if multiplexer == "" { + multiplexer = "auto" + } + perWindow := "" + if cfg.Multi.PanesPerWindow > 0 { + perWindow = strconv.Itoa(cfg.Multi.PanesPerWindow) + } + groups := formatGroups(cfg.Groups) + err := huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Multiplexer"). + Description("netssh multi opens hosts side by side. auto uses the one you are in, else tmux, else zellij."). + Options(huh.NewOption("auto", "auto"), huh.NewOption("tmux", "tmux"), huh.NewOption("zellij", "zellij")). + Value(&multiplexer), + huh.NewInput(). + Title("Panes per window"). + Description(fmt.Sprintf("More hosts open in further windows or tabs. Leave empty for %d.", config.DefaultPanesPerWindow)). + Value(&perWindow). + Validate(func(s string) error { + if s = strings.TrimSpace(s); s == "" { + return nil + } + if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 64 { + return errors.New("must be a number between 1 and 64") + } + return nil + }), + huh.NewText(). + Title("Groups"). + Description("One per line: NAME: host, tag:TAG, cluster:NAME, @other — open with netssh multi @NAME."). + Lines(4). + Value(&groups). + Validate(func(s string) error { + _, err := parseGroups(s) + return err + }), + ).Title("Multi-session")).Run() + if err != nil { + return err + } + cfg.Multi.Multiplexer = multiplexer + if multiplexer == "auto" { + cfg.Multi.Multiplexer = "" + } + cfg.Multi.PanesPerWindow, _ = strconv.Atoi(strings.TrimSpace(perWindow)) + cfg.Groups, _ = parseGroups(groups) + return nil +} + +// parseGroups reads "NAME: sel1, sel2" lines into groups:. +func parseGroups(s string) (map[string][]string, error) { + var out map[string][]string + for _, line := range parseLines(s) { + name, members, ok := strings.Cut(line, ":") + name = strings.ToLower(strings.TrimSpace(name)) + // "tag:x" alone has no group name before the first colon worth keeping. + if !ok || name == "" || name == "tag" || name == "cluster" { + return nil, fmt.Errorf("%q: expected NAME: host, tag:TAG, …", line) + } + var selectors []string + for _, field := range strings.FieldsFunc(members, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) { + selectors = append(selectors, field) + } + if out == nil { + out = make(map[string][]string) + } + out[name] = append(out[name], selectors...) + } + if err := (&config.Config{Groups: out}).ValidateMulti(); err != nil { + return nil, err + } + return out, nil +} + +func formatGroups(groups map[string][]string) string { + names := make([]string, 0, len(groups)) + for name := range groups { + names = append(names, name) + } + sort.Strings(names) + lines := make([]string, 0, len(names)) + for _, name := range names { + lines = append(lines, name+": "+strings.Join(groups[name], ", ")) + } + return strings.Join(lines, "\n") +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 7a8f254..5ac5af6 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -91,6 +91,7 @@ func runMenu(cfg *config.Config) error { huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"), huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"), huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"), + huh.NewOption(fmt.Sprintf("Multi-session — groups, tmux/zellij (%d groups)", len(cfg.Groups)), "multi"), huh.NewOption("Resolver & cache", "resolver"), huh.NewOption("Shortcuts & command alias", "shortcuts"), huh.NewOption("Completion", "completion"), @@ -115,6 +116,8 @@ func runMenu(cfg *config.Config) error { if hosts, err = configureHosts(cfg.Hosts); err == nil { cfg.Hosts = hosts } + case "multi": + err = editMulti(cfg) case "resolver": err = editResolverCache(cfg) case "shortcuts": @@ -369,6 +372,9 @@ func save(cfg config.Config) error { if err := cfg.ValidateHosts(); err != nil { return err } + if err := cfg.ValidateMulti(); err != nil { + return err + } path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config dir: %w", err) @@ -461,6 +467,31 @@ func save(cfg config.Config) error { } } + if len(cfg.Groups) > 0 { + b.WriteString("\ngroups:\n") + names := make([]string, 0, len(cfg.Groups)) + for name := range cfg.Groups { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + quoted := make([]string, 0, len(cfg.Groups[name])) + for _, selector := range cfg.Groups[name] { + quoted = append(quoted, strconv.Quote(selector)) + } + fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", ")) + } + } + if cfg.Multi.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 { + b.WriteString("\nmulti:\n") + if cfg.Multi.Multiplexer != "" { + fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer) + } + if cfg.Multi.PanesPerWindow > 0 { + fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow) + } + } + if cfg.Completion.MaxResults > 0 { fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) } diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index da1ecde..ae17f9c 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -617,3 +617,37 @@ func TestValidateHostIn(t *testing.T) { t.Fatal("invalid address accepted") } } + +func TestParseGroupsAndSaveMulti(t *testing.T) { + groups, err := parseGroups("K8s: master1, tag:k8s-worker\npve: cluster:pve-fsn1 @k8s\n") + want := map[string][]string{"k8s": {"master1", "tag:k8s-worker"}, "pve": {"cluster:pve-fsn1", "@k8s"}} + if err != nil || !reflect.DeepEqual(groups, want) { + t.Fatalf("got %v %v", groups, err) + } + if again, _ := parseGroups(formatGroups(groups)); !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: %v", again) + } + for _, bad := range []string{"no colon", "tag:x", "bad name: a", "empty:"} { + if _, err := parseGroups(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + cfg := config.Config{ + Hosts: []config.StaticHost{{Name: "a", Address: "10.0.0.1"}}, + Groups: groups, + Multi: config.MultiConfig{Multiplexer: "zellij", PanesPerWindow: 4}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.Groups, want) || loaded.Multi != cfg.Multi { + t.Fatalf("got %v %+v", loaded.Groups, loaded.Multi) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 4b369b4..0220bd2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -27,8 +27,9 @@ type SelectedHost struct { IP string User string // empty = use config default Port string // empty = use default port - Action string // ActionSSH, ActionSFTP, or ActionTunnel + Action string // ActionSSH, ActionSFTP, ActionTunnel, or ActionMulti Forwards []string // forward names or specs for ActionTunnel + Targets []string // marked hosts for ActionMulti } // Actions the user can start on a host. @@ -36,6 +37,7 @@ const ( ActionSSH = "" ActionSFTP = "sftp" ActionTunnel = "tunnel" + ActionMulti = "multi" ) // --- bubbletea messages --- @@ -74,7 +76,8 @@ func (h hostItem) FilterValue() string { return h.name } // --- compact list delegate --- -type compactDelegate struct{} +// compactDelegate shares the model's marks so marked hosts show a dot. +type compactDelegate struct{ marked map[string]bool } func (d compactDelegate) Height() int { return 1 } func (d compactDelegate) Spacing() int { return 0 } @@ -84,7 +87,11 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list. if !ok { return } - line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip)) + mark := " " + if d.marked[h.key()] { + mark = "● " + } + line := fmt.Sprintf(" %s%s %s", mark, h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip)) if index == m.Index() { line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " ")) } @@ -169,10 +176,12 @@ type Model struct { selected *SelectedHost width int height int - recentMode bool // true when showing recent hosts (empty search, initial state) - filterOpen bool // Ctrl+F toggles - editMode bool // Ctrl+E on the selected item - forwardMode bool // Ctrl+T on the selected item + recentMode bool // true when showing recent hosts (empty search, initial state) + filterOpen bool // Ctrl+F toggles + editMode bool // Ctrl+E on the selected item + forwardMode bool // Ctrl+T on the selected item + marked map[string]bool // hostItem.key() of hosts marked with space + markOrder []string // targets in the order they were marked } func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model { @@ -191,7 +200,8 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts fwd.Prompt = "" fwd.Placeholder = "web, 8080:localhost:80" - l := list.New(nil, compactDelegate{}, 0, 0) + marked := make(map[string]bool) + l := list.New(nil, compactDelegate{marked: marked}, 0, 0) l.SetShowHelp(false) l.SetShowTitle(false) l.SetShowStatusBar(false) @@ -212,6 +222,7 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts filterInput: fi, editInput: ei, forwardInput: fwd, + marked: marked, list: l, client: client, cache: c, @@ -386,11 +397,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "enter": + if len(m.markOrder) > 0 { + m.selected = &SelectedHost{Action: ActionMulti, Targets: append([]string(nil), m.markOrder...)} + return m, tea.Quit + } if item, ok := m.list.SelectedItem().(hostItem); ok { m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind} return m, tea.Quit } + case "space": + if item, ok := m.list.SelectedItem().(hostItem); ok { + m.toggleMark(item) + m.list.CursorDown() + return m, nil + } + + case "ctrl+a": + m.toggleAllVisible() + return m, nil + case "ctrl+e": if item, ok := m.list.SelectedItem().(hostItem); ok { m.openEdit(item) @@ -584,7 +610,10 @@ func (m *Model) viewContent() string { sb.WriteString(m.editInput.View() + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n") } else { - hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox" + hint := "ctrl+c quit enter connect space mark ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox" + if n := len(m.markOrder); n > 0 { + hint = fmt.Sprintf("%d marked — enter open side by side space unmark ctrl+a all esc quit", n) + } sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n") } @@ -724,6 +753,42 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost { func (m *Model) SetNotes(service *notes.Service) { m.noteService = service } +func (h hostItem) key() string { return h.name + "\x00" + h.kind } + +// toggleMark marks or unmarks a host for opening several side by side. +func (m *Model) toggleMark(item hostItem) { + key := item.key() + if m.marked[key] { + delete(m.marked, key) + for i, target := range m.markOrder { + if target == item.name { + m.markOrder = append(m.markOrder[:i], m.markOrder[i+1:]...) + break + } + } + return + } + m.marked[key] = true + m.markOrder = append(m.markOrder, item.name) +} + +// toggleAllVisible marks every listed host, or unmarks them if all are marked. +func (m *Model) toggleAllVisible() { + var items []hostItem + allMarked := true + for _, it := range m.list.Items() { + if item, ok := it.(hostItem); ok { + items = append(items, item) + allMarked = allMarked && m.marked[item.key()] + } + } + for _, item := range items { + if m.marked[item.key()] == allMarked { + m.toggleMark(item) + } + } +} + // SetForwards provides the forward names configured for a host (ctrl+t). func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) { m.forwardsFor = forwardsFor diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 21127b2..ae27176 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -122,3 +122,38 @@ func TestForwardInputEscCancels(t *testing.T) { t.Fatal("esc did not cancel") } } + +func TestSpaceMarksHostsAndEnterOpensThem(t *testing.T) { + m := New(nil, nil, "") + m.updateListItems([]netbox.HostEntry{{Name: "a", Kind: "device"}, {Name: "b", Kind: "device"}, {Name: "c", Kind: "vm"}}) + m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if len(m.markOrder) != 1 || m.markOrder[0] != "a" || m.list.Index() != 1 { + t.Fatalf("space: marks %v, cursor %d", m.markOrder, m.list.Index()) + } + if m.input.Value() != "" { + t.Fatal("space went into the search field") + } + m.list.Select(2) + m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if !strings.Contains(m.viewContent(), "2 marked") { + t.Fatalf("no marked count:\n%s", m.viewContent()) + } + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.selected == nil || m.selected.Action != ActionMulti || !reflect.DeepEqual(m.selected.Targets, []string{"a", "c"}) { + t.Fatalf("got %+v", m.selected) + } +} + +func TestCtrlATogglesAllVisible(t *testing.T) { + m := New(nil, nil, "") + m.updateListItems([]netbox.HostEntry{{Name: "a"}, {Name: "b"}}) + m.toggleMark(hostItem{name: "a"}) + m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}) + if !reflect.DeepEqual(m.markOrder, []string{"a", "b"}) { + t.Fatalf("mark all: %v", m.markOrder) + } + m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}) + if len(m.markOrder) != 0 { + t.Fatalf("unmark all: %v", m.markOrder) + } +} diff --git a/testenv/README.md b/testenv/README.md index a0959f7..f144959 100644 --- a/testenv/README.md +++ b/testenv/README.md @@ -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. diff --git a/testenv/tests/13-configure.sh b/testenv/tests/13-configure.sh index 62f526b..7a2ab02 100644 --- a/testenv/tests/13-configure.sh +++ b/testenv/tests/13-configure.sh @@ -3,7 +3,7 @@ config_only "$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 From 699b9469b6bd2143e7db133cdebd23bc32bb6cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sat, 14 Nov 2026 13:14:34 +0100 Subject: [PATCH 2/3] test: check what a real terminal renders for multi, zellij, and the TUI tests/16-render.sh runs tmux, zellij, and the TUI in xterm on Xvfb, takes screenshots, and reads them back with tesseract: remote prompts in all panes, borders and titles with the hosts, [SYNC] appearing and disappearing, panes side by side, broadcast output in every pane and input in one pane only without it, windows split by panes_per_window, the status bar, and TUI marks. Screenshots stay in testenv/.artifacts. OCR reads three preprocessing variants (plain, stretched for dim gray, brightest channel for colored text) and normalizes characters that DejaVu Sans Mono makes ambiguous (1/l, C/(). The screenshots showed tmux cutting the session name in the status bar to "[netssh-pv"; netssh multi now widens status-left-length for its own session. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 + .gitignore | 1 + CHANGELOG.md | 3 +- README.md | 3 +- internal/multiplex/multiplex_test.go | 1 + internal/multiplex/tmux.go | 7 +++ testenv/README.md | 24 ++++++++- testenv/client.Dockerfile | 3 ++ testenv/compose.yaml | 1 + testenv/tests/16-render.sh | 75 +++++++++++++++++++++++++++ testenv/tests/lib.sh | 76 ++++++++++++++++++++++++++++ testenv/up.sh | 4 +- 12 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 testenv/tests/16-render.sh diff --git a/.dockerignore b/.dockerignore index f727a7d..13c8c96 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ .git testenv/.keys testenv/.state +testenv/.artifacts diff --git a/.gitignore b/.gitignore index 60627d9..3040d9d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Thumbs.db # local test environment (testenv/up.sh) testenv/.keys/ testenv/.state/ +testenv/.artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 60db109..73ee771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ - Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths. - Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`). - Add `netssh multi` to open several hosts side by side in tmux or zellij: host lists, `tag:`, NetBox virtualization clusters (`cluster:`, devices by default, `--kind vm|all`), and groups from the config (`@name`). `--sync` sends typing to all panes; `netssh sync` toggles it later. In the TUI, `Space` marks hosts and `Enter` opens them together. Configurable in `netssh configure` (multiplexer, panes per window, groups). +- `netssh multi` widens the status bar of its own tmux session, which tmux otherwise cuts to `[netssh-pv`. - Cache entries remember the NetBox cluster of a host, for `cluster:` completion and offline use. -- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 160 end-to-end checks of all features, including `netssh multi` with real tmux and zellij (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD. +- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 178 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD. ## v0.0.23 diff --git a/README.md b/README.md index d77ff38..815df55 100644 --- a/README.md +++ b/README.md @@ -1085,6 +1085,7 @@ Both are non-blocking — the refresh runs as a background process and never del | Aliases missing in a new shell | `netssh hook install`, then a new terminal. The cache must not be empty. | | A host is named `sftp`, `scp`, `tunnel`, `hosts`, `multi`, `sync`, or like another subcommand | Use `netssh -- sftp`. | | `netssh multi needs tmux or zellij` | Install one of them (`apt install tmux`, `brew install tmux`). | +| zellij shows empty boxes in its tab and status bar | Those are Powerline arrows; use a Nerd Font in your terminal, or add `simplified_ui true` to `~/.config/zellij/config.kdl`. | | `cluster:x matches no hosts` | Clusters select devices by default; VMs need `--kind vm` or `--kind all`. The name is matched case-insensitively. | | `netssh multi` panes use another config | Inside an existing tmux/zellij session, panes get your `HOME` and `XDG_*` paths, but not `NETSSH_*` variables (they would be visible to other users in the process list). Put such settings in the config file. | @@ -1121,7 +1122,7 @@ End-to-end tests run against real services in a local Docker environment — two ```sh testenv/up.sh # build and start (first start ~6 min) -testenv/test.sh # 160 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij +testenv/test.sh # 178 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR) testenv/down.sh ``` diff --git a/internal/multiplex/multiplex_test.go b/internal/multiplex/multiplex_test.go index ab24f2f..ef71d8b 100644 --- a/internal/multiplex/multiplex_test.go +++ b/internal/multiplex/multiplex_test.go @@ -133,6 +133,7 @@ func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) { "tmux has-session -t =netssh-pve-2", "tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ", "tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ", + "tmux set-option -t netssh-pve-2 status-left-length 40", } { if !strings.Contains(joined, want) { t.Errorf("missing %q in\n%s", want, joined) diff --git a/internal/multiplex/tmux.go b/internal/multiplex/tmux.go index bf60987..5665037 100644 --- a/internal/multiplex/tmux.go +++ b/internal/multiplex/tmux.go @@ -42,6 +42,13 @@ func openTmux(panes []Pane, opts Options, r Runner) error { if err != nil { return err } + if !inside && i == 0 { + // tmux cuts the status bar's session name at 10 characters + // ("[netssh-pv"); widen it for this session only. + if _, err := r.Output("tmux", "set-option", "-t", session, "status-left-length", "40"); err != nil { + return err + } + } window, pane, _ := strings.Cut(out, " ") if first == "" { first = window diff --git a/testenv/README.md b/testenv/README.md index f144959..364f9cc 100644 --- a/testenv/README.md +++ b/testenv/README.md @@ -57,6 +57,7 @@ public 10.231.0.0/24 + fd00:30::/64 mgmt 10.231.1.0/24 prod 10.231.2 | `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 @@ -82,9 +83,30 @@ 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. + +```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. -- 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. +- 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. diff --git a/testenv/client.Dockerfile b/testenv/client.Dockerfile index 8cf9ff5..a91b7d0 100644 --- a/testenv/client.Dockerfile +++ b/testenv/client.Dockerfile @@ -11,8 +11,11 @@ COPY internal ./internal RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh FROM alpine:3.23 +# xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator, +# take screenshots, and read them back with OCR (tests/16-render.sh). RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \ curl python3 jq ncurses coreutils \ + xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \ && adduser -D -s /bin/bash tester COPY --from=build /out/netssh /usr/local/bin/netssh COPY testenv/client-entrypoint.sh /entrypoint.sh diff --git a/testenv/compose.yaml b/testenv/compose.yaml index 65b6c5b..a9059f4 100644 --- a/testenv/compose.yaml +++ b/testenv/compose.yaml @@ -165,6 +165,7 @@ services: - ./.keys:/keys:ro - ./.state:/state - ./tests:/tests:ro + - ./.artifacts:/artifacts networks: public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"} mgmt: {ipv4_address: 10.231.1.10} diff --git a/testenv/tests/16-render.sh b/testenv/tests/16-render.sh new file mode 100644 index 0000000..56153a8 --- /dev/null +++ b/testenv/tests/16-render.sh @@ -0,0 +1,75 @@ +# What a user sees: tmux, zellij, and the TUI rendered by a real terminal +# emulator (xterm on Xvfb), checked by OCR of screenshots. The screenshots stay +# in testenv/.artifacts/ for a look by eye. +config <<'EOF' +resolver: + strategies: [management_subnet, primary_ip] + management_subnets: [10.231.1.0/24] +ssh: {default_user: ops} +groups: + pve: ["cluster:pve-fsn1"] +EOF +check "refresh" netssh cache refresh +export TMUX_TMPDIR="$HOME/tmux" +mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij" +printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl" +start_screen && ok "X server and xterm" || bad "X server and xterm" "Xvfb did not start" + +p1=fsn/fsn1-pve1.hoster.test p2=fsn/fsn1-pve2.hoster.test p3=fsn/fsn1-pve3.hoster.test +prompts() { screen_shows "$1" "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"; } +# Two hosts on one OCR line means their panes sit side by side (tiled grid). +side_by_side() { screen_text "$1" | ocr_norm | grep -F "$(printf %s "$2" | ocr_norm)" | grep -qF "$(printf %s "$3" | ocr_norm)"; } +visible() { # NAME SHOT TEXT... — wait until the screen shows every TEXT + local name=$1 shot=$2 + shift 2 + if wait_for 30 screen_shows "$shot" "$@"; then ok "$name"; else bad "$name" "$(screen_text "$shot")"; fi +} + +# --- tmux, started from a plain terminal: new session, attached in xterm --- +term netssh multi --sync @pve +visible "tmux: three remote shells on screen" tmux-1 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$" +visible "tmux: borders show host and [SYNC]" tmux-1 "$p1 [SYNC]" "$p2 [SYNC]" "$p3 [SYNC]" +side_by_side tmux-1 "$p1" "$p2" && ok "tmux: tiled, first two panes side by side" || bad "tmux: tiled, first two panes side by side" "$(screen_text tmux-1)" +tmux send-keys -t netssh-pve "echo typed-once-\$(hostname)" Enter +visible "tmux: broadcast output in every pane" tmux-2 "typed-once-fsn1-pve1" "typed-once-fsn1-pve2" "typed-once-fsn1-pve3" +tmux set-window-option -t netssh-pve synchronize-panes off +tmux send-keys -t netssh-pve "echo just-this-pane" Enter +sleep 1 +shot tmux-3 +[ "$(screen_count tmux-3 "just-this-pane")" = 2 ] && ok "tmux: without broadcast only one pane gets input" || bad "tmux: without broadcast only one pane gets input" "$(screen_text tmux-3)" +shows_sync() { screen_shows tmux-3 "[SYNC]"; } +shows_sync && bad "tmux: [SYNC] disappears when broadcast is off" "$(screen_text tmux-3)" || ok "tmux: [SYNC] disappears when broadcast is off" +tmux kill-server + +# --- more hosts than panes_per_window: further windows --- +sed -i 's/^groups:/multi: {panes_per_window: 2}\ngroups:/' "$XDG_CONFIG_HOME/netssh.yaml" +term netssh multi @pve +visible "tmux: hosts split over windows pve-1 and pve-2" tmux-windows "pve-1" "pve-2" +visible "tmux: status bar shows the whole session name" tmux-windows "[netssh-pve]" +tmux kill-server +sed -i '/^multi: {panes_per_window: 2}$/d' "$XDG_CONFIG_HOME/netssh.yaml" + +# --- zellij: generated grid layout, broadcast through the sync tab --- +term netssh multi --zellij @pve +visible "zellij: three remote shells on screen" zellij-1 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$" +visible "zellij: pane titles show the hosts" zellij-1 "$p1" "$p2" "$p3" +side_by_side zellij-1 "$p1" "$p2" && ok "zellij: grid, first two panes side by side" || bad "zellij: grid, first two panes side by side" "$(screen_text zellij-1)" +session=$(zellij list-sessions --short --no-formatting 2>/dev/null | head -1) +zellij --session "$session" action toggle-active-sync-tab +zellij --session "$session" action write-chars 'echo zellij-$(hostname)' +zellij --session "$session" action write 13 +visible "zellij: broadcast output in every pane" zellij-2 "zellij-fsn1-pve1" "zellij-fsn1-pve2" "zellij-fsn1-pve3" +zellij kill-all-sessions --yes >/dev/null 2>&1 + +# --- TUI: mark hosts, open them together --- +term tmux new-session -s tui netssh +visible "TUI: search screen" tui-1 "Search hostname" +tmux send-keys -t tui "pve" +visible "TUI: results listed" tui-2 "$p1" "$p2" "$p3" +tmux send-keys -t tui C-a +visible "TUI: all marked" tui-3 "3 marked" +tmux send-keys -t tui Enter +visible "TUI: marked hosts open side by side" tui-4 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$" +tmux kill-server + +stop_screen diff --git a/testenv/tests/lib.sh b/testenv/tests/lib.sh index ed4efac..928e4bd 100644 --- a/testenv/tests/lib.sh +++ b/testenv/tests/lib.sh @@ -76,3 +76,79 @@ wait_for() { sleep 0.2 done } + +# --- rendering in a real terminal emulator (tests/16-render.sh) --- +# Xvfb provides a screen, xterm renders the terminal, `import` takes a +# screenshot, and tesseract reads it back, so checks see what a user sees. + +start_screen() { + Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 & + XVFB_PID=$! + export DISPLAY=:99 + wait_for 10 import -window root -crop 1x1+0+0 null: +} + +stop_screen() { + pkill -u "$(id -u)" xterm 2>/dev/null + kill "$XVFB_PID" 2>/dev/null + wait "$XVFB_PID" 2>/dev/null +} + +# term CMD... — run CMD in a full-screen xterm (UTF-8, DejaVu Sans Mono). +term() { + pkill -u "$(id -u)" xterm 2>/dev/null + xterm -u8 -geometry 190x52+0+0 -fa 'DejaVu Sans Mono' -fs 11 -bg '#1e1e1e' -fg '#d4d4d4' \ + -T netssh -e "$@" >/dev/null 2>&1 & + sleep 1 +} + +# shot NAME — screenshot to /artifacts/NAME.png. +shot() { import -window root "/artifacts/$1.png"; } + +# OCR preprocessing variants: no single one reads everything (dim hint lines, +# red/green borders): plain luminance, stretched dark range for dim gray, and +# the brightest color channel for colored text. +OCR_VARIANTS=("-colorspace gray" "-colorspace gray -level 10%,40%" + "-channel RGB -separate -evaluate-sequence max -level 10%,60%") + +# ocr_variant NAME I — OCR of screenshot NAME with preprocessing variant I. +ocr_variant() { + # shellcheck disable=SC2086 + magick "/artifacts/$1.png" ${OCR_VARIANTS[$2]} -negate -resize 300% png:- | + tesseract stdin stdout --psm 6 2>/dev/null +} + +# screen_text NAME — the text of all variants combined. +screen_text() { + local i + for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done +} + +# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O) +# to one form; expected text gets the same mapping, so comparisons stay strict +# about everything else. +ocr_norm() { tr 'lI|(O' '111C0'; } + +# screen_shows NAME TEXT... — take a screenshot and check that OCR finds every TEXT. +screen_shows() { + local name=$1 text want + shift + shot "$name" + text=$(screen_text "$name" | ocr_norm) + for want in "$@"; do + want=$(printf '%s' "$want" | ocr_norm) + [[ $text == *"$want"* ]] || return 1 + done +} + +# screen_count NAME TEXT — how often TEXT is on screenshot NAME: the highest +# count of any OCR variant (they read the same screen, so they are not added). +screen_count() { + local want i n max=0 + want=$(printf '%s' "$2" | ocr_norm) + for i in "${!OCR_VARIANTS[@]}"; do + n=$(ocr_variant "$1" "$i" | ocr_norm | grep -oF -- "$want" | wc -l) + [ "$n" -gt "$max" ] && max=$n + done + echo "$max" +} diff --git a/testenv/up.sh b/testenv/up.sh index 4e951ea..ffbc15f 100755 --- a/testenv/up.sh +++ b/testenv/up.sh @@ -5,8 +5,8 @@ set -euo pipefail cd "$(dirname "$0")" compose=(docker compose -f compose.yaml) -mkdir -p .keys .state -chmod a+rwx .state # written by the seed container's user +mkdir -p .keys .state .artifacts +chmod a+rwx .state .artifacts # written by the container user (screenshots in .artifacts) if [ ! -f .keys/id_ed25519 ]; then ssh-keygen -q -t ed25519 -N '' -C netssh-test -f .keys/id_ed25519 cp .keys/id_ed25519.pub .keys/authorized_keys From 396d4891871a854b68d3a1f342e73b056edfab07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sat, 14 Nov 2026 13:32:32 +0100 Subject: [PATCH 3/3] 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 --- CHANGELOG.md | 3 +- README.md | 19 ++++-- cmd/netssh/multi.go | 20 ++++-- internal/config/multi.go | 35 ++++++++++ internal/config/ssh_test.go | 10 ++- internal/multiplex/multiplex.go | 31 ++++++++- internal/multiplex/multiplex_test.go | 81 +++++++++++++++++++++-- internal/multiplex/tmux.go | 96 +++++++++++++++++++++++++++- internal/setup/multi.go | 26 ++++++++ internal/setup/wizard.go | 7 +- testenv/README.md | 6 +- testenv/client.Dockerfile | 2 +- testenv/tests/16-render.sh | 29 ++++++++- testenv/tests/lib.sh | 8 +-- 14 files changed, 342 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ee771..2973f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ - Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths. - Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`). - Add `netssh multi` to open several hosts side by side in tmux or zellij: host lists, `tag:`, NetBox virtualization clusters (`cluster:`, devices by default, `--kind vm|all`), and groups from the config (`@name`). `--sync` sends typing to all panes; `netssh sync` toggles it later. In the TUI, `Space` marks hosts and `Enter` opens them together. Configurable in `netssh configure` (multiplexer, panes per window, groups). +- tmux keys for broadcast in `netssh multi` windows: prefix `S` toggles all panes, prefix `e` excludes the current pane (border `[SOLO]`) or includes it again; also `netssh sync --pane`. Both keys are unbound in a default tmux, are not bound if your tmux.conf uses them, act only in netssh windows, and can be changed or turned off (`multi.sync_key`, `multi.solo_key`). - `netssh multi` widens the status bar of its own tmux session, which tmux otherwise cuts to `[netssh-pv`. - Cache entries remember the NetBox cluster of a host, for `cluster:` completion and offline use. -- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 178 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD. +- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 185 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD. ## v0.0.23 diff --git a/README.md b/README.md index 815df55..d503e8f 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ A menu then gives access to every other setting; open only the sections you want | SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) | | SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options | | Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options | -| Multi-session | multiplexer (auto, tmux, zellij), panes per window, groups (`NAME: sel1, sel2` per line) | +| Multi-session | multiplexer (auto, tmux, zellij), panes per window, tmux keys for broadcast and excluding a pane, groups (`NAME: sel1, sel2` per line) | | Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file | | Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` | | Completion | `completion.max_results` | @@ -237,6 +237,7 @@ Every key, its default, and the section that explains it. Keys marked *layered* | `groups` | map of lists | `{}` | Named host lists for `netssh multi @NAME`; entries are hosts or selectors. | | `multi.multiplexer` | `auto` \| `tmux` \| `zellij` | `auto` | Which multiplexer `netssh multi` uses. | | `multi.panes_per_window` | int | `9` | More hosts open in further tmux windows or zellij tabs. | +| `multi.sync_key`, `multi.solo_key` | tmux key \| `none` | `S`, `e` | Keys after the tmux prefix: broadcast to all panes, exclude/include the current pane. | ### Environment variables @@ -366,7 +367,7 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered. | `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). | | `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). | | `netssh multi [--sync] [--kind K] [--tmux\|--zellij] [-l U] [--jump H\|--no-jump] HOST\|tag:T\|cluster:C\|@GROUP...` | Several hosts side by side — see [Multiple servers](#multiple-servers-in-one-window). | -| `netssh sync` | Toggle typing into all panes of the current tmux window or zellij tab. | +| `netssh sync [--pane]` | Toggle typing into all panes of the current tmux window or zellij tab; `--pane` excludes/includes the current tmux pane. | | `netssh configure` | Setup wizard. | | `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. | | `netssh search QUERY` | Print matching cached hostnames, one per line. | @@ -581,6 +582,8 @@ groups: multi: multiplexer: auto # auto | tmux | zellij panes_per_window: 9 # more hosts open in further windows/tabs + sync_key: S # tmux: prefix S = broadcast on/off (none = no key) + solo_key: e # tmux: prefix e = exclude/include this pane ``` **tmux and zellij.** With `auto`, netssh uses the multiplexer you are in; otherwise tmux, then zellij, whichever is installed (`--tmux` / `--zellij` choose explicitly). @@ -590,7 +593,11 @@ multi: | Inside a session | new window in the current session | new tab in the current session | | Outside | new session `netssh-NAME`, attached | new session from a generated layout | | Layout | tiled; pane borders show the host | grid; pane titles show the host | -| Broadcast | `--sync`, or `netssh sync` / `Ctrl+b :setw synchronize-panes` to toggle; borders show `[SYNC]` | `--sync` inside zellij, or `Ctrl+t` then `s` (also `netssh sync`) | +| Broadcast to all panes | `--sync`; toggle with `Ctrl+b S` or `netssh sync` | `--sync` inside zellij; toggle with `Ctrl+t` then `s` or `netssh sync` | +| Exclude one pane | `Ctrl+b e` (again: include) or `netssh sync --pane` | not possible — zellij syncs whole tabs | +| Shown in the border | `[SYNC]` receives broadcast, `[SOLO]` excluded | `(SYNC)` in the tab name | + +**Keys in tmux.** `S` and `e` are free in a default tmux (`E` would be "spread panes evenly"). netssh binds them after your prefix when it opens panes, but only if your own `tmux.conf` does not already use them — then it keeps your binding and says so. They act only in windows opened by `netssh multi`; elsewhere they just show a message. Change or turn them off with `multi.sync_key` / `multi.solo_key` (`none`), e.g. `sync_key: C-s` for `prefix Ctrl+s`. **In the TUI**, mark hosts with `Space` (or all listed hosts with `Ctrl+A`) and press `Enter` to open them together. @@ -882,8 +889,8 @@ netssh fsnbx1 # also works as shell alias: fsnbx1 ```sh netssh multi --sync cluster:pve-fsn1 # every node in one window, typing goes to all -# … run apt upgrade on all nodes, then turn broadcast off for per-node work: -netssh sync # from another pane, or Ctrl+b :setw synchronize-panes +# … run apt upgrade on all nodes; Ctrl+b e on a node that needs something +# different excludes just that one, Ctrl+b S turns broadcast off for all ``` **Deploy a file and restart a service** @@ -1122,7 +1129,7 @@ End-to-end tests run against real services in a local Docker environment — two ```sh testenv/up.sh # build and start (first start ~6 min) -testenv/test.sh # 178 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR) +testenv/test.sh # 185 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR) testenv/down.sh ``` diff --git a/cmd/netssh/multi.go b/cmd/netssh/multi.go index ba1c2fc..9e16b60 100644 --- a/cmd/netssh/multi.go +++ b/cmd/netssh/multi.go @@ -83,12 +83,19 @@ netssh sync. Inside tmux or zellij the panes open in the current session.`, } func syncCmd() *cobra.Command { - return &cobra.Command{ - Use: "sync", + var pane bool + cmd := &cobra.Command{ + Use: "sync [--pane]", Short: "Toggle typing into all panes of the current tmux window or zellij tab", - Args: cobra.NoArgs, + Long: `Toggle broadcast in the current tmux window or zellij tab. With --pane, +exclude the current tmux pane from broadcast, or include it again. + +In windows opened by netssh multi, tmux also has keys for this: +prefix S (all panes) and prefix e (this pane); see multi.sync_key and +multi.solo_key. In zellij, use Ctrl+t then s.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - state, err := multiplex.ToggleSync(multiplex.System{}) + state, err := multiplex.ToggleSync(multiplex.System{}, pane) if err != nil { return err } @@ -96,6 +103,8 @@ func syncCmd() *cobra.Command { return nil }, } + cmd.Flags().BoolVar(&pane, "pane", false, "Exclude the current tmux pane from broadcast, or include it again") + return cmd } // runMulti expands selectors and opens one pane per host. @@ -147,7 +156,10 @@ func runMulti(cfg *config.Config, selectors []string, f multiFlags) error { if err := os.MkdirAll(layoutDir, 0o700); err != nil { return err } + syncKey, soloKey := cfg.Multi.Keys() return multiplex.Open(kind, panes, multiplex.Options{ + SyncKey: syncKey, + SoloKey: soloKey, Label: multiLabel(selectors), Sync: f.sync, PerWindow: cfg.Multi.PanesPerWindowOrDefault(), diff --git a/internal/config/multi.go b/internal/config/multi.go index a313ebf..a223874 100644 --- a/internal/config/multi.go +++ b/internal/config/multi.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "regexp" "sort" "strings" ) @@ -12,8 +13,34 @@ type MultiConfig struct { Multiplexer string `mapstructure:"multiplexer"` // PanesPerWindow splits larger host lists into more windows or tabs; 0 = 9. PanesPerWindow int `mapstructure:"panes_per_window"` + // SyncKey and SoloKey are tmux prefix keys for broadcast to all panes and + // for excluding the current pane; empty = default (S, e), "none" = off. + SyncKey string `mapstructure:"sync_key"` + SoloKey string `mapstructure:"solo_key"` } +// Default tmux keys: unbound in a stock tmux (E would be "spread evenly"). +const ( + DefaultSyncKey = "S" + DefaultSoloKey = "e" +) + +// Keys returns the tmux keys to bind; "" for a disabled one. +func (m MultiConfig) Keys() (sync, solo string) { + pick := func(key, fallback string) string { + switch key { + case "": + return fallback + case "none": + return "" + } + return key + } + return pick(m.SyncKey, DefaultSyncKey), pick(m.SoloKey, DefaultSoloKey) +} + +var tmuxKey = regexp.MustCompile(`^([CMS]-)*([A-Za-z0-9]|F[0-9]{1,2}|[-_=+!@#$%^&*,./?<>\[\]]|Tab|Space|Enter|BSpace|Up|Down|Left|Right|Home|End|PPage|NPage)$`) + // DefaultPanesPerWindow keeps panes readable on a normal screen (3×3). const DefaultPanesPerWindow = 9 @@ -27,6 +54,14 @@ func (c *Config) ValidateMulti() error { if c.Multi.PanesPerWindow < 0 || c.Multi.PanesPerWindow > 64 { return fmt.Errorf("multi.panes_per_window must be between 1 and 64") } + for name, key := range map[string]string{"sync_key": c.Multi.SyncKey, "solo_key": c.Multi.SoloKey} { + if key != "" && key != "none" && !tmuxKey.MatchString(key) { + return fmt.Errorf("multi.%s %q is not a tmux key (e.g. S, e, C-s, F5) or none", name, key) + } + } + if sync, solo := c.Multi.Keys(); sync != "" && sync == solo { + return fmt.Errorf("multi.sync_key and multi.solo_key must differ") + } names := make([]string, 0, len(c.Groups)) for name := range c.Groups { names = append(names, name) diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index 87c5ded..60d866e 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -196,7 +196,15 @@ func TestMultiConfig(t *testing.T) { if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 { t.Fatalf("%+v %v", cfg, err) } - for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}"} { + if sync, solo := cfg.Multi.Keys(); sync != "S" || solo != "e" { + t.Fatalf("default keys %q %q", sync, solo) + } + custom, err := loadYAML(t, "multi: {sync_key: C-s, solo_key: none}\n") + if sync, solo := custom.Multi.Keys(); err != nil || sync != "C-s" || solo != "" { + t.Fatalf("custom keys %q %q %v", sync, solo, err) + } + for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}", + "multi: {sync_key: \"x y\"}", "multi: {sync_key: \"{\"}", "multi: {sync_key: e}"} { if _, err := loadYAML(t, bad+"\n"); err == nil { t.Errorf("%s accepted", bad) } diff --git a/internal/multiplex/multiplex.go b/internal/multiplex/multiplex.go index 82e46be..621883a 100644 --- a/internal/multiplex/multiplex.go +++ b/internal/multiplex/multiplex.go @@ -22,7 +22,10 @@ type Options struct { Label string // window, session, and tab name Sync bool // type into all panes of a window at once PerWindow int // panes per tmux window or zellij tab - LayoutDir string // where zellij layout files are written + LayoutDir string // where zellij layout files and tmux key bindings are written + // SyncKey and SoloKey are tmux prefix keys that toggle broadcast for the + // window and exclude the current pane from it; empty means no binding. + SyncKey, SoloKey string // Hint receives notes for the user before the process is replaced. Hint func(string) } @@ -98,8 +101,30 @@ func Open(kind string, panes []Pane, opts Options, r Runner) error { return fmt.Errorf("unknown multiplexer %q", kind) } -// ToggleSync switches broadcast input in the current tmux window or zellij tab. -func ToggleSync(r Runner) (string, error) { +// ToggleSync switches broadcast input in the current tmux window or zellij +// tab; with pane it excludes the current tmux pane from it or includes it again. +func ToggleSync(r Runner, pane bool) (string, error) { + if pane { + if r.Getenv("TMUX") == "" { + if r.Getenv("ZELLIJ") != "" { + return "", errors.New("zellij broadcasts to a whole tab; single panes cannot be excluded") + } + return "", errors.New("netssh sync --pane works inside tmux") + } + solo, err := r.Output("tmux", "display-message", "-p", "#{@netssh_solo}") + if err != nil { + return "", err + } + for _, argv := range soloToggle(solo == "1") { + if _, err := r.Output(argv...); err != nil { + return "", err + } + } + if solo == "1" { + return "pane included in broadcast", nil + } + return "pane excluded from broadcast", nil + } switch { case r.Getenv("TMUX") != "": current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes") diff --git a/internal/multiplex/multiplex_test.go b/internal/multiplex/multiplex_test.go index ef71d8b..81fa298 100644 --- a/internal/multiplex/multiplex_test.go +++ b/internal/multiplex/multiplex_test.go @@ -16,6 +16,8 @@ type fake struct { exec []string files map[string]string syncState string + keys map[string]string // existing prefix bindings by key + solo string n int } @@ -42,6 +44,10 @@ func (f *fake) Output(argv ...string) (string, error) { return "", errors.New("can't find session") case "show-window-options": return f.syncState, nil + case "list-keys": + return f.keys[argv[4]], nil + case "display-message": + return f.solo, nil } return "", nil } @@ -94,7 +100,7 @@ func TestDetect(t *testing.T) { func TestTmuxInside(t *testing.T) { f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux") - if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true}, f); err != nil { + if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true, SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l"}, f); err != nil { t.Fatal(err) } script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh " @@ -107,9 +113,14 @@ func TestTmuxInside(t *testing.T) { "tmux set-option -p -t %1 @netssh_host a", "tmux set-option -p -t %2 @netssh_host b", "tmux set-option -p -t %4 @netssh_host c", + "tmux set-window-option -t @1 @netssh 1", "tmux set-window-option -t @1 pane-border-status top", - "tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],} ", + "tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} ", "tmux set-window-option -t @1 synchronize-panes on", + "tmux show-options -gv prefix", + "tmux list-keys -T prefix S", + "tmux list-keys -T prefix e", + "tmux source-file /tmp/l/netssh-multi.tmux.conf", "tmux select-window -t @1", } if strings.Join(f.calls, "\n") != strings.Join(want, "\n") { @@ -195,18 +206,76 @@ func TestZellijInsideAddsTabs(t *testing.T) { func TestToggleSync(t *testing.T) { f := newFake(map[string]string{"TMUX": "x"}) - if state, err := ToggleSync(f); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" { + if state, err := ToggleSync(f, false); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" { t.Fatalf("%q %v %v", state, err, f.calls) } f.syncState = "on" - if state, _ := ToggleSync(f); state != "broadcast off" { + if state, _ := ToggleSync(f, false); state != "broadcast off" { t.Fatalf("%q", state) } z := newFake(map[string]string{"ZELLIJ": "0"}) - if _, err := ToggleSync(z); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" { + if _, err := ToggleSync(z, false); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" { t.Fatalf("%v %v", err, z.calls) } - if _, err := ToggleSync(newFake(nil)); err == nil { + if _, err := ToggleSync(newFake(nil), false); err == nil { t.Fatal("outside a multiplexer accepted") } } + +func TestTmuxKeyBindings(t *testing.T) { + f := newFake(map[string]string{"TMUX": "x"}, "tmux") + var hint string + if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil { + t.Fatal(err) + } + conf := f.files["/tmp/l/netssh-multi.tmux.conf"] + for _, want := range []string{"bind-key -T prefix S if-shell -F '#{@netssh}'", "bind-key -T prefix e if-shell", "set-option -p synchronize-panes off"} { + if !strings.Contains(conf, want) { + t.Errorf("conf lacks %q:\n%s", want, conf) + } + } + if hint != "keys: C-b S broadcast to all panes, C-b e exclude/include this pane" { + t.Errorf("hint %q", hint) + } + + // A key bound in the user's tmux.conf is left alone; netssh's own is replaced. + f = newFake(map[string]string{"TMUX": "x"}, "tmux") + f.keys = map[string]string{"S": "bind-key -T prefix S kill-server", "e": "bind-key -T prefix e if-shell -F #{@netssh} …"} + if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil { + t.Fatal(err) + } + conf = f.files["/tmp/l/netssh-multi.tmux.conf"] + if strings.Contains(conf, "prefix S ") || !strings.Contains(conf, "prefix e ") { + t.Errorf("user binding overwritten or own binding not refreshed:\n%s", conf) + } + if !strings.Contains(hint, "C-b S kept your own binding") { + t.Errorf("hint %q", hint) + } + + // No keys configured: nothing is bound. + f = newFake(map[string]string{"TMUX": "x"}, "tmux") + if err := Open(Tmux, panes("a"), Options{LayoutDir: "/tmp/l"}, f); err != nil { + t.Fatal(err) + } + if len(f.files) != 0 || strings.Contains(strings.Join(f.calls, "\n"), "source-file") { + t.Errorf("bound keys although disabled: %v", f.calls) + } +} + +func TestToggleSyncPane(t *testing.T) { + f := newFake(map[string]string{"TMUX": "x"}) + if state, err := ToggleSync(f, true); err != nil || state != "pane excluded from broadcast" { + t.Fatalf("%q %v", state, err) + } + if got := strings.Join(f.calls[1:], "; "); got != "tmux set-option -p synchronize-panes off; tmux set-option -p @netssh_solo 1" { + t.Fatalf("exclude: %s", got) + } + f = newFake(map[string]string{"TMUX": "x"}) + f.solo = "1" + if state, _ := ToggleSync(f, true); state != "pane included in broadcast" || f.calls[1] != "tmux set-option -pu synchronize-panes" { + t.Fatalf("include: %q %v", state, f.calls) + } + if _, err := ToggleSync(newFake(map[string]string{"ZELLIJ": "0"}), true); err == nil || !strings.Contains(err.Error(), "whole tab") { + t.Fatalf("zellij: %v", err) + } +} diff --git a/internal/multiplex/tmux.go b/internal/multiplex/tmux.go index 5665037..c22c27f 100644 --- a/internal/multiplex/tmux.go +++ b/internal/multiplex/tmux.go @@ -1,6 +1,8 @@ package multiplex import ( + "fmt" + "path/filepath" "strconv" "strings" ) @@ -70,8 +72,9 @@ func openTmux(panes []Pane, opts Options, r Runner) error { _, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title) } windowOptions := [][]string{ + {"@netssh", "1"}, // the key bindings act only in these windows {"pane-border-status", "top"}, - {"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],} "}, + {"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} "}, } if opts.Sync { windowOptions = append(windowOptions, []string{"synchronize-panes", "on"}) @@ -82,14 +85,103 @@ func openTmux(panes []Pane, opts Options, r Runner) error { } } } + keys, err := bindKeys(r, opts) + if err != nil { + return err + } + if keys != "" { + opts.Hint(keys) + } if inside { _, err := r.Output("tmux", "select-window", "-t", first) return err } - opts.Hint("toggle broadcast with: netssh sync (or Ctrl+b :setw synchronize-panes)") return r.Exec("tmux", "attach-session", "-t", session) } +// soloToggle excludes the current pane from broadcast (a pane-level +// synchronize-panes off) or includes it again by removing that override. +func soloToggle(solo bool) [][]string { + if solo { + return [][]string{ + {"tmux", "set-option", "-pu", "synchronize-panes"}, + {"tmux", "set-option", "-pu", "@netssh_solo"}, + } + } + return [][]string{ + {"tmux", "set-option", "-p", "synchronize-panes", "off"}, + {"tmux", "set-option", "-p", "@netssh_solo", "1"}, + } +} + +// keyBindings toggle broadcast in netssh multi windows; elsewhere they only +// show a message, so they do not change what the keys mean to other windows. +const syncBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' { + set-window-option synchronize-panes + display-message 'netssh: broadcast #{?synchronize-panes,on,off}' +} { + display-message 'netssh: not a netssh multi window' +} +` + +const soloBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' { + if-shell -F '#{@netssh_solo}' { + set-option -pu synchronize-panes + set-option -pu @netssh_solo + display-message 'netssh: pane included in broadcast' + } { + set-option -p synchronize-panes off + set-option -p @netssh_solo 1 + display-message 'netssh: pane excluded from broadcast' + } +} { + display-message 'netssh: not a netssh multi window' +} +` + +// bindKeys installs the broadcast keys in the tmux server unless the user's +// own configuration already uses them, and returns a hint naming the keys. +func bindKeys(r Runner, opts Options) (string, error) { + prefix, err := r.Output("tmux", "show-options", "-gv", "prefix") + if err != nil || prefix == "" { + prefix = "C-b" + } + var conf strings.Builder + var hints, taken []string + for _, key := range []struct{ key, binding, what string }{ + {opts.SyncKey, syncBinding, "broadcast to all panes"}, + {opts.SoloKey, soloBinding, "exclude/include this pane"}, + } { + if key.key == "" { + continue + } + // An existing binding is replaced only if netssh made it. + if existing, _ := r.Output("tmux", "list-keys", "-T", "prefix", key.key); existing != "" && !strings.Contains(existing, "@netssh") { + taken = append(taken, prefix+" "+key.key) + continue + } + fmt.Fprintf(&conf, key.binding, key.key) + hints = append(hints, prefix+" "+key.key+" "+key.what) + } + if conf.Len() > 0 { + path := filepath.Join(opts.LayoutDir, "netssh-multi.tmux.conf") + if err := r.WriteFile(path, []byte(conf.String())); err != nil { + return "", err + } + if _, err := r.Output("tmux", "source-file", path); err != nil { + return "", err + } + } + hint := "keys: " + strings.Join(hints, ", ") + if len(hints) == 0 { + hint = "toggle broadcast with: netssh sync, netssh sync --pane" + } + if len(taken) > 0 { + hint += "; " + strings.Join(taken, " and ") + " kept your own binding (use netssh sync)" + } + return hint, nil +} + // freeSession returns name, or name-2, name-3, … if a session already uses it. func freeSession(r Runner, name string) string { candidate := name diff --git a/internal/setup/multi.go b/internal/setup/multi.go index 9c804eb..d02d503 100644 --- a/internal/setup/multi.go +++ b/internal/setup/multi.go @@ -22,6 +22,18 @@ func editMulti(cfg *config.Config) error { perWindow = strconv.Itoa(cfg.Multi.PanesPerWindow) } groups := formatGroups(cfg.Groups) + syncKey, soloKey := cfg.Multi.SyncKey, cfg.Multi.SoloKey + validKey := func(field string) func(string) error { + return func(s string) error { + candidate := config.Config{Multi: cfg.Multi} + if field == "sync" { + candidate.Multi.SyncKey = strings.TrimSpace(s) + } else { + candidate.Multi.SoloKey = strings.TrimSpace(s) + } + return candidate.ValidateMulti() + } + } err := huh.NewForm(huh.NewGroup( huh.NewSelect[string](). Title("Multiplexer"). @@ -41,6 +53,18 @@ func editMulti(cfg *config.Config) error { } return nil }), + huh.NewInput(). + Title("tmux key: broadcast to all panes"). + Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSyncKey)). + Placeholder(config.DefaultSyncKey). + Value(&syncKey). + Validate(validKey("sync")), + huh.NewInput(). + Title("tmux key: exclude/include this pane"). + Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSoloKey)). + Placeholder(config.DefaultSoloKey). + Value(&soloKey). + Validate(validKey("solo")), huh.NewText(). Title("Groups"). Description("One per line: NAME: host, tag:TAG, cluster:NAME, @other — open with netssh multi @NAME."). @@ -59,6 +83,8 @@ func editMulti(cfg *config.Config) error { cfg.Multi.Multiplexer = "" } cfg.Multi.PanesPerWindow, _ = strconv.Atoi(strings.TrimSpace(perWindow)) + cfg.Multi.SyncKey = strings.TrimSpace(syncKey) + cfg.Multi.SoloKey = strings.TrimSpace(soloKey) cfg.Groups, _ = parseGroups(groups) return nil } diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 5ac5af6..408efd9 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -482,7 +482,7 @@ func save(cfg config.Config) error { fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", ")) } } - if cfg.Multi.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 { + if cfg.Multi != (config.MultiConfig{}) { b.WriteString("\nmulti:\n") if cfg.Multi.Multiplexer != "" { fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer) @@ -490,6 +490,11 @@ func save(cfg config.Config) error { if cfg.Multi.PanesPerWindow > 0 { fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow) } + for _, key := range [][2]string{{"sync_key", cfg.Multi.SyncKey}, {"solo_key", cfg.Multi.SoloKey}} { + if key[1] != "" { + fmt.Fprintf(&b, " %s: %q\n", key[0], key[1]) + } + } } if cfg.Completion.MaxResults > 0 { diff --git a/testenv/README.md b/testenv/README.md index 364f9cc..1c13e21 100644 --- a/testenv/README.md +++ b/testenv/README.md @@ -90,7 +90,11 @@ 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. +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: diff --git a/testenv/client.Dockerfile b/testenv/client.Dockerfile index a91b7d0..2bc925c 100644 --- a/testenv/client.Dockerfile +++ b/testenv/client.Dockerfile @@ -14,7 +14,7 @@ FROM alpine:3.23 # xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator, # take screenshots, and read them back with OCR (tests/16-render.sh). RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \ - curl python3 jq ncurses coreutils \ + curl python3 jq ncurses coreutils grep \ xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \ && adduser -D -s /bin/bash tester COPY --from=build /out/netssh /usr/local/bin/netssh diff --git a/testenv/tests/16-render.sh b/testenv/tests/16-render.sh index 56153a8..d748f4f 100644 --- a/testenv/tests/16-render.sh +++ b/testenv/tests/16-render.sh @@ -32,13 +32,34 @@ visible "tmux: borders show host and [SYNC]" tmux-1 "$p1 [SYNC]" "$p2 [SYNC]" "$ side_by_side tmux-1 "$p1" "$p2" && ok "tmux: tiled, first two panes side by side" || bad "tmux: tiled, first two panes side by side" "$(screen_text tmux-1)" tmux send-keys -t netssh-pve "echo typed-once-\$(hostname)" Enter visible "tmux: broadcast output in every pane" tmux-2 "typed-once-fsn1-pve1" "typed-once-fsn1-pve2" "typed-once-fsn1-pve3" -tmux set-window-option -t netssh-pve synchronize-panes off +# Keys as typed in xterm (send-keys -K goes through the client's prefix and +# key tables): prefix e excludes the active pane, prefix S toggles all panes. +client=$(tmux list-clients -t netssh-pve -F '#{client_name}' | head -1) +press() { tmux send-keys -K -c "$client" C-b "$1"; sleep 0.5; } +panes=($(tmux list-panes -t netssh-pve -F '#{pane_id}')) +tmux select-pane -t "${panes[2]}" +press e +visible "tmux: prefix e marks the pane [SOLO]" tmux-solo "$p3 [SOLO]" "$p1 [SYNC]" "$p2 [SYNC]" +tmux send-keys -t "${panes[2]}" "echo solo-only" Enter +tmux send-keys -t "${panes[0]}" "echo two-panes" Enter +sleep 1 +shot tmux-solo-2 +[ "$(screen_count tmux-solo-2 "solo-only")" = 2 ] && ok "tmux: the excluded pane types alone" || bad "tmux: the excluded pane types alone" "$(screen_text tmux-solo-2)" +[ "$(screen_count tmux-solo-2 "two-panes")" = 4 ] && ok "tmux: broadcast skips the excluded pane" || bad "tmux: broadcast skips the excluded pane" "$(screen_text tmux-solo-2)" +press e +visible "tmux: prefix e again includes it" tmux-solo-3 "$p3 [SYNC]" + +press S +sync_is() { [ "$(tmux show-window-options -t netssh-pve -v synchronize-panes 2>/dev/null)" = "$1" ]; } +wait_for 5 sync_is off && ok "tmux: prefix S turns broadcast off" || bad "tmux: prefix S turns broadcast off" "$(tmux show-window-options -t netssh-pve)" tmux send-keys -t netssh-pve "echo just-this-pane" Enter sleep 1 shot tmux-3 [ "$(screen_count tmux-3 "just-this-pane")" = 2 ] && ok "tmux: without broadcast only one pane gets input" || bad "tmux: without broadcast only one pane gets input" "$(screen_text tmux-3)" shows_sync() { screen_shows tmux-3 "[SYNC]"; } shows_sync && bad "tmux: [SYNC] disappears when broadcast is off" "$(screen_text tmux-3)" || ok "tmux: [SYNC] disappears when broadcast is off" +press S +wait_for 5 sync_is on && ok "tmux: prefix S turns it on again" || bad "tmux: prefix S turns it on again" "$(tmux show-window-options -t netssh-pve)" tmux kill-server # --- more hosts than panes_per_window: further windows --- @@ -64,6 +85,12 @@ zellij kill-all-sessions --yes >/dev/null 2>&1 # --- TUI: mark hosts, open them together --- term tmux new-session -s tui netssh visible "TUI: search screen" tui-1 "Search hostname" +# The netssh keys do nothing outside netssh multi windows. +tmux source-file "$XDG_CACHE_HOME/netssh/netssh-multi.tmux.conf" +client=$(tmux list-clients -t tui -F '#{client_name}' | head -1) +tmux send-keys -K -c "$client" C-b S +sleep 0.5 +[ "$(tmux show-window-options -t tui -v synchronize-panes 2>/dev/null)" != on ] && ok "tmux: prefix S leaves other windows alone" || bad "tmux: prefix S leaves other windows alone" "synchronize-panes turned on in a non-netssh window" tmux send-keys -t tui "pve" visible "TUI: results listed" tui-2 "$p1" "$p2" "$p3" tmux send-keys -t tui C-a diff --git a/testenv/tests/lib.sh b/testenv/tests/lib.sh index 928e4bd..aeafc60 100644 --- a/testenv/tests/lib.sh +++ b/testenv/tests/lib.sh @@ -124,10 +124,10 @@ screen_text() { for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done } -# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O) -# to one form; expected text gets the same mapping, so comparisons stay strict -# about everything else. -ocr_norm() { tr 'lI|(O' '111C0'; } +# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O, +# typographic dashes for "-") to one form; expected text gets the same mapping, +# so comparisons stay strict about everything else. +ocr_norm() { sed 's/[‐‑‒–—―−]/-/g' | tr 'lI|(O' '111C0'; } # screen_shows NAME TEXT... — take a screenshot and check that OCR finds every TEXT. screen_shows() {