netssh multi: mehrere Server in einem Fenster (tmux/zellij) #37
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
.git
|
.git
|
||||||
testenv/.keys
|
testenv/.keys
|
||||||
testenv/.state
|
testenv/.state
|
||||||
|
testenv/.artifacts
|
||||||
@@ -23,3 +23,4 @@ Thumbs.db
|
|||||||
# local test environment (testenv/up.sh)
|
# local test environment (testenv/up.sh)
|
||||||
testenv/.keys/
|
testenv/.keys/
|
||||||
testenv/.state/
|
testenv/.state/
|
||||||
|
testenv/.artifacts/
|
||||||
+5
-1
@@ -5,7 +5,11 @@
|
|||||||
- 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).
|
||||||
|
- tmux keys for broadcast in `netssh multi` windows: prefix `S` toggles all panes, prefix `e` excludes the current pane (border `[SOLO]`) or includes it again; also `netssh sync --pane`. Both keys are unbound in a default tmux, are not bound if your tmux.conf uses them, act only in netssh windows, and can be changed or turned off (`multi.sync_key`, `multi.solo_key`).
|
||||||
|
- `netssh multi` widens the status bar of its own tmux session, which tmux otherwise cuts to `[netssh-pv`.
|
||||||
|
- Cache entries remember the NetBox cluster of a host, for `cluster:` completion and offline use.
|
||||||
|
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 185 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
|
||||||
|
|
||||||
## v0.0.23
|
## v0.0.23
|
||||||
|
|
||||||
|
|||||||
@@ -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, tmux keys for broadcast and excluding a pane, groups (`NAME: sel1, sel2` per line) |
|
||||||
| Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file |
|
| 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,14 @@ 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. |
|
||||||
|
| `multi.sync_key`, `multi.solo_key` | tmux key \| `none` | `S`, `e` | Keys after the tmux prefix: broadcast to all panes, exclude/include the current pane. |
|
||||||
|
|
||||||
### Environment variables
|
### 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 +252,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 +366,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 [--pane]` | Toggle typing into all panes of the current tmux window or zellij tab; `--pane` excludes/includes the current tmux pane. |
|
||||||
| `netssh configure` | Setup wizard. |
|
| `netssh 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 +522,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 +556,53 @@ 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
|
||||||
|
sync_key: S # tmux: prefix S = broadcast on/off (none = no key)
|
||||||
|
solo_key: e # tmux: prefix e = exclude/include this pane
|
||||||
|
```
|
||||||
|
|
||||||
|
**tmux and zellij.** With `auto`, netssh uses the multiplexer you are in; otherwise tmux, then zellij, whichever is installed (`--tmux` / `--zellij` choose explicitly).
|
||||||
|
|
||||||
|
| | 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 to all panes | `--sync`; toggle with `Ctrl+b S` or `netssh sync` | `--sync` inside zellij; toggle with `Ctrl+t` then `s` or `netssh sync` |
|
||||||
|
| Exclude one pane | `Ctrl+b e` (again: include) or `netssh sync --pane` | not possible — zellij syncs whole tabs |
|
||||||
|
| Shown in the border | `[SYNC]` receives broadcast, `[SOLO]` excluded | `(SYNC)` in the tab name |
|
||||||
|
|
||||||
|
**Keys in tmux.** `S` and `e` are free in a default tmux (`E` would be "spread panes evenly"). netssh binds them after your prefix when it opens panes, but only if your own `tmux.conf` does not already use them — then it keeps your binding and says so. They act only in windows opened by `netssh multi`; elsewhere they just show a message. Change or turn them off with `multi.sync_key` / `multi.solo_key` (`none`), e.g. `sync_key: C-s` for `prefix Ctrl+s`.
|
||||||
|
|
||||||
|
**In the TUI**, mark hosts with `Space` (or all listed hosts with `Ctrl+A`) and press `Enter` to open them together.
|
||||||
|
|
||||||
|
`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 +720,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 +885,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; Ctrl+b e on a node that needs something
|
||||||
|
# different excludes just that one, Ctrl+b S turns broadcast off for all
|
||||||
|
```
|
||||||
|
|
||||||
**Deploy a file and restart a service**
|
**Deploy a file and restart a service**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -1024,7 +1090,11 @@ 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`). |
|
||||||
|
| zellij shows empty boxes in its tab and status bar | Those are Powerline arrows; use a Nerd Font in your terminal, or add `simplified_ui true` to `~/.config/zellij/config.kdl`. |
|
||||||
|
| `cluster:x matches no hosts` | Clusters select devices by default; VMs need `--kind vm` or `--kind all`. The name is matched case-insensitively. |
|
||||||
|
| `netssh multi` panes use another config | Inside an existing tmux/zellij session, panes get your `HOME` and `XDG_*` paths, but not `NETSSH_*` variables (they would be visible to other users in the process list). Put such settings in the config file. |
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
@@ -1059,7 +1129,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 # 185 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR)
|
||||||
testenv/down.sh
|
testenv/down.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1075,6 +1145,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 +1158,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
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
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 {
|
||||||
|
var pane bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "sync [--pane]",
|
||||||
|
Short: "Toggle typing into all panes of the current tmux window or zellij tab",
|
||||||
|
Long: `Toggle broadcast in the current tmux window or zellij tab. With --pane,
|
||||||
|
exclude the current tmux pane from broadcast, or include it again.
|
||||||
|
|
||||||
|
In windows opened by netssh multi, tmux also has keys for this:
|
||||||
|
prefix S (all panes) and prefix e (this pane); see multi.sync_key and
|
||||||
|
multi.solo_key. In zellij, use Ctrl+t then s.`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
state, err := multiplex.ToggleSync(multiplex.System{}, pane)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "netssh: "+state)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&pane, "pane", false, "Exclude the current tmux pane from broadcast, or include it again")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMulti expands selectors and opens one pane per host.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
syncKey, soloKey := cfg.Multi.Keys()
|
||||||
|
return multiplex.Open(kind, panes, multiplex.Options{
|
||||||
|
SyncKey: syncKey,
|
||||||
|
SoloKey: soloKey,
|
||||||
|
Label: multiLabel(selectors),
|
||||||
|
Sync: f.sync,
|
||||||
|
PerWindow: cfg.Multi.PanesPerWindowOrDefault(),
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"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"`
|
||||||
|
// SyncKey and SoloKey are tmux prefix keys for broadcast to all panes and
|
||||||
|
// for excluding the current pane; empty = default (S, e), "none" = off.
|
||||||
|
SyncKey string `mapstructure:"sync_key"`
|
||||||
|
SoloKey string `mapstructure:"solo_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default tmux keys: unbound in a stock tmux (E would be "spread evenly").
|
||||||
|
const (
|
||||||
|
DefaultSyncKey = "S"
|
||||||
|
DefaultSoloKey = "e"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keys returns the tmux keys to bind; "" for a disabled one.
|
||||||
|
func (m MultiConfig) Keys() (sync, solo string) {
|
||||||
|
pick := func(key, fallback string) string {
|
||||||
|
switch key {
|
||||||
|
case "":
|
||||||
|
return fallback
|
||||||
|
case "none":
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return pick(m.SyncKey, DefaultSyncKey), pick(m.SoloKey, DefaultSoloKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmuxKey = regexp.MustCompile(`^([CMS]-)*([A-Za-z0-9]|F[0-9]{1,2}|[-_=+!@#$%^&*,./?<>\[\]]|Tab|Space|Enter|BSpace|Up|Down|Left|Right|Home|End|PPage|NPage)$`)
|
||||||
|
|
||||||
|
// DefaultPanesPerWindow keeps panes readable on a normal screen (3×3).
|
||||||
|
const DefaultPanesPerWindow = 9
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
for name, key := range map[string]string{"sync_key": c.Multi.SyncKey, "solo_key": c.Multi.SoloKey} {
|
||||||
|
if key != "" && key != "none" && !tmuxKey.MatchString(key) {
|
||||||
|
return fmt.Errorf("multi.%s %q is not a tmux key (e.g. S, e, C-s, F5) or none", name, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sync, solo := c.Multi.Keys(); sync != "" && sync == solo {
|
||||||
|
return fmt.Errorf("multi.sync_key and multi.solo_key must differ")
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(c.Groups))
|
||||||
|
for name := range c.Groups {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -190,3 +190,23 @@ 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)
|
||||||
|
}
|
||||||
|
if sync, solo := cfg.Multi.Keys(); sync != "S" || solo != "e" {
|
||||||
|
t.Fatalf("default keys %q %q", sync, solo)
|
||||||
|
}
|
||||||
|
custom, err := loadYAML(t, "multi: {sync_key: C-s, solo_key: none}\n")
|
||||||
|
if sync, solo := custom.Multi.Keys(); err != nil || sync != "C-s" || solo != "" {
|
||||||
|
t.Fatalf("custom keys %q %q %v", sync, solo, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}",
|
||||||
|
"multi: {sync_key: \"x y\"}", "multi: {sync_key: \"{\"}", "multi: {sync_key: e}"} {
|
||||||
|
if _, err := loadYAML(t, bad+"\n"); err == nil {
|
||||||
|
t.Errorf("%s accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// 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 and tmux key bindings are written
|
||||||
|
// SyncKey and SoloKey are tmux prefix keys that toggle broadcast for the
|
||||||
|
// window and exclude the current pane from it; empty means no binding.
|
||||||
|
SyncKey, SoloKey string
|
||||||
|
// Hint receives notes for the user before the process is replaced.
|
||||||
|
Hint func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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; with pane it excludes the current tmux pane from it or includes it again.
|
||||||
|
func ToggleSync(r Runner, pane bool) (string, error) {
|
||||||
|
if pane {
|
||||||
|
if r.Getenv("TMUX") == "" {
|
||||||
|
if r.Getenv("ZELLIJ") != "" {
|
||||||
|
return "", errors.New("zellij broadcasts to a whole tab; single panes cannot be excluded")
|
||||||
|
}
|
||||||
|
return "", errors.New("netssh sync --pane works inside tmux")
|
||||||
|
}
|
||||||
|
solo, err := r.Output("tmux", "display-message", "-p", "#{@netssh_solo}")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, argv := range soloToggle(solo == "1") {
|
||||||
|
if _, err := r.Output(argv...); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if solo == "1" {
|
||||||
|
return "pane included in broadcast", nil
|
||||||
|
}
|
||||||
|
return "pane excluded from broadcast", nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.Getenv("TMUX") != "":
|
||||||
|
current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes")
|
||||||
|
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, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
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
|
||||||
|
keys map[string]string // existing prefix bindings by key
|
||||||
|
solo 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
|
||||||
|
case "list-keys":
|
||||||
|
return f.keys[argv[4]], nil
|
||||||
|
case "display-message":
|
||||||
|
return f.solo, 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, SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l"}, 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 @netssh 1",
|
||||||
|
"tmux set-window-option -t @1 pane-border-status top",
|
||||||
|
"tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} ",
|
||||||
|
"tmux set-window-option -t @1 synchronize-panes on",
|
||||||
|
"tmux show-options -gv prefix",
|
||||||
|
"tmux list-keys -T prefix S",
|
||||||
|
"tmux list-keys -T prefix e",
|
||||||
|
"tmux source-file /tmp/l/netssh-multi.tmux.conf",
|
||||||
|
"tmux select-window -t @1",
|
||||||
|
}
|
||||||
|
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
||||||
|
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 ",
|
||||||
|
"tmux set-option -t netssh-pve-2 status-left-length 40",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Errorf("missing %q in\n%s", want, joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, false); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" {
|
||||||
|
t.Fatalf("%q %v %v", state, err, f.calls)
|
||||||
|
}
|
||||||
|
f.syncState = "on"
|
||||||
|
if state, _ := ToggleSync(f, false); state != "broadcast off" {
|
||||||
|
t.Fatalf("%q", state)
|
||||||
|
}
|
||||||
|
z := newFake(map[string]string{"ZELLIJ": "0"})
|
||||||
|
if _, err := ToggleSync(z, false); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" {
|
||||||
|
t.Fatalf("%v %v", err, z.calls)
|
||||||
|
}
|
||||||
|
if _, err := ToggleSync(newFake(nil), false); err == nil {
|
||||||
|
t.Fatal("outside a multiplexer accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTmuxKeyBindings(t *testing.T) {
|
||||||
|
f := newFake(map[string]string{"TMUX": "x"}, "tmux")
|
||||||
|
var hint string
|
||||||
|
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conf := f.files["/tmp/l/netssh-multi.tmux.conf"]
|
||||||
|
for _, want := range []string{"bind-key -T prefix S if-shell -F '#{@netssh}'", "bind-key -T prefix e if-shell", "set-option -p synchronize-panes off"} {
|
||||||
|
if !strings.Contains(conf, want) {
|
||||||
|
t.Errorf("conf lacks %q:\n%s", want, conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hint != "keys: C-b S broadcast to all panes, C-b e exclude/include this pane" {
|
||||||
|
t.Errorf("hint %q", hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key bound in the user's tmux.conf is left alone; netssh's own is replaced.
|
||||||
|
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
|
||||||
|
f.keys = map[string]string{"S": "bind-key -T prefix S kill-server", "e": "bind-key -T prefix e if-shell -F #{@netssh} …"}
|
||||||
|
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conf = f.files["/tmp/l/netssh-multi.tmux.conf"]
|
||||||
|
if strings.Contains(conf, "prefix S ") || !strings.Contains(conf, "prefix e ") {
|
||||||
|
t.Errorf("user binding overwritten or own binding not refreshed:\n%s", conf)
|
||||||
|
}
|
||||||
|
if !strings.Contains(hint, "C-b S kept your own binding") {
|
||||||
|
t.Errorf("hint %q", hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No keys configured: nothing is bound.
|
||||||
|
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
|
||||||
|
if err := Open(Tmux, panes("a"), Options{LayoutDir: "/tmp/l"}, f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.files) != 0 || strings.Contains(strings.Join(f.calls, "\n"), "source-file") {
|
||||||
|
t.Errorf("bound keys although disabled: %v", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleSyncPane(t *testing.T) {
|
||||||
|
f := newFake(map[string]string{"TMUX": "x"})
|
||||||
|
if state, err := ToggleSync(f, true); err != nil || state != "pane excluded from broadcast" {
|
||||||
|
t.Fatalf("%q %v", state, err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(f.calls[1:], "; "); got != "tmux set-option -p synchronize-panes off; tmux set-option -p @netssh_solo 1" {
|
||||||
|
t.Fatalf("exclude: %s", got)
|
||||||
|
}
|
||||||
|
f = newFake(map[string]string{"TMUX": "x"})
|
||||||
|
f.solo = "1"
|
||||||
|
if state, _ := ToggleSync(f, true); state != "pane included in broadcast" || f.calls[1] != "tmux set-option -pu synchronize-panes" {
|
||||||
|
t.Fatalf("include: %q %v", state, f.calls)
|
||||||
|
}
|
||||||
|
if _, err := ToggleSync(newFake(map[string]string{"ZELLIJ": "0"}), true); err == nil || !strings.Contains(err.Error(), "whole tab") {
|
||||||
|
t.Fatalf("zellij: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package multiplex
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
if !inside && i == 0 {
|
||||||
|
// tmux cuts the status bar's session name at 10 characters
|
||||||
|
// ("[netssh-pv"); widen it for this session only.
|
||||||
|
if _, err := r.Output("tmux", "set-option", "-t", session, "status-left-length", "40"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window, pane, _ := strings.Cut(out, " ")
|
||||||
|
if first == "" {
|
||||||
|
first = window
|
||||||
|
}
|
||||||
|
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{
|
||||||
|
{"@netssh", "1"}, // the key bindings act only in these windows
|
||||||
|
{"pane-border-status", "top"},
|
||||||
|
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} "},
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys, err := bindKeys(r, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if keys != "" {
|
||||||
|
opts.Hint(keys)
|
||||||
|
}
|
||||||
|
if inside {
|
||||||
|
_, err := r.Output("tmux", "select-window", "-t", first)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.Exec("tmux", "attach-session", "-t", session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// soloToggle excludes the current pane from broadcast (a pane-level
|
||||||
|
// synchronize-panes off) or includes it again by removing that override.
|
||||||
|
func soloToggle(solo bool) [][]string {
|
||||||
|
if solo {
|
||||||
|
return [][]string{
|
||||||
|
{"tmux", "set-option", "-pu", "synchronize-panes"},
|
||||||
|
{"tmux", "set-option", "-pu", "@netssh_solo"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [][]string{
|
||||||
|
{"tmux", "set-option", "-p", "synchronize-panes", "off"},
|
||||||
|
{"tmux", "set-option", "-p", "@netssh_solo", "1"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyBindings toggle broadcast in netssh multi windows; elsewhere they only
|
||||||
|
// show a message, so they do not change what the keys mean to other windows.
|
||||||
|
const syncBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' {
|
||||||
|
set-window-option synchronize-panes
|
||||||
|
display-message 'netssh: broadcast #{?synchronize-panes,on,off}'
|
||||||
|
} {
|
||||||
|
display-message 'netssh: not a netssh multi window'
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const soloBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' {
|
||||||
|
if-shell -F '#{@netssh_solo}' {
|
||||||
|
set-option -pu synchronize-panes
|
||||||
|
set-option -pu @netssh_solo
|
||||||
|
display-message 'netssh: pane included in broadcast'
|
||||||
|
} {
|
||||||
|
set-option -p synchronize-panes off
|
||||||
|
set-option -p @netssh_solo 1
|
||||||
|
display-message 'netssh: pane excluded from broadcast'
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
display-message 'netssh: not a netssh multi window'
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// bindKeys installs the broadcast keys in the tmux server unless the user's
|
||||||
|
// own configuration already uses them, and returns a hint naming the keys.
|
||||||
|
func bindKeys(r Runner, opts Options) (string, error) {
|
||||||
|
prefix, err := r.Output("tmux", "show-options", "-gv", "prefix")
|
||||||
|
if err != nil || prefix == "" {
|
||||||
|
prefix = "C-b"
|
||||||
|
}
|
||||||
|
var conf strings.Builder
|
||||||
|
var hints, taken []string
|
||||||
|
for _, key := range []struct{ key, binding, what string }{
|
||||||
|
{opts.SyncKey, syncBinding, "broadcast to all panes"},
|
||||||
|
{opts.SoloKey, soloBinding, "exclude/include this pane"},
|
||||||
|
} {
|
||||||
|
if key.key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// An existing binding is replaced only if netssh made it.
|
||||||
|
if existing, _ := r.Output("tmux", "list-keys", "-T", "prefix", key.key); existing != "" && !strings.Contains(existing, "@netssh") {
|
||||||
|
taken = append(taken, prefix+" "+key.key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&conf, key.binding, key.key)
|
||||||
|
hints = append(hints, prefix+" "+key.key+" "+key.what)
|
||||||
|
}
|
||||||
|
if conf.Len() > 0 {
|
||||||
|
path := filepath.Join(opts.LayoutDir, "netssh-multi.tmux.conf")
|
||||||
|
if err := r.WriteFile(path, []byte(conf.String())); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := r.Output("tmux", "source-file", path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hint := "keys: " + strings.Join(hints, ", ")
|
||||||
|
if len(hints) == 0 {
|
||||||
|
hint = "toggle broadcast with: netssh sync, netssh sync --pane"
|
||||||
|
}
|
||||||
|
if len(taken) > 0 {
|
||||||
|
hint += "; " + strings.Join(taken, " and ") + " kept your own binding (use netssh sync)"
|
||||||
|
}
|
||||||
|
return hint, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeSession returns name, or name-2, name-3, … if a session already uses it.
|
||||||
|
func freeSession(r Runner, name string) string {
|
||||||
|
candidate := name
|
||||||
|
for n := 2; ; n++ {
|
||||||
|
if _, err := r.Output("tmux", "has-session", "-t", "="+candidate); err != nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
candidate = name + "-" + strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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)
|
||||||
|
syncKey, soloKey := cfg.Multi.SyncKey, cfg.Multi.SoloKey
|
||||||
|
validKey := func(field string) func(string) error {
|
||||||
|
return func(s string) error {
|
||||||
|
candidate := config.Config{Multi: cfg.Multi}
|
||||||
|
if field == "sync" {
|
||||||
|
candidate.Multi.SyncKey = strings.TrimSpace(s)
|
||||||
|
} else {
|
||||||
|
candidate.Multi.SoloKey = strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
return candidate.ValidateMulti()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Multiplexer").
|
||||||
|
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.NewInput().
|
||||||
|
Title("tmux key: broadcast to all panes").
|
||||||
|
Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSyncKey)).
|
||||||
|
Placeholder(config.DefaultSyncKey).
|
||||||
|
Value(&syncKey).
|
||||||
|
Validate(validKey("sync")),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("tmux key: exclude/include this pane").
|
||||||
|
Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSoloKey)).
|
||||||
|
Placeholder(config.DefaultSoloKey).
|
||||||
|
Value(&soloKey).
|
||||||
|
Validate(validKey("solo")),
|
||||||
|
huh.NewText().
|
||||||
|
Title("Groups").
|
||||||
|
Description("One per line: NAME: host, tag:TAG, cluster:NAME, @other — open with netssh multi @NAME.").
|
||||||
|
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.Multi.SyncKey = strings.TrimSpace(syncKey)
|
||||||
|
cfg.Multi.SoloKey = strings.TrimSpace(soloKey)
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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,36 @@ 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 != (config.MultiConfig{}) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for _, key := range [][2]string{{"sync_key", cfg.Multi.SyncKey}, {"solo_key", cfg.Multi.SoloKey}} {
|
||||||
|
if key[1] != "" {
|
||||||
|
fmt.Fprintf(&b, " %s: %q\n", key[0], key[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Completion.MaxResults > 0 {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-1
@@ -57,6 +57,7 @@ public 10.231.0.0/24 + fd00:30::/64 mgmt 10.231.1.0/24 prod 10.231.2
|
|||||||
| `tests/NN-*.sh` | Test files; each runs in a fresh `HOME`. |
|
| `tests/NN-*.sh` | Test files; each runs in a fresh `HOME`. |
|
||||||
| `tests/drive.py` | Drives `netssh configure` in huh's accessible mode. |
|
| `tests/drive.py` | Drives `netssh configure` in huh's accessible mode. |
|
||||||
| `.keys/`, `.state/` | Generated test key and seed output (git-ignored). |
|
| `.keys/`, `.state/` | Generated test key and seed output (git-ignored). |
|
||||||
|
| `.artifacts/` | Screenshots from `tests/16-render.sh` (git-ignored). |
|
||||||
|
|
||||||
## Writing tests
|
## Writing tests
|
||||||
|
|
||||||
@@ -78,10 +79,38 @@ 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.
|
||||||
|
|
||||||
|
## What a user sees: `16-render.sh`
|
||||||
|
|
||||||
|
The render test runs tmux, zellij, and the TUI in a real terminal emulator —
|
||||||
|
xterm on a virtual X server (Xvfb) — takes screenshots, and reads them back
|
||||||
|
with OCR (tesseract). It checks what is actually drawn: remote prompts in all
|
||||||
|
panes, borders and titles with the host names, `[SYNC]` appearing and
|
||||||
|
disappearing, panes side by side, broadcast output in every pane, input in one
|
||||||
|
pane only without broadcast, the status bar, and the TUI marks. The broadcast
|
||||||
|
keys are pressed as a user would: `tmux send-keys -K` delivers them to the
|
||||||
|
xterm client, so they pass through the prefix and key tables (`prefix e` →
|
||||||
|
`[SOLO]` and the pane drops out of the broadcast; `prefix S` toggles all panes;
|
||||||
|
other windows stay unaffected).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
testenv/test.sh render # then look at the screenshots:
|
||||||
|
ls testenv/.artifacts/ # tmux-1.png, zellij-2.png, tui-3.png, …
|
||||||
|
```
|
||||||
|
|
||||||
|
Helpers in `lib.sh`: `start_screen`/`stop_screen`, `term CMD…` (full-screen
|
||||||
|
xterm), `shot NAME`, `screen_shows NAME TEXT…`, `screen_count NAME TEXT`.
|
||||||
|
OCR reads each screenshot in three preprocessing variants (plain, stretched for
|
||||||
|
dim gray, brightest channel for colored text) and normalizes characters it
|
||||||
|
confuses in DejaVu Sans Mono (`1`/`l`, `C`/`(`), so checks stay robust without
|
||||||
|
becoming vague.
|
||||||
|
|
||||||
## Not covered
|
## 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.
|
||||||
|
- Rendering is checked in xterm with DejaVu Sans Mono. Other terminals and fonts may differ, e.g. zellij's Powerline arrows need a Nerd Font.
|
||||||
@@ -11,8 +11,11 @@ COPY internal ./internal
|
|||||||
RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh
|
RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
# xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator,
|
||||||
|
# take screenshots, and read them back with OCR (tests/16-render.sh).
|
||||||
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
|
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
|
||||||
curl python3 jq ncurses coreutils \
|
curl python3 jq ncurses coreutils grep \
|
||||||
|
xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \
|
||||||
&& adduser -D -s /bin/bash tester
|
&& adduser -D -s /bin/bash tester
|
||||||
COPY --from=build /out/netssh /usr/local/bin/netssh
|
COPY --from=build /out/netssh /usr/local/bin/netssh
|
||||||
COPY testenv/client-entrypoint.sh /entrypoint.sh
|
COPY testenv/client-entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ services:
|
|||||||
- ./.keys:/keys:ro
|
- ./.keys:/keys:ro
|
||||||
- ./.state:/state
|
- ./.state:/state
|
||||||
- ./tests:/tests:ro
|
- ./tests:/tests:ro
|
||||||
|
- ./.artifacts:/artifacts
|
||||||
networks:
|
networks:
|
||||||
public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"}
|
public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"}
|
||||||
mgmt: {ipv4_address: 10.231.1.10}
|
mgmt: {ipv4_address: 10.231.1.10}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# What a user sees: tmux, zellij, and the TUI rendered by a real terminal
|
||||||
|
# emulator (xterm on Xvfb), checked by OCR of screenshots. The screenshots stay
|
||||||
|
# in testenv/.artifacts/ for a look by eye.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh: {default_user: ops}
|
||||||
|
groups:
|
||||||
|
pve: ["cluster:pve-fsn1"]
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
export TMUX_TMPDIR="$HOME/tmux"
|
||||||
|
mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij"
|
||||||
|
printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl"
|
||||||
|
start_screen && ok "X server and xterm" || bad "X server and xterm" "Xvfb did not start"
|
||||||
|
|
||||||
|
p1=fsn/fsn1-pve1.hoster.test p2=fsn/fsn1-pve2.hoster.test p3=fsn/fsn1-pve3.hoster.test
|
||||||
|
prompts() { screen_shows "$1" "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"; }
|
||||||
|
# Two hosts on one OCR line means their panes sit side by side (tiled grid).
|
||||||
|
side_by_side() { screen_text "$1" | ocr_norm | grep -F "$(printf %s "$2" | ocr_norm)" | grep -qF "$(printf %s "$3" | ocr_norm)"; }
|
||||||
|
visible() { # NAME SHOT TEXT... — wait until the screen shows every TEXT
|
||||||
|
local name=$1 shot=$2
|
||||||
|
shift 2
|
||||||
|
if wait_for 30 screen_shows "$shot" "$@"; then ok "$name"; else bad "$name" "$(screen_text "$shot")"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- tmux, started from a plain terminal: new session, attached in xterm ---
|
||||||
|
term netssh multi --sync @pve
|
||||||
|
visible "tmux: three remote shells on screen" tmux-1 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"
|
||||||
|
visible "tmux: borders show host and [SYNC]" tmux-1 "$p1 [SYNC]" "$p2 [SYNC]" "$p3 [SYNC]"
|
||||||
|
side_by_side tmux-1 "$p1" "$p2" && ok "tmux: tiled, first two panes side by side" || bad "tmux: tiled, first two panes side by side" "$(screen_text tmux-1)"
|
||||||
|
tmux send-keys -t netssh-pve "echo typed-once-\$(hostname)" Enter
|
||||||
|
visible "tmux: broadcast output in every pane" tmux-2 "typed-once-fsn1-pve1" "typed-once-fsn1-pve2" "typed-once-fsn1-pve3"
|
||||||
|
# Keys as typed in xterm (send-keys -K goes through the client's prefix and
|
||||||
|
# key tables): prefix e excludes the active pane, prefix S toggles all panes.
|
||||||
|
client=$(tmux list-clients -t netssh-pve -F '#{client_name}' | head -1)
|
||||||
|
press() { tmux send-keys -K -c "$client" C-b "$1"; sleep 0.5; }
|
||||||
|
panes=($(tmux list-panes -t netssh-pve -F '#{pane_id}'))
|
||||||
|
tmux select-pane -t "${panes[2]}"
|
||||||
|
press e
|
||||||
|
visible "tmux: prefix e marks the pane [SOLO]" tmux-solo "$p3 [SOLO]" "$p1 [SYNC]" "$p2 [SYNC]"
|
||||||
|
tmux send-keys -t "${panes[2]}" "echo solo-only" Enter
|
||||||
|
tmux send-keys -t "${panes[0]}" "echo two-panes" Enter
|
||||||
|
sleep 1
|
||||||
|
shot tmux-solo-2
|
||||||
|
[ "$(screen_count tmux-solo-2 "solo-only")" = 2 ] && ok "tmux: the excluded pane types alone" || bad "tmux: the excluded pane types alone" "$(screen_text tmux-solo-2)"
|
||||||
|
[ "$(screen_count tmux-solo-2 "two-panes")" = 4 ] && ok "tmux: broadcast skips the excluded pane" || bad "tmux: broadcast skips the excluded pane" "$(screen_text tmux-solo-2)"
|
||||||
|
press e
|
||||||
|
visible "tmux: prefix e again includes it" tmux-solo-3 "$p3 [SYNC]"
|
||||||
|
|
||||||
|
press S
|
||||||
|
sync_is() { [ "$(tmux show-window-options -t netssh-pve -v synchronize-panes 2>/dev/null)" = "$1" ]; }
|
||||||
|
wait_for 5 sync_is off && ok "tmux: prefix S turns broadcast off" || bad "tmux: prefix S turns broadcast off" "$(tmux show-window-options -t netssh-pve)"
|
||||||
|
tmux send-keys -t netssh-pve "echo just-this-pane" Enter
|
||||||
|
sleep 1
|
||||||
|
shot tmux-3
|
||||||
|
[ "$(screen_count tmux-3 "just-this-pane")" = 2 ] && ok "tmux: without broadcast only one pane gets input" || bad "tmux: without broadcast only one pane gets input" "$(screen_text tmux-3)"
|
||||||
|
shows_sync() { screen_shows tmux-3 "[SYNC]"; }
|
||||||
|
shows_sync && bad "tmux: [SYNC] disappears when broadcast is off" "$(screen_text tmux-3)" || ok "tmux: [SYNC] disappears when broadcast is off"
|
||||||
|
press S
|
||||||
|
wait_for 5 sync_is on && ok "tmux: prefix S turns it on again" || bad "tmux: prefix S turns it on again" "$(tmux show-window-options -t netssh-pve)"
|
||||||
|
tmux kill-server
|
||||||
|
|
||||||
|
# --- more hosts than panes_per_window: further windows ---
|
||||||
|
sed -i 's/^groups:/multi: {panes_per_window: 2}\ngroups:/' "$XDG_CONFIG_HOME/netssh.yaml"
|
||||||
|
term netssh multi @pve
|
||||||
|
visible "tmux: hosts split over windows pve-1 and pve-2" tmux-windows "pve-1" "pve-2"
|
||||||
|
visible "tmux: status bar shows the whole session name" tmux-windows "[netssh-pve]"
|
||||||
|
tmux kill-server
|
||||||
|
sed -i '/^multi: {panes_per_window: 2}$/d' "$XDG_CONFIG_HOME/netssh.yaml"
|
||||||
|
|
||||||
|
# --- zellij: generated grid layout, broadcast through the sync tab ---
|
||||||
|
term netssh multi --zellij @pve
|
||||||
|
visible "zellij: three remote shells on screen" zellij-1 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"
|
||||||
|
visible "zellij: pane titles show the hosts" zellij-1 "$p1" "$p2" "$p3"
|
||||||
|
side_by_side zellij-1 "$p1" "$p2" && ok "zellij: grid, first two panes side by side" || bad "zellij: grid, first two panes side by side" "$(screen_text zellij-1)"
|
||||||
|
session=$(zellij list-sessions --short --no-formatting 2>/dev/null | head -1)
|
||||||
|
zellij --session "$session" action toggle-active-sync-tab
|
||||||
|
zellij --session "$session" action write-chars 'echo zellij-$(hostname)'
|
||||||
|
zellij --session "$session" action write 13
|
||||||
|
visible "zellij: broadcast output in every pane" zellij-2 "zellij-fsn1-pve1" "zellij-fsn1-pve2" "zellij-fsn1-pve3"
|
||||||
|
zellij kill-all-sessions --yes >/dev/null 2>&1
|
||||||
|
|
||||||
|
# --- TUI: mark hosts, open them together ---
|
||||||
|
term tmux new-session -s tui netssh
|
||||||
|
visible "TUI: search screen" tui-1 "Search hostname"
|
||||||
|
# The netssh keys do nothing outside netssh multi windows.
|
||||||
|
tmux source-file "$XDG_CACHE_HOME/netssh/netssh-multi.tmux.conf"
|
||||||
|
client=$(tmux list-clients -t tui -F '#{client_name}' | head -1)
|
||||||
|
tmux send-keys -K -c "$client" C-b S
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(tmux show-window-options -t tui -v synchronize-panes 2>/dev/null)" != on ] && ok "tmux: prefix S leaves other windows alone" || bad "tmux: prefix S leaves other windows alone" "synchronize-panes turned on in a non-netssh window"
|
||||||
|
tmux send-keys -t tui "pve"
|
||||||
|
visible "TUI: results listed" tui-2 "$p1" "$p2" "$p3"
|
||||||
|
tmux send-keys -t tui C-a
|
||||||
|
visible "TUI: all marked" tui-3 "3 marked"
|
||||||
|
tmux send-keys -t tui Enter
|
||||||
|
visible "TUI: marked hosts open side by side" tui-4 "fsn1-pve1:~\$" "fsn1-pve2:~\$" "fsn1-pve3:~\$"
|
||||||
|
tmux kill-server
|
||||||
|
|
||||||
|
stop_screen
|
||||||
@@ -76,3 +76,79 @@ wait_for() {
|
|||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- rendering in a real terminal emulator (tests/16-render.sh) ---
|
||||||
|
# Xvfb provides a screen, xterm renders the terminal, `import` takes a
|
||||||
|
# screenshot, and tesseract reads it back, so checks see what a user sees.
|
||||||
|
|
||||||
|
start_screen() {
|
||||||
|
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
|
export DISPLAY=:99
|
||||||
|
wait_for 10 import -window root -crop 1x1+0+0 null:
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_screen() {
|
||||||
|
pkill -u "$(id -u)" xterm 2>/dev/null
|
||||||
|
kill "$XVFB_PID" 2>/dev/null
|
||||||
|
wait "$XVFB_PID" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# term CMD... — run CMD in a full-screen xterm (UTF-8, DejaVu Sans Mono).
|
||||||
|
term() {
|
||||||
|
pkill -u "$(id -u)" xterm 2>/dev/null
|
||||||
|
xterm -u8 -geometry 190x52+0+0 -fa 'DejaVu Sans Mono' -fs 11 -bg '#1e1e1e' -fg '#d4d4d4' \
|
||||||
|
-T netssh -e "$@" >/dev/null 2>&1 &
|
||||||
|
sleep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# shot NAME — screenshot to /artifacts/NAME.png.
|
||||||
|
shot() { import -window root "/artifacts/$1.png"; }
|
||||||
|
|
||||||
|
# OCR preprocessing variants: no single one reads everything (dim hint lines,
|
||||||
|
# red/green borders): plain luminance, stretched dark range for dim gray, and
|
||||||
|
# the brightest color channel for colored text.
|
||||||
|
OCR_VARIANTS=("-colorspace gray" "-colorspace gray -level 10%,40%"
|
||||||
|
"-channel RGB -separate -evaluate-sequence max -level 10%,60%")
|
||||||
|
|
||||||
|
# ocr_variant NAME I — OCR of screenshot NAME with preprocessing variant I.
|
||||||
|
ocr_variant() {
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
magick "/artifacts/$1.png" ${OCR_VARIANTS[$2]} -negate -resize 300% png:- |
|
||||||
|
tesseract stdin stdout --psm 6 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# screen_text NAME — the text of all variants combined.
|
||||||
|
screen_text() {
|
||||||
|
local i
|
||||||
|
for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O,
|
||||||
|
# typographic dashes for "-") to one form; expected text gets the same mapping,
|
||||||
|
# so comparisons stay strict about everything else.
|
||||||
|
ocr_norm() { sed 's/[‐‑‒–—―−]/-/g' | tr 'lI|(O' '111C0'; }
|
||||||
|
|
||||||
|
# screen_shows NAME TEXT... — take a screenshot and check that OCR finds every TEXT.
|
||||||
|
screen_shows() {
|
||||||
|
local name=$1 text want
|
||||||
|
shift
|
||||||
|
shot "$name"
|
||||||
|
text=$(screen_text "$name" | ocr_norm)
|
||||||
|
for want in "$@"; do
|
||||||
|
want=$(printf '%s' "$want" | ocr_norm)
|
||||||
|
[[ $text == *"$want"* ]] || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# screen_count NAME TEXT — how often TEXT is on screenshot NAME: the highest
|
||||||
|
# count of any OCR variant (they read the same screen, so they are not added).
|
||||||
|
screen_count() {
|
||||||
|
local want i n max=0
|
||||||
|
want=$(printf '%s' "$2" | ocr_norm)
|
||||||
|
for i in "${!OCR_VARIANTS[@]}"; do
|
||||||
|
n=$(ocr_variant "$1" "$i" | ocr_norm | grep -oF -- "$want" | wc -l)
|
||||||
|
[ "$n" -gt "$max" ] && max=$n
|
||||||
|
done
|
||||||
|
echo "$max"
|
||||||
|
}
|
||||||
+2
-2
@@ -5,8 +5,8 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
compose=(docker compose -f compose.yaml)
|
compose=(docker compose -f compose.yaml)
|
||||||
|
|
||||||
mkdir -p .keys .state
|
mkdir -p .keys .state .artifacts
|
||||||
chmod a+rwx .state # written by the seed container's user
|
chmod a+rwx .state .artifacts # written by the container user (screenshots in .artifacts)
|
||||||
if [ ! -f .keys/id_ed25519 ]; then
|
if [ ! -f .keys/id_ed25519 ]; then
|
||||||
ssh-keygen -q -t ed25519 -N '' -C netssh-test -f .keys/id_ed25519
|
ssh-keygen -q -t ed25519 -N '' -C netssh-test -f .keys/id_ed25519
|
||||||
cp .keys/id_ed25519.pub .keys/authorized_keys
|
cp .keys/id_ed25519.pub .keys/authorized_keys
|
||||||
|
|||||||
Reference in new issue
Block a user