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

+2
View File
@@ -46,6 +46,8 @@ func newStrategy(name string, cfg config.ResolverConfig) (Strategy, error) {
return nil, err
}
return s, nil
case "hostname":
return &HostnameStrategy{domain: cfg.HostnameDomain}, nil
case "interface_name":
if cfg.InterfaceName == "" {
return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set")
+31
View File
@@ -0,0 +1,31 @@
package resolver
import (
"context"
"regexp"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
var validHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
// HostnameStrategy connects by the NetBox name instead of an IP, so DNS or
// ~/.ssh/config resolves it. Names without a dot get the optional domain.
// Names that are no valid hostname fall through to the next strategy.
type HostnameStrategy struct {
domain string
}
func (s *HostnameStrategy) Name() string { return "hostname" }
func (s *HostnameStrategy) Resolve(_ context.Context, entry *netbox.HostEntry, _ *netbox.Client) (string, error) {
name := strings.ToLower(entry.Name)
if domain := strings.Trim(s.domain, "."); domain != "" && !strings.Contains(name, ".") {
name += "." + strings.ToLower(domain)
}
if !validHostname.MatchString(name) {
return "", ErrNoIP
}
return name, nil
}
+43
View File
@@ -0,0 +1,43 @@
package resolver
import (
"context"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
func TestHostnameStrategy(t *testing.T) {
for _, tc := range []struct {
name, domain, want string
}{
{"Web01.Example.com", "", "web01.example.com"},
{"web01", "example.com", "web01.example.com"},
{"web01", ".example.com.", "web01.example.com"},
{"web01.lab", "example.com", "web01.lab"}, // already qualified
} {
got, err := (&HostnameStrategy{domain: tc.domain}).Resolve(context.Background(), &netbox.HostEntry{Name: tc.name}, nil)
if err != nil || got != tc.want {
t.Errorf("%q + %q: got %q, %v; want %q", tc.name, tc.domain, got, err, tc.want)
}
}
for _, name := range []string{"Core Switch 1", "-x", "a..b", "web_01"} {
if _, err := (&HostnameStrategy{}).Resolve(context.Background(), &netbox.HostEntry{Name: name}, nil); err != ErrNoIP {
t.Errorf("%q: got %v, want ErrNoIP", name, err)
}
}
}
func TestHostnameAsFallback(t *testing.T) {
chain, err := New(config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"})
if err != nil {
t.Fatal(err)
}
if ip, _ := chain.Resolve(context.Background(), &netbox.HostEntry{Name: "web01", PrimaryIP4: "192.0.2.1"}, nil); ip != "192.0.2.1" {
t.Fatalf("primary IP first: %q", ip)
}
if ip, _ := chain.Resolve(context.Background(), &netbox.HostEntry{Name: "web02"}, nil); ip != "web02.example.com" {
t.Fatalf("hostname fallback: %q", ip)
}
}