9 Commits

Author SHA1 Message Date
Sebastian Unterschütz d127a3b957 feat: enhance host resolution, filtering, and cache management
Release / release (push) Successful in 49s
- **Strategies**: Add resolver strategy input validation and parsing in setup wizard. Support comma-separated input with known strategy mapping.
- **Client**: Extend Search and SearchAll to include kind and tag filters. Add pagination for full cache refresh handling large datasets.
- **Cache**: Introduce `RecentlyUsed` and `MarkUsed`. Persist `LastUsed` timestamps for entries.
- **TUI**: Add recent hosts view, tag/kind filters, and inline editor for user/port override.
- **Tests**: Comprehensive unit tests for new features, including strategy validation, cache behavior, and client filtering.
- **Docs**: Update README with new TUI features and cache subcommands.
2026-05-23 17:06:24 +02:00
Sebastian Unterschütz cdf750081e feat: show subcommands after hostnames in shell completion
Release / release (push) Successful in 48s
Use ShellCompDirectiveKeepOrder so fish/zsh/bash preserve the returned
order instead of sorting alphabetically. ValidArgsFunction now appends
subcommand names (with their short descriptions) after all hostname
entries, so hosts always appear first in the completion list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:42:41 +02:00
Sebastian Unterschütz 574c4dbf58 fix: completion install writes non-empty scripts
Release / release (push) Successful in 47s
GenBashCompletionV2/GenZshCompletion/GenFishCompletion write into the
buffer as a side effect; capturing buf.String() in the return statement
before the Gen* call runs means the buffer is always empty. Separate
the call from the return to fix evaluation order.

Also call InitDefaultCompletionCmd() before iterating root.Commands()
so the lazily-initialized completion subtree is visible before Execute().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:50:53 +02:00
Sebastian Unterschütz 8fc7896b35 docs: update README for wizard, token versions, completion install
Release / release (push) Successful in 46s
2026-05-23 13:31:08 +02:00
Sebastian Unterschütz da3a280a43 feat: netssh completion install — user-space shell completion setup
Adds `netssh completion install` subcommand:
- Auto-detects shell from $SHELL, override with --shell bash|zsh|fish
- bash  → ~/.local/share/bash-completion/completions/netssh
- zsh   → ~/.zfunc/_netssh  (prints fpath hint)
- fish  → ~/.config/fish/completions/netssh.fish
- No sudo required

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:28:43 +02:00
Sebastian Unterschütz a4fa33d224 feat: v2 token support in client + comprehensive tests
Release / release (push) Successful in 51s
API client:
- NewClient now accepts tokenVersion (0 = auto-detect from token prefix)
- tokenVersion stored on Client, used for 403 error hints
- All callers pass cfg.NetBox.TokenVersion

Tests added:
- netbox: TokenVersion, NewClient auto-detect, explicit version,
  403 v1 hint, 403 v2 no-hint, Authorization header verification
- config: token_version preserved/auto-detected, defaults, missing
  file, invalid YAML, Path()
- setup: save roundtrip, file permissions (0600), empty fields
  omitted, dir creation, full save→load roundtrip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:17:34 +02:00
