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
Vendored
+94
-3
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,9 @@ type Entry struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
LastUsed time.Time `json:"last_used,omitempty"`
|
||||
// Static marks a host from the config's hosts: list. It never expires;
|
||||
// it is stored only to remember LastUsed for the recently-used list.
|
||||
Static bool `json:"static,omitempty"`
|
||||
}
|
||||
|
||||
type changeKind uint8
|
||||
@@ -40,6 +44,7 @@ type Cache struct {
|
||||
path string
|
||||
ttl time.Duration
|
||||
refreshStamp string // path to last_refresh timestamp file
|
||||
static []config.StaticHost
|
||||
}
|
||||
|
||||
type diskFormat struct {
|
||||
@@ -98,6 +103,7 @@ func (c *Cache) Load() error {
|
||||
|
||||
data, err := os.ReadFile(c.path)
|
||||
if os.IsNotExist(err) {
|
||||
c.applyStatic()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -115,6 +121,7 @@ func (c *Cache) Load() error {
|
||||
for _, e := range df.Entries {
|
||||
c.entries[e.Name] = e
|
||||
}
|
||||
c.applyStatic()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -188,6 +195,15 @@ func (c *Cache) Save() error {
|
||||
if err := storage.WriteFile(c.path, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Static hosts come from the config, not the file: keep them in memory.
|
||||
for name, e := range c.entries {
|
||||
if e.Static {
|
||||
if old, ok := merged[name]; ok && old.LastUsed.After(e.LastUsed) {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
merged[name] = e
|
||||
}
|
||||
}
|
||||
c.entries = merged
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = false
|
||||
@@ -221,7 +237,10 @@ func (c *Cache) MarkUsed(name string) {
|
||||
if e, ok := c.entries[name]; ok {
|
||||
e.LastUsed = time.Now()
|
||||
c.entries[name] = e
|
||||
if _, changed := c.dirty[name]; !changed {
|
||||
if e.Static {
|
||||
// A static host may not be on disk yet; usage alone would not add it.
|
||||
c.dirty[name] = upsertChange
|
||||
} else if _, changed := c.dirty[name]; !changed {
|
||||
c.dirty[name] = usageChange
|
||||
}
|
||||
}
|
||||
@@ -279,7 +298,7 @@ func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (e
|
||||
entry, found = e, true
|
||||
}
|
||||
}
|
||||
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil
|
||||
return entry, found, found && (entry.Static || c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl), nil
|
||||
}
|
||||
|
||||
// Get returns an entry and reports whether it is still within the TTL.
|
||||
@@ -291,20 +310,92 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||
if !ok {
|
||||
return Entry{}, false
|
||||
}
|
||||
if e.Static {
|
||||
return e, true
|
||||
}
|
||||
if c.ttl == 0 {
|
||||
return e, false
|
||||
}
|
||||
return e, time.Since(e.CachedAt) < c.ttl
|
||||
}
|
||||
|
||||
// Clear removes all NetBox entries; static hosts come from the config and stay.
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
c.entries = make(map[string]Entry)
|
||||
static := make(map[string]Entry)
|
||||
for name, e := range c.entries {
|
||||
if e.Static {
|
||||
static[name] = e
|
||||
}
|
||||
}
|
||||
c.entries = static
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetStatic adds the config's static hosts. They replace cached entries of the
|
||||
// same name, keep their recorded LastUsed, and static entries that are no
|
||||
// longer configured are removed on the next Save.
|
||||
func (c *Cache) SetStatic(hosts []config.StaticHost) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.static = hosts
|
||||
c.applyStatic()
|
||||
}
|
||||
|
||||
// WithStatic sets the static hosts now and after every Load.
|
||||
func (c *Cache) WithStatic(hosts []config.StaticHost) *Cache {
|
||||
c.SetStatic(hosts)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Cache) applyStatic() {
|
||||
hosts := c.static
|
||||
configured := make(map[string]bool, len(hosts))
|
||||
for _, host := range hosts {
|
||||
e := Entry{Name: host.Name, IP: host.Address, Kind: config.StaticKind, Tags: host.Tags, CachedAt: time.Now(), Static: true}
|
||||
if old, ok := c.entries[host.Name]; ok && old.Static {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
c.entries[host.Name] = e
|
||||
configured[host.Name] = true
|
||||
}
|
||||
for name, e := range c.entries {
|
||||
if e.Static && !configured[name] {
|
||||
delete(c.entries, name)
|
||||
c.dirty[name] = deleteChange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatic finds a static host by name or by shortcut. Static hosts are
|
||||
// checked before NetBox, so they also work when NetBox is unreachable.
|
||||
func (c *Cache) GetStatic(name string, normalize func(string) string) (Entry, bool, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
var match Entry
|
||||
found := false
|
||||
for key, e := range c.entries {
|
||||
if e.Static && strings.EqualFold(key, name) {
|
||||
return e, true, nil
|
||||
}
|
||||
}
|
||||
if normalize == nil {
|
||||
return Entry{}, false, nil
|
||||
}
|
||||
short := normalize(name)
|
||||
for _, e := range c.entries {
|
||||
if e.Static && normalize(e.Name) == short {
|
||||
if found {
|
||||
return Entry{}, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", name)
|
||||
}
|
||||
match, found = e, true
|
||||
}
|
||||
}
|
||||
return match, found, nil
|
||||
}
|
||||
|
||||
func (c *Cache) All() []Entry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
Vendored
+62
@@ -4,8 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -495,3 +498,62 @@ func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHosts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
hosts := []config.StaticHost{{Name: "router", Address: "192.168.1.1", Tags: []string{"home"}}, {Name: "nas", Address: "nas.lan"}}
|
||||
c := New(path, 1).WithStatic(hosts)
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1", Kind: "device"})
|
||||
if err := c.Load(); err != nil { // no file yet; static hosts stay
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, fresh := c.Get("router"); !fresh || e.IP != "192.168.1.1" || e.Kind != config.StaticKind || !e.Static {
|
||||
t.Fatalf("static get: %+v %v", e, fresh)
|
||||
}
|
||||
if e, found, err := c.GetStatic("ROUTER", nil); !found || err != nil || e.Name != "router" {
|
||||
t.Fatalf("by name: %+v %v %v", e, found, err)
|
||||
}
|
||||
normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(s), ".lan") }
|
||||
if e, found, _ := c.GetStatic("nas.lan", normalize); !found || e.Name != "nas" {
|
||||
t.Fatalf("by shortcut: %+v", e)
|
||||
}
|
||||
if _, found, _ := c.GetStatic("web01", normalize); found {
|
||||
t.Fatal("NetBox entry returned as static")
|
||||
}
|
||||
|
||||
// Usage of a static host persists; the host itself stays after Save and Clear.
|
||||
c.MarkUsed("router")
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, fresh := c.Get("nas"); !fresh {
|
||||
t.Fatal("static host lost after Save")
|
||||
}
|
||||
reloaded := New(path, 1).WithStatic(hosts)
|
||||
if err := reloaded.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recent := reloaded.RecentlyUsed(5); len(recent) != 1 || recent[0].Name != "router" {
|
||||
t.Fatalf("recent: %+v", recent)
|
||||
}
|
||||
reloaded.Clear()
|
||||
if _, fresh := reloaded.Get("router"); !fresh {
|
||||
t.Fatal("Clear removed a static host")
|
||||
}
|
||||
|
||||
// A host removed from the config disappears, also from the file.
|
||||
removed := New(path, 1).WithStatic(hosts[1:])
|
||||
if err := removed.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := removed.Get("router"); e.Name != "" {
|
||||
t.Fatalf("removed static host still present: %+v", e)
|
||||
}
|
||||
if err := removed.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(data), "192.168.1.1") {
|
||||
t.Fatalf("removed static host still on disk:\n%s", data)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Config struct {
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
Hosts []StaticHost `mapstructure:"hosts"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
@@ -37,6 +38,8 @@ type ResolverConfig struct {
|
||||
Strategies []string `mapstructure:"strategies"`
|
||||
ManagementSubnets []string `mapstructure:"management_subnets"`
|
||||
InterfaceName string `mapstructure:"interface_name"`
|
||||
// HostnameDomain is appended by the hostname strategy to names without a dot.
|
||||
HostnameDomain string `mapstructure:"hostname_domain"`
|
||||
}
|
||||
|
||||
type CacheConfig struct {
|
||||
@@ -114,7 +117,7 @@ func Load() (*Config, error) {
|
||||
v.SetEnvPrefix("NETSSH")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,6 +160,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateHosts(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaticKind is the kind of hosts defined under hosts: instead of NetBox.
|
||||
const StaticKind = "static"
|
||||
|
||||
// StaticHost is a host that is not in NetBox, defined in the config.
|
||||
type StaticHost struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Address string `mapstructure:"address"` // IP address or DNS name
|
||||
User string `mapstructure:"user"`
|
||||
Port int `mapstructure:"port"`
|
||||
Tags []string `mapstructure:"tags"`
|
||||
SSH SSHOptions `mapstructure:"ssh"`
|
||||
}
|
||||
|
||||
var staticHostName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
// ValidateHosts keeps static host names usable as selectors and addresses
|
||||
// safe to pass to ssh.
|
||||
func (c *Config) ValidateHosts() error {
|
||||
seen := make(map[string]bool)
|
||||
for i, host := range c.Hosts {
|
||||
scope := fmt.Sprintf("hosts[%d]", i)
|
||||
if !staticHostName.MatchString(host.Name) {
|
||||
return fmt.Errorf("%s: name %q must start with a letter or digit and contain only letters, digits, dots, underscores, or hyphens", scope, host.Name)
|
||||
}
|
||||
name := strings.ToLower(host.Name)
|
||||
if seen[name] {
|
||||
return fmt.Errorf("%s: duplicate host name %q", scope, host.Name)
|
||||
}
|
||||
seen[name] = true
|
||||
if host.Address == "" || strings.HasPrefix(host.Address, "-") || strings.ContainsAny(host.Address, " \t\n\r/@") {
|
||||
return fmt.Errorf("%s: address %q must be an IP address or hostname", scope, host.Address)
|
||||
}
|
||||
if host.Port < 0 || host.Port > 65535 {
|
||||
return fmt.Errorf("%s: port must be between 1 and 65535", scope)
|
||||
}
|
||||
if strings.HasPrefix(host.User, "-") || strings.ContainsAny(host.User, " \t\n\r@") {
|
||||
return fmt.Errorf("%s: invalid user %q", scope, host.User)
|
||||
}
|
||||
if err := host.SSH.Validate(scope + ".ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StaticHost returns the static host with the given name, or nil.
|
||||
func (c *Config) StaticHost(name string) *StaticHost {
|
||||
for i := range c.Hosts {
|
||||
if strings.EqualFold(c.Hosts[i].Name, name) {
|
||||
return &c.Hosts[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -40,6 +40,9 @@ func (c *Config) ValidateNetBoxes() error {
|
||||
|
||||
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
|
||||
|
||||
// HasHosts reports whether netssh has anything to connect to: NetBox or static hosts.
|
||||
func (c *Config) HasHosts() bool { return c.HasNetBox() || len(c.Hosts) > 0 }
|
||||
|
||||
// CachePath isolates named sources from legacy caches and from changed endpoints.
|
||||
// Rotating a token or reordering the sources does not discard usage history.
|
||||
func (c *Config) CachePath() string {
|
||||
|
||||
@@ -169,7 +169,11 @@ func (o SSHOptions) Validate(scope string) error {
|
||||
// SSHOptionsFor merges global, instance, and matching rule options for host.
|
||||
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
|
||||
merged := c.SSH.SSHOptions.merge(SSHOptions{})
|
||||
if host.Instance == "" {
|
||||
if host.Kind == StaticKind {
|
||||
if static := c.StaticHost(host.Name); static != nil {
|
||||
merged = merged.merge(static.SSH)
|
||||
}
|
||||
} else if host.Instance == "" {
|
||||
merged = merged.merge(c.NetBox.SSH)
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
|
||||
@@ -135,3 +135,58 @@ func TestForwardsValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHosts(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
resolver:
|
||||
hostname_domain: example.com
|
||||
ssh:
|
||||
proxy_jump: jh
|
||||
rules:
|
||||
- match: {kind: static, tag: home}
|
||||
host_key_checking: off
|
||||
hosts:
|
||||
- name: Router
|
||||
address: 192.168.1.1
|
||||
user: admin
|
||||
port: 2222
|
||||
tags: [home]
|
||||
ssh:
|
||||
proxy_jump: none
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" {
|
||||
t.Fatalf("got %+v", cfg)
|
||||
}
|
||||
host := cfg.StaticHost("router")
|
||||
if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 {
|
||||
t.Fatalf("host: %+v", host)
|
||||
}
|
||||
opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}})
|
||||
if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" {
|
||||
t.Fatalf("static host options: %+v", opts)
|
||||
}
|
||||
// A NetBox host of the same name does not get the static host's options.
|
||||
if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" {
|
||||
t.Fatalf("netbox host: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHostsValidation(t *testing.T) {
|
||||
for _, hosts := range []string{
|
||||
"- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}",
|
||||
"- {name: 'bad name', address: 10.0.0.1}",
|
||||
"- {name: prod/a, address: 10.0.0.1}",
|
||||
"- {name: a}",
|
||||
"- {name: a, address: '-oProxyCommand=x'}",
|
||||
"- {name: a, address: 10.0.0.1, port: 70000}",
|
||||
"- {name: a, address: 10.0.0.1, user: 'a b'}",
|
||||
"- {name: a, address: 10.0.0.1, ssh: {target: nope}}",
|
||||
} {
|
||||
if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") {
|
||||
t.Errorf("%s: got %v", hosts, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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