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:
1 parent
5821dfbebd
commit
a950a82552
22 files changed
+1024
-49
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user