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

+14 -5
View File
@@ -46,6 +46,8 @@ var managedSubcommands = map[string]bool{
"scp": true,
"tunnel": true,
"hosts": true,
"multi": true,
"sync": true,
}
func main() {
@@ -291,7 +293,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
fatalf("IP resolution for %q: %v", host, err)
}
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}
c.Upsert(entry)
return used(entry)
}
@@ -389,6 +391,12 @@ func runTUI() {
if host == nil {
return
}
if host.Action == tui.ActionMulti {
if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil {
fatalf("%v", err)
}
return
}
// Revalidate stale or live TUI results through the configured resolver chain.
var tags []string
@@ -413,7 +421,7 @@ func runTUI() {
if err != nil {
fatalf("IP resolution: %v", err)
}
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster})
tags = target.Tags
}
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
@@ -499,7 +507,7 @@ func rootCmd() *cobra.Command {
},
}
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd())
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
@@ -754,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command {
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue
}
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster})
written++
}
@@ -995,7 +1003,8 @@ func hookUninstallCmd() *cobra.Command {
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if !cfg.HasNetBox() {
// netssh multi checks once and starts its panes with NETSSH_NO_REFRESH.
if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" {
return
}
if !c.NeedsRefresh(24 * time.Hour) {
+402
View File
@@ -0,0 +1,402 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/multiplex"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// maxHostsWithoutConfirm guards against opening hundreds of panes by accident.
const maxHostsWithoutConfirm = 24
// multiFlags are the options shared by netssh multi and the TUI.
type multiFlags struct {
kind, user, jump, multiplexer string
noJump, sync, dryRun, yes bool
}
func multiCmd() *cobra.Command {
var f multiFlags
var useTmux, useZellij bool
cmd := &cobra.Command{
Use: "multi [flags] HOST|tag:TAG|cluster:NAME|@GROUP...",
Short: "Open several hosts side by side in tmux or zellij",
Long: `Open one pane per host in a tmux window or zellij tab. Hosts are given as
names (shortcuts, instance/host, static hosts) or selectors:
tag:TAG all hosts with this NetBox tag, and static hosts with this tag
cluster:NAME devices of a NetBox virtualization cluster (--kind vm|all for VMs)
@GROUP a group from the config (groups:)
Broadcast typing to all panes of a window with --sync, or toggle it later with
netssh sync. Inside tmux or zellij the panes open in the current session.`,
Example: ` netssh multi web01 web02 db01
netssh multi cluster:pve-fsn1
netssh multi --kind vm cluster:pve-fsn1
netssh multi --sync tag:k8s-worker
netssh multi @k8s`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if useTmux && useZellij {
return errors.New("--tmux and --zellij are mutually exclusive")
}
if useTmux {
f.multiplexer = multiplex.Tmux
} else if useZellij {
f.multiplexer = multiplex.Zellij
}
return runMulti(loadConfigOrSetup(), args, f)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return multiCompletions(cfg, toComplete)
},
}
cmd.Flags().StringVar(&f.kind, "kind", "", "Host kind for tag: and cluster: — device, vm, or all (default: all for tags, device for clusters)")
cmd.Flags().BoolVar(&f.sync, "sync", false, "Start with typing broadcast to all panes")
cmd.Flags().BoolVar(&useTmux, "tmux", false, "Use tmux")
cmd.Flags().BoolVar(&useZellij, "zellij", false, "Use zellij")
cmd.Flags().StringVarP(&f.user, "user", "l", "", "Login user for every host")
cmd.Flags().StringVar(&f.jump, "jump", "", "Jump host for every host")
cmd.Flags().BoolVar(&f.noJump, "no-jump", false, "Disable jump hosts")
cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "Print the hosts and multiplexer commands")
cmd.Flags().BoolVarP(&f.yes, "yes", "y", false, fmt.Sprintf("Open more than %d hosts without asking", maxHostsWithoutConfirm))
_ = cmd.RegisterFlagCompletionFunc("kind", cobra.FixedCompletions([]cobra.Completion{"device", "vm", "all"}, cobra.ShellCompDirectiveNoFileComp))
return cmd
}
func syncCmd() *cobra.Command {
return &cobra.Command{
Use: "sync",
Short: "Toggle typing into all panes of the current tmux window or zellij tab",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
state, err := multiplex.ToggleSync(multiplex.System{})
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "netssh: "+state)
return nil
},
}
}
// runMulti expands selectors and opens one pane per host.
func runMulti(cfg *config.Config, selectors []string, f multiFlags) error {
if f.jump != "" && f.noJump {
return errors.New("--jump and --no-jump are mutually exclusive")
}
switch f.kind {
case "", "device", "vm", "all":
default:
return fmt.Errorf("--kind must be device, vm, or all")
}
c := newCache(cfg)
_ = c.Load()
targets, warnings, err := expandSelectors(cfg, c, selectors, f.kind)
for _, warning := range warnings {
fmt.Fprintln(os.Stderr, "netssh: warning: "+warning)
}
if err != nil {
return err
}
if len(targets) > maxHostsWithoutConfirm && !f.yes && !f.dryRun {
return fmt.Errorf("%d hosts selected; pass --yes to open them all", len(targets))
}
var runner multiplex.Runner = multiplex.System{}
if f.dryRun {
fmt.Printf("# %d hosts: %s\n", len(targets), strings.Join(targets, " "))
runner = &multiplex.DryRun{W: os.Stdout}
}
requested := f.multiplexer
if requested == "" {
requested = cfg.Multi.Multiplexer
}
kind, err := multiplex.Detect(requested, runner)
if err != nil {
return err
}
if !f.dryRun {
// Checked once here; the panes skip it (NETSSH_NO_REFRESH).
maybeBackgroundRefresh(cfg, c)
}
panes := make([]multiplex.Pane, 0, len(targets))
for _, target := range targets {
panes = append(panes, multiplex.Pane{Title: target, Argv: paneArgv(target, f)})
}
layoutDir := filepath.Dir(cfg.Cache.Path)
if err := os.MkdirAll(layoutDir, 0o700); err != nil {
return err
}
return multiplex.Open(kind, panes, multiplex.Options{
Label: multiLabel(selectors),
Sync: f.sync,
PerWindow: cfg.Multi.PanesPerWindowOrDefault(),
LayoutDir: layoutDir,
Hint: func(s string) { fmt.Fprintln(os.Stderr, "netssh: "+s) },
}, runner)
}
// paneArgv runs netssh for one host. Panes of an existing tmux or zellij
// session inherit that server's environment, so the paths that select the
// config and cache are passed explicitly; NETSSH_* values are not, because
// arguments are visible to other users (tokens).
func paneArgv(target string, f multiFlags) []string {
self, err := os.Executable()
if err != nil {
self = "netssh"
}
env, err := exec.LookPath("env")
if err != nil {
env = "/usr/bin/env"
}
argv := []string{env, "NETSSH_NO_REFRESH=1"}
for _, key := range []string{"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} {
if value, ok := os.LookupEnv(key); ok {
argv = append(argv, key+"="+value)
}
}
argv = append(argv, self)
switch {
case f.jump != "":
argv = append(argv, "--jump", f.jump)
case f.noJump:
argv = append(argv, "--no-jump")
}
if f.user != "" {
argv = append(argv, "-l", f.user)
}
return append(argv, target)
}
var unsafeLabel = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
// multiLabel names the window, session, or tab after a single selector.
func multiLabel(selectors []string) string {
if len(selectors) != 1 {
return "multi"
}
s := selectors[0]
for _, prefix := range []string{"@", "tag:", "cluster:"} {
s = strings.TrimPrefix(s, prefix)
}
if s = strings.Trim(unsafeLabel.ReplaceAllString(s, "-"), "-"); s == "" {
return "multi"
}
return s
}
// expander resolves selectors into an ordered list of unique host targets.
type expander struct {
cfg *config.Config
cache *cache.Cache
kind string
client *netbox.Client
seen map[string]bool
visiting map[string]bool
targets []string
warnings []string
}
func expandSelectors(cfg *config.Config, c *cache.Cache, selectors []string, kind string) ([]string, []string, error) {
e := &expander{cfg: cfg, cache: c, kind: kind, seen: map[string]bool{}, visiting: map[string]bool{}}
for _, selector := range selectors {
if err := e.expand(selector); err != nil {
return nil, e.warnings, err
}
}
if len(e.targets) == 0 {
return nil, e.warnings, errors.New("no hosts selected")
}
return e.targets, e.warnings, nil
}
func (e *expander) add(target string) {
key := strings.ToLower(target)
if !e.seen[key] {
e.seen[key] = true
e.targets = append(e.targets, target)
}
}
func (e *expander) expand(selector string) error {
switch {
case strings.HasPrefix(selector, "@"):
return e.group(strings.ToLower(strings.TrimPrefix(selector, "@")))
case strings.HasPrefix(selector, "tag:"):
return e.query(selector, netbox.SearchOptions{Tag: strings.TrimPrefix(selector, "tag:")}, "all")
case strings.HasPrefix(selector, "cluster:"):
return e.query(selector, netbox.SearchOptions{Cluster: strings.TrimPrefix(selector, "cluster:")}, "device")
case selector == "":
return nil
}
e.add(selector)
return nil
}
func (e *expander) group(name string) error {
members, ok := e.cfg.Groups[name]
if !ok {
names := make([]string, 0, len(e.cfg.Groups))
for group := range e.cfg.Groups {
names = append(names, "@"+group)
}
sort.Strings(names)
if len(names) == 0 {
return fmt.Errorf("unknown group @%s; no groups are configured (groups:)", name)
}
return fmt.Errorf("unknown group @%s; available: %s", name, strings.Join(names, ", "))
}
if e.visiting[name] {
return fmt.Errorf("group @%s contains itself", name)
}
e.visiting[name] = true
defer delete(e.visiting, name)
for _, member := range members {
if err := e.expand(member); err != nil {
return err
}
}
return nil
}
// query expands tag: and cluster: through NetBox, plus static hosts for tags;
// if NetBox fails, the cache answers with a warning.
func (e *expander) query(selector string, opts netbox.SearchOptions, defaultKind string) error {
kind := e.kind
if kind == "" {
kind = defaultKind
}
if kind != "all" {
opts.Kind = kind
}
before := len(e.targets)
if opts.Tag != "" && (kind == "all" || kind == config.StaticKind) {
for _, host := range e.cfg.Hosts {
if hasTag(host.Tags, opts.Tag) {
e.add(host.Name)
}
}
}
if e.cfg.HasNetBox() {
if e.client == nil {
e.client = newNetBoxClient(e.cfg)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
entries, err := e.client.SearchAll(ctx, "", opts)
cancel()
if err != nil {
e.warnings = append(e.warnings, fmt.Sprintf("%s: NetBox unavailable, using the cache: %v", selector, err))
entries = e.fromCache(opts)
} else {
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
}
for _, entry := range entries {
e.add(entry.Target())
}
}
if len(e.targets) == before {
hint := ""
if opts.Cluster != "" && e.kind == "" {
hint = " (only devices by default; try --kind vm or --kind all)"
}
return fmt.Errorf("%s matches no hosts%s", selector, hint)
}
return nil
}
func (e *expander) fromCache(opts netbox.SearchOptions) []netbox.HostEntry {
var out []netbox.HostEntry
for _, entry := range e.cache.All() {
if entry.Static || opts.Kind != "" && entry.Kind != opts.Kind {
continue
}
if opts.Tag != "" && !hasTag(entry.Tags, opts.Tag) || opts.Cluster != "" && !strings.EqualFold(entry.Cluster, opts.Cluster) {
continue
}
source, name := netbox.SplitTarget(entry.Name)
out = append(out, netbox.HostEntry{Source: source, Name: name, Kind: entry.Kind})
}
sort.Slice(out, func(i, j int) bool { return out[i].Target() < out[j].Target() })
return out
}
// hasTag compares a tag by name or slug, like ssh.rules.
func hasTag(tags []string, want string) bool {
for _, tag := range tags {
if strings.EqualFold(tag, want) || strings.EqualFold(strings.Join(strings.Fields(strings.ToLower(tag)), "-"), want) {
return true
}
}
return false
}
// multiCompletions offers hosts, @groups, and tag:/cluster: values from the cache.
func multiCompletions(cfg *config.Config, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
c := newCache(cfg)
_ = c.Load()
lower := strings.ToLower(toComplete)
var out []string
switch {
case strings.HasPrefix(lower, "tag:"), strings.HasPrefix(lower, "cluster:"):
prefix, value, _ := strings.Cut(lower, ":")
values := map[string]bool{}
for _, entry := range c.All() {
if prefix == "tag" {
for _, tag := range entry.Tags {
values[strings.ToLower(tag)] = true
}
} else if entry.Cluster != "" {
values[entry.Cluster] = true
}
}
for v := range values {
if strings.HasPrefix(strings.ToLower(v), value) {
out = append(out, prefix+":"+v)
}
}
case strings.HasPrefix(lower, "@"):
for group := range cfg.Groups {
if strings.HasPrefix("@"+group, lower) {
out = append(out, "@"+group)
}
}
default:
out = completeHosts(toComplete)
var prefixes []string
for _, p := range []string{"tag:", "cluster:"} {
if toComplete != "" && strings.HasPrefix(p, lower) {
prefixes = append(prefixes, p)
}
}
if len(out) == 0 && len(prefixes) > 0 {
return prefixes, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
for group := range cfg.Groups {
if toComplete == "" {
out = append(out, "@"+group)
}
}
}
sort.Strings(out)
return out, cobra.ShellCompDirectiveNoFileComp
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"reflect"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestExpandSelectors(t *testing.T) {
cfg := &config.Config{
Hosts: []config.StaticHost{{Name: "box", Address: "10.0.0.1", Tags: []string{"Web Servers"}}},
Groups: map[string][]string{
"all": {"@web", "db1", "box"},
"web": {"web1", "tag:web-servers", "web1"},
"loop": {"@loop"},
},
}
c := cache.New(t.TempDir()+"/hosts.json", 60).WithStatic(cfg.Hosts)
got, _, err := expandSelectors(cfg, c, []string{"@all", "WEB1"}, "")
if err != nil || !reflect.DeepEqual(got, []string{"web1", "box", "db1"}) {
t.Fatalf("got %v %v", got, err)
}
if _, _, err := expandSelectors(cfg, c, []string{"@loop"}, ""); err == nil || !strings.Contains(err.Error(), "contains itself") {
t.Fatalf("cycle: %v", err)
}
if _, _, err := expandSelectors(cfg, c, []string{"@nope"}, ""); err == nil || !strings.Contains(err.Error(), "available: @all, @loop, @web") {
t.Fatalf("unknown group: %v", err)
}
if _, _, err := expandSelectors(cfg, c, []string{"tag:web-servers"}, "device"); err == nil {
t.Fatal("--kind device must exclude static hosts")
}
}
// Without a reachable NetBox, tag: and cluster: fall back to the cache.
func TestExpandSelectorsCacheFallback(t *testing.T) {
cfg := &config.Config{NetBox: config.NetBoxConfig{URL: "http://127.0.0.1:1", Token: "t", TokenVersion: 1}}
c := cache.New(t.TempDir()+"/hosts.json", 60)
c.Upsert(cache.Entry{Name: "node2", Kind: "device", Cluster: "pve"})
c.Upsert(cache.Entry{Name: "node1", Kind: "device", Cluster: "pve", Tags: []string{"pve"}})
c.Upsert(cache.Entry{Name: "vm1", Kind: "vm", Cluster: "pve"})
got, warnings, err := expandSelectors(cfg, c, []string{"cluster:PVE"}, "")
if err != nil || !reflect.DeepEqual(got, []string{"node1", "node2"}) || len(warnings) != 1 {
t.Fatalf("devices: %v %v %v", got, warnings, err)
}
if got, _, _ := expandSelectors(cfg, c, []string{"cluster:pve"}, "all"); len(got) != 3 {
t.Fatalf("all: %v", got)
}
if _, _, err := expandSelectors(cfg, c, []string{"cluster:other"}, ""); err == nil || !strings.Contains(err.Error(), "--kind vm") {
t.Fatalf("empty cluster: %v", err)
}
}
func TestMultiLabelAndPaneArgv(t *testing.T) {
for selectors, want := range map[string]string{"@k8s": "k8s", "cluster:pve-fsn1": "pve-fsn1", "tag:a.b": "a-b", "a b": "multi"} {
if got := multiLabel(strings.Fields(selectors)); got != want {
t.Errorf("%q: got %q", selectors, got)
}
}
t.Setenv("XDG_CONFIG_HOME", "/cfg")
t.Setenv("NETSSH_NETBOX_TOKEN", "secret")
argv := strings.Join(paneArgv("prod/web1", multiFlags{user: "ops", jump: "jh"}), " ")
for _, want := range []string{"NETSSH_NO_REFRESH=1", "XDG_CONFIG_HOME=/cfg", "--jump jh -l ops prod/web1"} {
if !strings.Contains(argv, want) {
t.Errorf("argv lacks %q: %s", want, argv)
}
}
if strings.Contains(argv, "secret") {
t.Fatalf("token leaked into the pane arguments: %s", argv)
}
}
func TestMultiDryRun(t *testing.T) {
prepareConfig(t, "hosts:\n - {name: a, address: 10.0.0.1, tags: [web]}\n - {name: b, address: 10.0.0.2, tags: [web]}\ngroups:\n web: [\"tag:web\"]\n")
out, err := runNetssh(t, "multi", "--dry-run", "--tmux", "--sync", "@web")
if err != nil {
t.Fatalf("%s %v", out, err)
}
for _, want := range []string{"# 2 hosts: a b", "tmux new-session -d -x 240 -y 70 -s netssh-web", "synchronize-panes on", "tmux attach-session -t netssh-web"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q:\n%s", want, out)
}
}
if out, err := runNetssh(t, "multi", "--dry-run", "--zellij", "a", "b"); err != nil || !strings.Contains(out, `pane name="b"`) {
t.Errorf("zellij: %s %v", out, err)
}
if out, err := runNetssh(t, "sync"); err == nil || !strings.Contains(out, "inside tmux or zellij") {
t.Errorf("sync outside: %s %v", out, err)
}
}