Files
ssh-netbox-wrapper/internal/setup/hosts.go
T
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

141 lines
4.4 KiB
Go

package setup
import (
"fmt"
"strconv"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// configureHosts edits a copy of the static hosts; the caller decides whether to keep it.
func configureHosts(hosts []config.StaticHost) ([]config.StaticHost, error) {
hosts = append([]config.StaticHost(nil), hosts...)
for {
action := "done"
var options []huh.Option[string]
for i, host := range hosts {
index := strconv.Itoa(i)
options = append(options,
huh.NewOption("Edit "+host.Name+" ("+host.Address+")", "edit:"+index),
huh.NewOption("SSH options for "+host.Name, "ssh:"+index),
huh.NewOption("Remove "+host.Name, "remove:"+index))
}
options = append(options, huh.NewOption("Add host", "add"), huh.NewOption("Back", "done"))
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("Static hosts").
Description("Hosts that are not in NetBox. They are found before NetBox and work offline.").
Options(options...).
Value(&action))).Run()
if err != nil {
return nil, err
}
if action == "done" {
return hosts, nil
}
if action == "add" {
host, err := editHost(config.StaticHost{}, hosts, -1)
if err != nil {
return nil, err
}
hosts = append(hosts, host)
continue
}
verb, index, _ := strings.Cut(action, ":")
i, _ := strconv.Atoi(index)
switch verb {
case "edit":
host, err := editHost(hosts[i], hosts, i)
if err != nil {
return nil, err
}
hosts[i] = host
case "ssh":
options, err := editSSHOptions("SSH options for "+hosts[i].Name, hosts[i].SSH)
if err != nil {
return nil, err
}
hosts[i].SSH = options
case "remove":
remove := false
if err := huh.NewConfirm().Title("Remove " + hosts[i].Name + "?").Value(&remove).Run(); err != nil {
return nil, err
}
if remove {
hosts = append(hosts[:i], hosts[i+1:]...)
}
}
}
}
func editHost(host config.StaticHost, hosts []config.StaticHost, index int) (config.StaticHost, error) {
name, address, user := host.Name, host.Address, host.User
port := ""
if host.Port > 0 {
port = strconv.Itoa(host.Port)
}
tags := strings.Join(host.Tags, ", ")
candidate := func() config.StaticHost {
out := host
out.Name = strings.TrimSpace(name)
out.Address = strings.TrimSpace(address)
out.User = strings.TrimSpace(user)
out.Port, _ = strconv.Atoi(strings.TrimSpace(port))
out.Tags = splitList(tags)
return out
}
// huh validates the typed value before storing it, so each validator
// checks the host with its own field taken from the argument.
validate := func(set func(*config.StaticHost, string)) func(string) error {
return func(value string) error {
h := candidate()
set(&h, strings.TrimSpace(value))
return validateHostIn(h, hosts, index)
}
}
err := huh.NewForm(huh.NewGroup(
huh.NewInput().Title("Name").Description("What you type: netssh NAME. Letters, digits, dots, hyphens.").Value(&name).
Validate(validate(func(h *config.StaticHost, v string) { h.Name = v })),
huh.NewInput().Title("Address").Description("IP address or DNS name to connect to.").Value(&address).
Validate(validate(func(h *config.StaticHost, v string) {
h.Address = v
if v == "" {
h.Address = "-" // required: reported as invalid address
}
})),
huh.NewInput().Title("User").Description("Leave empty for ssh.default_user.").Value(&user).
Validate(validate(func(h *config.StaticHost, v string) { h.User = v })),
huh.NewInput().Title("Port").Description("Leave empty for ssh.default_port or 22.").Value(&port).Validate(func(s string) error {
if s = strings.TrimSpace(s); s == "" {
return nil
}
if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("must be a port number between 1 and 65535")
}
return nil
}),
huh.NewInput().Title("Tags").Description("Comma-separated, for ssh.rules match: {tag: …}.").Value(&tags),
).Title("Static host")).Run()
if err != nil {
return host, err
}
return candidate(), nil
}
// validateHostIn checks host as entry index of hosts (-1 for a new host).
func validateHostIn(host config.StaticHost, hosts []config.StaticHost, index int) error {
others := make([]config.StaticHost, 0, len(hosts)+1)
for i, other := range hosts {
if i != index {
others = append(others, other)
}
}
if host.Address == "" {
host.Address = "placeholder" // reported once the address field is filled
}
return (&config.Config{Hosts: append(others, host)}).ValidateHosts()
}