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:
1 parent
dbb99bdded
commit
a31aab4110
24 files changed
+1754
-40
No files matched your search
@@ -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, " ")
|
||||
}
|
||||
Reference in new issue
Block a user