Add SSH settings that apply only to netssh connections, so ~/.ssh/config does not need global changes: - proxy_jump (passed as -J; "none" disables an inherited jump host) - host_key_checking: strict | accept-new | off (off prints a notice) - target: ip (default) | name | hostname; "name" connects to the NetBox name with -o HostName=<ip> and -o HostKeyAlias=<name>, so `Host *.example.com` rules apply and known_hosts entries follow the device instead of a reused IP - address_family: any | inet | inet6 - options (-o Key=Value) and extra_args Settings layer global -> netboxes[].ssh -> ssh.rules (match by tag, instance, kind, or name glob). Options given on the command line always win: -o values are inserted after the user's own, and extra_args before them. New wrapper flags: --jump HOST, --no-jump, and --dry-run, which prints the final ssh command instead of connecting. The TUI uses the same options. The setup wizard keeps all new settings when it saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
5.0 KiB
Go
167 lines
5.0 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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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", "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"} {
|
|
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
|
|
}
|
|
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
|
|
}
|