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

netssh multi opens one pane per host in a tmux window or zellij tab.
Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME
(NetBox virtualization clusters; devices by default, --kind vm|all),
and groups from the config (@name, nested, with cycle detection).
Every pane runs netssh HOST, so lookup, rules, and jump hosts apply.

- tmux: a new window inside tmux, else a new session that is attached;
  tiled layout, pane borders with the host and a [SYNC] marker.
- zellij: a generated grid layout; a new tab inside zellij, else a new
  session.
- --sync broadcasts typing to all panes; netssh sync toggles it in the
  current tmux window or zellij tab.
- TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together.
- Configurator: multiplexer, panes per window, groups.

NetBox SearchAll gains a cluster filter (name to cluster IDs per
instance), and cache entries keep the host's cluster for completion
and an offline fallback. Panes skip the 24-hour background refresh
(checked once by multi) and get HOME/XDG paths but no NETSSH_* values,
which would expose tokens in the process list.

testenv/tests/15-multi.sh checks real tmux and zellij sessions,
including a broadcast command arriving on all three cluster nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-14 12:45:05 +01:00
1 parent dbb99bdded
commit a31aab4110
24 files changed
+1754 -40

No files matched your search

+3 -1
View File
@@ -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 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 `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`). - 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 ## v0.0.23
+69 -6
View File
@@ -21,7 +21,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int
- [Installation](#installation) - [Installation](#installation)
- [Quick start](#quick-start) - [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) - [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) - [Recipes](#recipes)
- [IP Resolution Strategies](#ip-resolution-strategies) - [IP Resolution Strategies](#ip-resolution-strategies)
- [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook) - [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 - **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` - **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 - **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 - **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 - **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 - **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 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 | | 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 | | 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 | | 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` | | Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
| Completion | `completion.max_results` | | 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 `<Tab>`; `0` = unlimited. | | `completion.max_results` | int | `0` | Hostnames offered per `<Tab>`; `0` = unlimited. |
| `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). | | `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). | | `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 ### 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 | | 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_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_SHORTCUTS_DOMAINS`, `NETSSH_SHORTCUTS_STRIP_HYPHENS` | `shortcuts.*` |
| `NETSSH_COMPLETION_MAX_RESULTS` | `completion.max_results` | | `NETSSH_COMPLETION_MAX_RESULTS` | `completion.max_results` |
| `NETSSH_MULTI_MULTIPLEXER`, `NETSSH_MULTI_PANES_PER_WINDOW` | `multi.*` |
| `NETSSH_SHELL_ALIAS` | `shell.alias` | | `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`). 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 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 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 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 configure` | Setup wizard. |
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. | | `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. | | `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 <Tab>`) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards. 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 <Tab>`) 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 ### 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. - 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. - `--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 ### Default username
Set `ssh.default_user` in the config to avoid typing a username every time: 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 | | `Tab` | autocomplete top result into the search field |
| `↑` / `↓` | navigate results | | `↑` / `↓` | navigate results |
| `Enter` | connect to selected host | | `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+O` | open an SFTP session to the selected host |
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) | | `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 | | `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 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** **Deploy a file and restart a service**
```sh ```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. | | Saving notes with `--netbox` fails with a permission error | The API token is read-only. Local notes work without write permission. |
| `<Tab>` completes files instead of hosts | Run `netssh completion install` and open a new terminal; for zsh also `rm -f ~/.zcompdump*`. | | `<Tab>` 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. | | 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 ## Uninstall
@@ -1059,7 +1121,7 @@ End-to-end tests run against real services in a local Docker environment — two
```sh ```sh
testenv/up.sh # build and start (first start ~6 min) 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 testenv/down.sh
``` ```
@@ -1075,6 +1137,7 @@ Project layout:
| `internal/resolver` | IP resolution strategies. | | `internal/resolver` | IP resolution strategies. |
| `internal/cache` | Host cache. | | `internal/cache` | Host cache. |
| `internal/ssh` | Argument parsing and option injection for ssh, sftp, scp. | | `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/shortcuts` | Shortcut normalization. |
| `internal/tui` | Interactive search. | | `internal/tui` | Interactive search. |
| `internal/setup` | Setup wizard. | | `internal/setup` | Setup wizard. |
@@ -1087,7 +1150,7 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
## How it works ## 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. 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. 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. 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.
+14 -5
View File
@@ -46,6 +46,8 @@ var managedSubcommands = map[string]bool{
"scp": true, "scp": true,
"tunnel": true, "tunnel": true,
"hosts": true, "hosts": true,
"multi": true,
"sync": true,
} }
func main() { func main() {
@@ -291,7 +293,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
fatalf("IP resolution for %q: %v", host, err) 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) c.Upsert(entry)
return used(entry) return used(entry)
} }
@@ -389,6 +391,12 @@ func runTUI() {
if host == nil { if host == nil {
return 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. // Revalidate stale or live TUI results through the configured resolver chain.
var tags []string var tags []string
@@ -413,7 +421,7 @@ func runTUI() {
if err != nil { if err != nil {
fatalf("IP resolution: %v", err) 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 tags = target.Tags
} }
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) 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. // cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd() root.InitDefaultCompletionCmd()
@@ -754,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err) fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue 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++ written++
} }
@@ -995,7 +1003,8 @@ func hookUninstallCmd() *cobra.Command {
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache // maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours. // has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { 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 return
} }
if !c.NeedsRefresh(24 * time.Hour) { if !c.NeedsRefresh(24 * time.Hour) {
+402
View File
@@ -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
}
+92
View File
@@ -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)
}
}
+1
View File
@@ -21,6 +21,7 @@ type Entry struct {
IP string `json:"ip"` IP string `json:"ip"`
Kind string `json:"kind"` Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Cluster string `json:"cluster,omitempty"`
CachedAt time.Time `json:"cached_at"` CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"` LastUsed time.Time `json:"last_used,omitempty"`
// Static marks a host from the config's hosts: list. It never expires; // Static marks a host from the config's hosts: list. It never expires;
+7 -1
View File
@@ -20,6 +20,9 @@ type Config struct {
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"` Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
Completion CompletionConfig `mapstructure:"completion"` Completion CompletionConfig `mapstructure:"completion"`
Hosts []StaticHost `mapstructure:"hosts"` 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 { type ShellConfig struct {
@@ -117,7 +120,7 @@ func Load() (*Config, error) {
v.SetEnvPrefix("NETSSH") v.SetEnvPrefix("NETSSH")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv() 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 { if err := v.BindEnv(key); err != nil {
return nil, err return nil, err
} }
@@ -163,6 +166,9 @@ func Load() (*Config, error) {
if err := cfg.ValidateHosts(); err != nil { if err := cfg.ValidateHosts(); err != nil {
return nil, err return nil, err
} }
if err := cfg.ValidateMulti(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes { for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i] source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name) source.Name = strings.ToLower(source.Name)
+57
View File
@@ -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
}
+12
View File
@@ -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)
}
}
}
+226
View File
@@ -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, " ")
}
+211
View File
@@ -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")
}
}
+95
View File
@@ -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)
}
}
+101
View File
@@ -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) + `"`
}
+43 -12
View File
@@ -105,6 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind) 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 ( var (
mu sync.Mutex mu sync.Mutex
results []HostEntry results []HostEntry
@@ -116,7 +132,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
devices, err := c.fetchAllDevices(ctx, query, opts.Tag) devices, err := c.fetchAllDevices(ctx, query, filters)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
@@ -131,7 +147,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
vms, err := c.fetchAllVMs(ctx, query, opts.Tag) vms, err := c.fetchAllVMs(ctx, query, filters)
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if err != nil { if err != nil {
@@ -231,15 +247,27 @@ func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry,
return entries, nil 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 const pageSize = 50
var all []HostEntry var all []HostEntry
for offset := 0; ; offset += pageSize { for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d", apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset) c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice] var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -254,15 +282,12 @@ func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]Host
return all, nil 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 const pageSize = 50
var all []HostEntry var all []HostEntry
for offset := 0; ; offset += pageSize { for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d", apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset) c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM] var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -343,6 +368,9 @@ func deviceToEntry(d netboxDevice) HostEntry {
for _, t := range d.Tags { for _, t := range d.Tags {
e.Tags = append(e.Tags, t.Name) e.Tags = append(e.Tags, t.Name)
} }
if d.Cluster != nil {
e.Cluster = d.Cluster.Name
}
return e return e
} }
@@ -357,6 +385,9 @@ func vmToEntry(v netboxVM) HostEntry {
for _, t := range v.Tags { for _, t := range v.Tags {
e.Tags = append(e.Tags, t.Name) e.Tags = append(e.Tags, t.Name)
} }
if v.Cluster != nil {
e.Cluster = v.Cluster.Name
}
return e return e
} }
+51
View File
@@ -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)
}
}
+15 -4
View File
@@ -7,6 +7,8 @@ import "strings"
type SearchOptions struct { type SearchOptions struct {
Tag string // filter by tag slug; empty = no filter Tag string // filter by tag slug; empty = no filter
Kind string // "device" | "vm" | "" (both) 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. // 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) PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped)
PrimaryIP6 string PrimaryIP6 string
Tags []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. // netboxIP represents an IP address as returned by the NetBox API.
@@ -32,8 +41,9 @@ type netboxDevice struct {
Tags []struct { Tags []struct {
Name string `json:"name"` Name string `json:"name"`
} `json:"tags"` } `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"` PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"` PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
} }
// netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response. // netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response.
@@ -43,8 +53,9 @@ type netboxVM struct {
Tags []struct { Tags []struct {
Name string `json:"name"` Name string `json:"name"`
} `json:"tags"` } `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"` PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"` PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
} }
type netboxListResponse[T any] struct { type netboxListResponse[T any] struct {
+102
View File
@@ -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")
}
+31
View File
@@ -91,6 +91,7 @@ func runMenu(cfg *config.Config) error {
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"), 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("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("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("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"), huh.NewOption("Shortcuts & command alias", "shortcuts"),
huh.NewOption("Completion", "completion"), huh.NewOption("Completion", "completion"),
@@ -115,6 +116,8 @@ func runMenu(cfg *config.Config) error {
if hosts, err = configureHosts(cfg.Hosts); err == nil { if hosts, err = configureHosts(cfg.Hosts); err == nil {
cfg.Hosts = hosts cfg.Hosts = hosts
} }
case "multi":
err = editMulti(cfg)
case "resolver": case "resolver":
err = editResolverCache(cfg) err = editResolverCache(cfg)
case "shortcuts": case "shortcuts":
@@ -369,6 +372,9 @@ func save(cfg config.Config) error {
if err := cfg.ValidateHosts(); err != nil { if err := cfg.ValidateHosts(); err != nil {
return err return err
} }
if err := cfg.ValidateMulti(); err != nil {
return err
}
path := config.Path() path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err) 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 { if cfg.Completion.MaxResults > 0 {
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
} }
+34
View File
@@ -617,3 +617,37 @@ func TestValidateHostIn(t *testing.T) {
t.Fatal("invalid address accepted") 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)
}
}
+74 -9
View File
@@ -27,8 +27,9 @@ type SelectedHost struct {
IP string IP string
User string // empty = use config default User string // empty = use config default
Port string // empty = use default port 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 Forwards []string // forward names or specs for ActionTunnel
Targets []string // marked hosts for ActionMulti
} }
// Actions the user can start on a host. // Actions the user can start on a host.
@@ -36,6 +37,7 @@ const (
ActionSSH = "" ActionSSH = ""
ActionSFTP = "sftp" ActionSFTP = "sftp"
ActionTunnel = "tunnel" ActionTunnel = "tunnel"
ActionMulti = "multi"
) )
// --- bubbletea messages --- // --- bubbletea messages ---
@@ -74,7 +76,8 @@ func (h hostItem) FilterValue() string { return h.name }
// --- compact list delegate --- // --- 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) Height() int { return 1 }
func (d compactDelegate) Spacing() int { return 0 } 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 { if !ok {
return 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() { if index == m.Index() {
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " ")) line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
} }
@@ -169,10 +176,12 @@ type Model struct {
selected *SelectedHost selected *SelectedHost
width int width int
height int height int
recentMode bool // true when showing recent hosts (empty search, initial state) recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T 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 { 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.Prompt = ""
fwd.Placeholder = "web, 8080:localhost:80" 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.SetShowHelp(false)
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
@@ -212,6 +222,7 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
filterInput: fi, filterInput: fi,
editInput: ei, editInput: ei,
forwardInput: fwd, forwardInput: fwd,
marked: marked,
list: l, list: l,
client: client, client: client,
cache: c, cache: c,
@@ -386,11 +397,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case "enter": 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 { if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind} m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
return m, tea.Quit 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": case "ctrl+e":
if item, ok := m.list.SelectedItem().(hostItem); ok { if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item) m.openEdit(item)
@@ -584,7 +610,10 @@ func (m *Model) viewContent() string {
sb.WriteString(m.editInput.View() + "\n") sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else { } 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") 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 (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). // SetForwards provides the forward names configured for a host (ctrl+t).
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) { func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
m.forwardsFor = forwardsFor m.forwardsFor = forwardsFor
+35
View File
@@ -122,3 +122,38 @@ func TestForwardInputEscCancels(t *testing.T) {
t.Fatal("esc did not cancel") 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)
}
}
+4 -1
View File
@@ -78,10 +78,13 @@ ssh always reads `/home/tester/.ssh/config` (OpenSSH ignores `$HOME`): it uses
the test key, `BatchMode`, `StrictHostKeyChecking accept-new`, and the test key, `BatchMode`, `StrictHostKeyChecking accept-new`, and
`LogLevel ERROR`. Tests that need their own known_hosts set `LogLevel ERROR`. Tests that need their own known_hosts set
`ssh.options.UserKnownHostsFile`. The TUI test runs netssh inside tmux and `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 ## Not covered
- The installer (`install.sh`) downloads release assets from Gitea; it is tested by the Go tests with local fixtures. - The installer (`install.sh`) downloads release assets from Gitea; it is tested by the Go tests with local fixtures.
- macOS paths and behaviour. - macOS paths and behaviour.
- `netssh uninstall` runs only with `--dry-run`, so the client keeps its binary. - `netssh uninstall` runs only with `--dry-run`, so the client keeps its binary.
- tmux and zellij run headless inside the client; the checks read their state (`tmux list-panes`, `zellij action dump-layout`) and screen contents, not what a real terminal would render.
+1 -1
View File
@@ -3,7 +3,7 @@
config_only </dev/null config_only </dev/null
rm -f "$XDG_CONFIG_HOME/netssh.yaml" rm -f "$XDG_CONFIG_HOME/netssh.yaml"
# no NetBox, static hosts, add, name, address, user, port, tags, back, save # no NetBox, static hosts, add, name, address, user, port, tags, back, save
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 8) out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 9)
[[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out" [[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out"
expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml" expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "connect to the configured host" "admin" netssh legacy-box whoami expect_eq "connect to the configured host" "admin" netssh legacy-box whoami
+74
View File
@@ -0,0 +1,74 @@
# netssh multi with real tmux and zellij: selectors against NetBox, panes,
# broadcast typing reaching every host, netssh sync, zellij layouts and tabs.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
groups:
pve: ["cluster:pve-fsn1"]
mixed: ["@pve", "lab/lab-db1", "tag:web"]
EOF
check "refresh" netssh cache refresh
nodes="fsn/fsn1-pve1.hoster.test fsn/fsn1-pve2.hoster.test fsn/fsn1-pve3.hoster.test"
expect_out "cluster: selects the devices" "# 3 hosts: $nodes" netssh multi --dry-run --tmux cluster:pve-fsn1
expect_out "--kind vm selects the VMs" "# 2 hosts: fsn/web1 fsn/web2" netssh multi --dry-run --tmux --kind vm cluster:pve-fsn1
expect_out "groups, tags, and hosts combined" "# 7 hosts: $nodes lab/lab-db1 fsn/web1 fsn/web2 lab/web1" netssh multi --dry-run --tmux @mixed
expect_fail "unknown cluster" "matches no hosts" netssh multi --dry-run --tmux cluster:nope
expect_out "completion offers clusters" "cluster:pve-fsn1" netssh __complete multi cluster:
expect_out "completion offers groups" "@pve" netssh __complete multi @
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij"
# No first-start tips or release notes on top of the panes.
printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl"
envs=(-e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME")
hosts_of() { tmux list-panes -t "$1" -F '#{@netssh_host}' 2>/dev/null | sort | tr '\n' ' '; }
# wait_for retries a command, so checks must re-evaluate on every call.
panes_in() { [ "$(tmux list-panes -t "$1" 2>/dev/null | wc -l)" = "$2" ]; }
sync_is() { [ "$(tmux show-window-options -t "$1" -v synchronize-panes 2>/dev/null)" = "$2" ]; }
prompts() { [ "$(tmux capture-pane -p -t z | grep -o 'fsn1-pve[123]:~\$' | sort -u | wc -l)" = 3 ]; }
remote_file() { for n in 1 2 3; do netssh "fsn/fsn1-pve$n.hoster.test" test -f "$1" || return 1; done; }
# Inside tmux: a new window in the current session, broadcast from the start.
tmux new-session -d -s outer -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t outer "netssh multi --sync @pve" Enter
if wait_for 20 panes_in outer:pve 3; then ok "tmux: three panes in a new window"; else bad "tmux: three panes in a new window" "$(tmux list-windows -a)"; fi
expect_eq "tmux: panes are labelled" "$nodes " hosts_of outer:pve
expect_eq "tmux: broadcast is on" "on" tmux show-window-options -t outer:pve -v synchronize-panes
sleep 2
marker="/tmp/netssh-multi-$$"
tmux send-keys -t outer:pve "touch $marker" Enter
if wait_for 15 remote_file "$marker"; then ok "tmux: one command reached all three hosts"; else bad "tmux: one command reached all three hosts" "$(tmux capture-pane -p -t outer:pve)"; fi
tmux new-window -t outer -n ctl "${envs[@]}" bash
tmux send-keys -t outer:ctl "netssh sync" Enter
if wait_for 10 sync_is outer:ctl on; then ok "netssh sync toggles broadcast"; else bad "netssh sync toggles broadcast" "$(tmux capture-pane -p -t outer:ctl)"; fi
# Outside tmux: a new session, attached.
tmux send-keys -t outer:ctl "netssh sync; env -u TMUX netssh multi --kind vm cluster:pve-fsn1" Enter
if wait_for 20 panes_in netssh-pve-fsn1 2; then ok "tmux: new session outside tmux"; else bad "tmux: new session outside tmux" "$(tmux list-sessions)"; fi
expect_eq "tmux: VMs through the bastion" "fsn/web1 fsn/web2 " hosts_of netssh-pve-fsn1
tmux kill-server
# zellij outside a session: netssh starts zellij with a grid layout.
tmux new-session -d -s z -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t z "netssh multi --zellij @pve" Enter
zscreen() { tmux capture-pane -p -t z; }
if wait_for 30 prompts; then ok "zellij: three panes with remote shells"; else bad "zellij: three panes with remote shells" "$(zscreen)"; fi
expect_out "zellij: pane titles" "fsn/fsn1-pve2.hoster.test" zscreen
zsession() { zellij list-sessions --short --no-formatting 2>/dev/null | head -1 | grep .; }
if wait_for 10 zsession; then ok "zellij: session is running"; else bad "zellij: session is running" "$(zellij list-sessions 2>&1)"; fi
session=$(zsession)
# zellij inside that session: netssh multi adds a tab.
ZELLIJ_SESSION_NAME=$session ZELLIJ=0 netssh multi --kind vm cluster:pve-fsn1 >/dev/null 2>&1
expect_out "zellij: new tab inside a session" "pve-fsn1" zellij --session "$session" action query-tab-names
expect_out "zellij: tab layout runs netssh for the VMs" "fsn/web2" zellij --session "$session" action dump-layout
zellij kill-all-sessions --yes >/dev/null 2>&1
tmux kill-server 2>/dev/null
true