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, " ")
}
+211
View File
@@ -0,0 +1,211 @@
package multiplex
import (
"errors"
"fmt"
"strings"
"testing"
)
// fake records commands and answers like tmux and zellij would.
type fake struct {
env map[string]string
installed map[string]bool
sessions map[string]bool
calls []string
exec []string
files map[string]string
syncState string
n int
}
func newFake(env map[string]string, installed ...string) *fake {
f := &fake{env: env, installed: map[string]bool{}, sessions: map[string]bool{}, files: map[string]string{}, syncState: "off"}
for _, name := range installed {
f.installed[name] = true
}
return f
}
func (f *fake) Output(argv ...string) (string, error) {
f.calls = append(f.calls, strings.Join(argv, " "))
f.n++
switch argv[1] {
case "new-window", "new-session":
return fmt.Sprintf("@%d %%%d", f.n, f.n), nil
case "split-window":
return fmt.Sprintf("%%%d", f.n), nil
case "has-session":
if f.sessions[strings.TrimPrefix(argv[3], "=")] {
return "", nil
}
return "", errors.New("can't find session")
case "show-window-options":
return f.syncState, nil
}
return "", nil
}
func (f *fake) Exec(argv ...string) error { f.exec = argv; return nil }
func (f *fake) LookPath(name string) (string, error) {
if f.installed[name] {
return "/usr/bin/" + name, nil
}
return "", errors.New("not found")
}
func (f *fake) Getenv(key string) string { return f.env[key] }
func (f *fake) WriteFile(path string, data []byte) error {
f.files[path] = string(data)
return nil
}
func panes(names ...string) []Pane {
out := make([]Pane, 0, len(names))
for _, name := range names {
out = append(out, Pane{Title: name, Argv: []string{"/usr/bin/netssh", name}})
}
return out
}
func TestDetect(t *testing.T) {
for _, tc := range []struct {
requested string
env map[string]string
installed []string
want string
}{
{"", map[string]string{"TMUX": "/tmp/tmux"}, []string{"tmux", "zellij"}, Tmux},
{"auto", map[string]string{"ZELLIJ": "0"}, []string{"tmux", "zellij"}, Zellij},
{"", nil, []string{"tmux", "zellij"}, Tmux},
{"", nil, []string{"zellij"}, Zellij},
{"zellij", map[string]string{"TMUX": "x"}, []string{"tmux", "zellij"}, Zellij},
} {
got, err := Detect(tc.requested, newFake(tc.env, tc.installed...))
if err != nil || got != tc.want {
t.Errorf("%+v: got %q %v", tc, got, err)
}
}
if _, err := Detect("", newFake(nil)); !errors.Is(err, ErrNone) {
t.Errorf("nothing installed: %v", err)
}
if _, err := Detect("tmux", newFake(nil, "zellij")); err == nil {
t.Error("missing requested multiplexer accepted")
}
}
func TestTmuxInside(t *testing.T) {
f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux")
if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true}, f); err != nil {
t.Fatal(err)
}
script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh "
want := []string{
"tmux new-window -P -F #{window_id} #{pane_id} -n pve " + script + "a",
"tmux split-window -t @1 -P -F #{pane_id} " + script + "b",
"tmux select-layout -t @1 tiled",
"tmux split-window -t @1 -P -F #{pane_id} " + script + "c",
"tmux select-layout -t @1 tiled",
"tmux set-option -p -t %1 @netssh_host a",
"tmux set-option -p -t %2 @netssh_host b",
"tmux set-option -p -t %4 @netssh_host c",
"tmux set-window-option -t @1 pane-border-status top",
"tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],} ",
"tmux set-window-option -t @1 synchronize-panes on",
"tmux select-window -t @1",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
if f.exec != nil {
t.Fatal("inside tmux must not attach")
}
}
func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) {
f := newFake(nil, "tmux")
f.sessions["netssh-pve"] = true
var hints []string
err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Hint: func(s string) { hints = append(hints, s) }}, f)
if err != nil {
t.Fatal(err)
}
joined := strings.Join(f.calls, "\n")
for _, want := range []string{
"tmux has-session -t =netssh-pve-2",
"tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ",
"tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ",
} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in\n%s", want, joined)
}
}
if strings.Contains(joined, "synchronize-panes") {
t.Error("sync set without --sync")
}
if strings.Join(f.exec, " ") != "tmux attach-session -t netssh-pve-2" || len(hints) != 1 {
t.Fatalf("exec %v hints %v", f.exec, hints)
}
}
func TestZellijOutsideWritesGridLayout(t *testing.T) {
f := newFake(nil, "zellij")
if err := Open(Zellij, panes("a", "b", "c", `q"x`), Options{Label: "pve", LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
layout := f.files["/tmp/l/netssh-multi.kdl"]
for _, want := range []string{
`plugin location="zellij:tab-bar"`,
`tab name="pve" focus=true {`,
`pane split_direction="vertical" {`,
`pane name="a" command="/usr/bin/netssh" {`,
`args "a"`,
`pane name="q\"x" command="/usr/bin/netssh" {`,
} {
if !strings.Contains(layout, want) {
t.Errorf("layout lacks %q:\n%s", want, layout)
}
}
// 4 panes → 2 columns × 2 rows.
if n := strings.Count(layout, `split_direction="vertical"`); n != 2 {
t.Errorf("%d rows, want 2:\n%s", n, layout)
}
if strings.Join(f.exec, " ") != "zellij --layout /tmp/l/netssh-multi.kdl" {
t.Fatalf("exec %v", f.exec)
}
}
func TestZellijInsideAddsTabs(t *testing.T) {
f := newFake(map[string]string{"ZELLIJ": "0"}, "zellij")
if err := Open(Zellij, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Sync: true, LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
want := []string{
"zellij action new-tab --layout /tmp/l/netssh-multi-1.kdl",
"zellij action toggle-active-sync-tab",
"zellij action new-tab --layout /tmp/l/netssh-multi-2.kdl",
"zellij action toggle-active-sync-tab",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Fatalf("calls:\n%s", strings.Join(f.calls, "\n"))
}
if !strings.Contains(f.files["/tmp/l/netssh-multi-2.kdl"], `tab name="pve-2"`) {
t.Fatalf("second tab: %s", f.files["/tmp/l/netssh-multi-2.kdl"])
}
}
func TestToggleSync(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"})
if state, err := ToggleSync(f); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" {
t.Fatalf("%q %v %v", state, err, f.calls)
}
f.syncState = "on"
if state, _ := ToggleSync(f); state != "broadcast off" {
t.Fatalf("%q", state)
}
z := newFake(map[string]string{"ZELLIJ": "0"})
if _, err := ToggleSync(z); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" {
t.Fatalf("%v %v", err, z.calls)
}
if _, err := ToggleSync(newFake(nil)); err == nil {
t.Fatal("outside a multiplexer accepted")
}
}
+95
View File
@@ -0,0 +1,95 @@
package multiplex
import (
"strconv"
"strings"
)
// paneScript runs the pane's command and keeps a failed one on screen, so an
// unreachable host shows its error instead of the pane vanishing.
const paneScript = `"$@"; status=$?; if [ "$status" -ne 0 ]; then echo; echo "netssh exited with status $status, press Enter to close"; read -r _; fi`
// tmuxArgv runs the pane through sh with argv as arguments: no quoting, and
// independent of the user's default-shell (which may be fish).
func tmuxArgv(p Pane) []string {
return append([]string{"sh", "-c", paneScript, "netssh-pane"}, p.Argv...)
}
// openTmux adds a window per chunk of panes: to the current session inside
// tmux, else to a new detached session that is attached at the end.
func openTmux(panes []Pane, opts Options, r Runner) error {
inside := r.Getenv("TMUX") != ""
session := ""
if !inside {
session = freeSession(r, "netssh-"+opts.Label)
}
groups := chunks(panes, opts.PerWindow)
first := ""
for i, group := range groups {
name := windowLabel(opts.Label, i, len(groups))
format := []string{"-P", "-F", "#{window_id} #{pane_id}", "-n", name}
var create []string
switch {
case inside:
create = append([]string{"tmux", "new-window"}, format...)
case i == 0:
// A large virtual size leaves room for the splits; attaching resizes it.
create = append([]string{"tmux", "new-session", "-d", "-x", "240", "-y", "70", "-s", session}, format...)
default:
create = append([]string{"tmux", "new-window", "-t", session + ":"}, format...)
}
out, err := r.Output(append(create, tmuxArgv(group[0])...)...)
if err != nil {
return err
}
window, pane, _ := strings.Cut(out, " ")
if first == "" {
first = window
}
paneIDs := []string{pane}
for _, p := range group[1:] {
id, err := r.Output(append([]string{"tmux", "split-window", "-t", window, "-P", "-F", "#{pane_id}"}, tmuxArgv(p)...)...)
if err != nil {
return err
}
paneIDs = append(paneIDs, id)
// Re-tile after every split so the next one still has room.
if _, err := r.Output("tmux", "select-layout", "-t", window, "tiled"); err != nil {
return err
}
}
for j, id := range paneIDs {
// A pane may already be gone (host unreachable and closed); not fatal.
_, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title)
}
windowOptions := [][]string{
{"pane-border-status", "top"},
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],} "},
}
if opts.Sync {
windowOptions = append(windowOptions, []string{"synchronize-panes", "on"})
}
for _, option := range windowOptions {
if _, err := r.Output("tmux", "set-window-option", "-t", window, option[0], option[1]); err != nil {
return err
}
}
}
if inside {
_, err := r.Output("tmux", "select-window", "-t", first)
return err
}
opts.Hint("toggle broadcast with: netssh sync (or Ctrl+b :setw synchronize-panes)")
return r.Exec("tmux", "attach-session", "-t", session)
}
// freeSession returns name, or name-2, name-3, … if a session already uses it.
func freeSession(r Runner, name string) string {
candidate := name
for n := 2; ; n++ {
if _, err := r.Output("tmux", "has-session", "-t", "="+candidate); err != nil {
return candidate
}
candidate = name + "-" + strconv.Itoa(n)
}
}
+101
View File
@@ -0,0 +1,101 @@
package multiplex
import (
"fmt"
"math"
"path/filepath"
"strings"
)
// openZellij writes a layout with one tab per chunk of panes. Inside zellij
// each tab is added to the current session; outside, a new session starts.
func openZellij(panes []Pane, opts Options, r Runner) error {
groups := chunks(panes, opts.PerWindow)
if r.Getenv("ZELLIJ") != "" {
for i, group := range groups {
path := filepath.Join(opts.LayoutDir, fmt.Sprintf("netssh-multi-%d.kdl", i+1))
tab := zellijTab(windowLabel(opts.Label, i, len(groups)), group, true)
if err := r.WriteFile(path, []byte(zellijLayout(tab))); err != nil {
return err
}
if _, err := r.Output("zellij", "action", "new-tab", "--layout", path); err != nil {
return err
}
if opts.Sync {
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
return err
}
}
}
return nil
}
var tabs strings.Builder
for i, group := range groups {
tabs.WriteString(zellijTab(windowLabel(opts.Label, i, len(groups)), group, i == 0))
}
path := filepath.Join(opts.LayoutDir, "netssh-multi.kdl")
if err := r.WriteFile(path, []byte(zellijLayout(tabs.String()))); err != nil {
return err
}
if opts.Sync {
opts.Hint("zellij starts without broadcast: press Ctrl+t, then s, to type into all panes")
} else {
opts.Hint("toggle broadcast with Ctrl+t, then s (or netssh sync)")
}
return r.Exec("zellij", "--layout", path)
}
// zellijLayout wraps tabs with zellij's tab and status bars.
func zellijLayout(tabs string) string {
return `layout {
default_tab_template {
pane size=1 borderless=true {
plugin location="zellij:tab-bar"
}
children
pane size=2 borderless=true {
plugin location="zellij:status-bar"
}
}
` + tabs + "}\n"
}
// zellijTab arranges panes in a grid: rows of ceil(sqrt(n)) columns.
func zellijTab(name string, panes []Pane, focus bool) string {
cols := int(math.Ceil(math.Sqrt(float64(len(panes)))))
var b strings.Builder
fmt.Fprintf(&b, " tab name=%s", kdlString(name))
if focus {
b.WriteString(" focus=true")
}
b.WriteString(" {\n")
for start := 0; start < len(panes); start += cols {
row := panes[start:min(start+cols, len(panes))]
indent := " "
if len(row) > 1 {
b.WriteString(indent + "pane split_direction=\"vertical\" {\n")
indent += " "
}
for _, p := range row {
fmt.Fprintf(&b, "%spane name=%s command=%s {\n", indent, kdlString(p.Title), kdlString(p.Argv[0]))
if len(p.Argv) > 1 {
args := make([]string, 0, len(p.Argv)-1)
for _, arg := range p.Argv[1:] {
args = append(args, kdlString(arg))
}
fmt.Fprintf(&b, "%s args %s\n", indent, strings.Join(args, " "))
}
b.WriteString(indent + "}\n")
}
if len(row) > 1 {
b.WriteString(" }\n")
}
}
b.WriteString(" }\n")
return b.String()
}
// kdlString quotes s as a KDL string.
func kdlString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + `"`
}