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
@@ -0,0 +1,140 @@
|
||||
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()
|
||||
}
|
||||
@@ -20,6 +20,18 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,12 @@ func TestParseStrategies_PreservesOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategiesHostname(t *testing.T) {
|
||||
if err := validateStrategies("primary_ip, hostname"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategies_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"primary_ip",
|
||||
@@ -568,3 +574,46 @@ func TestSaveForwardsRoundtrip(t *testing.T) {
|
||||
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStaticHostsRoundtrip(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
hosts := []config.StaticHost{
|
||||
{Name: "router", Address: "192.168.1.1", User: "admin", Port: 2222, Tags: []string{"home"}, SSH: config.SSHOptions{ProxyJump: "none"}},
|
||||
{Name: "nas", Address: "nas.example.org"},
|
||||
}
|
||||
cfg := config.Config{
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"},
|
||||
Hosts: hosts,
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(config.Path())
|
||||
if strings.Contains(string(data), "netbox:") {
|
||||
t.Fatalf("static-only config got an empty netbox section:\n%s", data)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.Hosts, hosts) || loaded.Resolver.HostnameDomain != "example.com" || loaded.HasNetBox() {
|
||||
t.Fatalf("got %+v / %+v", loaded.Hosts, loaded.Resolver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHostIn(t *testing.T) {
|
||||
hosts := []config.StaticHost{{Name: "router", Address: "10.0.0.1"}, {Name: "nas", Address: "10.0.0.2"}}
|
||||
if err := validateHostIn(config.StaticHost{Name: "Router", Address: "10.0.0.9"}, hosts, -1); err == nil {
|
||||
t.Fatal("duplicate name accepted")
|
||||
}
|
||||
if err := validateHostIn(config.StaticHost{Name: "router", Address: "10.0.0.9"}, hosts, 0); err != nil {
|
||||
t.Fatalf("editing a host in place rejected: %v", err)
|
||||
}
|
||||
if err := validateHostIn(config.StaticHost{Name: "new"}, hosts, -1); err != nil {
|
||||
t.Fatalf("empty address while typing rejected: %v", err)
|
||||
}
|
||||
if err := validateHostIn(config.StaticHost{Name: "new", Address: "a b"}, hosts, -1); err == nil {
|
||||
t.Fatal("invalid address accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user