netssh multi: mehrere Server in einem Fenster (tmux/zellij) #37

Merged
Sebi merged 3 commits from feat/multi into main 2026-11-14 12:37:29 +00:00
31 changed files with 2257 additions and 43 deletions

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
.git
testenv/.keys
testenv/.state
testenv/.artifacts
+1
View File
@@ -23,3 +23,4 @@ Thumbs.db
# local test environment (testenv/up.sh)
testenv/.keys/
testenv/.state/
testenv/.artifacts/
+5 -1
View File
@@ -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 `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths.
- Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`).
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 141 end-to-end checks of all features (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
- Add `netssh multi` to open several hosts side by side in tmux or zellij: host lists, `tag:`, NetBox virtualization clusters (`cluster:`, devices by default, `--kind vm|all`), and groups from the config (`@name`). `--sync` sends typing to all panes; `netssh sync` toggles it later. In the TUI, `Space` marks hosts and `Enter` opens them together. Configurable in `netssh configure` (multiplexer, panes per window, groups).
- 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
+77 -6
View File
@@ -21,7 +21,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int
- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuration](#configuration) — [wizard](#interactive-wizard), [manual config](#manual-config), [reference](#configuration-reference), [environment variables](#environment-variables), [files](#files-and-directories), [multiple instances](#multiple-netbox-instances)
- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management)
- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [multiple servers](#multiple-servers-in-one-window), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management)
- [Recipes](#recipes)
- [IP Resolution Strategies](#ip-resolution-strategies)
- [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook)
@@ -50,6 +50,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh
- **Several servers in one window** — `netssh multi` opens hosts, a tag, a NetBox cluster, or a saved group side by side in tmux or zellij, with optional typing into all of them
- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
@@ -149,6 +150,7 @@ A menu then gives access to every other setting; open only the sections you want
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) |
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
| Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options |
| Multi-session | multiplexer (auto, tmux, zellij), panes per window, tmux keys for broadcast and excluding a pane, groups (`NAME: sel1, sel2` per line) |
| Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file |
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
| Completion | `completion.max_results` |
@@ -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. |
| `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). |
| `hosts[]` | list | `[]` | Static hosts with `name`, `address`, `user`, `port`, `tags`, `ssh` — see [Static hosts](#static-hosts). |
| `groups` | map of lists | `{}` | Named host lists for `netssh multi @NAME`; entries are hosts or selectors. |
| `multi.multiplexer` | `auto` \| `tmux` \| `zellij` | `auto` | Which multiplexer `netssh multi` uses. |
| `multi.panes_per_window` | int | `9` | More hosts open in further tmux windows or zellij tabs. |
| `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
These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`) can only be set in the file.
These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`, `groups`) can only be set in the file.
| Variable | Key |
|---|---|
@@ -246,6 +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_SHORTCUTS_DOMAINS`, `NETSSH_SHORTCUTS_STRIP_HYPHENS` | `shortcuts.*` |
| `NETSSH_COMPLETION_MAX_RESULTS` | `completion.max_results` |
| `NETSSH_MULTI_MULTIPLEXER`, `NETSSH_MULTI_PANES_PER_WINDOW` | `multi.*` |
| `NETSSH_SHELL_ALIAS` | `shell.alias` |
netssh also respects `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `ZDOTDIR`, and `SHELL` (for `hook install`, `completion install`, and `alias` without `--shell`).
@@ -359,6 +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 tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). |
| `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). |
| `netssh multi [--sync] [--kind K] [--tmux\|--zellij] [-l U] [--jump H\|--no-jump] HOST\|tag:T\|cluster:C\|@GROUP...` | Several hosts side by side — see [Multiple servers](#multiple-servers-in-one-window). |
| `netssh sync [--pane]` | Toggle typing into all panes of the current tmux window or zellij tab; `--pane` excludes/includes the current tmux pane. |
| `netssh configure` | Setup wizard. |
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. |
| `netssh search QUERY` | Print matching cached hostnames, one per line. |
@@ -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.
`sftp`, `scp`, `tunnel`, and `hosts` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
`sftp`, `scp`, `tunnel`, `hosts`, `multi`, and `sync` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
### Static hosts
@@ -547,6 +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.
- `--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
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 |
| `↑` / `↓` | navigate results |
| `Enter` | connect to selected host |
| `Space` | mark/unmark the host; with marks, `Enter` opens them side by side (`netssh multi`) |
| `Ctrl+A` | mark all listed hosts (again: unmark all) |
| `Ctrl+O` | open an SFTP session to the selected host |
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) |
| `Ctrl+E` | open inline editor to override user/port before connecting |
@@ -827,6 +885,14 @@ netssh cache refresh && netssh alias --test fsn1-bx1.hoster.de
netssh fsnbx1 # also works as shell alias: fsnbx1
```
**Maintenance on all nodes of a cluster at once**
```sh
netssh multi --sync cluster:pve-fsn1 # every node in one window, typing goes to all
# … run apt upgrade on all nodes; 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**
```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. |
| `<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. |
| 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
@@ -1059,7 +1129,7 @@ End-to-end tests run against real services in a local Docker environment — two
```sh
testenv/up.sh # build and start (first start ~6 min)
testenv/test.sh # 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
```
@@ -1075,6 +1145,7 @@ Project layout:
| `internal/resolver` | IP resolution strategies. |
| `internal/cache` | Host cache. |
| `internal/ssh` | Argument parsing and option injection for ssh, sftp, scp. |
| `internal/multiplex` | tmux and zellij layouts for `netssh multi`. |
| `internal/shortcuts` | Shortcut normalization. |
| `internal/tui` | Interactive search. |
| `internal/setup` | Setup wizard. |
@@ -1087,7 +1158,7 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
## How it works
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI.
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `multi`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI.
2. On first run, or when neither a NetBox nor static hosts are configured, the interactive setup wizard starts automatically.
3. It removes its own long options (`--dry-run`, `--jump`, `--no-jump`, `--forward`) and parses the remaining arguments with the flag table of the program it will run (ssh, sftp, or scp differ, e.g. in `-l` and `-P`) to find the destination: `[user@]host` for ssh, `[user@]host[:path]` for sftp, and every `host:path` operand for scp.
4. It resolves the host: first the static hosts from `hosts:` (by name or shortcut), then an exact cache lookup, a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached.
+14 -5
View File
@@ -46,6 +46,8 @@ var managedSubcommands = map[string]bool{
"scp": true,
"tunnel": true,
"hosts": true,
"multi": true,
"sync": true,
}
func main() {
@@ -291,7 +293,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
fatalf("IP resolution for %q: %v", host, err)
}
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}
c.Upsert(entry)
return used(entry)
}
@@ -389,6 +391,12 @@ func runTUI() {
if host == nil {
return
}
if host.Action == tui.ActionMulti {
if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil {
fatalf("%v", err)
}
return
}
// Revalidate stale or live TUI results through the configured resolver chain.
var tags []string
@@ -413,7 +421,7 @@ func runTUI() {
if err != nil {
fatalf("IP resolution: %v", err)
}
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster})
tags = target.Tags
}
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
@@ -499,7 +507,7 @@ func rootCmd() *cobra.Command {
},
}
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd())
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
@@ -754,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue
}
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster})
written++
}
@@ -995,7 +1003,8 @@ func hookUninstallCmd() *cobra.Command {
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if !cfg.HasNetBox() {
// netssh multi checks once and starts its panes with NETSSH_NO_REFRESH.
if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" {
return
}
if !c.NeedsRefresh(24 * time.Hour) {
+414
View File
@@ -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
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestExpandSelectors(t *testing.T) {
cfg := &config.Config{
Hosts: []config.StaticHost{{Name: "box", Address: "10.0.0.1", Tags: []string{"Web Servers"}}},
Groups: map[string][]string{
"all": {"@web", "db1", "box"},
"web": {"web1", "tag:web-servers", "web1"},
"loop": {"@loop"},
},
}
c := cache.New(t.TempDir()+"/hosts.json", 60).WithStatic(cfg.Hosts)
got, _, err := expandSelectors(cfg, c, []string{"@all", "WEB1"}, "")
if err != nil || !reflect.DeepEqual(got, []string{"web1", "box", "db1"}) {
t.Fatalf("got %v %v", got, err)
}
if _, _, err := expandSelectors(cfg, c, []string{"@loop"}, ""); err == nil || !strings.Contains(err.Error(), "contains itself") {
t.Fatalf("cycle: %v", err)
}
if _, _, err := expandSelectors(cfg, c, []string{"@nope"}, ""); err == nil || !strings.Contains(err.Error(), "available: @all, @loop, @web") {
t.Fatalf("unknown group: %v", err)
}
if _, _, err := expandSelectors(cfg, c, []string{"tag:web-servers"}, "device"); err == nil {
t.Fatal("--kind device must exclude static hosts")
}
}
// Without a reachable NetBox, tag: and cluster: fall back to the cache.
func TestExpandSelectorsCacheFallback(t *testing.T) {
cfg := &config.Config{NetBox: config.NetBoxConfig{URL: "http://127.0.0.1:1", Token: "t", TokenVersion: 1}}
c := cache.New(t.TempDir()+"/hosts.json", 60)
c.Upsert(cache.Entry{Name: "node2", Kind: "device", Cluster: "pve"})
c.Upsert(cache.Entry{Name: "node1", Kind: "device", Cluster: "pve", Tags: []string{"pve"}})
c.Upsert(cache.Entry{Name: "vm1", Kind: "vm", Cluster: "pve"})
got, warnings, err := expandSelectors(cfg, c, []string{"cluster:PVE"}, "")
if err != nil || !reflect.DeepEqual(got, []string{"node1", "node2"}) || len(warnings) != 1 {
t.Fatalf("devices: %v %v %v", got, warnings, err)
}
if got, _, _ := expandSelectors(cfg, c, []string{"cluster:pve"}, "all"); len(got) != 3 {
t.Fatalf("all: %v", got)
}
if _, _, err := expandSelectors(cfg, c, []string{"cluster:other"}, ""); err == nil || !strings.Contains(err.Error(), "--kind vm") {
t.Fatalf("empty cluster: %v", err)
}
}
func TestMultiLabelAndPaneArgv(t *testing.T) {
for selectors, want := range map[string]string{"@k8s": "k8s", "cluster:pve-fsn1": "pve-fsn1", "tag:a.b": "a-b", "a b": "multi"} {
if got := multiLabel(strings.Fields(selectors)); got != want {
t.Errorf("%q: got %q", selectors, got)
}
}
t.Setenv("XDG_CONFIG_HOME", "/cfg")
t.Setenv("NETSSH_NETBOX_TOKEN", "secret")
argv := strings.Join(paneArgv("prod/web1", multiFlags{user: "ops", jump: "jh"}), " ")
for _, want := range []string{"NETSSH_NO_REFRESH=1", "XDG_CONFIG_HOME=/cfg", "--jump jh -l ops prod/web1"} {
if !strings.Contains(argv, want) {
t.Errorf("argv lacks %q: %s", want, argv)
}
}
if strings.Contains(argv, "secret") {
t.Fatalf("token leaked into the pane arguments: %s", argv)
}
}
func TestMultiDryRun(t *testing.T) {
prepareConfig(t, "hosts:\n - {name: a, address: 10.0.0.1, tags: [web]}\n - {name: b, address: 10.0.0.2, tags: [web]}\ngroups:\n web: [\"tag:web\"]\n")
out, err := runNetssh(t, "multi", "--dry-run", "--tmux", "--sync", "@web")
if err != nil {
t.Fatalf("%s %v", out, err)
}
for _, want := range []string{"# 2 hosts: a b", "tmux new-session -d -x 240 -y 70 -s netssh-web", "synchronize-panes on", "tmux attach-session -t netssh-web"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q:\n%s", want, out)
}
}
if out, err := runNetssh(t, "multi", "--dry-run", "--zellij", "a", "b"); err != nil || !strings.Contains(out, `pane name="b"`) {
t.Errorf("zellij: %s %v", out, err)
}
if out, err := runNetssh(t, "sync"); err == nil || !strings.Contains(out, "inside tmux or zellij") {
t.Errorf("sync outside: %s %v", out, err)
}
}
+1
View File
@@ -21,6 +21,7 @@ type Entry struct {
IP string `json:"ip"`
Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"`
Cluster string `json:"cluster,omitempty"`
CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"`
// Static marks a host from the config's hosts: list. It never expires;
+7 -1
View File
@@ -20,6 +20,9 @@ type Config struct {
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
Completion CompletionConfig `mapstructure:"completion"`
Hosts []StaticHost `mapstructure:"hosts"`
// Groups name lists of hosts and selectors for netssh multi (@name).
Groups map[string][]string `mapstructure:"groups"`
Multi MultiConfig `mapstructure:"multi"`
}
type ShellConfig struct {
@@ -117,7 +120,7 @@ func Load() (*Config, error) {
v.SetEnvPrefix("NETSSH")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results", "multi.multiplexer", "multi.panes_per_window"} {
if err := v.BindEnv(key); err != nil {
return nil, err
}
@@ -163,6 +166,9 @@ func Load() (*Config, error) {
if err := cfg.ValidateHosts(); err != nil {
return nil, err
}
if err := cfg.ValidateMulti(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name)
+92
View File
@@ -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
}
+20
View File
@@ -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)
}
}
}
+251
View File
@@ -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, " ")
}
+281
View File
@@ -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)
}
}
+194
View File
@@ -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)
}
}
+101
View File
@@ -0,0 +1,101 @@
package multiplex
import (
"fmt"
"math"
"path/filepath"
"strings"
)
// openZellij writes a layout with one tab per chunk of panes. Inside zellij
// each tab is added to the current session; outside, a new session starts.
func openZellij(panes []Pane, opts Options, r Runner) error {
groups := chunks(panes, opts.PerWindow)
if r.Getenv("ZELLIJ") != "" {
for i, group := range groups {
path := filepath.Join(opts.LayoutDir, fmt.Sprintf("netssh-multi-%d.kdl", i+1))
tab := zellijTab(windowLabel(opts.Label, i, len(groups)), group, true)
if err := r.WriteFile(path, []byte(zellijLayout(tab))); err != nil {
return err
}
if _, err := r.Output("zellij", "action", "new-tab", "--layout", path); err != nil {
return err
}
if opts.Sync {
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
return err
}
}
}
return nil
}
var tabs strings.Builder
for i, group := range groups {
tabs.WriteString(zellijTab(windowLabel(opts.Label, i, len(groups)), group, i == 0))
}
path := filepath.Join(opts.LayoutDir, "netssh-multi.kdl")
if err := r.WriteFile(path, []byte(zellijLayout(tabs.String()))); err != nil {
return err
}
if opts.Sync {
opts.Hint("zellij starts without broadcast: press Ctrl+t, then s, to type into all panes")
} else {
opts.Hint("toggle broadcast with Ctrl+t, then s (or netssh sync)")
}
return r.Exec("zellij", "--layout", path)
}
// zellijLayout wraps tabs with zellij's tab and status bars.
func zellijLayout(tabs string) string {
return `layout {
default_tab_template {
pane size=1 borderless=true {
plugin location="zellij:tab-bar"
}
children
pane size=2 borderless=true {
plugin location="zellij:status-bar"
}
}
` + tabs + "}\n"
}
// zellijTab arranges panes in a grid: rows of ceil(sqrt(n)) columns.
func zellijTab(name string, panes []Pane, focus bool) string {
cols := int(math.Ceil(math.Sqrt(float64(len(panes)))))
var b strings.Builder
fmt.Fprintf(&b, " tab name=%s", kdlString(name))
if focus {
b.WriteString(" focus=true")
}
b.WriteString(" {\n")
for start := 0; start < len(panes); start += cols {
row := panes[start:min(start+cols, len(panes))]
indent := " "
if len(row) > 1 {
b.WriteString(indent + "pane split_direction=\"vertical\" {\n")
indent += " "
}
for _, p := range row {
fmt.Fprintf(&b, "%spane name=%s command=%s {\n", indent, kdlString(p.Title), kdlString(p.Argv[0]))
if len(p.Argv) > 1 {
args := make([]string, 0, len(p.Argv)-1)
for _, arg := range p.Argv[1:] {
args = append(args, kdlString(arg))
}
fmt.Fprintf(&b, "%s args %s\n", indent, strings.Join(args, " "))
}
b.WriteString(indent + "}\n")
}
if len(row) > 1 {
b.WriteString(" }\n")
}
}
b.WriteString(" }\n")
return b.String()
}
// kdlString quotes s as a KDL string.
func kdlString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + `"`
}
+43 -12
View File
@@ -105,6 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
}
filters := ""
if opts.Tag != "" {
filters += "&tag=" + url.QueryEscape(opts.Tag)
}
if opts.Cluster != "" {
ids, err := c.clusterIDs(ctx, opts.Cluster)
if err != nil {
return nil, fmt.Errorf("clusters: %w", err)
}
if len(ids) == 0 {
return nil, nil // no such cluster in this NetBox
}
for _, id := range ids {
filters += fmt.Sprintf("&cluster_id=%d", id)
}
}
var (
mu sync.Mutex
results []HostEntry
@@ -116,7 +132,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1)
go func() {
defer wg.Done()
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
devices, err := c.fetchAllDevices(ctx, query, filters)
mu.Lock()
defer mu.Unlock()
if err != nil {
@@ -131,7 +147,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1)
go func() {
defer wg.Done()
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
vms, err := c.fetchAllVMs(ctx, query, filters)
mu.Lock()
defer mu.Unlock()
if err != nil {
@@ -231,15 +247,27 @@ func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry,
return entries, nil
}
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
// clusterIDs returns the IDs of the clusters named name (case-insensitive).
func (c *Client) clusterIDs(ctx context.Context, name string) ([]int, error) {
var resp netboxListResponse[netboxCluster]
apiURL := fmt.Sprintf("%s/api/virtualization/clusters/?name__ie=%s&limit=50", c.baseURL, url.QueryEscape(name))
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
ids := make([]int, 0, len(resp.Results))
for _, cluster := range resp.Results {
ids = append(ids, cluster.ID)
}
return ids, nil
}
// fetchAllDevices pages through devices; filters are extra "&key=value" parameters.
func (c *Client) fetchAllDevices(ctx context.Context, query, filters string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
@@ -254,15 +282,12 @@ func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]Host
return all, nil
}
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
func (c *Client) fetchAllVMs(ctx context.Context, query, filters string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
@@ -343,6 +368,9 @@ func deviceToEntry(d netboxDevice) HostEntry {
for _, t := range d.Tags {
e.Tags = append(e.Tags, t.Name)
}
if d.Cluster != nil {
e.Cluster = d.Cluster.Name
}
return e
}
@@ -357,6 +385,9 @@ func vmToEntry(v netboxVM) HostEntry {
for _, t := range v.Tags {
e.Tags = append(e.Tags, t.Name)
}
if v.Cluster != nil {
e.Cluster = v.Cluster.Name
}
return e
}
+51
View File
@@ -0,0 +1,51 @@
package netbox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestSearchAllByCluster(t *testing.T) {
var deviceQuery, vmQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/virtualization/clusters/":
if r.URL.Query().Get("name__ie") == "PVE" {
fmt.Fprint(w, `{"count":2,"results":[{"id":3,"name":"pve"},{"id":4,"name":"pve"}]}`)
return
}
fmt.Fprint(w, `{"count":0,"results":[]}`)
case "/api/dcim/devices/":
deviceQuery = r.URL.RawQuery
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"node1","cluster":{"id":3,"name":"pve"}}]}`)
case "/api/virtualization/virtual-machines/":
vmQuery = r.URL.RawQuery
fmt.Fprint(w, `{"count":1,"results":[{"id":2,"name":"vm1","cluster":{"id":4,"name":"pve"}}]}`)
}
}))
defer srv.Close()
c := NewClient(srv.URL, "t", 1)
hosts, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE", Kind: "device"})
if err != nil || len(hosts) != 1 || hosts[0].Name != "node1" || hosts[0].Cluster != "pve" {
t.Fatalf("got %+v %v", hosts, err)
}
if want := "name__ic=&limit=50&offset=0&cluster_id=3&cluster_id=4"; deviceQuery != want {
t.Errorf("device query %q, want %q", deviceQuery, want)
}
if vmQuery != "" {
t.Error("VMs queried for kind device")
}
all, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE"})
if err != nil || len(all) != 2 {
t.Fatalf("all kinds: %+v %v", all, err)
}
none, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "missing"})
if err != nil || len(none) != 0 {
t.Fatalf("unknown cluster: %+v %v", none, err)
}
}
+15 -4
View File
@@ -7,6 +7,8 @@ import "strings"
type SearchOptions struct {
Tag string // filter by tag slug; empty = no filter
Kind string // "device" | "vm" | "" (both)
// Cluster filters SearchAll by virtualization cluster name (case-insensitive).
Cluster string
}
// HostEntry is a unified model for both devices and virtual machines from NetBox.
@@ -18,6 +20,13 @@ type HostEntry struct {
PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped)
PrimaryIP6 string
Tags []string
Cluster string // virtualization cluster name, if assigned
}
// netboxCluster is the nested cluster of a device or virtual machine.
type netboxCluster struct {
ID int `json:"id"`
Name string `json:"name"`
}
// netboxIP represents an IP address as returned by the NetBox API.
@@ -32,8 +41,9 @@ type netboxDevice struct {
Tags []struct {
Name string `json:"name"`
} `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
}
// netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response.
@@ -43,8 +53,9 @@ type netboxVM struct {
Tags []struct {
Name string `json:"name"`
} `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
}
type netboxListResponse[T any] struct {
+128
View File
@@ -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")
}
+36
View File
@@ -91,6 +91,7 @@ func runMenu(cfg *config.Config) error {
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
huh.NewOption(fmt.Sprintf("Multi-session — groups, tmux/zellij (%d groups)", len(cfg.Groups)), "multi"),
huh.NewOption("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"),
huh.NewOption("Completion", "completion"),
@@ -115,6 +116,8 @@ func runMenu(cfg *config.Config) error {
if hosts, err = configureHosts(cfg.Hosts); err == nil {
cfg.Hosts = hosts
}
case "multi":
err = editMulti(cfg)
case "resolver":
err = editResolverCache(cfg)
case "shortcuts":
@@ -369,6 +372,9 @@ func save(cfg config.Config) error {
if err := cfg.ValidateHosts(); err != nil {
return err
}
if err := cfg.ValidateMulti(); err != nil {
return err
}
path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err)
@@ -461,6 +467,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 {
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
}
+34
View File
@@ -617,3 +617,37 @@ func TestValidateHostIn(t *testing.T) {
t.Fatal("invalid address accepted")
}
}
func TestParseGroupsAndSaveMulti(t *testing.T) {
groups, err := parseGroups("K8s: master1, tag:k8s-worker\npve: cluster:pve-fsn1 @k8s\n")
want := map[string][]string{"k8s": {"master1", "tag:k8s-worker"}, "pve": {"cluster:pve-fsn1", "@k8s"}}
if err != nil || !reflect.DeepEqual(groups, want) {
t.Fatalf("got %v %v", groups, err)
}
if again, _ := parseGroups(formatGroups(groups)); !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: %v", again)
}
for _, bad := range []string{"no colon", "tag:x", "bad name: a", "empty:"} {
if _, err := parseGroups(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cfg := config.Config{
Hosts: []config.StaticHost{{Name: "a", Address: "10.0.0.1"}},
Groups: groups,
Multi: config.MultiConfig{Multiplexer: "zellij", PanesPerWindow: 4},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Groups, want) || loaded.Multi != cfg.Multi {
t.Fatalf("got %v %+v", loaded.Groups, loaded.Multi)
}
}
+74 -9
View File
@@ -27,8 +27,9 @@ type SelectedHost struct {
IP string
User string // empty = use config default
Port string // empty = use default port
Action string // ActionSSH, ActionSFTP, or ActionTunnel
Action string // ActionSSH, ActionSFTP, ActionTunnel, or ActionMulti
Forwards []string // forward names or specs for ActionTunnel
Targets []string // marked hosts for ActionMulti
}
// Actions the user can start on a host.
@@ -36,6 +37,7 @@ const (
ActionSSH = ""
ActionSFTP = "sftp"
ActionTunnel = "tunnel"
ActionMulti = "multi"
)
// --- bubbletea messages ---
@@ -74,7 +76,8 @@ func (h hostItem) FilterValue() string { return h.name }
// --- compact list delegate ---
type compactDelegate struct{}
// compactDelegate shares the model's marks so marked hosts show a dot.
type compactDelegate struct{ marked map[string]bool }
func (d compactDelegate) Height() int { return 1 }
func (d compactDelegate) Spacing() int { return 0 }
@@ -84,7 +87,11 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
if !ok {
return
}
line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
mark := " "
if d.marked[h.key()] {
mark = "● "
}
line := fmt.Sprintf(" %s%s %s", mark, h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
if index == m.Index() {
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
}
@@ -169,10 +176,12 @@ type Model struct {
selected *SelectedHost
width int
height int
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T on the selected item
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T on the selected item
marked map[string]bool // hostItem.key() of hosts marked with space
markOrder []string // targets in the order they were marked
}
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
@@ -191,7 +200,8 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
fwd.Prompt = ""
fwd.Placeholder = "web, 8080:localhost:80"
l := list.New(nil, compactDelegate{}, 0, 0)
marked := make(map[string]bool)
l := list.New(nil, compactDelegate{marked: marked}, 0, 0)
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
@@ -212,6 +222,7 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
filterInput: fi,
editInput: ei,
forwardInput: fwd,
marked: marked,
list: l,
client: client,
cache: c,
@@ -386,11 +397,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "enter":
if len(m.markOrder) > 0 {
m.selected = &SelectedHost{Action: ActionMulti, Targets: append([]string(nil), m.markOrder...)}
return m, tea.Quit
}
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
return m, tea.Quit
}
case "space":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.toggleMark(item)
m.list.CursorDown()
return m, nil
}
case "ctrl+a":
m.toggleAllVisible()
return m, nil
case "ctrl+e":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item)
@@ -584,7 +610,10 @@ func (m *Model) viewContent() string {
sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else {
hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
hint := "ctrl+c quit enter connect space mark ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
if n := len(m.markOrder); n > 0 {
hint = fmt.Sprintf("%d marked — enter open side by side space unmark ctrl+a all esc quit", n)
}
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
}
@@ -724,6 +753,42 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
func (h hostItem) key() string { return h.name + "\x00" + h.kind }
// toggleMark marks or unmarks a host for opening several side by side.
func (m *Model) toggleMark(item hostItem) {
key := item.key()
if m.marked[key] {
delete(m.marked, key)
for i, target := range m.markOrder {
if target == item.name {
m.markOrder = append(m.markOrder[:i], m.markOrder[i+1:]...)
break
}
}
return
}
m.marked[key] = true
m.markOrder = append(m.markOrder, item.name)
}
// toggleAllVisible marks every listed host, or unmarks them if all are marked.
func (m *Model) toggleAllVisible() {
var items []hostItem
allMarked := true
for _, it := range m.list.Items() {
if item, ok := it.(hostItem); ok {
items = append(items, item)
allMarked = allMarked && m.marked[item.key()]
}
}
for _, item := range items {
if m.marked[item.key()] == allMarked {
m.toggleMark(item)
}
}
}
// SetForwards provides the forward names configured for a host (ctrl+t).
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
m.forwardsFor = forwardsFor
+35
View File
@@ -122,3 +122,38 @@ func TestForwardInputEscCancels(t *testing.T) {
t.Fatal("esc did not cancel")
}
}
func TestSpaceMarksHostsAndEnterOpensThem(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "a", Kind: "device"}, {Name: "b", Kind: "device"}, {Name: "c", Kind: "vm"}})
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if len(m.markOrder) != 1 || m.markOrder[0] != "a" || m.list.Index() != 1 {
t.Fatalf("space: marks %v, cursor %d", m.markOrder, m.list.Index())
}
if m.input.Value() != "" {
t.Fatal("space went into the search field")
}
m.list.Select(2)
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if !strings.Contains(m.viewContent(), "2 marked") {
t.Fatalf("no marked count:\n%s", m.viewContent())
}
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected == nil || m.selected.Action != ActionMulti || !reflect.DeepEqual(m.selected.Targets, []string{"a", "c"}) {
t.Fatalf("got %+v", m.selected)
}
}
func TestCtrlATogglesAllVisible(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "a"}, {Name: "b"}})
m.toggleMark(hostItem{name: "a"})
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
if !reflect.DeepEqual(m.markOrder, []string{"a", "b"}) {
t.Fatalf("mark all: %v", m.markOrder)
}
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
if len(m.markOrder) != 0 {
t.Fatalf("unmark all: %v", m.markOrder)
}
}
+30 -1
View File
@@ -57,6 +57,7 @@ public 10.231.0.0/24 + fd00:30::/64 mgmt 10.231.1.0/24 prod 10.231.2
| `tests/NN-*.sh` | Test files; each runs in a fresh `HOME`. |
| `tests/drive.py` | Drives `netssh configure` in huh's accessible mode. |
| `.keys/`, `.state/` | Generated test key and seed output (git-ignored). |
| `.artifacts/` | Screenshots from `tests/16-render.sh` (git-ignored). |
## Writing tests
@@ -78,10 +79,38 @@ ssh always reads `/home/tester/.ssh/config` (OpenSSH ignores `$HOME`): it uses
the test key, `BatchMode`, `StrictHostKeyChecking accept-new`, and
`LogLevel ERROR`. Tests that need their own known_hosts set
`ssh.options.UserKnownHostsFile`. The TUI test runs netssh inside tmux and
reads the screen with `tmux capture-pane`.
reads the screen with `tmux capture-pane`. The multi test (`15-multi.sh`)
opens panes in real tmux and zellij sessions and checks that broadcast typing
reaches every node.
## What a user sees: `16-render.sh`
The render test runs tmux, zellij, and the TUI in a real terminal emulator —
xterm on a virtual X server (Xvfb) — takes screenshots, and reads them back
with OCR (tesseract). It checks what is actually drawn: remote prompts in all
panes, borders and titles with the host names, `[SYNC]` appearing and
disappearing, panes side by side, broadcast output in every pane, input in one
pane only without broadcast, the status bar, and the TUI marks. The broadcast
keys are pressed as a user would: `tmux send-keys -K` delivers them to the
xterm client, so they pass through the prefix and key tables (`prefix e` →
`[SOLO]` and the pane drops out of the broadcast; `prefix S` toggles all panes;
other windows stay unaffected).
```sh
testenv/test.sh render # then look at the screenshots:
ls testenv/.artifacts/ # tmux-1.png, zellij-2.png, tui-3.png, …
```
Helpers in `lib.sh`: `start_screen`/`stop_screen`, `term CMD…` (full-screen
xterm), `shot NAME`, `screen_shows NAME TEXT…`, `screen_count NAME TEXT`.
OCR reads each screenshot in three preprocessing variants (plain, stretched for
dim gray, brightest channel for colored text) and normalizes characters it
confuses in DejaVu Sans Mono (`1`/`l`, `C`/`(`), so checks stay robust without
becoming vague.
## Not covered
- The installer (`install.sh`) downloads release assets from Gitea; it is tested by the Go tests with local fixtures.
- macOS paths and behaviour.
- `netssh uninstall` runs only with `--dry-run`, so the client keeps its binary.
- Rendering is checked in xterm with DejaVu Sans Mono. Other terminals and fonts may differ, e.g. zellij's Powerline arrows need a Nerd Font.
+4 -1
View File
@@ -11,8 +11,11 @@ COPY internal ./internal
RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh
FROM alpine:3.23
# xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator,
# take screenshots, and read them back with OCR (tests/16-render.sh).
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
curl python3 jq ncurses coreutils \
curl python3 jq ncurses coreutils grep \
xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \
&& adduser -D -s /bin/bash tester
COPY --from=build /out/netssh /usr/local/bin/netssh
COPY testenv/client-entrypoint.sh /entrypoint.sh
+1
View File
@@ -165,6 +165,7 @@ services:
- ./.keys:/keys:ro
- ./.state:/state
- ./tests:/tests:ro
- ./.artifacts:/artifacts
networks:
public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"}
mgmt: {ipv4_address: 10.231.1.10}
+1 -1
View File
@@ -3,7 +3,7 @@
config_only </dev/null
rm -f "$XDG_CONFIG_HOME/netssh.yaml"
# no NetBox, static hosts, add, name, address, user, port, tags, back, save
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 8)
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 9)
[[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out"
expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "connect to the configured host" "admin" netssh legacy-box whoami
+74
View File
@@ -0,0 +1,74 @@
# netssh multi with real tmux and zellij: selectors against NetBox, panes,
# broadcast typing reaching every host, netssh sync, zellij layouts and tabs.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
groups:
pve: ["cluster:pve-fsn1"]
mixed: ["@pve", "lab/lab-db1", "tag:web"]
EOF
check "refresh" netssh cache refresh
nodes="fsn/fsn1-pve1.hoster.test fsn/fsn1-pve2.hoster.test fsn/fsn1-pve3.hoster.test"
expect_out "cluster: selects the devices" "# 3 hosts: $nodes" netssh multi --dry-run --tmux cluster:pve-fsn1
expect_out "--kind vm selects the VMs" "# 2 hosts: fsn/web1 fsn/web2" netssh multi --dry-run --tmux --kind vm cluster:pve-fsn1
expect_out "groups, tags, and hosts combined" "# 7 hosts: $nodes lab/lab-db1 fsn/web1 fsn/web2 lab/web1" netssh multi --dry-run --tmux @mixed
expect_fail "unknown cluster" "matches no hosts" netssh multi --dry-run --tmux cluster:nope
expect_out "completion offers clusters" "cluster:pve-fsn1" netssh __complete multi cluster:
expect_out "completion offers groups" "@pve" netssh __complete multi @
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR" "$HOME/.config/zellij"
# No first-start tips or release notes on top of the panes.
printf 'show_startup_tips false\nshow_release_notes false\n' >"$HOME/.config/zellij/config.kdl"
envs=(-e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME")
hosts_of() { tmux list-panes -t "$1" -F '#{@netssh_host}' 2>/dev/null | sort | tr '\n' ' '; }
# wait_for retries a command, so checks must re-evaluate on every call.
panes_in() { [ "$(tmux list-panes -t "$1" 2>/dev/null | wc -l)" = "$2" ]; }
sync_is() { [ "$(tmux show-window-options -t "$1" -v synchronize-panes 2>/dev/null)" = "$2" ]; }
prompts() { [ "$(tmux capture-pane -p -t z | grep -o 'fsn1-pve[123]:~\$' | sort -u | wc -l)" = 3 ]; }
remote_file() { for n in 1 2 3; do netssh "fsn/fsn1-pve$n.hoster.test" test -f "$1" || return 1; done; }
# Inside tmux: a new window in the current session, broadcast from the start.
tmux new-session -d -s outer -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t outer "netssh multi --sync @pve" Enter
if wait_for 20 panes_in outer:pve 3; then ok "tmux: three panes in a new window"; else bad "tmux: three panes in a new window" "$(tmux list-windows -a)"; fi
expect_eq "tmux: panes are labelled" "$nodes " hosts_of outer:pve
expect_eq "tmux: broadcast is on" "on" tmux show-window-options -t outer:pve -v synchronize-panes
sleep 2
marker="/tmp/netssh-multi-$$"
tmux send-keys -t outer:pve "touch $marker" Enter
if wait_for 15 remote_file "$marker"; then ok "tmux: one command reached all three hosts"; else bad "tmux: one command reached all three hosts" "$(tmux capture-pane -p -t outer:pve)"; fi
tmux new-window -t outer -n ctl "${envs[@]}" bash
tmux send-keys -t outer:ctl "netssh sync" Enter
if wait_for 10 sync_is outer:ctl on; then ok "netssh sync toggles broadcast"; else bad "netssh sync toggles broadcast" "$(tmux capture-pane -p -t outer:ctl)"; fi
# Outside tmux: a new session, attached.
tmux send-keys -t outer:ctl "netssh sync; env -u TMUX netssh multi --kind vm cluster:pve-fsn1" Enter
if wait_for 20 panes_in netssh-pve-fsn1 2; then ok "tmux: new session outside tmux"; else bad "tmux: new session outside tmux" "$(tmux list-sessions)"; fi
expect_eq "tmux: VMs through the bastion" "fsn/web1 fsn/web2 " hosts_of netssh-pve-fsn1
tmux kill-server
# zellij outside a session: netssh starts zellij with a grid layout.
tmux new-session -d -s z -x 200 -y 60 "${envs[@]}" bash
tmux send-keys -t z "netssh multi --zellij @pve" Enter
zscreen() { tmux capture-pane -p -t z; }
if wait_for 30 prompts; then ok "zellij: three panes with remote shells"; else bad "zellij: three panes with remote shells" "$(zscreen)"; fi
expect_out "zellij: pane titles" "fsn/fsn1-pve2.hoster.test" zscreen
zsession() { zellij list-sessions --short --no-formatting 2>/dev/null | head -1 | grep .; }
if wait_for 10 zsession; then ok "zellij: session is running"; else bad "zellij: session is running" "$(zellij list-sessions 2>&1)"; fi
session=$(zsession)
# zellij inside that session: netssh multi adds a tab.
ZELLIJ_SESSION_NAME=$session ZELLIJ=0 netssh multi --kind vm cluster:pve-fsn1 >/dev/null 2>&1
expect_out "zellij: new tab inside a session" "pve-fsn1" zellij --session "$session" action query-tab-names
expect_out "zellij: tab layout runs netssh for the VMs" "fsn/web2" zellij --session "$session" action dump-layout
zellij kill-all-sessions --yes >/dev/null 2>&1
tmux kill-server 2>/dev/null
true
+102
View File
@@ -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
View File
@@ -76,3 +76,79 @@ wait_for() {
sleep 0.2
done
}
# --- rendering in a real terminal emulator (tests/16-render.sh) ---
# Xvfb provides a screen, xterm renders the terminal, `import` takes a
# screenshot, and tesseract reads it back, so checks see what a user sees.
start_screen() {
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
export DISPLAY=:99
wait_for 10 import -window root -crop 1x1+0+0 null:
}
stop_screen() {
pkill -u "$(id -u)" xterm 2>/dev/null
kill "$XVFB_PID" 2>/dev/null
wait "$XVFB_PID" 2>/dev/null
}
# term CMD... — run CMD in a full-screen xterm (UTF-8, DejaVu Sans Mono).
term() {
pkill -u "$(id -u)" xterm 2>/dev/null
xterm -u8 -geometry 190x52+0+0 -fa 'DejaVu Sans Mono' -fs 11 -bg '#1e1e1e' -fg '#d4d4d4' \
-T netssh -e "$@" >/dev/null 2>&1 &
sleep 1
}
# shot NAME — screenshot to /artifacts/NAME.png.
shot() { import -window root "/artifacts/$1.png"; }
# OCR preprocessing variants: no single one reads everything (dim hint lines,
# red/green borders): plain luminance, stretched dark range for dim gray, and
# the brightest color channel for colored text.
OCR_VARIANTS=("-colorspace gray" "-colorspace gray -level 10%,40%"
"-channel RGB -separate -evaluate-sequence max -level 10%,60%")
# ocr_variant NAME I — OCR of screenshot NAME with preprocessing variant I.
ocr_variant() {
# shellcheck disable=SC2086
magick "/artifacts/$1.png" ${OCR_VARIANTS[$2]} -negate -resize 300% png:- |
tesseract stdin stdout --psm 6 2>/dev/null
}
# screen_text NAME — the text of all variants combined.
screen_text() {
local i
for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done
}
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O,
# 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
View File
@@ -5,8 +5,8 @@ set -euo pipefail
cd "$(dirname "$0")"
compose=(docker compose -f compose.yaml)
mkdir -p .keys .state
chmod a+rwx .state # written by the seed container's user
mkdir -p .keys .state .artifacts
chmod a+rwx .state .artifacts # written by the container user (screenshots in .artifacts)
if [ ! -f .keys/id_ed25519 ]; then
ssh-keygen -q -t ed25519 -N '' -C netssh-test -f .keys/id_ed25519
cp .keys/id_ed25519.pub .keys/authorized_keys