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

+32
View File
@@ -0,0 +1,32 @@
package main
import (
"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"
)
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
}
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func prepareInstancesTest(t *testing.T, officeURL, labURL string) *config.Config {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Setenv("NETSSH_NETBOX_TOKEN", "")
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
t.Setenv("NETSSH_SHELL_ALIAS", "")
data := fmt.Sprintf("netboxes:\n - name: office\n url: %q\n token: office-token\n - name: lab\n url: %q\n token: lab-token\nresolver:\n strategies: [primary_ip]\n", officeURL, labURL)
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
return cfg
}
func multiHost(name, ip string) connectionTestHost {
return connectionTestHost{ID: 1, Name: name, PrimaryIP4: &struct {
Address string `json:"address"`
}{Address: ip + "/24"}}
}
func TestRefreshStoresSameHostnameFromBothInstances(t *testing.T) {
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
cfg := prepareInstancesTest(t, office.URL, lab.URL)
cmd := rootCmd()
cmd.SetArgs([]string{"cache", "refresh"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
t.Fatal(err)
}
for name, ip := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2"} {
got, fresh := c.Get(name)
if !fresh || got.IP != ip {
t.Fatalf("cache source collision: %+v, fresh=%v", got, fresh)
}
}
if len(c.All()) != 2 || c.NeedsRefresh(time.Hour) {
t.Fatal("full refresh is incomplete")
}
}
func TestRefreshFailureAndPerInstanceRefresh(t *testing.T) {
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) }))
defer failed.Close()
cfg := prepareInstancesTest(t, office.URL, failed.URL)
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c.Upsert(cache.Entry{Name: "lab/old", Kind: "device", IP: "10.0.0.2"})
if err := c.Save(); err != nil {
t.Fatal(err)
}
cmd := rootCmd()
cmd.SetArgs([]string{"cache", "refresh"})
if err := cmd.Execute(); err == nil {
t.Fatal("failed instance marked as full refresh")
}
if err := c.Load(); err != nil {
t.Fatal(err)
}
if len(c.All()) != 1 || !c.NeedsRefresh(time.Hour) {
t.Fatal("partial refresh changed cache or full-refresh stamp")
}
cmd = rootCmd()
cmd.SetArgs([]string{"cache", "refresh", "--instance", "office"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if err := c.Load(); err != nil {
t.Fatal(err)
}
if len(c.All()) != 2 || !c.NeedsRefresh(time.Hour) {
t.Fatal("per-instance refresh lost other source or updated full stamp")
}
}
func TestLocalNotesCLIKeepsInstancesSeparate(t *testing.T) {
cfg := prepareInstancesTest(t, "https://office.example", "https://lab.example")
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
for _, name := range []string{"office/web", "lab/web"} {
c.Upsert(cache.Entry{Name: name, Kind: "device", IP: "10.0.0.1"})
}
if err := c.Save(); err != nil {
t.Fatal(err)
}
run := func(args ...string) (string, error) {
var out bytes.Buffer
cmd := rootCmd()
cmd.SetArgs(append([]string{"notes"}, args...))
cmd.SetOut(&out)
cmd.SetErr(&out)
err := cmd.Execute()
return out.String(), err
}
for _, source := range []string{"office", "lab"} {
if _, err := run("set", source+"/web", source+" note"); err != nil {
t.Fatal(err)
}
}
for _, source := range []string{"office", "lab"} {
got, err := run("show", source+"/web")
if err != nil || got != source+" note" {
t.Fatalf("CLI notes crossed sources: %q, %v", got, err)
}
}
if _, err := run("set", "web", "wrong note"); err == nil {
t.Fatal("ambiguous note target accepted")
}
}
func TestMultiInstanceWrapperHelper(t *testing.T) {
if target := os.Getenv("NETSSH_TEST_MULTI_TARGET"); target != "" {
runSSHWrapper([]string{target})
return
}
}
func TestWrapperRoutesQualifiedTargetsAndRejectsAmbiguity(t *testing.T) {
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
cfg := prepareInstancesTest(t, office.URL, lab.URL)
c := cache.New(cfg.CachePath(), 3600)
if err := c.SetRefreshed(); err != nil {
t.Fatal(err)
}
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
for target, want := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2", "web": "ambiguous"} {
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET="+target, "PATH="+bin+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if want == "ambiguous" {
if err == nil || !strings.Contains(string(out), "2 exact matches") {
t.Fatalf("ambiguous connection accepted: %s, %v", out, err)
}
} else if err != nil || !strings.Contains(string(out), want+"\n") {
t.Fatalf("wrong SSH destination for %s: %s, %v", target, out, err)
}
}
}
func TestQualifiedShortcutDoesNotMatchOtherInstance(t *testing.T) {
cfg := config.ShortcutsConfig{Domains: []string{".example.com"}, StripHyphens: true}
normalize := normalizeTarget(cfg, true)
if normalize("office/web-01.example.com") != normalize("office/web01") {
t.Fatal("qualified shortcut does not normalize")
}
if normalize("office/web01") == normalize("lab/web01") {
t.Fatal("qualified shortcut lost instance")
}
}
func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
office := connectionTestServer(t, []connectionTestHost{multiHost("router", "10.1.0.1")}, []connectionTestHost{multiHost("app", "10.1.0.2")})
lab := connectionTestServer(t, []connectionTestHost{multiHost("switch", "10.2.0.1")}, []connectionTestHost{multiHost("test-vm", "10.2.0.2")})
cfg := prepareInstancesTest(t, office.URL, lab.URL)
cmd := rootCmd()
cmd.SetArgs([]string{"cache", "refresh"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if err := c.Load(); err != nil {
t.Fatal(err)
}
want := map[string]struct{ kind, ip string }{
"office/router": {"device", "10.1.0.1"}, "office/app": {"vm", "10.1.0.2"},
"lab/switch": {"device", "10.2.0.1"}, "lab/test-vm": {"vm", "10.2.0.2"},
}
if len(c.All()) != len(want) {
t.Fatalf("missing inventory entries: %+v", c.All())
}
for name, entry := range want {
got, fresh := c.Get(name)
if !fresh || got.Kind != entry.kind || got.IP != entry.ip {
t.Fatalf("wrong cached host %s: %+v", name, got)
}
}
}
+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) {
+17 -7
View File
@@ -34,11 +34,11 @@ func notesCmd() *cobra.Command {
return err
}
var client *netbox.Client
if cfg.NetBox.URL != "" {
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
if cfg.HasNetBox() {
client = newNetBoxClient(cfg)
}
host := netbox.HostEntry{Name: args[0], Kind: kind}
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
}
@@ -46,13 +46,23 @@ func notesCmd() *cobra.Command {
if !remote {
cached = c.All()
}
query := host.Name
source, rawName := netbox.SplitTarget(query)
var matches []cache.Entry
for _, entry := range cached {
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
host.Name = entry.Name
host.Kind = entry.Kind
break
_, entryName := netbox.SplitTarget(entry.Name)
if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
matches = append(matches, entry)
}
}
if len(matches) > 1 {
return fmt.Errorf("ambiguous hostname %q; use instance/hostname", query)
}
if len(matches) == 1 {
host.Name = matches[0].Name
host.Kind = matches[0].Kind
}
if !remote && host.Kind == "" {
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
}