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>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 23:46:13 +01:00
1 parent 5821dfbebd
commit a950a82552
22 files changed
+1024 -49

No files matched your search

+50 -5
View File
@@ -36,7 +36,7 @@ func RunWizard(cfg *config.Config) error {
if err != nil {
return err
}
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
c := cache.New(loaded.CachePath(), loaded.Cache.TTL).WithStatic(loaded.Hosts)
if err := c.Load(); err != nil {
return fmt.Errorf("updating aliases: %w", err)
}
@@ -90,6 +90,7 @@ func runMenu(cfg *config.Config) error {
huh.NewOption("SSH defaults — user, port", "defaults"),
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
huh.NewOption("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"),
huh.NewOption("Completion", "completion"),
@@ -109,6 +110,11 @@ func runMenu(cfg *config.Config) error {
if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil {
cfg.SSH.Rules = rules
}
case "hosts":
var hosts []config.StaticHost
if hosts, err = configureHosts(cfg.Hosts); err == nil {
cfg.Hosts = hosts
}
case "resolver":
err = editResolverCache(cfg)
case "shortcuts":
@@ -163,12 +169,13 @@ func editResolverCache(cfg *config.Config) error {
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName
hostnameDomain := cfg.Resolver.HostnameDomain
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
cachePath := cfg.Cache.Path
err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Resolver strategies").
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name, hostname").
Placeholder("primary_ip, management_subnet").
Value(&strategiesRaw).
Validate(validateStrategies),
@@ -181,6 +188,10 @@ func editResolverCache(cfg *config.Config) error {
Description("Only used when interface_name strategy is active.").
Placeholder("eth0").
Value(&interfaceName),
huh.NewInput().
Title("Hostname domain").
Description("Only used by the hostname strategy: appended to NetBox names without a dot,\ne.g. example.com turns web01 into web01.example.com.").
Value(&hostnameDomain),
huh.NewInput().
Title("Cache TTL (seconds)").
Value(&cacheTTL).
@@ -201,6 +212,7 @@ func editResolverCache(cfg *config.Config) error {
cfg.Resolver.Strategies = parseStrategies(strategiesRaw)
cfg.Resolver.ManagementSubnets = splitList(subnets)
cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName)
cfg.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".")
cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL)
cfg.Cache.Path = strings.TrimSpace(cachePath)
return nil
@@ -321,6 +333,7 @@ var knownStrategies = map[string]bool{
"primary_ip": true,
"management_subnet": true,
"interface_name": true,
"hostname": true,
}
func validateStrategies(s string) error {
@@ -329,7 +342,7 @@ func validateStrategies(s string) error {
}
for _, name := range parseStrategies(s) {
if !knownStrategies[name] {
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name)
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name, hostname", name)
}
}
return nil
@@ -337,6 +350,9 @@ func validateStrategies(s string) error {
func parseStrategies(s string) []string { return splitList(s) }
// Save validates cfg and writes it to the config file, replacing the old file.
func Save(cfg config.Config) error { return save(cfg) }
func save(cfg config.Config) error {
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
return err
@@ -350,6 +366,9 @@ func save(cfg config.Config) error {
if err := cfg.Shortcuts.Validate(); err != nil {
return err
}
if err := cfg.ValidateHosts(); err != nil {
return err
}
path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err)
@@ -365,7 +384,7 @@ func save(cfg config.Config) error {
writeSSHOptions(&b, " ", source.SSH)
}
}
} else {
} else if cfg.NetBox.URL != "" || len(cfg.Hosts) == 0 {
b.WriteString("netbox:\n")
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
if !sshOptionsEmpty(cfg.NetBox.SSH) {
@@ -388,6 +407,9 @@ func save(cfg config.Config) error {
if cfg.Resolver.InterfaceName != "" {
fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName)
}
if cfg.Resolver.HostnameDomain != "" {
fmt.Fprintf(&b, " hostname_domain: %q\n", cfg.Resolver.HostnameDomain)
}
b.WriteString("\ncache:\n")
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
@@ -416,6 +438,29 @@ func save(cfg config.Config) error {
}
}
if len(cfg.Hosts) > 0 {
b.WriteString("\nhosts:\n")
for _, host := range cfg.Hosts {
fmt.Fprintf(&b, " - name: %q\n address: %q\n", host.Name, host.Address)
if host.User != "" {
fmt.Fprintf(&b, " user: %q\n", host.User)
}
if host.Port > 0 {
fmt.Fprintf(&b, " port: %d\n", host.Port)
}
if len(host.Tags) > 0 {
b.WriteString(" tags:\n")
for _, tag := range host.Tags {
fmt.Fprintf(&b, " - %q\n", tag)
}
}
if !sshOptionsEmpty(host.SSH) {
b.WriteString(" ssh:\n")
writeSSHOptions(&b, " ", host.SSH)
}
}
}
if cfg.Completion.MaxResults > 0 {
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
}
@@ -443,7 +488,7 @@ func save(cfg config.Config) error {
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
}
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil {
if err := storage.WriteFile(path, []byte(strings.TrimLeft(b.String(), "\n")), 0o600); err != nil {
return fmt.Errorf("writing config: %w", err)
}