Compare commits

...
2 Commits
Author SHA1 Message Date
Sebastian Unterschütz 7f53b90266 feat: search multiple NetBox instances with isolated host identities
Release / release (push) Successful in 5m16s
2026-10-27 23:14:42 +01:00
Sebastian Unterschütz a2a6dd7591 chore(deps): update github.com/charmbracelet/ultraviolet digest to a45b7c8
Release / release (push) Successful in 5m7s
2026-10-27 22:44:00 +01:00
28 changed files with 1299 additions and 115 deletions

No files matched your search

+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## v0.0.20
- Configure multiple named NetBox instances and search them together in parallel.
- Add, edit, and remove instances in the interactive setup wizard while retaining legacy single-instance configuration support.
- Display instance-qualified hostnames and support `instance/hostname` for SSH, completion, aliases, and notes.
- Keep credentials, IP resolution, comments, cache entries, and local notes tied to the correct instance.
- Preserve partial interactive search results with an outage warning; reject incomplete full refreshes and ambiguous direct connections.
- Add `cache refresh --instance NAME` and isolate caches when configured endpoints change.
- Add regression tests covering duplicate names and IDs across instances, outages, SSH routing, notes, aliases, and configuration round trips.
## v0.0.19
- Update Ultraviolet to commit `a45b7c8`.
## v0.0.18 ## v0.0.18
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`. - Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
+39 -2
View File
@@ -103,7 +103,7 @@ Re-run it at any time to change settings without editing the file manually:
netssh configure netssh configure
``` ```
The wizard walks through NetBox connection, SSH defaults, resolver strategies, cache TTL, and an optional command alias, The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias,
then saves to `~/.config/netssh.yaml`. then saves to `~/.config/netssh.yaml`.
### Manual config ### Manual config
@@ -142,7 +142,44 @@ shortcuts:
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution) strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
``` ```
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.). Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
### Multiple NetBox instances
Use `netssh configure` to manage instances, or replace the `netbox:` section with a `netboxes:` list:
```yaml
netboxes:
- name: office
url: https://netbox.office.example
token: nbt_office-token
- name: lab
url: https://netbox.lab.example
token: nbt_lab-token
```
Names must be unique and contain only letters, digits, underscores, or hyphens. Names are normalized to lowercase. Each instance has its own token and optional `token_version`; SSH, resolver, and shortcut settings apply to all instances. Configure either `netbox:` or `netboxes:`. Existing single-instance files continue to work; the wizard migrates them to a named `default` instance when saved.
The legacy `NETSSH_NETBOX_URL` and `NETSSH_NETBOX_TOKEN` variables apply to single-instance configuration. They are ignored when the file contains only a named `netboxes:` list.
Each NetBox keeps its own inventory of devices and virtual machines; either inventory may be empty. The interactive search queries all instances in parallel, including when one already has cached matches. Results show the instance as part of the hostname, for example `office/web01`. If an instance is unavailable, available results and cached hosts remain visible with a warning.
```sh
netssh # search all instances interactively
netssh office/web01 # choose an instance explicitly
netssh admin@lab/web01 uptime
netssh web01 # accepted only if the live lookup is unambiguous
netssh cache refresh # refresh all instances
netssh cache refresh --instance lab # refresh just one instance
netssh notes set office/web01 "Office maintenance"
netssh notes show lab/web01 --netbox --kind device
```
For direct connections without an instance prefix, every instance must be reachable to verify uniqueness. A qualified target queries only its own instance and can use a fresh cached IP while other instances are offline. Qualified shortcuts such as `office/web01` also support configured domain suffixes and hyphen folding.
Cache entries and generated aliases retain the instance name (`office_web01` and `lab_web01`). Named-instance caches live in a configuration-specific subdirectory beneath the configured cache path, so changing an endpoint cannot reuse another server's cached IPs. Run `netssh cache refresh` after adding or changing instances. A failed full refresh leaves the cache and its refresh timestamp unchanged; a per-instance refresh preserves the other instances and does not mark the whole cache as refreshed.
Local notes remain keyed by the NetBox URL, host kind, and canonical hostname. Migrating a single instance or renaming it preserves its notes. Use `instance/hostname` and `--kind` for uncached local notes; these operations work offline. NetBox comments and interface/IP lookups always use the selected instance's credentials.
### Short command alias ### Short command alias
+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/notes"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver" "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/setup"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh" internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
) )
@@ -66,7 +65,7 @@ func loadConfigOrSetup() *config.Config {
if err != nil { if err != nil {
fatalf("config: %v", err) fatalf("config: %v", err)
} }
if cfg.NetBox.URL == "" { if !cfg.HasNetBox() {
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…") fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
if err := setup.RunWizard(cfg); err != nil { if err := setup.RunWizard(cfg); err != nil {
fatalf("setup: %v", err) fatalf("setup: %v", err)
@@ -103,12 +102,15 @@ func runSSHWrapper(args []string) {
parsed.DestIdx += 2 parsed.DestIdx += 2
} }
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c) 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. // 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.MarkUsed(parsed.Host)
_ = c.Save() _ = c.Save()
connect(entry.IP, parsed, args) 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. // If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host lookupHost := parsed.Host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" { if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts) normalize := normalizeTarget(cfg.Shortcuts, source != "")
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
if err != nil { if err != nil {
fatalf("%v", err) 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)") 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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains) 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 { if err != nil {
fatalf("NetBox: %v", err) 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) chain, err := resolver.New(cfg.Resolver)
if err != nil { if err != nil {
fatalf("resolver: %v", err) fatalf("resolver: %v", err)
@@ -160,8 +178,8 @@ func runSSHWrapper(args []string) {
fatalf("IP resolution for %q: %v", parsed.Host, err) 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.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Name) c.MarkUsed(target.Target())
_ = c.Save() _ = c.Save()
connect(ip, parsed, args) connect(ip, parsed, args)
@@ -198,13 +216,13 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
func runTUI() { func runTUI() {
cfg := loadConfigOrSetup() cfg := loadConfigOrSetup()
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c) maybeBackgroundRefresh(cfg, c)
var nbClient *netbox.Client var nbClient *netbox.Client
if cfg.NetBox.URL != "" { if cfg.HasNetBox() {
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) nbClient = newNetBoxClient(cfg)
} }
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
@@ -245,7 +263,7 @@ func runTUI() {
if err != nil { if err != nil {
fatalf("IP resolution: %v", err) 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) fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
@@ -290,7 +308,7 @@ func rootCmd() *cobra.Command {
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
var completions []cobra.Completion var completions []cobra.Completion
@@ -403,9 +421,9 @@ func configureCmd() *cobra.Command {
Use: "configure", Use: "configure",
Short: "Interactively configure netssh", Short: "Interactively configure netssh",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg, _ := config.Load() cfg, err := config.Load()
if cfg == nil { if err != nil {
cfg = &config.Config{} return err
} }
return setup.RunWizard(cfg) return setup.RunWizard(cfg)
}, },
@@ -422,7 +440,7 @@ func searchCmd() *cobra.Command {
if err != nil { if err != nil {
return err 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 { if err := c.Load(); err != nil {
return err return err
} }
@@ -456,7 +474,7 @@ func cacheListCmd() *cobra.Command {
if err != nil { if err != nil {
return err 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 { if err := c.Load(); err != nil {
return err return err
} }
@@ -482,7 +500,7 @@ func cacheClearCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
c.Clear() c.Clear()
if err := c.Save(); err != nil { if err := c.Save(); err != nil {
return err return err
@@ -494,7 +512,7 @@ func cacheClearCmd() *cobra.Command {
} }
func cacheRefreshCmd() *cobra.Command { func cacheRefreshCmd() *cobra.Command {
var filterTag, filterKind string var filterTag, filterKind, filterInstance string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "refresh", Use: "refresh",
Short: "Re-fetch all known hosts from NetBox", Short: "Re-fetch all known hosts from NetBox",
@@ -503,21 +521,28 @@ func cacheRefreshCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
if cfg.NetBox.URL == "" { if !cfg.HasNetBox() {
return fmt.Errorf("netbox.url is not configured") 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) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind} 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 { if err != nil {
return fmt.Errorf("NetBox: %w", err) 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() _ = c.Load()
chain, err := resolver.New(cfg.Resolver) chain, err := resolver.New(cfg.Resolver)
@@ -527,12 +552,12 @@ func cacheRefreshCmd() *cobra.Command {
written := 0 written := 0
nameCounts := make(map[string]int) nameCounts := make(map[string]int)
for _, e := range entries { for _, e := range entries {
nameCounts[strings.ToLower(e.Name)]++ nameCounts[strings.ToLower(e.Target())]++
} }
for i := range entries { for i := range entries {
e := &entries[i] e := &entries[i]
if nameCounts[strings.ToLower(e.Name)] > 1 { if nameCounts[strings.ToLower(e.Target())] > 1 {
c.Delete(e.Name) c.Delete(e.Target())
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name) fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
continue continue
} }
@@ -544,18 +569,18 @@ func cacheRefreshCmd() *cobra.Command {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }
c.Delete(e.Name) c.Delete(e.Target())
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err) fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
continue 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++ written++
} }
if err := c.Save(); err != nil { if err := c.Save(); err != nil {
return err return err
} }
if filterTag == "" && filterKind == "" { if filterTag == "" && filterKind == "" && filterInstance == "" {
if err := c.SetRefreshed(); err != nil { if err := c.SetRefreshed(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err) 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(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm") cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
return cmd return cmd
} }
@@ -599,7 +625,7 @@ Or use in a script:
if err != nil { if err != nil {
return err 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 { if err := c.Load(); err != nil {
return err return err
} }
@@ -644,7 +670,7 @@ func shellInitCmd() *cobra.Command {
if err != nil { if err != nil {
return nil return nil
} }
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
// Regenerate alias files from the current cache every shell start // 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. // Generate the aliases file immediately so it is ready on the next shell start.
cfg, cfgErr := config.Load() cfg, cfgErr := config.Load()
if cfgErr == nil && cfg.NetBox.URL != "" { if cfgErr == nil && cfg.HasNetBox() {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
if _ = c.Load(); true { if _ = c.Load(); true {
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
if err := hook.WriteAliasFiles(entries); err != nil { 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 // maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours. // has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if cfg.NetBox.URL == "" { if !cfg.HasNetBox() {
return return
} }
if !c.NeedsRefresh(24 * time.Hour) { if !c.NeedsRefresh(24 * time.Hour) {
+17 -7
View File
@@ -34,11 +34,11 @@ func notesCmd() *cobra.Command {
return err return err
} }
var client *netbox.Client var client *netbox.Client
if cfg.NetBox.URL != "" { if cfg.HasNetBox() {
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) client = newNetBoxClient(cfg)
} }
host := netbox.HostEntry{Name: args[0], Kind: kind} 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 { if err := c.Load(); err != nil {
return err return err
} }
@@ -46,13 +46,23 @@ func notesCmd() *cobra.Command {
if !remote { if !remote {
cached = c.All() cached = c.All()
} }
query := host.Name
source, rawName := netbox.SplitTarget(query)
var matches []cache.Entry
for _, entry := range cached { for _, entry := range cached {
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) { _, entryName := netbox.SplitTarget(entry.Name)
host.Name = entry.Name if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
host.Kind = entry.Kind matches = append(matches, entry)
break
} }
} }
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 == "" { if !remote && host.Kind == "" {
return fmt.Errorf("host is not cached; specify --kind device or --kind vm") return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
} }
+1 -1
View File
@@ -16,7 +16,7 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/exp/golden v0.1.0 // indirect github.com/charmbracelet/x/exp/golden v0.1.0 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
+2 -2
View File
@@ -16,8 +16,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw= github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f h1:D2JpB65eehsnkEwEG401dMBSNYtTDgvKXZZSl7ZCxWI=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio= github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM= github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w= github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY= github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
+2 -1
View File
@@ -258,7 +258,8 @@ func (c *Cache) Search(prefix string) []Entry {
prefix = strings.ToLower(prefix) prefix = strings.ToLower(prefix)
var out []Entry var out []Entry
for name, e := range c.entries { for name, e := range c.entries {
if strings.HasPrefix(strings.ToLower(name), prefix) { _, hostname, qualified := strings.Cut(strings.ToLower(name), "/")
if strings.HasPrefix(strings.ToLower(name), prefix) || (qualified && !strings.Contains(prefix, "/") && strings.HasPrefix(hostname, prefix)) {
out = append(out, e) out = append(out, e)
} }
} }
+12
View File
@@ -483,3 +483,15 @@ func TestUpsertPreservesUsageHistory(t *testing.T) {
t.Fatalf("history lost: %+v", after) t.Fatalf("history lost: %+v", after)
} }
} }
func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"})
c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"})
c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"})
for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} {
if got := len(c.Search(query)); got != want {
t.Errorf("Search(%q)=%d, want %d", query, got, want)
}
}
}
+21
View File
@@ -13,6 +13,7 @@ import (
type Config struct { type Config struct {
Shell ShellConfig `mapstructure:"shell"` Shell ShellConfig `mapstructure:"shell"`
NetBox NetBoxConfig `mapstructure:"netbox"` NetBox NetBoxConfig `mapstructure:"netbox"`
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
Resolver ResolverConfig `mapstructure:"resolver"` Resolver ResolverConfig `mapstructure:"resolver"`
Cache CacheConfig `mapstructure:"cache"` Cache CacheConfig `mapstructure:"cache"`
SSH SSHConfig `mapstructure:"ssh"` SSH SSHConfig `mapstructure:"ssh"`
@@ -24,6 +25,7 @@ type ShellConfig struct {
} }
type NetBoxConfig struct { type NetBoxConfig struct {
Name string `mapstructure:"name"`
URL string `mapstructure:"url"` URL string `mapstructure:"url"`
Token string `mapstructure:"token"` Token string `mapstructure:"token"`
TokenVersion int `mapstructure:"token_version"` TokenVersion int `mapstructure:"token_version"`
@@ -93,6 +95,11 @@ func Load() (*Config, error) {
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err) return nil, fmt.Errorf("parsing config: %w", err)
} }
// Legacy environment variables must not invent an additional unnamed source
// when a file explicitly configures named instances (including wizard migration).
if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") {
cfg.NetBox = NetBoxConfig{}
}
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil { if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
return nil, err return nil, err
@@ -106,6 +113,20 @@ func Load() (*Config, error) {
} }
} }
if err := cfg.ValidateNetBoxes(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name)
if source.TokenVersion == 0 && source.Token != "" {
source.TokenVersion = 1
if strings.HasPrefix(source.Token, "nbt_") {
source.TokenVersion = 2
}
}
}
if cfg.Cache.Path == "" { if cfg.Cache.Path == "" {
cacheDir, err := os.UserCacheDir() cacheDir, err := os.UserCacheDir()
if err != nil { if err != nil {
+56
View File
@@ -0,0 +1,56 @@
package config
import (
"crypto/sha256"
"fmt"
"net/url"
"path/filepath"
"regexp"
"sort"
"strings"
)
var instanceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
// ValidateNetBoxes keeps source names usable as instance/hostname selectors.
func (c *Config) ValidateNetBoxes() error {
if len(c.NetBoxes) > 0 && c.NetBox.URL != "" {
return fmt.Errorf("configure either netbox or netboxes, not both")
}
seen := make(map[string]bool)
for _, source := range c.NetBoxes {
if !instanceName.MatchString(source.Name) {
return fmt.Errorf("netboxes.name %q must contain only letters, digits, underscores, or hyphens", source.Name)
}
name := strings.ToLower(source.Name)
if seen[name] {
return fmt.Errorf("duplicate NetBox instance name %q", source.Name)
}
seen[name] = true
u, err := url.Parse(source.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name)
}
if source.TokenVersion < 0 || source.TokenVersion > 2 {
return fmt.Errorf("NetBox %q: token_version must be 0, 1, or 2", source.Name)
}
}
return nil
}
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 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 {
if len(c.NetBoxes) == 0 {
return c.Cache.Path
}
var identities []string
for _, source := range c.NetBoxes {
identities = append(identities, strings.ToLower(source.Name)+"\x00"+strings.TrimRight(source.URL, "/"))
}
sort.Strings(identities)
sum := sha256.Sum256([]byte(strings.Join(identities, "\x00")))
return filepath.Join(filepath.Dir(c.Cache.Path), fmt.Sprintf("instances-%x", sum[:8]), filepath.Base(c.Cache.Path))
}
+79
View File
@@ -0,0 +1,79 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadNamedNetBoxes(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
t.Setenv("NETSSH_NETBOX_URL", "https://legacy-env.example")
t.Setenv("NETSSH_NETBOX_TOKEN", "legacy-env-token")
data := `netboxes:
- name: Office
url: https://office.example/netbox
token: nbt_office
- name: lab
url: https://lab.example
token: legacy-token
cache:
path: /tmp/netssh-tests/hosts.json
`
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if !cfg.HasNetBox() || len(cfg.NetBoxes) != 2 || cfg.NetBoxes[0].Name != "office" || cfg.NetBoxes[0].TokenVersion != 2 || cfg.NetBoxes[1].TokenVersion != 1 {
t.Fatalf("bad instances: %+v", cfg.NetBoxes)
}
if cfg.NetBox.URL != "" {
t.Fatal("legacy environment variables added an unnamed instance")
}
first := cfg.CachePath()
if first == cfg.Cache.Path {
t.Fatal("named sources reuse legacy cache")
}
cfg.NetBoxes[0], cfg.NetBoxes[1] = cfg.NetBoxes[1], cfg.NetBoxes[0]
cfg.NetBoxes[0].Token = "rotated"
if cfg.CachePath() != first {
t.Fatal("reordering/rotating credentials discarded cache")
}
cfg.NetBoxes[0].URL = "https://other.example"
if cfg.CachePath() == first {
t.Fatal("changed endpoint reuses old hosts")
}
}
func TestValidateNetBoxes(t *testing.T) {
good := NetBoxConfig{Name: "office", URL: "https://office.example"}
for _, tc := range []struct {
name string
cfg Config
}{
{"duplicate", Config{NetBoxes: []NetBoxConfig{good, {Name: "OFFICE", URL: "https://other.example"}}}},
{"empty name", Config{NetBoxes: []NetBoxConfig{{URL: "https://office.example"}}}},
{"invalid name", Config{NetBoxes: []NetBoxConfig{{Name: "a/b", URL: "https://office.example"}}}},
{"missing URL", Config{NetBoxes: []NetBoxConfig{{Name: "office"}}}},
{"invalid URL", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: "file:///tmp/netbox"}}}},
{"conflicting config", Config{NetBox: good, NetBoxes: []NetBoxConfig{good}}},
{"invalid token version", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: good.URL, TokenVersion: 3}}}},
} {
t.Run(tc.name, func(t *testing.T) {
if err := tc.cfg.ValidateNetBoxes(); err == nil {
t.Fatal("invalid configuration accepted")
}
})
}
legacy := Config{NetBox: good, Cache: CacheConfig{Path: "hosts.json"}}
if err := legacy.ValidateNetBoxes(); err != nil {
t.Fatal(err)
}
if !legacy.HasNetBox() || legacy.CachePath() != "hosts.json" {
t.Fatal("legacy behavior changed")
}
}
+15
View File
@@ -227,3 +227,18 @@ func TestCommandAliasGenerationAndCollision(t *testing.T) {
t.Fatal(got) t.Fatal(got)
} }
} }
func TestAliasesIncludeInstanceName(t *testing.T) {
cfg := config.Config{Shortcuts: config.ShortcutsConfig{Domains: []string{".example.com"}}}
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "office/web01.example.com"}, {Name: "lab/web01.example.com"}}, cfg)
if len(entries) != 2 {
t.Fatalf("missing qualified aliases: %+v", entries)
}
got := map[string]string{}
for _, entry := range entries {
got[entry.Name] = entry.Host
}
if got["office_web01"] != "office/web01.example.com" || got["lab_web01"] != "lab/web01.example.com" {
t.Fatalf("alias lost source: %+v", entries)
}
}
+36 -1
View File
@@ -15,6 +15,7 @@ import (
) )
type Client struct { type Client struct {
sources map[string]*Client
baseURL string baseURL string
token string token string
tokenVersion int tokenVersion int
@@ -38,6 +39,9 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
// Search queries up to 50 devices and VMs in parallel and merges the results. // Search queries up to 50 devices and VMs in parallel and merges the results.
// Use SearchOptions to restrict by kind or tag. // Use SearchOptions to restrict by kind or tag.
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) { func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
if c.sources != nil {
return c.searchSources(ctx, query, opts, false)
}
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind) return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
} }
@@ -95,6 +99,9 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
// SearchAll paginates through all matching devices and VMs, fetching every page. // SearchAll paginates through all matching devices and VMs, fetching every page.
// Intended for cache refresh; use Search for interactive queries. // Intended for cache refresh; use Search for interactive queries.
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) { func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
if c.sources != nil {
return c.searchSources(ctx, query, opts, true)
}
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind) return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
} }
@@ -146,6 +153,13 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies // GetIPs returns all IP addresses assigned to a host, used by resolver strategies
// that need more than just the primary IP. // that need more than just the primary IP.
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) { func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
if c.sources != nil {
source, host, err := c.sourceForHost(entry)
if err != nil {
return nil, err
}
return source.GetIPs(ctx, host)
}
var apiURL string var apiURL string
switch entry.Kind { switch entry.Kind {
case "device": case "device":
@@ -170,6 +184,9 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error)
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters. // GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) { func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
if c.sources != nil {
return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost")
}
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams) apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
var resp netboxIPListResponse var resp netboxIPListResponse
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
@@ -360,12 +377,13 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er
return HostEntry{}, fmt.Errorf("invalid kind %q", kind) return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
} }
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind}) entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
_, hostname := SplitTarget(name)
if err != nil { if err != nil {
return HostEntry{}, err return HostEntry{}, err
} }
var matches []HostEntry var matches []HostEntry
for _, e := range entries { for _, e := range entries {
if strings.EqualFold(e.Name, name) { if strings.EqualFold(e.Name, hostname) {
matches = append(matches, e) matches = append(matches, e)
} }
} }
@@ -373,6 +391,9 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name) return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
} }
if len(matches) != 1 { if len(matches) != 1 {
if c.sources != nil {
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use instance/hostname and --kind to select a unique host", name, len(matches))
}
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches)) return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
} }
return matches[0], nil return matches[0], nil
@@ -395,6 +416,13 @@ func (c *Client) hostURL(host HostEntry) (string, error) {
} }
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) { func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
if c.sources != nil {
source, host, err := c.sourceForHost(host)
if err != nil {
return "", "", err
}
return source.GetComments(ctx, host)
}
u, err := c.hostURL(host) u, err := c.hostURL(host)
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -407,6 +435,13 @@ func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, strin
} }
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error { func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
if c.sources != nil {
source, host, err := c.sourceForHost(host)
if err != nil {
return err
}
return source.SetComments(ctx, host, comments, etag)
}
u, err := c.hostURL(host) u, err := c.hostURL(host)
if err != nil { if err != nil {
return err return err
+19
View File
@@ -1,5 +1,7 @@
package netbox package netbox
import "strings"
// SearchOptions filters a Search or SearchAll query. // SearchOptions filters a Search or SearchAll query.
// Zero value means no filtering (return all kinds, no tag filter). // Zero value means no filtering (return all kinds, no tag filter).
type SearchOptions struct { type SearchOptions struct {
@@ -9,6 +11,7 @@ type SearchOptions struct {
// HostEntry is a unified model for both devices and virtual machines from NetBox. // HostEntry is a unified model for both devices and virtual machines from NetBox.
type HostEntry struct { type HostEntry struct {
Source string // named NetBox instance; empty for the legacy single-instance config
ID int ID int
Name string Name string
Kind string // "device" | "vm" Kind string // "device" | "vm"
@@ -58,3 +61,19 @@ type netboxIPListResponse struct {
} `json:"assigned_object"` } `json:"assigned_object"`
} `json:"results"` } `json:"results"`
} }
// Target returns an unambiguous selector for cache, completion, and SSH.
func (h HostEntry) Target() string {
if h.Source == "" {
return h.Name
}
return h.Source + "/" + h.Name
}
// SplitTarget separates an optional instance prefix from a hostname.
func SplitTarget(target string) (source, name string) {
if source, name, ok := strings.Cut(target, "/"); ok {
return strings.ToLower(source), name
}
return "", target
}
+113
View File
@@ -0,0 +1,113 @@
package netbox
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
)
// Source binds a name to its own API client and credentials.
type Source struct {
Name string
URL string
Token string
TokenVersion int
}
func NewMultiClient(sources []Source) *Client {
c := &Client{sources: make(map[string]*Client)}
for _, source := range sources {
c.sources[strings.ToLower(source.Name)] = NewClient(source.URL, source.Token, source.TokenVersion)
}
return c
}
func (c *Client) searchSources(ctx context.Context, query string, opts SearchOptions, all bool) ([]HostEntry, error) {
requested, query := SplitTarget(query)
if requested != "" {
if _, ok := c.sources[requested]; !ok {
return nil, fmt.Errorf("unknown NetBox instance %q", requested)
}
}
var mu sync.Mutex
var wg sync.WaitGroup
var entries []HostEntry
var errs []error
started := 0
for name, client := range c.sources {
if requested != "" && name != requested {
continue
}
started++
wg.Add(1)
go func(name string, client *Client) {
defer wg.Done()
var found []HostEntry
var err error
if all {
found, err = client.SearchAll(ctx, query, opts)
} else {
found, err = client.Search(ctx, query, opts)
}
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("NetBox %s: %w", name, err))
return
}
for i := range found {
found[i].Source = name
}
entries = append(entries, found...)
}(name, client)
}
wg.Wait()
// Full lookups must see every source to rule out ambiguous hostnames.
if len(errs) > 0 && (all || len(errs) == started) {
return nil, errors.Join(errs...)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
return entries, errors.Join(errs...)
}
func (c *Client) sourceForHost(host HostEntry) (*Client, HostEntry, error) {
prefix, name := SplitTarget(host.Name)
if prefix != "" {
if host.Source != "" && !strings.EqualFold(prefix, host.Source) {
return nil, host, fmt.Errorf("conflicting NetBox instance for %q", host.Name)
}
host.Source, host.Name = prefix, name
}
if c.sources == nil {
return c, host, nil
}
source, ok := c.sources[strings.ToLower(host.Source)]
if !ok {
return nil, host, fmt.Errorf("specify a configured NetBox instance as instance/hostname (got %q)", host.Target())
}
host.Source = strings.ToLower(host.Source)
return source, host, nil
}
// HostScope identifies a local note by its original NetBox URL and canonical
// hostname, so renaming an instance does not move or mix its notes.
func (c *Client) HostScope(host HostEntry) (HostEntry, string, error) {
source, host, err := c.sourceForHost(host)
if err != nil {
return host, "", err
}
return host, source.baseURL, nil
}
func (c *Client) GetIPsWithFilterForHost(ctx context.Context, host HostEntry, filters string) ([]string, error) {
source, _, err := c.sourceForHost(host)
if err != nil {
return nil, err
}
return source.GetIPsWithFilter(ctx, filters)
}
func (c *Client) MultipleSources() bool { return c.sources != nil }
+192
View File
@@ -0,0 +1,192 @@
package netbox
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Token "+token {
t.Errorf("credentials crossed sources: %q, want %q", got, token)
http.Error(w, "bad token", 401)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/dcim/devices/":
json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}}))
case "/api/virtualization/virtual-machines/":
json.NewEncoder(w).Encode(vmListResponse())
case "/api/ipam/ip-addresses/":
if r.URL.Query().Get("device_id") != "1" {
t.Errorf("wrong host ID: %s", r.URL.RawQuery)
}
json.NewEncoder(w).Encode(ipListResponse(ip + "/24"))
case "/api/dcim/devices/1/":
if r.Method == http.MethodPatch {
var data map[string]string
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Error(err)
}
if data["comments"] != comment+" updated" {
t.Errorf("comment write crossed sources: %v", data)
}
}
json.NewEncoder(w).Encode(map[string]string{"comments": comment})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestMultiSourceSearchAndRouting(t *testing.T) {
office := sourceTestServer(t, "office-token", "10.0.0.1", "office note")
lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note")
c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}})
ctx := context.Background()
entries, err := c.Search(ctx, "web", SearchOptions{})
if err != nil || len(entries) != 2 {
t.Fatalf("combined search: %+v, %v", entries, err)
}
if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" {
t.Fatalf("source lost: %+v", entries)
}
if _, err := c.FindHost(ctx, "web", ""); err == nil {
t.Fatal("ambiguous unqualified hostname accepted")
}
for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} {
t.Run(tc.name, func(t *testing.T) {
host, err := c.FindHost(ctx, tc.name+"/web", "")
if err != nil {
t.Fatal(err)
}
if host.Source != tc.name || host.Name != "web" || host.ID != 1 {
t.Fatalf("wrong host identity: %+v", host)
}
ips, err := c.GetIPs(ctx, host)
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
t.Fatalf("wrong IP source: %v, %v", ips, err)
}
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0")
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
t.Fatalf("wrong interface source: %v, %v", ips, err)
}
text, _, err := c.GetComments(ctx, host)
if err != nil || text != tc.note {
t.Fatalf("wrong comment source: %q, %v", text, err)
}
// TUI cache items carry a qualified name rather than a separate source.
qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"}
if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil {
t.Fatal(err)
}
})
}
if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil {
t.Fatal("unknown source accepted")
}
if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil {
t.Fatal("unqualified note namespace accepted")
}
if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil {
t.Fatal("IP filter without instance accepted")
}
}
func TestMultiSourceSearchRunsConcurrently(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
makeServer := func() *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wg.Done()
both := make(chan struct{})
go func() { wg.Wait(); close(both) }()
select {
case <-both:
fmt.Fprint(w, `{"count":0,"results":[]}`)
case <-r.Context().Done():
}
}))
t.Cleanup(srv.Close)
return srv
}
a, b := makeServer(), makeServer()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}})
if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil {
t.Fatalf("sources did not run concurrently: %v", err)
}
}
func TestMultiSourcePartialFailure(t *testing.T) {
good := sourceTestServer(t, "good-token", "10.0.0.1", "note")
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) }))
defer failed.Close()
c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}})
ctx := context.Background()
entries, err := c.Search(ctx, "web", SearchOptions{})
if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") {
t.Fatalf("partial search lost results or warning: %+v, %v", entries, err)
}
if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil {
t.Fatal("incomplete full search succeeded")
}
if _, err := c.FindHost(ctx, "web", ""); err == nil {
t.Fatal("incomplete search claimed unique host")
}
if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" {
t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err)
}
}
func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) {
office := newTestServer(t, map[string]any{
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}),
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}),
})
defer office.Close()
lab := newTestServer(t, map[string]any{
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}),
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}),
})
defer lab.Close()
client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
entries, err := client.SearchAll(context.Background(), "", SearchOptions{})
if err != nil {
t.Fatal(err)
}
want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"}
if len(entries) != len(want) {
t.Fatalf("independent inventories missing: %+v", entries)
}
for _, entry := range entries {
if want[entry.Target()] != entry.Kind {
t.Fatalf("inventory crossed source or kind: %+v", entry)
}
}
vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"})
if err != nil || len(vms) != 2 {
t.Fatalf("VM filter across instances: %+v, %v", vms, err)
}
for _, host := range vms {
if host.Kind != "vm" {
t.Fatalf("device leaked into VM filter: %+v", host)
}
}
host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm")
if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" {
t.Fatalf("wrong VM selected: %+v, %v", host, err)
}
}
+23 -2
View File
@@ -52,7 +52,11 @@ func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool)
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host) note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
return note, err return note, err
} }
data, err := os.ReadFile(s.path(host)) path, err := s.localPath(host)
if err != nil {
return note, err
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return note, nil return note, nil
} }
@@ -67,5 +71,22 @@ func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
} }
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag) return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
} }
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600) path, err := s.localPath(note.Host)
if err != nil {
return err
}
return storage.WriteFile(path, []byte(note.Text), 0o600)
}
func (s *Service) localPath(host netbox.HostEntry) (string, error) {
if s.Client == nil {
return s.path(host), nil
}
host, namespace, err := s.Client.HostScope(host)
if err != nil {
return "", err
}
scoped := *s
scoped.Namespace = namespace
return scoped.path(host), nil
} }
+32
View File
@@ -50,3 +50,35 @@ func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
t.Fatal("expected read error") t.Fatal("expected read error")
} }
} }
func TestLocalNotesSeparateInstancesAndSurviveRename(t *testing.T) {
ctx := context.Background()
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: "https://office.example"}, {Name: "lab", URL: "https://lab.example"}})
service := &Service{Dir: t.TempDir(), Client: client}
for _, source := range []string{"office", "lab"} {
note := Note{Host: netbox.HostEntry{Name: source + "/web", Kind: "device"}, Text: source + " private note"}
if err := service.Write(ctx, note, false); err != nil {
t.Fatal(err)
}
}
for _, source := range []string{"office", "lab"} {
note, err := service.Read(ctx, netbox.HostEntry{Name: source + "/web", Kind: "device"}, false)
if err != nil || note.Text != source+" private note" {
t.Fatalf("notes crossed instances: %+v, %v", note, err)
}
}
service.Client = netbox.NewMultiClient([]netbox.Source{{Name: "renamed", URL: "https://office.example"}})
note, err := service.Read(ctx, netbox.HostEntry{Name: "renamed/web", Kind: "device"}, false)
if err != nil || note.Text != "office private note" {
t.Fatalf("renaming source lost note: %+v, %v", note, err)
}
// This is also the namespace used before migrating a legacy single NetBox.
legacy := &Service{Dir: service.Dir, Namespace: "https://office.example"}
note, err = legacy.Read(ctx, netbox.HostEntry{Name: "web", Kind: "device"}, false)
if err != nil || note.Text != "office private note" {
t.Fatalf("legacy note namespace changed: %+v, %v", note, err)
}
if err := service.Write(ctx, Note{Host: netbox.HostEntry{Name: "web", Kind: "device"}}, false); err == nil {
t.Fatal("unqualified notes accepted for multiple sources")
}
}
+43
View File
@@ -0,0 +1,43 @@
package resolver
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
func TestVMResolversStayWithinTheirNetBoxInstance(t *testing.T) {
sources := make([]netbox.Source, 0, 2)
for i, name := range []string{"office", "lab"} {
ip := fmt.Sprintf("10.0.0.%d", i+1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("virtual_machine_id") != "7" {
t.Errorf("wrong VM filter: %s", r.URL.RawQuery)
}
if r.Header.Get("Authorization") != "Token "+name {
t.Error("VM lookup used another instance's credentials")
}
fmt.Fprintf(w, `{"count":1,"results":[{"address":%q}]}`, ip+"/24")
}))
t.Cleanup(srv.Close)
sources = append(sources, netbox.Source{Name: name, URL: srv.URL, Token: name})
}
client := netbox.NewMultiClient(sources)
management, err := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
if err != nil {
t.Fatal(err)
}
for i, source := range sources {
host := &netbox.HostEntry{Source: source.Name, Name: "vm", Kind: "vm", ID: 7}
for _, strategy := range []Strategy{management, &InterfaceNameStrategy{name: "mgmt0"}} {
ip, err := strategy.Resolve(context.Background(), host, client)
if want := fmt.Sprintf("10.0.0.%d", i+1); err != nil || ip != want {
t.Fatalf("%s VM resolved through wrong instance: %q, %v", strategy.Name(), ip, err)
}
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE
return "", fmt.Errorf("unknown kind %q", entry.Kind) return "", fmt.Errorf("unknown kind %q", entry.Kind)
} }
ips, err := client.GetIPsWithFilter(ctx, filterParam) ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
if err != nil { if err != nil {
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err) return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
} }
+112
View File
@@ -0,0 +1,112 @@
package setup
import (
"fmt"
"os"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// configureInstances edits a copy; nothing is written until the whole wizard succeeds.
func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
sources := append([]config.NetBoxConfig(nil), cfg.NetBoxes...)
if len(sources) == 0 && cfg.NetBox.URL != "" {
source := cfg.NetBox
source.Name = "default"
sources = append(sources, source)
}
for {
if len(sources) == 0 {
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
if err != nil {
return nil, err
}
sources = append(sources, source)
}
action := "done"
var options []huh.Option[string]
for _, source := range sources {
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
}
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("NetBox instances").Description("All configured instances are searched together.").Options(options...).Value(&action))).Run()
if err != nil {
return nil, err
}
if action == "done" {
return sources, nil
}
if action == "add" {
source, err := editInstance(config.NetBoxConfig{}, sources, "")
if err != nil {
return nil, err
}
sources = append(sources, source)
continue
}
verb, name, _ := strings.Cut(action, ":")
for i, source := range sources {
if source.Name != name {
continue
}
if verb == "edit" {
edited, err := editInstance(source, sources, name)
if err != nil {
return nil, err
}
sources[i] = edited
} else {
remove := false
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
return nil, err
}
if remove {
sources = append(sources[:i], sources[i+1:]...)
}
}
break
}
}
}
func editInstance(source config.NetBoxConfig, sources []config.NetBoxConfig, original string) (config.NetBoxConfig, error) {
err := huh.NewForm(huh.NewGroup(
huh.NewInput().Title("Instance name").Description("Used in selectors such as office/web01.").Value(&source.Name).Validate(func(name string) error {
name = strings.ToLower(strings.TrimSpace(name))
candidate := config.Config{NetBoxes: []config.NetBoxConfig{{Name: name, URL: "https://example.invalid"}}}
if err := candidate.ValidateNetBoxes(); err != nil {
return err
}
for _, existing := range sources {
if strings.EqualFold(existing.Name, name) && !strings.EqualFold(existing.Name, original) {
return fmt.Errorf("instance name %q is already used", name)
}
}
return nil
}),
huh.NewInput().Title("NetBox URL").Placeholder("https://netbox.example.com").Value(&source.URL).Validate(func(value string) error {
return (&config.Config{NetBoxes: []config.NetBoxConfig{{Name: "instance", URL: strings.TrimSpace(value)}}}).ValidateNetBoxes()
}),
huh.NewInput().Title("NetBox API token").EchoMode(huh.EchoModePassword).Value(&source.Token).Validate(func(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("required")
}
return nil
}),
).Title("NetBox connection")).Run()
if err != nil {
return source, err
}
source.Name = strings.ToLower(strings.TrimSpace(source.Name))
source.URL = strings.TrimRight(strings.TrimSpace(source.URL), "/")
source.Token = strings.TrimSpace(source.Token)
source.TokenVersion = netbox.TokenVersion(source.Token)
if source.TokenVersion == 1 {
fmt.Fprintf(os.Stderr, "Note: NetBox %s uses a legacy v1 token. A v2 token starts with nbt_.\n", source.Name)
}
return source, nil
}
+21 -41
View File
@@ -13,15 +13,20 @@ import (
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "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/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
) )
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values. // RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
// It saves the result to the config file on success. // It saves the result to the config file on success.
func RunWizard(cfg *config.Config) error { func RunWizard(cfg *config.Config) error {
url := cfg.NetBox.URL sources, err := configureInstances(cfg)
token := cfg.NetBox.Token if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
}
defaultUser := cfg.SSH.DefaultUser defaultUser := cfg.SSH.DefaultUser
defaultPort := "" defaultPort := ""
if cfg.SSH.DefaultPort > 0 { if cfg.SSH.DefaultPort > 0 {
@@ -43,30 +48,6 @@ func RunWizard(cfg *config.Config) error {
} }
form := huh.NewForm( form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("NetBox URL").
Description("e.g. https://netbox.example.com").
Placeholder("https://").
Value(&url).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("required")
}
return nil
}),
huh.NewInput().
Title("NetBox API token").
EchoMode(huh.EchoModePassword).
Value(&token).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("required")
}
return nil
}),
).Title("NetBox connection"),
huh.NewGroup( huh.NewGroup(
huh.NewInput(). huh.NewInput().
Title("Default SSH user"). Title("Default SSH user").
@@ -144,11 +125,6 @@ func RunWizard(cfg *config.Config) error {
} }
strategies := parseStrategies(strategiesRaw) strategies := parseStrategies(strategiesRaw)
tokenVersion := netbox.TokenVersion(token)
if tokenVersion == 1 {
fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.")
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
}
ttl, _ := strconv.Atoi(cacheTTL) ttl, _ := strconv.Atoi(cacheTTL)
@@ -176,11 +152,7 @@ func RunWizard(cfg *config.Config) error {
out := config.Config{ out := config.Config{
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)}, Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
NetBox: config.NetBoxConfig{ NetBoxes: sources,
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
TokenVersion: tokenVersion,
},
SSH: config.SSHConfig{ SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser), DefaultUser: strings.TrimSpace(defaultUser),
DefaultPort: port, DefaultPort: port,
@@ -207,7 +179,7 @@ func RunWizard(cfg *config.Config) error {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL) c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return fmt.Errorf("updating aliases: %w", err) return fmt.Errorf("updating aliases: %w", err)
} }
@@ -266,16 +238,24 @@ func save(cfg config.Config) error {
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
return err return err
} }
if err := cfg.ValidateNetBoxes(); err != nil {
return err
}
path := config.Path() path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err) return fmt.Errorf("creating config dir: %w", err)
} }
var b strings.Builder var b strings.Builder
if len(cfg.NetBoxes) > 0 {
b.WriteString("netboxes:\n")
for _, source := range cfg.NetBoxes {
fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion)
}
} else {
b.WriteString("netbox:\n") b.WriteString("netbox:\n")
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL)) fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token)) }
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
b.WriteString("\nresolver:\n") b.WriteString("\nresolver:\n")
b.WriteString(" strategies:\n") b.WriteString(" strategies:\n")
+34
View File
@@ -386,3 +386,37 @@ func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
t.Fatalf("disable: %+v, %v", loaded, err) t.Fatalf("disable: %+v, %v", loaded, err)
} }
} }
func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := config.Config{
NetBoxes: []config.NetBoxConfig{
{Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2},
{Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1},
},
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
Cache: config.CacheConfig{TTL: 3600},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" {
t.Fatalf("instances lost during save: %+v", loaded)
}
for i, want := range cfg.NetBoxes {
if loaded.NetBoxes[i] != want {
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
}
}
info, err := os.Stat(config.Path())
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatal("credentials are not private")
}
}
+1 -1
View File
@@ -38,5 +38,5 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
// replaces any remaining dots with underscores so the result is a valid identifier. // replaces any remaining dots with underscores so the result is a valid identifier.
func AliasName(name string, cfg config.ShortcutsConfig) string { func AliasName(name string, cfg config.ShortcutsConfig) string {
s := Normalize(name, cfg) s := Normalize(name, cfg)
return strings.ReplaceAll(s, ".", "_") return strings.NewReplacer(".", "_", "/", "_").Replace(s)
} }
+81
View File
@@ -0,0 +1,81 @@
package tui
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
func tuiSourceServer(t *testing.T, fail bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail {
http.Error(w, "offline", 503)
return
}
if r.URL.Path == "/api/dcim/devices/" {
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"web","primary_ip4":{"address":"10.0.0.1/24"}}]}`)
} else {
fmt.Fprint(w, `{"count":0,"results":[]}`)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestCombinedSearchDoesNotStopAtFirstCachedInstance(t *testing.T) {
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, false)
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
c := cache.New("", 3600)
c.Upsert(cache.Entry{Name: "office/web", Kind: "device", IP: "10.0.0.9"})
m := New(client, c, "")
msg := m.doSearch("web", 1)().(searchResultMsg)
if msg.err != nil || len(msg.entries) != 2 {
t.Fatalf("cache hid another instance: %+v, %v", msg.entries, msg.err)
}
m.input.SetValue("web")
m.seq = 1
m.Update(msg)
if len(m.list.Items()) != 2 {
t.Fatal("combined results not displayed")
}
for _, item := range m.list.Items() {
if !strings.Contains(item.(hostItem).name, "/") {
t.Fatal("instance not visible in results")
}
}
m.list.Select(0)
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected == nil || m.selected.Name != "lab/web" {
t.Fatalf("selection lost source: %+v", m.selected)
}
}
func TestPartialOutageKeepsLiveAndCachedResultsVisible(t *testing.T) {
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, true)
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
c := cache.New("", 3600)
c.Upsert(cache.Entry{Name: "lab/web", Kind: "device", IP: "10.0.0.2"})
m := New(client, c, "")
m.input.SetValue("web")
m.seq = 1
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
msg := m.doSearch("web", 1)().(searchResultMsg)
if len(msg.entries) != 2 || msg.err == nil {
t.Fatalf("partial outage lost cache or warning: %+v, %v", msg.entries, msg.err)
}
m.Update(msg)
view := m.View().Content
if !strings.Contains(view, "warning:") || !strings.Contains(view, "lab/web") || !strings.Contains(view, "office/web") {
t.Fatalf("partial results hidden: %s", view)
}
if rows := strings.Count(view, "\n"); rows > 24 {
t.Fatalf("outage warning overflowed view: %d", rows)
}
}
+30 -19
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -412,13 +413,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil // discard stale result return m, nil // discard stale result
} }
m.loading = false m.loading = false
if msg.err != nil {
m.err = msg.err m.err = msg.err
return m, nil
}
m.lastResults = msg.entries m.lastResults = msg.entries
m.updateListItems(msg.entries) m.updateListItems(msg.entries)
m.err = nil
return m, nil return m, nil
} }
@@ -495,9 +492,15 @@ func (m *Model) viewContent() string {
if m.loading { if m.loading {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
} else if m.err != nil {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
} else { } else {
if m.err != nil {
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
if m.width > 0 {
style = style.MaxWidth(m.width)
}
sb.WriteString(style.Render(warning) + "\n")
}
if m.recentMode && m.input.Value() == "" { if m.recentMode && m.input.Value() == "" {
if len(m.list.Items()) > 0 { if len(m.list.Items()) > 0 {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
@@ -534,25 +537,30 @@ func (m *Model) startDebounce() tea.Cmd {
func (m *Model) doSearch(query string, seq int) tea.Cmd { func (m *Model) doSearch(query string, seq int) tea.Cmd {
opts := m.netboxSearchOpts() opts := m.netboxSearchOpts()
return func() tea.Msg { return func() tea.Msg {
// Return cache hits immediately without a network round-trip. var cached []netbox.HostEntry
if m.cache != nil { if m.cache != nil {
if cached := m.cache.Search(query); len(cached) > 0 { for _, entry := range m.cache.Search(query) {
entries := make([]netbox.HostEntry, len(cached)) source, name := netbox.SplitTarget(entry.Name)
for i, c := range cached { cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
}
return searchResultMsg{query: query, seq: seq, entries: entries}
} }
} }
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
if m.client == nil { return searchResultMsg{query: query, seq: seq, entries: cached}
return searchResultMsg{query: query, seq: seq, entries: nil}
} }
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
entries, err := m.client.Search(ctx, query, opts) entries, err := m.client.Search(ctx, query, opts)
// A cached hit in one instance must not hide live hosts in other instances.
seen := make(map[string]bool)
for _, e := range entries {
seen[e.Target()+"\x00"+e.Kind] = true
}
for _, e := range cached {
if !seen[e.Target()+"\x00"+e.Kind] {
entries = append(entries, e)
}
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
return searchResultMsg{query: query, seq: seq, entries: entries, err: err} return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
} }
} }
@@ -577,7 +585,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
if ip == "" { if ip == "" {
ip = e.PrimaryIP6 ip = e.PrimaryIP6
} }
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags} items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
} }
m.list.SetItems(items) m.list.SetItems(items)
m.resizeList() m.resizeList()
@@ -589,6 +597,9 @@ func (m *Model) resizeList() {
return return
} }
extraRows := 5 // title, blank line, search, footer separator, footer extraRows := 5 // title, blank line, search, footer separator, footer
if m.err != nil {
extraRows++
}
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 { if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
extraRows++ extraRows++
} }