Files
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

125 lines
3.9 KiB
Go

package main
import (
"sort"
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
)
// newCache opens the host cache with the config's static hosts.
func newCache(cfg *config.Config) *cache.Cache {
return cache.New(cfg.CachePath(), cfg.Cache.TTL).WithStatic(cfg.Hosts)
}
func newNetBoxClient(cfg *config.Config) *netbox.Client {
if len(cfg.NetBoxes) > 0 {
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
for _, source := range cfg.NetBoxes {
sources = append(sources, netbox.Source{Name: source.Name, URL: source.URL, Token: source.Token, TokenVersion: source.TokenVersion})
}
return netbox.NewMultiClient(sources)
}
if cfg.NetBox.URL == "" {
return nil
}
return netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
}
func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) string {
return func(target string) string {
source, name := netbox.SplitTarget(target)
name = shortcuts.Normalize(name, cfg)
if qualified && source != "" {
return source + "/" + name
}
return name
}
}
// hostCompletions returns the cached hosts matching toComplete in a form that
// starts with the typed text, because bash and fish only accept such
// candidates. Without an instance prefix, a hostname that is unique across
// all instances is offered bare ("web01"), so it completes like with a single
// NetBox; connecting to it still checks every instance for ambiguity.
// Names present in several instances stay qualified ("prod/web01").
// Hosts whose full name does not match are offered by their configured
// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com).
func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string {
prefix := strings.ToLower(toComplete)
qualifiedInput := strings.Contains(prefix, "/")
instances := make(map[string]int)
for _, e := range entries {
_, name := netbox.SplitTarget(e.Name)
instances[strings.ToLower(name)]++
}
var unmatched []cache.Entry
var names []string
for _, e := range entries {
source, name := netbox.SplitTarget(e.Name)
target := strings.ToLower(e.Name)
switch {
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
if instances[strings.ToLower(name)] == 1 {
names = append(names, name)
} else {
names = append(names, e.Name)
}
case strings.HasPrefix(target, prefix):
names = append(names, e.Name)
default:
unmatched = append(unmatched, e)
}
}
if sc.Enabled() {
names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...)
}
sort.Strings(names)
return names
}
// shortcutCompletions offers the shortcut of each unmatched host when it starts
// with prefix and selects exactly one host, qualified by instance if needed.
func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string {
bare := make(map[string]int)
qualified := make(map[string]int)
short := func(e cache.Entry) (string, string) {
source, name := netbox.SplitTarget(e.Name)
s := shortcuts.Normalize(name, sc)
if source == "" {
return s, s
}
return s, strings.ToLower(source) + "/" + s
}
for _, e := range all {
b, q := short(e)
bare[b]++
qualified[q]++
}
seen := make(map[string]bool)
var out []string
for _, e := range unmatched {
b, q := short(e)
candidate := ""
switch {
case strings.Contains(prefix, "/"):
if qualified[q] == 1 && strings.HasPrefix(q, prefix) {
candidate = q
}
case !strings.HasPrefix(b, prefix):
case bare[b] == 1:
candidate = b
case q != b && qualified[q] == 1:
candidate = q // like full names: zsh accepts it, bash/fish ignore it
}
if candidate != "" && !seen[candidate] {
seen[candidate] = true
out = append(out, candidate)
}
}
return out
}