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

133 lines
4.6 KiB
Go

package setup
import (
"fmt"
"os"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// configureInstances edits a copy; nothing is written until the whole wizard succeeds.
func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
sources := append([]config.NetBoxConfig(nil), cfg.NetBoxes...)
if len(sources) == 0 && cfg.NetBox.URL != "" {
source := cfg.NetBox
source.Name = "default"
sources = append(sources, source)
}
for {
if len(sources) == 0 {
choice := "add"
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("NetBox").
Description("netssh looks up hosts in NetBox. Without NetBox, only static hosts (configured next) are available.").
Options(huh.NewOption("Add a NetBox instance", "add"), huh.NewOption("Continue without NetBox (static hosts only)", "none")).
Value(&choice))).Run()
if err != nil {
return nil, err
}
if choice == "none" {
return nil, nil
}
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
if err != nil {
return nil, err
}
sources = append(sources, source)
}
action := "done"
var options []huh.Option[string]
for _, source := range sources {
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
options = append(options, huh.NewOption("SSH options for "+source.Name, "ssh:"+source.Name))
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
}
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("NetBox instances").Description("All configured instances are searched together.").Options(options...).Value(&action))).Run()
if err != nil {
return nil, err
}
if action == "done" {
return sources, nil
}
if action == "add" {
source, err := editInstance(config.NetBoxConfig{}, sources, "")
if err != nil {
return nil, err
}
sources = append(sources, source)
continue
}
verb, name, _ := strings.Cut(action, ":")
for i, source := range sources {
if source.Name != name {
continue
}
switch verb {
case "edit":
edited, err := editInstance(source, sources, name)
if err != nil {
return nil, err
}
sources[i] = edited
case "ssh":
options, err := editSSHOptions("SSH options for "+name, source.SSH)
if err != nil {
return nil, err
}
sources[i].SSH = options
default:
remove := false
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
return nil, err
}
if remove {
sources = append(sources[:i], sources[i+1:]...)
}
}
break
}
}
}
func editInstance(source config.NetBoxConfig, sources []config.NetBoxConfig, original string) (config.NetBoxConfig, error) {
err := huh.NewForm(huh.NewGroup(
huh.NewInput().Title("Instance name").Description("Used in selectors such as office/web01.").Value(&source.Name).Validate(func(name string) error {
name = strings.ToLower(strings.TrimSpace(name))
candidate := config.Config{NetBoxes: []config.NetBoxConfig{{Name: name, URL: "https://example.invalid"}}}
if err := candidate.ValidateNetBoxes(); err != nil {
return err
}
for _, existing := range sources {
if strings.EqualFold(existing.Name, name) && !strings.EqualFold(existing.Name, original) {
return fmt.Errorf("instance name %q is already used", name)
}
}
return nil
}),
huh.NewInput().Title("NetBox URL").Placeholder("https://netbox.example.com").Value(&source.URL).Validate(func(value string) error {
return (&config.Config{NetBoxes: []config.NetBoxConfig{{Name: "instance", URL: strings.TrimSpace(value)}}}).ValidateNetBoxes()
}),
huh.NewInput().Title("NetBox API token").EchoMode(huh.EchoModePassword).Value(&source.Token).Validate(func(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("required")
}
return nil
}),
).Title("NetBox connection")).Run()
if err != nil {
return source, err
}
source.Name = strings.ToLower(strings.TrimSpace(source.Name))
source.URL = strings.TrimRight(strings.TrimSpace(source.URL), "/")
source.Token = strings.TrimSpace(source.Token)
source.TokenVersion = netbox.TokenVersion(source.Token)
if source.TokenVersion == 1 {
fmt.Fprintf(os.Stderr, "Note: NetBox %s uses a legacy v1 token. A v2 token starts with nbt_.\n", source.Name)
}
return source, nil
}