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>
208 lines
6.5 KiB
Go
208 lines
6.5 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
type Config struct {
|
|
Shell ShellConfig `mapstructure:"shell"`
|
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
|
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
|
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
|
Cache CacheConfig `mapstructure:"cache"`
|
|
SSH SSHConfig `mapstructure:"ssh"`
|
|
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
|
Completion CompletionConfig `mapstructure:"completion"`
|
|
Hosts []StaticHost `mapstructure:"hosts"`
|
|
// Groups name lists of hosts and selectors for netssh multi (@name).
|
|
Groups map[string][]string `mapstructure:"groups"`
|
|
Multi MultiConfig `mapstructure:"multi"`
|
|
}
|
|
|
|
type ShellConfig struct {
|
|
Alias string `mapstructure:"alias"`
|
|
}
|
|
|
|
type NetBoxConfig struct {
|
|
Name string `mapstructure:"name"`
|
|
URL string `mapstructure:"url"`
|
|
Token string `mapstructure:"token"`
|
|
TokenVersion int `mapstructure:"token_version"`
|
|
SSH SSHOptions `mapstructure:"ssh"`
|
|
}
|
|
|
|
type ResolverConfig struct {
|
|
Strategies []string `mapstructure:"strategies"`
|
|
ManagementSubnets []string `mapstructure:"management_subnets"`
|
|
InterfaceName string `mapstructure:"interface_name"`
|
|
// HostnameDomain is appended by the hostname strategy to names without a dot.
|
|
HostnameDomain string `mapstructure:"hostname_domain"`
|
|
}
|
|
|
|
type CacheConfig struct {
|
|
TTL int `mapstructure:"ttl"`
|
|
Path string `mapstructure:"path"`
|
|
}
|
|
|
|
type SSHConfig struct {
|
|
DefaultUser string `mapstructure:"default_user"`
|
|
DefaultPort int `mapstructure:"default_port"`
|
|
SSHOptions `mapstructure:",squash"`
|
|
Rules []SSHRule `mapstructure:"rules"`
|
|
}
|
|
|
|
type CompletionConfig struct {
|
|
// MaxResults limits the number of hostnames offered per completion; 0 = unlimited.
|
|
MaxResults int `mapstructure:"max_results"`
|
|
}
|
|
|
|
type ShortcutsConfig struct {
|
|
Domains []string `mapstructure:"domains"`
|
|
StripHyphens bool `mapstructure:"strip_hyphens"`
|
|
Rewrites []ShortcutRewrite `mapstructure:"rewrites"`
|
|
}
|
|
|
|
// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with
|
|
// Replace, which may reference groups as $1. Applied after domain stripping.
|
|
type ShortcutRewrite struct {
|
|
Pattern string `mapstructure:"pattern"`
|
|
Replace string `mapstructure:"replace"`
|
|
}
|
|
|
|
// Enabled reports whether any shortcut normalization is configured.
|
|
func (s ShortcutsConfig) Enabled() bool {
|
|
return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0
|
|
}
|
|
|
|
// Validate rejects rewrite rules that cannot be compiled.
|
|
func (s ShortcutsConfig) Validate() error {
|
|
for i, rewrite := range s.Rewrites {
|
|
if rewrite.Pattern == "" {
|
|
return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i)
|
|
}
|
|
if _, err := regexp.Compile(rewrite.Pattern); err != nil {
|
|
return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Path returns the canonical config file path.
|
|
func Path() string {
|
|
configDir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
|
}
|
|
return filepath.Join(configDir, "netssh.yaml")
|
|
}
|
|
|
|
func Load() (*Config, error) {
|
|
v := viper.New()
|
|
|
|
v.SetDefault("resolver.strategies", []string{"management_subnet", "primary_ip"})
|
|
v.SetDefault("resolver.management_subnets", []string{})
|
|
v.SetDefault("cache.ttl", 3600)
|
|
|
|
configDir, err := os.UserConfigDir()
|
|
if err == nil {
|
|
v.SetConfigName("netssh")
|
|
v.SetConfigType("yaml")
|
|
v.AddConfigPath(filepath.Join(configDir))
|
|
v.AddConfigPath(".")
|
|
}
|
|
|
|
v.SetEnvPrefix("NETSSH")
|
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
v.AutomaticEnv()
|
|
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results", "multi.multiplexer", "multi.panes_per_window"} {
|
|
if err := v.BindEnv(key); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := v.ReadInConfig(); err != nil {
|
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
|
return nil, fmt.Errorf("reading config: %w", err)
|
|
}
|
|
}
|
|
|
|
var cfg Config
|
|
if err := v.Unmarshal(&cfg); err != nil {
|
|
return nil, fmt.Errorf("parsing config: %w", err)
|
|
}
|
|
// Legacy environment variables must not invent an additional unnamed source
|
|
// when a file explicitly configures named instances (including wizard migration).
|
|
if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") {
|
|
cfg.NetBox = NetBoxConfig{}
|
|
}
|
|
|
|
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
|
|
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
|
|
cfg.NetBox.TokenVersion = 2
|
|
} else {
|
|
cfg.NetBox.TokenVersion = 1
|
|
}
|
|
}
|
|
|
|
if err := cfg.ValidateNetBoxes(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cfg.ValidateSSH(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cfg.Shortcuts.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cfg.ValidateHosts(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cfg.ValidateMulti(); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range cfg.NetBoxes {
|
|
source := &cfg.NetBoxes[i]
|
|
source.Name = strings.ToLower(source.Name)
|
|
if source.TokenVersion == 0 && source.Token != "" {
|
|
source.TokenVersion = 1
|
|
if strings.HasPrefix(source.Token, "nbt_") {
|
|
source.TokenVersion = 2
|
|
}
|
|
}
|
|
}
|
|
|
|
if cfg.Cache.Path == "" {
|
|
cacheDir, err := os.UserCacheDir()
|
|
if err != nil {
|
|
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
|
|
}
|
|
cfg.Cache.Path = filepath.Join(cacheDir, "netssh", "hosts.json")
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// ValidateCommandAlias restricts aliases to portable shell identifiers.
|
|
func ValidateCommandAlias(name string) error {
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
if !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`).MatchString(name) {
|
|
return fmt.Errorf("shell.alias must be a shell identifier (e.g. j)")
|
|
}
|
|
switch name {
|
|
case "netssh", "alias", "unalias", "source", "eval", "exec", "command", "builtin", "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "in", "function", "select", "time", "coproc", "end", "begin", "switch", "set", "export", "return", "break", "continue", "not", "and", "or":
|
|
return fmt.Errorf("shell.alias %q is reserved; choose another name", name)
|
|
}
|
|
return nil
|
|
}
|