Files
ssh-netbox-wrapper/internal/config/config.go
T
Sebastian UnterschützandClaude Fable 5.2 4287b7b650 fix: harden config loading and ssh addresses, stop resolver on API errors
- read netssh.yaml from the user config directory only, not from the
  working directory
- refuse addresses from NetBox or the cache that are neither an IP
  address nor a hostname (ssh read "-oProxyCommand=…" as an option)
- resolver chain stops at an API failure; only "no address" falls
  through to the next strategy
- netssh --help and -h show netssh's help
- start at most one background cache refresh per 5 minutes
- validate the legacy netbox.url
- page through all IP addresses of a host
- CI: run format check, vet, and tests on pull requests and main
- changelog for v0.0.25

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-15 23:25:28 +01:00

209 lines
6.6 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")
// Only the user's own directory: a netssh.yaml in the working directory
// (e.g. of a cloned repository) could inject ssh options and a NetBox URL.
v.AddConfigPath(configDir)
}
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
}