In windows opened by netssh multi, prefix S toggles broadcast for all panes and prefix e excludes the current pane (a pane-level synchronize-panes off, shown as [SOLO] in the border) or includes it again. netssh sync --pane does the same from a shell. S and e are unbound in a default tmux (E would be "spread evenly"). netssh writes the bindings to a small tmux config and sources it, binds a key only if the user's tmux.conf does not use it (netssh's own earlier binding is refreshed), and the bindings act only in windows marked @netssh. multi.sync_key and multi.solo_key change the keys or turn them off (none); also in the configurator. zellij syncs whole tabs only, so excluding a pane is tmux-only. testenv: the render test presses the keys through the xterm client (send-keys -K) and checks [SOLO], that broadcast skips the excluded pane, that S toggles, and that other windows are unaffected. The client gets GNU grep: BusyBox grep -oF counted only one match per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
415 lines
13 KiB
Go
415 lines
13 KiB
Go
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 {
|
|
var pane bool
|
|
cmd := &cobra.Command{
|
|
Use: "sync [--pane]",
|
|
Short: "Toggle typing into all panes of the current tmux window or zellij tab",
|
|
Long: `Toggle broadcast in the current tmux window or zellij tab. With --pane,
|
|
exclude the current tmux pane from broadcast, or include it again.
|
|
|
|
In windows opened by netssh multi, tmux also has keys for this:
|
|
prefix S (all panes) and prefix e (this pane); see multi.sync_key and
|
|
multi.solo_key. In zellij, use Ctrl+t then s.`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
state, err := multiplex.ToggleSync(multiplex.System{}, pane)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintln(os.Stderr, "netssh: "+state)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&pane, "pane", false, "Exclude the current tmux pane from broadcast, or include it again")
|
|
return cmd
|
|
}
|
|
|
|
// 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
|
|
}
|
|
syncKey, soloKey := cfg.Multi.Keys()
|
|
return multiplex.Open(kind, panes, multiplex.Options{
|
|
SyncKey: syncKey,
|
|
SoloKey: soloKey,
|
|
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
|
|
}
|