feat: search multiple NetBox instances with isolated host identities
Release / release (push) Successful in 5m16s

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 23:14:42 +01:00
1 parent a2a6dd7591
commit 7f53b90266
26 files changed
+1299 -115

No files matched your search

+63 -37
View File
@@ -23,7 +23,6 @@ import (
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
)
@@ -66,7 +65,7 @@ func loadConfigOrSetup() *config.Config {
if err != nil {
fatalf("config: %v", err)
}
if cfg.NetBox.URL == "" {
if !cfg.HasNetBox() {
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
if err := setup.RunWizard(cfg); err != nil {
fatalf("setup: %v", err)
@@ -103,12 +102,15 @@ func runSSHWrapper(args []string) {
parsed.DestIdx += 2
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
source, _ := netbox.SplitTarget(parsed.Host)
useCache := len(cfg.NetBoxes) == 0 || source != ""
// Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(parsed.Host); fresh {
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
c.MarkUsed(parsed.Host)
_ = c.Save()
connect(entry.IP, parsed, args)
@@ -119,8 +121,8 @@ func runSSHWrapper(args []string) {
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" {
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
normalize := normalizeTarget(cfg.Shortcuts, source != "")
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
if err != nil {
fatalf("%v", err)
@@ -137,19 +139,35 @@ func runSSHWrapper(args []string) {
}
}
if cfg.NetBox.URL == "" {
if !cfg.HasNetBox() {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
}
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
nbClient := newNetBoxClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
if cacheErr != nil {
fatalf("%v", cacheErr)
}
if found {
target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains)
}
}
if err != nil {
fatalf("NetBox: %v", err)
}
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
c.MarkUsed(target.Target())
_ = c.Save()
connect(cached.IP, parsed, args)
return
}
chain, err := resolver.New(cfg.Resolver)
if err != nil {
fatalf("resolver: %v", err)
@@ -160,8 +178,8 @@ func runSSHWrapper(args []string) {
fatalf("IP resolution for %q: %v", parsed.Host, err)
}
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Name)
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Target())
_ = c.Save()
connect(ip, parsed, args)
@@ -198,13 +216,13 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
func runTUI() {
cfg := loadConfigOrSetup()
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
var nbClient *netbox.Client
if cfg.NetBox.URL != "" {
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
if cfg.HasNetBox() {
nbClient = newNetBoxClient(cfg)
}
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
@@ -245,7 +263,7 @@ func runTUI() {
if err != nil {
fatalf("IP resolution: %v", err)
}
c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
}
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
@@ -290,7 +308,7 @@ func rootCmd() *cobra.Command {
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
var completions []cobra.Completion
@@ -403,9 +421,9 @@ func configureCmd() *cobra.Command {
Use: "configure",
Short: "Interactively configure netssh",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, _ := config.Load()
if cfg == nil {
cfg = &config.Config{}
cfg, err := config.Load()
if err != nil {
return err
}
return setup.RunWizard(cfg)
},
@@ -422,7 +440,7 @@ func searchCmd() *cobra.Command {
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
@@ -456,7 +474,7 @@ func cacheListCmd() *cobra.Command {
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
@@ -482,7 +500,7 @@ func cacheClearCmd() *cobra.Command {
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c.Clear()
if err := c.Save(); err != nil {
return err
@@ -494,7 +512,7 @@ func cacheClearCmd() *cobra.Command {
}
func cacheRefreshCmd() *cobra.Command {
var filterTag, filterKind string
var filterTag, filterKind, filterInstance string
cmd := &cobra.Command{
Use: "refresh",
Short: "Re-fetch all known hosts from NetBox",
@@ -503,21 +521,28 @@ func cacheRefreshCmd() *cobra.Command {
if err != nil {
return err
}
if cfg.NetBox.URL == "" {
if !cfg.HasNetBox() {
return fmt.Errorf("netbox.url is not configured")
}
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
nbClient := newNetBoxClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
entries, err := nbClient.SearchAll(ctx, "", opts)
query := ""
if filterInstance != "" {
if !nbClient.MultipleSources() {
return fmt.Errorf("--instance requires named netboxes configuration")
}
query = filterInstance + "/"
}
entries, err := nbClient.SearchAll(ctx, query, opts)
if err != nil {
return fmt.Errorf("NetBox: %w", err)
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
chain, err := resolver.New(cfg.Resolver)
@@ -527,12 +552,12 @@ func cacheRefreshCmd() *cobra.Command {
written := 0
nameCounts := make(map[string]int)
for _, e := range entries {
nameCounts[strings.ToLower(e.Name)]++
nameCounts[strings.ToLower(e.Target())]++
}
for i := range entries {
e := &entries[i]
if nameCounts[strings.ToLower(e.Name)] > 1 {
c.Delete(e.Name)
if nameCounts[strings.ToLower(e.Target())] > 1 {
c.Delete(e.Target())
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
continue
}
@@ -544,18 +569,18 @@ func cacheRefreshCmd() *cobra.Command {
if ctx.Err() != nil {
return ctx.Err()
}
c.Delete(e.Name)
c.Delete(e.Target())
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue
}
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
written++
}
if err := c.Save(); err != nil {
return err
}
if filterTag == "" && filterKind == "" {
if filterTag == "" && filterKind == "" && filterInstance == "" {
if err := c.SetRefreshed(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
}
@@ -571,6 +596,7 @@ func cacheRefreshCmd() *cobra.Command {
}
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
return cmd
}
@@ -599,7 +625,7 @@ Or use in a script:
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
@@ -644,7 +670,7 @@ func shellInitCmd() *cobra.Command {
if err != nil {
return nil
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load()
// Regenerate alias files from the current cache every shell start
@@ -705,8 +731,8 @@ After installation, reload your profile or open a new shell.`,
// Generate the aliases file immediately so it is ready on the next shell start.
cfg, cfgErr := config.Load()
if cfgErr == nil && cfg.NetBox.URL != "" {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
if cfgErr == nil && cfg.HasNetBox() {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if _ = c.Load(); true {
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
if err := hook.WriteAliasFiles(entries); err != nil {
@@ -758,7 +784,7 @@ func hookUninstallCmd() *cobra.Command {
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if cfg.NetBox.URL == "" {
if !cfg.HasNetBox() {
return
}
if !c.NeedsRefresh(24 * time.Hour) {