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

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

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

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

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

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

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
// Package multiplex opens several commands side by side in tmux or zellij.
package multiplex
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
)
// Pane is one command shown in its own pane.
type Pane struct {
Title string
Argv []string
}
// Options control how panes are laid out.
type Options struct {
Label string // window, session, and tab name
Sync bool // type into all panes of a window at once
PerWindow int // panes per tmux window or zellij tab
LayoutDir string // where zellij layout files are written
// Hint receives notes for the user before the process is replaced.
Hint func(string)
}
// Runner executes multiplexer commands; tests and --dry-run replace it.
type Runner interface {
// Output runs argv and returns its trimmed standard output.
Output(argv ...string) (string, error)
// Exec replaces the current process with argv.
Exec(argv ...string) error
LookPath(name string) (string, error)
Getenv(key string) string
WriteFile(path string, data []byte) error
}
// Supported multiplexers.
const (
Tmux = "tmux"
Zellij = "zellij"
)
// ErrNone means neither tmux nor zellij is available.
var ErrNone = errors.New("netssh multi needs tmux or zellij; install one of them (e.g. apt install tmux)")
// Detect picks the multiplexer: the requested one, else the one netssh runs
// in, else tmux, else zellij.
func Detect(requested string, r Runner) (string, error) {
switch requested {
case Tmux, Zellij:
if _, err := r.LookPath(requested); err != nil {
return "", fmt.Errorf("%s is not installed", requested)
}
return requested, nil
case "", "auto":
default:
return "", fmt.Errorf("unknown multiplexer %q", requested)
}
if r.Getenv("TMUX") != "" {
return Tmux, nil
}
if r.Getenv("ZELLIJ") != "" {
return Zellij, nil
}
for _, name := range []string{Tmux, Zellij} {
if _, err := r.LookPath(name); err == nil {
return name, nil
}
}
return "", ErrNone
}
// Open shows panes in the multiplexer. Outside of it, Open replaces the
// process with the multiplexer client and only returns on error.
func Open(kind string, panes []Pane, opts Options, r Runner) error {
if len(panes) == 0 {
return errors.New("no hosts to open")
}
if opts.Hint == nil {
opts.Hint = func(string) {}
}
if opts.PerWindow <= 0 {
opts.PerWindow = 9
}
if opts.Label == "" {
opts.Label = "netssh"
}
switch kind {
case Tmux:
return openTmux(panes, opts, r)
case Zellij:
return openZellij(panes, opts, r)
}
return fmt.Errorf("unknown multiplexer %q", kind)
}
// ToggleSync switches broadcast input in the current tmux window or zellij tab.
func ToggleSync(r Runner) (string, error) {
switch {
case r.Getenv("TMUX") != "":
current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes")
if err != nil {
return "", err
}
next := "on"
if current == "on" {
next = "off"
}
if _, err := r.Output("tmux", "set-window-option", "synchronize-panes", next); err != nil {
return "", err
}
return "broadcast " + next, nil
case r.Getenv("ZELLIJ") != "":
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
return "", err
}
return "broadcast toggled", nil
}
return "", errors.New("netssh sync works inside tmux or zellij")
}
// chunks splits panes into groups of at most n.
func chunks(panes []Pane, n int) [][]Pane {
var out [][]Pane
for len(panes) > n {
out = append(out, panes[:n])
panes = panes[n:]
}
return append(out, panes)
}
// windowLabel numbers windows when the panes need more than one.
func windowLabel(label string, i, total int) string {
if total == 1 {
return label
}
return fmt.Sprintf("%s-%d", label, i+1)
}
// System runs real commands.
type System struct{}
func (System) Output(argv ...string) (string, error) {
cmd := exec.Command(argv[0], argv[1:]...)
var stderr strings.Builder
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return "", fmt.Errorf("%s: %s", argv[0], msg)
}
return "", fmt.Errorf("%s: %w", argv[0], err)
}
return strings.TrimSpace(string(out)), nil
}
func (System) Exec(argv ...string) error {
path, err := exec.LookPath(argv[0])
if err != nil {
return err
}
return syscall.Exec(path, argv, os.Environ())
}
func (System) LookPath(name string) (string, error) { return exec.LookPath(name) }
func (System) Getenv(key string) string { return os.Getenv(key) }
func (System) WriteFile(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
// DryRun prints commands instead of running them; Output returns fake IDs.
type DryRun struct {
W io.Writer
Env map[string]string // values for Getenv, e.g. TMUX
n int
}
func (d *DryRun) Output(argv ...string) (string, error) {
fmt.Fprintln(d.W, shellJoin(argv))
d.n++
switch {
case len(argv) > 1 && (argv[1] == "new-window" || argv[1] == "new-session"):
return fmt.Sprintf("@%d %%%d", d.n, d.n), nil
case len(argv) > 1 && argv[1] == "split-window":
return fmt.Sprintf("%%%d", d.n), nil
case len(argv) > 1 && argv[1] == "has-session":
return "", errors.New("no session")
}
return "", nil
}
func (d *DryRun) Exec(argv ...string) error {
fmt.Fprintln(d.W, shellJoin(argv))
return nil
}
// LookPath pretends every multiplexer is installed, so --dry-run shows the
// commands on machines without tmux or zellij.
func (d *DryRun) LookPath(name string) (string, error) { return name, nil }
func (d *DryRun) Getenv(key string) string {
if v, ok := d.Env[key]; ok {
return v
}
return os.Getenv(key)
}
func (d *DryRun) WriteFile(path string, data []byte) error {
fmt.Fprintf(d.W, "# %s\n%s", path, data)
return nil
}
// shellJoin quotes argv for display.
func shellJoin(argv []string) string {
quoted := make([]string, len(argv))
for i, arg := range argv {
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
quoted[i] = arg
continue
}
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
}
return strings.Join(quoted, " ")
}