Sebastian Unterschütz 8ae28b3474 feat: persist token_version in config, auto-detect on load
- NetBoxConfig.TokenVersion saved to netssh.yaml by the wizard
- config.Load() auto-detects the version from the token prefix if the
  field is missing (backwards-compatible with existing configs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:12:35 +02:00
Sebastian Unterschütz 9334003c9e feat: detect NetBox token version, hint on v1, better 403 message
- TokenVersion() distinguishes nbt_-prefixed v2 tokens from legacy v1
- 403 errors now say "check token permissions" + v1 hint if applicable
- Setup wizard prints a note after saving if a v1 token was entered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:11:01 +02:00
Sebastian Unterschütz ff9c61c087 feat: interactive setup wizard for first-run and netssh configure
Release / release (push) Successful in 1m36s
- Auto-detects missing config (netbox.url empty) and launches wizard
- `netssh configure` re-runs the wizard anytime to change settings
- 3-page huh form: NetBox connection, SSH defaults, resolver & cache
- Saves to ~/.config/netssh.yaml (permissions 0600)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:04:55 +02:00
15 changed files with 1630 additions and 149 deletions
+91 -30
View File
@@ -1,5 +1,7 @@
# netssh # netssh
> **Vibe-coded project** — this codebase was written entirely by an AI assistant (Claude) without human code review. Use in production at your own risk.
A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting. A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting.
Instead of looking up an IP manually, you just type the hostname as it appears in NetBox: Instead of looking up an IP manually, you just type the hostname as it appears in NetBox:
@@ -16,8 +18,14 @@ netssh -p 2222 admin@app-server-03 uptime
- **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options - **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface - **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments) - **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
- **User/port override** — press `e` in the TUI to override the SSH user or port before connecting
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion - **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
- **Shell completion** — tab-complete hostnames from the cache in zsh, bash, and fish - **Full pagination** — `cache refresh` fetches all hosts from NetBox (not just the first 50)
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
- **Shell completion** — install without sudo via `netssh completion install`
- **Default SSH user** — set a fallback username once in config instead of typing it every time - **Default SSH user** — set a fallback username once in config instead of typing it every time
## Installation ## Installation
@@ -46,12 +54,27 @@ go build -o netssh ./cmd/netssh
## Configuration ## Configuration
Create `~/.config/netssh.yaml`: ### Interactive wizard
On first run (when no config exists), `netssh` automatically starts an interactive setup wizard.
Re-run it at any time to change settings without editing the file manually:
```sh
netssh configure
```
The wizard walks through NetBox connection, SSH defaults, resolver strategies, and cache TTL,
then saves to `~/.config/netssh.yaml`.
### Manual config
`~/.config/netssh.yaml`:
```yaml ```yaml
netbox: netbox:
url: https://netbox.example.com url: https://netbox.example.com
token: your-api-token-here token: nbt_your-api-token-here # v2 token (nbt_ prefix) recommended
token_version: 2 # auto-detected from token; 1 = legacy, 2 = nbt_
resolver: resolver:
# Strategies are tried in order; the first to return an IP wins. # Strategies are tried in order; the first to return an IP wins.
@@ -73,7 +96,19 @@ ssh:
default_user: admin # used when no user is specified on the command line default_user: admin # used when no user is specified on the command line
``` ```
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.) or will be read from the config file. Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
### API tokens
NetBox supports two token formats:
| Format | Example | Notes |
|--------|---------|-------|
| v2 (recommended) | `nbt_abc123…` | Create in NetBox → Admin → API Tokens |
| v1 (legacy) | `abc123def456…` | Older format; still works, but v2 is preferred |
`netssh` auto-detects the version from the token prefix and stores it as `token_version` in the config.
A hint is shown during `netssh configure` if a legacy v1 token is entered.
## Usage ## Usage
@@ -112,20 +147,43 @@ Run without arguments to open the interactive search:
netssh netssh
``` ```
The TUI opens with your 10 most-recently-connected hosts. Start typing to search all cached hosts or query NetBox live.
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| type | filter hosts (300 ms debounce → NetBox query) | | type | filter hosts (300 ms debounce → NetBox query) |
| `Tab` | autocomplete top result into the search field | | `Tab` | autocomplete top result into the search field |
| `↑` / `↓` | navigate results | | `↑` / `↓` | navigate results |
| `Enter` | connect to selected host | | `Enter` | connect to selected host |
| `Esc` / `Ctrl+C` | quit | | `e` | open inline editor to override user/port before connecting |
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
**Tag and kind filter** — press `Ctrl+F` to open a second input line:
```
Filter: tag:prod kind:vm
```
Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter.
**User/port override** — press `e` on any highlighted host:
```
Connect as: admin@my-router:22
```
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. Port 22 is treated as default and omitted from the ssh command.
### Cache management ### Cache management
```sh ```sh
netssh cache list # show all cached entries netssh cache list # show all cached entries
netssh cache refresh # re-fetch all hosts from NetBox netssh cache refresh # re-fetch ALL hosts from NetBox (paginated)
netssh cache clear # wipe the cache netssh cache refresh --tag prod # only hosts with the "prod" tag
netssh cache refresh --kind vm # only virtual machines
netssh cache refresh --tag prod --kind vm # combine filters
netssh cache clear # wipe the cache
``` ```
### Search (for scripting) ### Search (for scripting)
@@ -146,28 +204,26 @@ Strategies are tried in the configured order; the first to succeed wins.
## Shell Completion ## Shell Completion
### zsh Install completion for the current user (no sudo required):
```sh ```sh
netssh completion zsh > "${fpath[1]}/_netssh" netssh completion install # auto-detects $SHELL
netssh completion install --shell bash
netssh completion install --shell zsh
netssh completion install --shell fish
``` ```
Or add to `.zshrc`: | Shell | Install path |
|-------|-------------|
| bash | `~/.local/share/bash-completion/completions/netssh` |
| zsh | `~/.zfunc/_netssh` |
| fish | `~/.config/fish/completions/netssh.fish` |
For zsh, make sure `~/.zfunc` is in your `fpath` (add to `~/.zshrc`):
```zsh ```zsh
source <(netssh completion zsh) fpath=(~/.zfunc $fpath)
``` autoload -Uz compinit && compinit
### bash
```sh
netssh completion bash > /etc/bash_completion.d/netssh
```
### fish
```sh
netssh completion fish > ~/.config/fish/completions/netssh.fish
``` ```
Completions are served from the local cache — no network request on every `<Tab>`. Completions are served from the local cache — no network request on every `<Tab>`.
@@ -179,12 +235,17 @@ go test ./... # run all tests
go build ./... # build all packages go build ./... # build all packages
``` ```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, and SSH argument parser. The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, and the setup wizard.
## Disclaimer
This is a **vibe-coded** project: the entire codebase — architecture, implementation, tests, and docs — was generated by an AI assistant (Claude by Anthropic). No human has reviewed or audited the code. It works for the author's personal use case, but correctness and security are not guaranteed. Read the source before running it in sensitive environments.
## How it works ## How it works
1. `netssh` checks whether the first argument is a known subcommand (`search`, `cache`, `completion`). If not, it enters SSH wrapper mode. 1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …). 2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
3. It checks the local cache. If the entry exists and is within the TTL, it connects immediately. 3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
4. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP. 4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
5. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP. 5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
+169 -28
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
@@ -16,12 +17,14 @@ import (
"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/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"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"
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"
) )
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode. // managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
var managedSubcommands = map[string]bool{ var managedSubcommands = map[string]bool{
"configure": true,
"search": true, "search": true,
"cache": true, "cache": true,
"completion": true, "completion": true,
@@ -47,12 +50,28 @@ func main() {
runSSHWrapper(args) runSSHWrapper(args)
} }
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary. // loadConfigOrSetup loads the config and runs the setup wizard if NetBox is not configured.
func runSSHWrapper(args []string) { func loadConfigOrSetup() *config.Config {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
fatalf("config: %v", err) fatalf("config: %v", err)
} }
if cfg.NetBox.URL == "" {
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
if err := setup.RunWizard(cfg); err != nil {
fatalf("setup: %v", err)
}
cfg, err = config.Load()
if err != nil {
fatalf("config: %v", err)
}
}
return cfg
}
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
func runSSHWrapper(args []string) {
cfg := loadConfigOrSetup()
parsed := internalssh.Parse(args) parsed := internalssh.Parse(args)
if parsed == nil { if parsed == nil {
@@ -74,6 +93,8 @@ func runSSHWrapper(args []string) {
// 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 {
c.MarkUsed(parsed.Host)
_ = c.Save()
connect(entry.IP, parsed, args) connect(entry.IP, parsed, args)
return return
} }
@@ -81,12 +102,12 @@ func runSSHWrapper(args []string) {
if cfg.NetBox.URL == "" { if cfg.NetBox.URL == "" {
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) nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
entries, err := nbClient.Search(ctx, parsed.Host) entries, err := nbClient.Search(ctx, parsed.Host, netbox.SearchOptions{})
if err != nil { if err != nil {
fatalf("NetBox search failed: %v", err) fatalf("NetBox search failed: %v", err)
} }
@@ -113,6 +134,7 @@ func runSSHWrapper(args []string) {
} }
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags}) c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Name)
_ = c.Save() _ = c.Save()
connect(ip, parsed, args) connect(ip, parsed, args)
@@ -127,20 +149,17 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
// runTUI starts the interactive host search. // runTUI starts the interactive host search.
func runTUI() { func runTUI() {
cfg, err := config.Load() cfg := loadConfigOrSetup()
if err != nil {
fatalf("config: %v", err)
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
var nbClient *netbox.Client var nbClient *netbox.Client
if cfg.NetBox.URL != "" { if cfg.NetBox.URL != "" {
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token) nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
} }
m := tui.New(nbClient, c) m := tui.New(nbClient, c, cfg.SSH.DefaultUser)
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen())
final, err := p.Run() final, err := p.Run()
if err != nil { if err != nil {
@@ -159,11 +178,25 @@ func runTUI() {
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)
var sshArgs []string var sshArgs []string
if cfg.SSH.DefaultUser != "" {
sshArgs = append(sshArgs, "-l", cfg.SSH.DefaultUser) // User override from TUI edit mode takes priority, then config default.
user := host.User
if user == "" {
user = cfg.SSH.DefaultUser
} }
if user != "" {
sshArgs = append(sshArgs, "-l", user)
}
if host.Port != "" {
sshArgs = append(sshArgs, "-p", host.Port)
}
sshArgs = append(sshArgs, host.IP) sshArgs = append(sshArgs, host.IP)
c.MarkUsed(host.Name)
_ = c.Save()
if err := internalssh.Exec(sshArgs); err != nil { if err := internalssh.Exec(sshArgs); err != nil {
fatalf("%v", err) fatalf("%v", err)
} }
@@ -183,20 +216,117 @@ func rootCmd() *cobra.Command {
} }
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load() _ = c.Load()
entries := c.Search(toComplete)
names := make([]cobra.Completion, len(entries)) var completions []cobra.Completion
for i, e := range entries { for _, e := range c.Search(toComplete) {
names[i] = cobra.Completion(e.Name) completions = append(completions, cobra.Completion(e.Name))
} }
return names, cobra.ShellCompDirectiveNoFileComp // Subcommands at the end, after all hostnames.
for _, sub := range cmd.Commands() {
if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) {
completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short))
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
}, },
} }
// cobra automatically adds a "completion" subcommand root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
root.AddCommand(searchCmd(), cacheCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
for _, cmd := range root.Commands() {
if cmd.Name() == "completion" {
cmd.AddCommand(completionInstallCmd(root))
break
}
}
return root return root
} }
func completionInstallCmd(root *cobra.Command) *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Install shell completion for the current user (no sudo required)",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
var (
dir string
file string
gen func() ([]byte, error)
note string
)
switch shell {
case "bash":
dir = filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions")
file = filepath.Join(dir, "netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenBashCompletionV2(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or run: source " + file
case "zsh":
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
file = filepath.Join(dir, "_netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenZshCompletion(&buf)
return []byte(buf.String()), err
}
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
case "fish":
configDir, _ := os.UserConfigDir()
dir = filepath.Join(configDir, "fish", "completions")
file = filepath.Join(dir, "netssh.fish")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenFishCompletion(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or start a new fish session."
default:
return fmt.Errorf("unsupported shell %q — use --shell bash|zsh|fish", shell)
}
script, err := gen()
if err != nil {
return fmt.Errorf("generating completion: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}
if err := os.WriteFile(file, script, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", file, err)
}
fmt.Printf("Completion installed → %s\n%s\n", file, note)
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func configureCmd() *cobra.Command {
return &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{}
}
return setup.RunWizard(cfg)
},
}
}
func searchCmd() *cobra.Command { func searchCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "search <query>", Use: "search <query>",
@@ -279,7 +409,8 @@ func cacheClearCmd() *cobra.Command {
} }
func cacheRefreshCmd() *cobra.Command { func cacheRefreshCmd() *cobra.Command {
return &cobra.Command{ var filterTag, filterKind string
cmd := &cobra.Command{
Use: "refresh", Use: "refresh",
Short: "Re-fetch all known hosts from NetBox", Short: "Re-fetch all known hosts from NetBox",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -291,12 +422,12 @@ func cacheRefreshCmd() *cobra.Command {
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) nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// An empty query returns up to 50 entries per type. opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
entries, err := nbClient.Search(ctx, "") entries, err := nbClient.SearchAll(ctx, "", opts)
if err != nil { if err != nil {
return fmt.Errorf("NetBox: %w", err) return fmt.Errorf("NetBox: %w", err)
} }
@@ -307,13 +438,20 @@ func cacheRefreshCmd() *cobra.Command {
chain, _ := resolver.New(cfg.Resolver) chain, _ := resolver.New(cfg.Resolver)
for i := range entries { for i := range entries {
e := &entries[i] e := &entries[i]
ip := e.PrimaryIP4
if ip == "" && chain != nil { var ip string
resolved, err := chain.Resolve(ctx, e, nbClient) if chain != nil {
if err == nil { if resolved, err := chain.Resolve(ctx, e, nbClient); err == nil {
ip = resolved ip = resolved
} }
} }
if ip == "" {
ip = e.PrimaryIP4
}
if ip == "" {
ip = e.PrimaryIP6
}
if ip != "" { if ip != "" {
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags}) c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
} }
@@ -326,6 +464,9 @@ func cacheRefreshCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
return cmd
} }
func fatalf(format string, args ...any) { func fatalf(format string, args ...any) {
+5
View File
@@ -5,16 +5,20 @@ go 1.26.3
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/huh v1.0.0 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -23,6 +27,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
+10
View File
@@ -2,18 +2,24 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
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/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
@@ -23,6 +29,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -39,6 +47,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+35
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -15,6 +16,7 @@ type Entry struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
CachedAt time.Time `json:"cached_at"` CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"`
} }
type Cache struct { type Cache struct {
@@ -86,6 +88,39 @@ func (c *Cache) Upsert(e Entry) {
c.mu.Unlock() c.mu.Unlock()
} }
// MarkUsed records the current time as LastUsed for the named entry.
// It is a no-op if the entry does not exist.
func (c *Cache) MarkUsed(name string) {
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.entries[name]; ok {
e.LastUsed = time.Now()
c.entries[name] = e
}
}
// RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc.
// Entries that have never been used (LastUsed zero) are excluded.
// If n <= 0, all used entries are returned.
func (c *Cache) RecentlyUsed(n int) []Entry {
c.mu.RLock()
defer c.mu.RUnlock()
var used []Entry
for _, e := range c.entries {
if !e.LastUsed.IsZero() {
used = append(used, e)
}
}
sort.Slice(used, func(i, j int) bool {
return used[i].LastUsed.After(used[j].LastUsed)
})
if n > 0 && len(used) > n {
used = used[:n]
}
return used
}
// Search returns all entries whose name starts with prefix (case-insensitive). // Search returns all entries whose name starts with prefix (case-insensitive).
// TTL is intentionally ignored — this is used for shell completion. // TTL is intentionally ignored — this is used for shell completion.
func (c *Cache) Search(prefix string) []Entry { func (c *Cache) Search(prefix string) []Entry {
+80
View File
@@ -220,6 +220,86 @@ func TestSave_ProducesValidJSON(t *testing.T) {
} }
} }
func TestMarkUsed_SetsLastUsed(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
before := time.Now()
c.MarkUsed("host")
e, _ := c.Get("host")
if e.LastUsed.Before(before) {
t.Error("LastUsed should be set to current time by MarkUsed")
}
}
func TestMarkUsed_NoopForMissingEntry(t *testing.T) {
c := New("", 60)
c.MarkUsed("nonexistent") // should not panic
}
func TestRecentlyUsed_ReturnsTopN(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "device"})
c.Upsert(Entry{Name: "c", IP: "3.3.3.3", Kind: "device"})
c.MarkUsed("c")
time.Sleep(time.Millisecond)
c.MarkUsed("a")
results := c.RecentlyUsed(2)
if len(results) != 2 {
t.Fatalf("RecentlyUsed(2): got %d results, want 2", len(results))
}
if results[0].Name != "a" {
t.Errorf("first result: got %q, want %q", results[0].Name, "a")
}
if results[1].Name != "c" {
t.Errorf("second result: got %q, want %q", results[1].Name, "c")
}
}
func TestRecentlyUsed_ExcludesNeverUsed(t *testing.T) {
c := New("", 60)
c.Upsert(Entry{Name: "used", IP: "1.1.1.1", Kind: "device"})
c.Upsert(Entry{Name: "unused", IP: "2.2.2.2", Kind: "device"})
c.MarkUsed("used")
results := c.RecentlyUsed(10)
if len(results) != 1 || results[0].Name != "used" {
t.Errorf("RecentlyUsed should exclude entries with zero LastUsed, got %v", results)
}
}
func TestRecentlyUsed_EmptyCache(t *testing.T) {
c := New("", 60)
if results := c.RecentlyUsed(10); len(results) != 0 {
t.Errorf("RecentlyUsed on empty cache: got %d, want 0", len(results))
}
}
func TestMarkUsed_RoundtripViaSave(t *testing.T) {
path := filepath.Join(t.TempDir(), "cache.json")
c := New(path, 3600)
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
c.MarkUsed("host")
if err := c.Save(); err != nil {
t.Fatalf("Save: %v", err)
}
c2 := New(path, 3600)
if err := c2.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
results := c2.RecentlyUsed(1)
if len(results) != 1 || results[0].Name != "host" {
t.Errorf("LastUsed not persisted: %v", results)
}
}
// tempFile writes content to a temp file and returns its path. // tempFile writes content to a temp file and returns its path.
func tempFile(t *testing.T, content []byte) string { func tempFile(t *testing.T, content []byte) string {
t.Helper() t.Helper()
+21 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -16,8 +17,9 @@ type Config struct {
} }
type NetBoxConfig struct { type NetBoxConfig struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`
Token string `mapstructure:"token"` Token string `mapstructure:"token"`
TokenVersion int `mapstructure:"token_version"`
} }
type ResolverConfig struct { type ResolverConfig struct {
@@ -35,6 +37,15 @@ type SSHConfig struct {
DefaultUser string `mapstructure:"default_user"` DefaultUser string `mapstructure:"default_user"`
} }
// Path returns the canonical config file path.
func Path() string {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(configDir, "netssh.yaml")
}
func Load() (*Config, error) { func Load() (*Config, error) {
v := viper.New() v := viper.New()
@@ -64,6 +75,14 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("parsing config: %w", err) return nil, fmt.Errorf("parsing config: %w", err)
} }
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
cfg.NetBox.TokenVersion = 2
} else {
cfg.NetBox.TokenVersion = 1
}
}
if cfg.Cache.Path == "" { if cfg.Cache.Path == "" {
cacheDir, err := os.UserCacheDir() cacheDir, err := os.UserCacheDir()
if err != nil { if err != nil {
+145
View File
@@ -0,0 +1,145 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "netssh.yaml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func loadFromDir(t *testing.T, dir string) *Config {
t.Helper()
// Override UserConfigDir by pointing viper at our temp dir via env isn't
// straightforward, so we exercise Load() by temporarily changing XDG_CONFIG_HOME.
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
t.Cleanup(func() { os.Setenv("XDG_CONFIG_HOME", orig) })
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
return cfg
}
func TestLoad_V2TokenVersion_Preserved(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "nbt_abc123"
token_version: 2
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 2 {
t.Errorf("TokenVersion: got %d, want 2", cfg.NetBox.TokenVersion)
}
}
func TestLoad_V1TokenVersion_Preserved(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "legacyhex123"
token_version: 1
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 1 {
t.Errorf("TokenVersion: got %d, want 1", cfg.NetBox.TokenVersion)
}
}
func TestLoad_AutoDetectsV2_WhenFieldMissing(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "nbt_mytoken"
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 2 {
t.Errorf("TokenVersion: got %d, want 2 (auto-detected from nbt_ prefix)", cfg.NetBox.TokenVersion)
}
}
func TestLoad_AutoDetectsV1_WhenFieldMissing(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "abc123def456"
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 1 {
t.Errorf("TokenVersion: got %d, want 1 (auto-detected from plain token)", cfg.NetBox.TokenVersion)
}
}
func TestLoad_TokenVersionZero_WhenNoToken(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 0 {
t.Errorf("TokenVersion: got %d, want 0 (no token present)", cfg.NetBox.TokenVersion)
}
}
func TestLoad_Defaults(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "nbt_x"
`)
cfg := loadFromDir(t, dir)
if cfg.Cache.TTL != 3600 {
t.Errorf("default cache.ttl: got %d, want 3600", cfg.Cache.TTL)
}
if cfg.Cache.Path == "" {
t.Error("cache.path should be auto-set when empty")
}
}
func TestLoad_MissingFile_ReturnsEmptyConfig(t *testing.T) {
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", t.TempDir()) // dir exists but no netssh.yaml
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg, err := Load()
if err != nil {
t.Fatalf("Load on missing file should not error: %v", err)
}
if cfg.NetBox.URL != "" {
t.Errorf("expected empty URL, got %q", cfg.NetBox.URL)
}
}
func TestLoad_InvalidYAML_ReturnsError(t *testing.T) {
dir := writeConfig(t, "not: valid: yaml: [[[")
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
_, err := Load()
if err == nil {
t.Error("invalid YAML should return an error")
}
}
func TestPath_ReturnsNonEmpty(t *testing.T) {
p := Path()
if p == "" {
t.Error("Path() should return a non-empty string")
}
if filepath.Base(p) != "netssh.yaml" {
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
}
}
+175 -36
View File
@@ -11,60 +11,131 @@ import (
) )
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
httpClient *http.Client tokenVersion int
httpClient *http.Client
} }
func NewClient(baseURL, token string) *Client { // NewClient creates a NetBox API client. Pass tokenVersion=0 to auto-detect
// from the token string (1 for legacy, 2 for nbt_-prefixed tokens).
func NewClient(baseURL, token string, tokenVersion int) *Client {
if tokenVersion == 0 {
tokenVersion = TokenVersion(token)
}
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
token: token, token: token,
httpClient: &http.Client{}, tokenVersion: tokenVersion,
httpClient: &http.Client{},
} }
} }
// Search queries devices and VMs in parallel and merges the results. // Search queries up to 50 devices and VMs in parallel and merges the results.
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) { // Use SearchOptions to restrict by kind or tag.
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
var ( var (
mu sync.Mutex mu sync.Mutex
results []HostEntry results []HostEntry
errs []error errs []error
wg sync.WaitGroup wg sync.WaitGroup
started int
) )
wg.Add(2) if opts.Kind != "vm" {
started++
wg.Add(1)
go func() {
defer wg.Done()
devices, err := c.searchDevices(ctx, query, opts.Tag)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("devices: %w", err))
return
}
results = append(results, devices...)
}()
}
go func() { if opts.Kind != "device" {
defer wg.Done() started++
devices, err := c.searchDevices(ctx, query) wg.Add(1)
mu.Lock() go func() {
defer mu.Unlock() defer wg.Done()
if err != nil { vms, err := c.searchVMs(ctx, query, opts.Tag)
errs = append(errs, fmt.Errorf("devices: %w", err)) mu.Lock()
return defer mu.Unlock()
} if err != nil {
results = append(results, devices...) errs = append(errs, fmt.Errorf("vms: %w", err))
}() return
}
go func() { results = append(results, vms...)
defer wg.Done() }()
vms, err := c.searchVMs(ctx, query) }
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("vms: %w", err))
return
}
results = append(results, vms...)
}()
wg.Wait() wg.Wait()
if len(errs) == 2 { if len(errs) == started {
if started == 1 {
return nil, errs[0]
}
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1]) return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
} }
return results, nil
}
// SearchAll paginates through all matching devices and VMs, fetching every page.
// Intended for cache refresh; use Search for interactive queries.
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
var (
mu sync.Mutex
results []HostEntry
errs []error
wg sync.WaitGroup
started int
)
if opts.Kind != "vm" {
started++
wg.Add(1)
go func() {
defer wg.Done()
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("devices: %w", err))
return
}
results = append(results, devices...)
}()
}
if opts.Kind != "device" {
started++
wg.Add(1)
go func() {
defer wg.Done()
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("vms: %w", err))
return
}
results = append(results, vms...)
}()
}
wg.Wait()
if len(errs) == started {
if started == 1 {
return nil, errs[0]
}
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
}
return results, nil return results, nil
} }
@@ -107,8 +178,11 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
return ips, nil return ips, nil
} }
func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry, error) { func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query)) apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice] var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -120,8 +194,11 @@ func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry,
return entries, nil return entries, nil
} }
func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, error) { func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query)) apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM] var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
@@ -133,6 +210,60 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
return entries, nil return entries, nil
} }
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
for _, d := range resp.Results {
all = append(all, deviceToEntry(d))
}
if len(resp.Results) == 0 || len(all) >= resp.Count {
break
}
}
return all, nil
}
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
for _, v := range resp.Results {
all = append(all, vmToEntry(v))
}
if len(resp.Results) == 0 || len(all) >= resp.Count {
break
}
}
return all, nil
}
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
func TokenVersion(token string) int {
if strings.HasPrefix(token, "nbt_") {
return 2
}
return 1
}
func (c *Client) get(ctx context.Context, apiURL string, out any) error { func (c *Client) get(ctx context.Context, apiURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil { if err != nil {
@@ -147,6 +278,14 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
hint := "check token permissions in NetBox"
if c.tokenVersion == 1 {
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
}
return fmt.Errorf("%s: %s", apiURL, hint)
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL) return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
} }
+191 -16
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@@ -58,8 +59,8 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "") results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil { if err != nil {
t.Fatalf("Search: %v", err) t.Fatalf("Search: %v", err)
} }
@@ -87,8 +88,8 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "") results, _ := c.Search(context.Background(), "", SearchOptions{})
for _, r := range results { for _, r := range results {
switch r.Name { switch r.Name {
@@ -113,8 +114,8 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "host") results, _ := c.Search(context.Background(), "host", SearchOptions{})
if len(results) == 0 { if len(results) == 0 {
t.Fatal("expected at least one result") t.Fatal("expected at least one result")
} }
@@ -138,8 +139,8 @@ func TestSearch_TagsAreMapped(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "") results, _ := c.Search(context.Background(), "", SearchOptions{})
if len(results[0].Tags) != 2 { if len(results[0].Tags) != 2 {
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags) t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
} }
@@ -159,8 +160,8 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "") results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil { if err != nil {
t.Fatalf("partial failure should not return error, got: %v", err) t.Fatalf("partial failure should not return error, got: %v", err)
} }
@@ -177,8 +178,8 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
_, err := c.Search(context.Background(), "") _, err := c.Search(context.Background(), "", SearchOptions{})
if err == nil { if err == nil {
t.Error("both endpoints failing should return an error") t.Error("both endpoints failing should return an error")
} }
@@ -190,7 +191,7 @@ func TestGetIPs_Device(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"}) ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"})
if err != nil { if err != nil {
t.Fatalf("GetIPs: %v", err) t.Fatalf("GetIPs: %v", err)
@@ -209,7 +210,7 @@ func TestGetIPs_VM(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"}) ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"})
if err != nil { if err != nil {
t.Fatalf("GetIPs: %v", err) t.Fatalf("GetIPs: %v", err)
@@ -220,7 +221,7 @@ func TestGetIPs_VM(t *testing.T) {
} }
func TestGetIPs_UnknownKind(t *testing.T) { func TestGetIPs_UnknownKind(t *testing.T) {
c := NewClient("http://localhost", "token") c := NewClient("http://localhost", "token", 0)
_, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"}) _, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"})
if err == nil { if err == nil {
t.Error("unknown kind should return an error") t.Error("unknown kind should return an error")
@@ -233,7 +234,7 @@ func TestGetIPsWithFilter(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0") ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0")
if err != nil { if err != nil {
t.Fatalf("GetIPsWithFilter: %v", err) t.Fatalf("GetIPsWithFilter: %v", err)
@@ -243,6 +244,180 @@ func TestGetIPsWithFilter(t *testing.T) {
} }
} }
func TestTokenVersion(t *testing.T) {
tests := []struct {
token string
want int
}{
{"nbt_abc123", 2},
{"nbt_", 2},
{"abc123def456", 1},
{"", 1},
{"Token abc", 1},
}
for _, tt := range tests {
if got := TokenVersion(tt.token); got != tt.want {
t.Errorf("TokenVersion(%q) = %d, want %d", tt.token, got, tt.want)
}
}
}
func TestNewClient_AutoDetectsVersion(t *testing.T) {
c := NewClient("http://localhost", "nbt_secret", 0)
if c.tokenVersion != 2 {
t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion)
}
c2 := NewClient("http://localhost", "legacytoken", 0)
if c2.tokenVersion != 1 {
t.Errorf("tokenVersion: got %d, want 1", c2.tokenVersion)
}
}
func TestNewClient_RespectsExplicitVersion(t *testing.T) {
// Explicit version overrides auto-detection.
c := NewClient("http://localhost", "legacytoken", 2)
if c.tokenVersion != 2 {
t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion)
}
}
func Test403_V1Token_HintsUpgrade(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
c := NewClient(srv.URL, "legacytoken", 1)
_, err := c.Search(context.Background(), "host", SearchOptions{})
if err == nil {
t.Fatal("expected error on 403")
}
if !strings.Contains(err.Error(), "v1 token") {
t.Errorf("expected v1 hint in error, got: %v", err)
}
}
func Test403_V2Token_NoV1Hint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
c := NewClient(srv.URL, "nbt_secret", 2)
_, err := c.Search(context.Background(), "host", SearchOptions{})
if err == nil {
t.Fatal("expected error on 403")
}
if strings.Contains(err.Error(), "v1 token") {
t.Errorf("v1 hint should not appear for v2 token, got: %v", err)
}
if !strings.Contains(err.Error(), "check token permissions") {
t.Errorf("expected permissions hint in error, got: %v", err)
}
}
func TestGet_SendsAuthorizationHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(deviceListResponse())
w.Write(b)
}))
defer srv.Close()
c := NewClient(srv.URL, "nbt_mytoken", 2)
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
want := "Token nbt_mytoken"
if gotAuth != want {
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
}
}
func TestSearchAll_PaginatesResults(t *testing.T) {
// Simulate a NetBox endpoint with 3 total devices split across 2 pages.
// count=3 throughout; first page has 2 results, second has 1.
callCount := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
callCount++
offset := r.URL.Query().Get("offset")
w.Header().Set("Content-Type", "application/json")
var resp netboxListResponse[netboxDevice]
switch offset {
case "", "0":
resp = netboxListResponse[netboxDevice]{
Count: 3,
Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}},
}
default:
resp = netboxListResponse[netboxDevice]{
Count: 3,
Results: []netboxDevice{{ID: 3, Name: "d-03"}},
}
}
b, _ := json.Marshal(resp)
w.Write(b)
})
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(vmListResponse())
w.Write(b)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.SearchAll(context.Background(), "", SearchOptions{})
if err != nil {
t.Fatalf("SearchAll: %v", err)
}
if len(results) != 3 {
t.Errorf("SearchAll: got %d results, want 3", len(results))
}
if callCount < 2 {
t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount)
}
}
func TestSearch_KindFilterDeviceOnly(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/dcim/devices/": deviceListResponse(
netboxDevice{ID: 1, Name: "sw-01"},
),
})
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Name != "sw-01" {
t.Errorf("expected 1 device result, got %v", results)
}
}
func TestSearch_KindFilterVMOnly(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/virtualization/virtual-machines/": vmListResponse(
netboxVM{ID: 1, Name: "vm-01"},
),
})
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(results) != 1 || results[0].Name != "vm-01" {
t.Errorf("expected 1 vm result, got %v", results)
}
}
func TestStripPrefix(t *testing.T) { func TestStripPrefix(t *testing.T) {
tests := []struct { tests := []struct {
in string in string
+7
View File
@@ -1,5 +1,12 @@
package netbox package netbox
// SearchOptions filters a Search or SearchAll query.
// Zero value means no filtering (return all kinds, no tag filter).
type SearchOptions struct {
Tag string // filter by tag slug; empty = no filter
Kind string // "device" | "vm" | "" (both)
}
// 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 {
ID int ID int
+5 -5
View File
@@ -36,7 +36,7 @@ func TestManagementSubnetStrategy_MatchesSubnet(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"}) s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
client := netbox.NewClient(srv.URL, "token") client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil { if err != nil {
@@ -52,7 +52,7 @@ func TestManagementSubnetStrategy_NoMatch(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"}) s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
client := netbox.NewClient(srv.URL, "token") client := netbox.NewClient(srv.URL, "token", 0)
_, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client) _, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != ErrNoIP { if err != ErrNoIP {
@@ -65,7 +65,7 @@ func TestManagementSubnetStrategy_FirstMatchWins(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"}) s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
client := netbox.NewClient(srv.URL, "token") client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil { if err != nil {
@@ -81,7 +81,7 @@ func TestManagementSubnetStrategy_VMKind(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"172.16.0.0/12"}) s, _ := NewManagementSubnetStrategy([]string{"172.16.0.0/12"})
client := netbox.NewClient(srv.URL, "token") client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 2, Kind: "vm"}, client) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 2, Kind: "vm"}, client)
if err != nil { if err != nil {
@@ -97,7 +97,7 @@ func TestManagementSubnetStrategy_IPv6Subnet(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"fd00::/8"}) s, _ := NewManagementSubnetStrategy([]string{"fd00::/8"})
client := netbox.NewClient(srv.URL, "token") client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil { if err != nil {
+210
View File
@@ -0,0 +1,210 @@
package setup
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/charmbracelet/huh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
// It saves the result to the config file on success.
func RunWizard(cfg *config.Config) error {
url := cfg.NetBox.URL
token := cfg.NetBox.Token
defaultUser := cfg.SSH.DefaultUser
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
if strategiesRaw == "" {
strategiesRaw = "primary_ip"
}
if cacheTTL == "0" {
cacheTTL = "3600"
}
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.NewInput().
Title("Default SSH user").
Description("Leave empty to use your system user ($USER).").
Value(&defaultUser),
).Title("SSH defaults"),
huh.NewGroup(
huh.NewInput().
Title("Resolver strategies").
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
Placeholder("primary_ip, management_subnet").
Value(&strategiesRaw).
Validate(validateStrategies),
huh.NewInput().
Title("Management subnets").
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
Value(&subnets),
huh.NewInput().
Title("Interface name").
Description("Only used when interface_name strategy is active.").
Placeholder("eth0").
Value(&interfaceName),
huh.NewInput().
Title("Cache TTL (seconds)").
Value(&cacheTTL).
Validate(func(s string) error {
if _, err := strconv.Atoi(s); err != nil {
return errors.New("must be a number")
}
return nil
}),
).Title("Resolver & cache"),
)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Fprintln(os.Stderr, "Setup cancelled.")
os.Exit(0)
}
return err
}
strategies := parseStrategies(strategiesRaw)
tokenVersion := netbox.TokenVersion(token)
if tokenVersion == 1 {
fmt.Fprintln(os.Stderr, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
}
ttl, _ := strconv.Atoi(cacheTTL)
var subnetList []string
for _, s := range strings.Split(subnets, ",") {
if s = strings.TrimSpace(s); s != "" {
subnetList = append(subnetList, s)
}
}
out := config.Config{
NetBox: config.NetBoxConfig{
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
TokenVersion: tokenVersion,
},
SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser),
},
Resolver: config.ResolverConfig{
Strategies: strategies,
ManagementSubnets: subnetList,
InterfaceName: strings.TrimSpace(interfaceName),
},
Cache: config.CacheConfig{
TTL: ttl,
},
}
return save(out)
}
var knownStrategies = map[string]bool{
"primary_ip": true,
"management_subnet": true,
"interface_name": true,
}
func validateStrategies(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("at least one strategy is required")
}
for _, name := range parseStrategies(s) {
if !knownStrategies[name] {
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name)
}
}
return nil
}
func parseStrategies(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if name := strings.TrimSpace(part); name != "" {
out = append(out, name)
}
}
return out
}
func save(cfg config.Config) error {
path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err)
}
var b strings.Builder
b.WriteString("netbox:\n")
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
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(" strategies:\n")
for _, s := range cfg.Resolver.Strategies {
fmt.Fprintf(&b, " - %s\n", s)
}
if len(cfg.Resolver.ManagementSubnets) > 0 {
b.WriteString(" management_subnets:\n")
for _, s := range cfg.Resolver.ManagementSubnets {
fmt.Fprintf(&b, " - %s\n", s)
}
}
if cfg.Resolver.InterfaceName != "" {
fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName)
}
b.WriteString("\ncache:\n")
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
b.WriteString("\nssh:\n")
if cfg.SSH.DefaultUser != "" {
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
}
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
return fmt.Errorf("writing config: %w", err)
}
fmt.Printf("\nConfig saved → %s\n", path)
return nil
}
+226
View File
@@ -0,0 +1,226 @@
package setup
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSave_WritesFile(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.example.com",
Token: "nbt_abc123",
TokenVersion: 2,
},
SSH: config.SSHConfig{DefaultUser: "admin"},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip", "management_subnet"},
ManagementSubnets: []string{"10.0.0.0/8"},
},
Cache: config.CacheConfig{TTL: 3600},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if err != nil {
t.Fatalf("reading saved file: %v", err)
}
content := string(data)
for _, want := range []string{
`"https://netbox.example.com"`,
`"nbt_abc123"`,
`token_version: 2`,
`- primary_ip`,
`- management_subnet`,
`- 10.0.0.0/8`,
`ttl: 3600`,
`"admin"`,
} {
if !strings.Contains(content, want) {
t.Errorf("saved config missing %q\nfull content:\n%s", want, content)
}
}
}
func TestSave_FilePermissions(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
if err := save(config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
}); err != nil {
t.Fatalf("save: %v", err)
}
info, err := os.Stat(filepath.Join(dir, "netssh.yaml"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("file permissions: got %o, want 600", perm)
}
}
func TestSave_OmitsEmptyOptionalFields(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
// No DefaultUser, no ManagementSubnets, no InterfaceName
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
content := string(data)
for _, absent := range []string{"default_user", "management_subnets", "interface_name"} {
if strings.Contains(content, absent) {
t.Errorf("config should not contain %q when field is empty\nfull content:\n%s", absent, content)
}
}
}
func TestSave_CreatesConfigDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "does", "not", "exist")
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
if err := save(config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
}); err != nil {
t.Fatalf("save should create missing directories: %v", err)
}
}
func TestParseStrategies(t *testing.T) {
tests := []struct {
in string
want []string
}{
{"primary_ip", []string{"primary_ip"}},
{"management_subnet, primary_ip", []string{"management_subnet", "primary_ip"}},
{"primary_ip,management_subnet,interface_name", []string{"primary_ip", "management_subnet", "interface_name"}},
{" primary_ip , management_subnet ", []string{"primary_ip", "management_subnet"}},
{"", nil},
{" , ", nil},
}
for _, tt := range tests {
got := parseStrategies(tt.in)
if len(got) != len(tt.want) {
t.Errorf("parseStrategies(%q): got %v, want %v", tt.in, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("parseStrategies(%q)[%d]: got %q, want %q", tt.in, i, got[i], tt.want[i])
}
}
}
}
func TestParseStrategies_PreservesOrder(t *testing.T) {
got := parseStrategies("interface_name, management_subnet, primary_ip")
want := []string{"interface_name", "management_subnet", "primary_ip"}
for i, s := range got {
if s != want[i] {
t.Errorf("order not preserved at [%d]: got %q, want %q", i, s, want[i])
}
}
}
func TestValidateStrategies_Valid(t *testing.T) {
cases := []string{
"primary_ip",
"management_subnet, primary_ip",
"interface_name, management_subnet, primary_ip",
}
for _, c := range cases {
if err := validateStrategies(c); err != nil {
t.Errorf("validateStrategies(%q) should be valid, got: %v", c, err)
}
}
}
func TestValidateStrategies_Invalid(t *testing.T) {
cases := []string{
"",
"unknown_strategy",
"primary_ip, typo",
}
for _, c := range cases {
if err := validateStrategies(c); err == nil {
t.Errorf("validateStrategies(%q) should return an error", c)
}
}
}
func TestSave_RoundtripViaLoad(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
original := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.zb-server.de",
Token: "nbt_supersecret",
TokenVersion: 2,
},
SSH: config.SSHConfig{DefaultUser: "root"},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip"},
ManagementSubnets: []string{"192.168.0.0/16"},
InterfaceName: "eth0",
},
Cache: config.CacheConfig{TTL: 7200},
}
if err := save(original); err != nil {
t.Fatalf("save: %v", err)
}
loaded, err := config.Load()
if err != nil {
t.Fatalf("Load after save: %v", err)
}
if loaded.NetBox.URL != original.NetBox.URL {
t.Errorf("URL: got %q, want %q", loaded.NetBox.URL, original.NetBox.URL)
}
if loaded.NetBox.Token != original.NetBox.Token {
t.Errorf("Token: got %q, want %q", loaded.NetBox.Token, original.NetBox.Token)
}
if loaded.NetBox.TokenVersion != original.NetBox.TokenVersion {
t.Errorf("TokenVersion: got %d, want %d", loaded.NetBox.TokenVersion, original.NetBox.TokenVersion)
}
if loaded.SSH.DefaultUser != original.SSH.DefaultUser {
t.Errorf("DefaultUser: got %q, want %q", loaded.SSH.DefaultUser, original.SSH.DefaultUser)
}
if loaded.Cache.TTL != original.Cache.TTL {
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
}
}
+260 -32
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strconv"
"strings" "strings"
"time" "time"
@@ -20,6 +21,8 @@ import (
type SelectedHost struct { type SelectedHost struct {
Name string Name string
IP string IP string
User string // empty = use config default
Port string // empty = use default port
} }
// --- bubbletea messages --- // --- bubbletea messages ---
@@ -30,6 +33,7 @@ type searchResultMsg struct {
query string query string
entries []netbox.HostEntry entries []netbox.HostEntry
err error err error
recent bool // true when this is a recently-used list, not a search result
} }
// --- list item --- // --- list item ---
@@ -38,6 +42,7 @@ type hostItem struct {
name string name string
ip string ip string
kind string kind string
tags []string
} }
func (h hostItem) Title() string { return h.name } func (h hostItem) Title() string { return h.name }
@@ -63,27 +68,90 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
fmt.Fprintln(w, line) fmt.Fprintln(w, line)
} }
// --- filter ---
type filterOpts struct {
tags []string
kind string
}
func parseFilter(s string) filterOpts {
var f filterOpts
for _, part := range strings.Fields(s) {
if after, ok := strings.CutPrefix(part, "tag:"); ok {
f.tags = append(f.tags, after)
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
f.kind = after
}
}
return f
}
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
if len(f.tags) == 0 && f.kind == "" {
return entries
}
out := make([]netbox.HostEntry, 0, len(entries))
for _, e := range entries {
if f.kind != "" && e.Kind != f.kind {
continue
}
if len(f.tags) > 0 {
tagSet := make(map[string]bool, len(e.Tags))
for _, t := range e.Tags {
tagSet[strings.ToLower(t)] = true
}
allMatch := true
for _, want := range f.tags {
if !tagSet[strings.ToLower(want)] {
allMatch = false
break
}
}
if !allMatch {
continue
}
}
out = append(out, e)
}
return out
}
// --- Model --- // --- Model ---
type Model struct { type Model struct {
input textinput.Model input textinput.Model
list list.Model filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
client *netbox.Client editInput textinput.Model // user@host:port inline editor
cache *cache.Cache list list.Model
lastSent string // last query sent to NetBox (or served from cache) client *netbox.Client
seq int // sequence number to discard stale results cache *cache.Cache
loading bool defaultUser string
err error lastSent string // last query sent to NetBox (or served from cache)
selected *SelectedHost lastResults []netbox.HostEntry // raw results before filter applied
width int seq int // sequence number to discard stale results
height int loading bool
err error
selected *SelectedHost
width int
height int
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // 'e' on selected item
} }
func New(client *netbox.Client, c *cache.Cache) *Model { func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
ti := textinput.New() ti := textinput.New()
ti.Placeholder = "Search hostname…" ti.Placeholder = "Search hostname…"
ti.Focus() ti.Focus()
fi := textinput.New()
fi.Placeholder = "tag:prod kind:vm"
fi.Prompt = "Filter: "
ei := textinput.New()
ei.Prompt = ""
l := list.New(nil, compactDelegate{}, 0, 0) l := list.New(nil, compactDelegate{}, 0, 0)
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetShowTitle(false) l.SetShowTitle(false)
@@ -91,15 +159,34 @@ func New(client *netbox.Client, c *cache.Cache) *Model {
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
return &Model{ return &Model{
input: ti, input: ti,
list: l, filterInput: fi,
client: client, editInput: ei,
cache: c, list: l,
client: client,
cache: c,
defaultUser: defaultUser,
recentMode: true,
} }
} }
func (m *Model) Init() tea.Cmd { func (m *Model) Init() tea.Cmd {
return textinput.Blink return tea.Batch(textinput.Blink, m.loadRecent())
}
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
func (m *Model) loadRecent() tea.Cmd {
return func() tea.Msg {
if m.cache == nil {
return searchResultMsg{query: "", entries: nil, recent: true}
}
recent := m.cache.RecentlyUsed(10)
entries := make([]netbox.HostEntry, len(recent))
for i, e := range recent {
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
}
return searchResultMsg{query: "", entries: entries, recent: true}
}
} }
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -108,22 +195,83 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.list.SetSize(msg.Width, msg.Height-4) extraRows := 4
if m.filterOpen {
extraRows++
}
if m.editMode {
extraRows += 2
}
m.list.SetSize(msg.Width, msg.Height-extraRows)
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
// --- Edit mode ---
if m.editMode {
switch msg.String() {
case "esc":
m.editMode = false
m.editInput.Blur()
m.input.Focus()
return m, nil
case "enter":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = m.buildSelected(item)
}
return m, tea.Quit
}
var cmd tea.Cmd
m.editInput, cmd = m.editInput.Update(msg)
return m, cmd
}
// --- Filter focused ---
if m.filterOpen {
switch msg.String() {
case "ctrl+f", "esc":
m.filterOpen = false
m.filterInput.Blur()
m.filterInput.SetValue("")
m.input.Focus()
m.updateListItems(m.lastResults)
return m, nil
case "enter":
m.filterOpen = false
m.filterInput.Blur()
m.input.Focus()
m.updateListItems(m.lastResults)
return m, nil
}
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.updateListItems(m.lastResults)
return m, cmd
}
// --- Normal mode ---
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": case "ctrl+c", "esc":
return m, tea.Quit return m, tea.Quit
case "ctrl+f":
m.filterOpen = true
m.input.Blur()
m.filterInput.Focus()
return m, nil
case "enter": case "enter":
if item, ok := m.list.SelectedItem().(hostItem); ok { if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip} m.selected = &SelectedHost{Name: item.name, IP: item.ip}
return m, tea.Quit return m, tea.Quit
} }
case "e":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item)
return m, nil
}
case "tab": case "tab":
// Copy the top result into the search field.
if m.list.Items() != nil && len(m.list.Items()) > 0 { if m.list.Items() != nil && len(m.list.Items()) > 0 {
if item, ok := m.list.Items()[0].(hostItem); ok { if item, ok := m.list.Items()[0].(hostItem); ok {
m.input.SetValue(item.name) m.input.SetValue(item.name)
@@ -134,18 +282,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case debounceMsg: case debounceMsg:
// Only query if the input has changed since the last request.
q := m.input.Value() q := m.input.Value()
if q == m.lastSent { if q == m.lastSent {
return m, nil return m, nil
} }
m.lastSent = q m.lastSent = q
m.recentMode = false
m.loading = true m.loading = true
m.seq++ m.seq++
seq := m.seq seq := m.seq
return m, m.doSearch(q, seq) return m, m.doSearch(q, seq)
case searchResultMsg: case searchResultMsg:
if msg.recent {
m.recentMode = true
m.lastResults = msg.entries
m.updateListItems(msg.entries)
return m, nil
}
if msg.query != m.lastSent { if msg.query != m.lastSent {
return m, nil // discard stale result return m, nil // discard stale result
} }
@@ -154,20 +308,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.err = msg.err m.err = msg.err
return m, nil return m, nil
} }
items := make([]list.Item, len(msg.entries)) m.lastResults = msg.entries
for i, e := range msg.entries { m.updateListItems(msg.entries)
ip := e.PrimaryIP4
if ip == "" {
ip = e.PrimaryIP6
}
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind}
}
m.list.SetItems(items)
m.err = nil m.err = nil
return m, nil return m, nil
} }
// Forward to text input and restart the debounce timer. // Forward to search input and restart debounce timer.
var cmds []tea.Cmd var cmds []tea.Cmd
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg) m.input, inputCmd = m.input.Update(msg)
@@ -188,14 +335,33 @@ func (m *Model) View() string {
sb.WriteString(title + "\n\n") sb.WriteString(title + "\n\n")
sb.WriteString(m.input.View() + "\n") sb.WriteString(m.input.View() + "\n")
if m.filterOpen {
sb.WriteString(m.filterInput.View() + "\n")
}
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 { } else if m.err != nil {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
} else { } else {
if m.recentMode && m.input.Value() == "" {
if len(m.list.Items()) > 0 {
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
}
}
sb.WriteString(m.list.View()) sb.WriteString(m.list.View())
} }
if m.editMode {
sb.WriteString("\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else {
hint := "ctrl+c quit enter connect e edit ctrl+f filter"
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
}
return sb.String() return sb.String()
} }
@@ -211,13 +377,14 @@ 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()
return func() tea.Msg { return func() tea.Msg {
// Return cache hits immediately without a network round-trip. // Return cache hits immediately without a network round-trip.
if m.cache != nil { if m.cache != nil {
if cached := m.cache.Search(query); len(cached) > 0 { if cached := m.cache.Search(query); len(cached) > 0 {
entries := make([]netbox.HostEntry, len(cached)) entries := make([]netbox.HostEntry, len(cached))
for i, c := range cached { for i, c := range cached {
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind} entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
} }
return searchResultMsg{query: query, entries: entries} return searchResultMsg{query: query, entries: entries}
} }
@@ -230,8 +397,69 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
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) entries, err := m.client.Search(ctx, query, opts)
_ = seq _ = seq
return searchResultMsg{query: query, entries: entries, err: err} return searchResultMsg{query: query, entries: entries, err: err}
} }
} }
// netboxSearchOpts derives SearchOptions from the current filter input.
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
f := parseFilter(m.filterInput.Value())
var tag string
if len(f.tags) > 0 {
tag = f.tags[0]
}
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
}
// updateListItems applies the active filter and sets the list items.
func (m *Model) updateListItems(entries []netbox.HostEntry) {
f := parseFilter(m.filterInput.Value())
filtered := applyFilter(entries, f)
items := make([]list.Item, len(filtered))
for i, e := range filtered {
ip := e.PrimaryIP4
if ip == "" {
ip = e.PrimaryIP6
}
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
}
m.list.SetItems(items)
}
// openEdit switches to edit mode for the given list item.
func (m *Model) openEdit(item hostItem) {
m.editMode = true
m.input.Blur()
user := m.defaultUser
if user == "" {
user = item.name
}
m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name))
m.editInput.CursorEnd()
m.editInput.Focus()
}
// buildSelected parses the editInput to extract user/port overrides.
func (m *Model) buildSelected(item hostItem) *SelectedHost {
s := strings.TrimSpace(m.editInput.Value())
sel := &SelectedHost{Name: item.name, IP: item.ip}
// Extract user: everything before @
if idx := strings.Index(s, "@"); idx != -1 {
sel.User = strings.TrimSpace(s[:idx])
s = s[idx+1:]
}
// Extract port: after the last colon, if it's a number
if idx := strings.LastIndex(s, ":"); idx != -1 {
portStr := strings.TrimSpace(s[idx+1:])
if _, err := strconv.Atoi(portStr); err == nil && portStr != "22" {
sel.Port = portStr
}
}
return sel
}