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()
|
||||
|
||||
Reference in new issue
Block a user