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

+102
View File
@@ -0,0 +1,102 @@
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)
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.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.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")
}