Files
Sebastian UnterschützandClaude Opus 5.5 a950a82552 feat: hostname resolver strategy and static hosts
Add the hostname resolver strategy. It connects by the NetBox name
instead of an IP, with resolver.hostname_domain appended to names
without a dot. Its position in the chain decides whether it is always
used or only as a fallback (e.g. [primary_ip, hostname]); names that
are not valid DNS hostnames fall through to the next strategy.

Add static hosts under hosts: for machines that are not in NetBox,
with address, user, port, tags, and their own SSH options. They are
checked before NetBox (by name or shortcut), never expire, survive
cache refresh and clear, and work without any NetBox configured. The
cache overlays them after every load and stores only their last use,
so they appear in completion, aliases, the TUI and its recent list.
SSH options merge global, static host, then rules; rules can match
kind: static. Manage them with netssh hosts list|add|remove and in
netssh configure, which can now also continue without NetBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:46:13 +01:00

202 lines
6.2 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"`
}
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"} {
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
}
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
}