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,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")
|
||||
}
|
||||
@@ -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,31 @@ 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.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user