10 Commits

Author SHA1 Message Date
Sebastian Unterschütz fa646f25a6 feat: add alias file generation and management for shell hooks
Release / release (push) Successful in 49s
- **Aliases**: Generate shell alias files (`aliases.sh` for bash/zsh, `netssh.fish` for fish) from cached hosts. Regenerate on each shell startup and cache refresh to keep aliases updated.
- **Hooks**: Extend shell hook functionality to include alias file support. Install and uninstall commands updated for bash, zsh, and fish.
- **Tests**: Add unit tests to verify alias file generation, path resolution, and idempotent hook installation.
- **Docs**: Update README with instructions for alias file usage, installation, and relation to hooks.
2026-05-27 23:08:30 +02:00
Sebastian Unterschütz 7c902cab3a feat: introduce shortcuts and shell hook support
Release / release (push) Successful in 50s
- **Shortcuts**: Add hostname normalization with domain stripping and hyphen folding. Include alias generation for cached hosts.
- **Shell Hook**: Automate 24h cache refresh trigger with shell startup hook. Add install/uninstall commands for bash, zsh, and fish.
- **Wizard**: Extend setup wizard to configure shortcuts (domains, hyphen stripping) and default SSH port.
- **Cache**: Add `GetByShortcut` for resolving hosts via normalized shortcuts. Implement `NeedsRefresh` / `SetRefreshed` logic for refresh timestamps.
- **Tests**: Comprehensive unit tests for shortcuts, hook installation, cache refresh, and alias generation.
- **Docs**: Update README with shortcuts, shell hook, and default SSH port configuration.
2026-05-27 22:53:24 +02:00
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
21 changed files with 3173 additions and 169 deletions
+279 -30
View File
@@ -1,5 +1,7 @@
# 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.
Instead of looking up an IP manually, you just type the hostname as it appears in NetBox:
@@ -16,9 +18,19 @@ netssh -p 2222 admin@app-server-03 uptime
- **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
- **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
- **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 port** — set a fallback port in config; injected as `-p` when not specified on the command line
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
## Installation
@@ -46,12 +58,27 @@ go build -o netssh ./cmd/netssh
## 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
netbox:
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:
# Strategies are tried in order; the first to return an IP wins.
@@ -71,9 +98,27 @@ cache:
ssh:
default_user: admin # used when no user is specified on the command line
default_port: 2222 # optional; injected as -p if not specified on the command line
shortcuts:
domains: # strip these suffixes to create short aliases
- .example.com
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.) 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
@@ -104,6 +149,76 @@ netssh root@my-router # user@ prefix wins → ssh root@10.0.0.1
netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1
```
### Default SSH port
Set `ssh.default_port` in the config to use a non-standard port without specifying it every time:
```sh
netssh my-router # → ssh -p 2222 -l admin 10.0.0.1
```
The default is only applied when `-p` is not already present in the SSH arguments. An explicit `-p` always takes precedence:
```sh
netssh -p 22 my-router # -p flag wins → ssh -p 22 10.0.0.1
```
### Host shortcuts
Configure short aliases so you can type `web01` instead of `web01.example.com`:
```yaml
shortcuts:
domains:
- .example.com
- .example.de
strip_hyphens: false # optional
```
Resolution order:
1. **Exact match** — the input is looked up as-is in the cache.
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01``web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01``fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
| Input | Config | Resolved as |
|-------|--------|-------------|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
### Shell aliases
Generate shell aliases for all cached hosts based on the shortcut settings:
```sh
netssh alias # bash/zsh syntax
netssh alias --shell fish # fish syntax
```
Example output:
```bash
# netssh aliases (3 hosts) — source with: eval "$(netssh alias)"
alias db01='netssh db01.example.com'
alias fsn1web01='netssh fsn1-web01.example.com'
alias web01='netssh web01.example.com'
```
Add to your shell startup file:
```sh
# bash / zsh — in ~/.bashrc or ~/.zshrc
eval "$(netssh alias)"
# fish — in ~/.config/fish/config.fish
netssh alias --shell fish | source
# scripts
source <(netssh alias)
```
Without a `shortcuts` config, the alias name is derived from the full hostname with dots replaced by underscores (e.g. `web01_example_com`). If two hosts would produce the same alias name, the first entry wins and a warning is printed to stderr.
### Interactive TUI
Run without arguments to open the interactive search:
@@ -112,22 +227,64 @@ Run without arguments to open the interactive search:
netssh
```
The TUI opens with your 10 most-recently-connected hosts. Start typing to search all cached hosts or query NetBox live.
| Key | Action |
|-----|--------|
| type | filter hosts (300 ms debounce → NetBox query) |
| `Tab` | autocomplete top result into the search field |
| `↑` / `↓` | navigate results |
| `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
```sh
netssh cache list # show all cached entries
netssh cache refresh # re-fetch all hosts from NetBox
netssh cache clear # wipe the cache
netssh cache list # show all cached entries
netssh cache refresh # re-fetch ALL hosts from NetBox (paginated)
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
```
The cache is refreshed automatically in the background every 24 hours. The trigger fires on the next SSH connect or TUI start after the interval has elapsed — there is no delay to the connection itself. The timestamp of the last refresh is stored in `~/.cache/netssh/last_refresh`.
To also trigger the check at shell startup (before you run any SSH command), install the shell hook:
```sh
netssh hook install # auto-detects $SHELL
netssh hook install --shell bash
netssh hook install --shell zsh
netssh hook install --shell fish
```
This appends a single line (`netssh shell-init`) to your shell profile. To remove it:
```sh
netssh hook uninstall
```
The hook is a no-op when the cache is fresh — it adds no measurable delay to your shell startup.
### Search (for scripting)
```sh
@@ -146,32 +303,119 @@ Strategies are tried in the configured order; the first to succeed wins.
## Shell Completion
### zsh
Install completion for the current user (no sudo required):
```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
source <(netssh completion zsh)
```
### bash
```sh
netssh completion bash > /etc/bash_completion.d/netssh
```
### fish
```sh
netssh completion fish > ~/.config/fish/completions/netssh.fish
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
```
Completions are served from the local cache — no network request on every `<Tab>`.
## Shell Hook
`netssh hook install` sets up two things in your shell profile:
1. **`netssh shell-init`** — runs on every new shell session. It regenerates the alias file from the current cache so your aliases are always up to date, and starts a background cache refresh if the last full refresh was more than 24 hours ago.
2. **A `source` line** (bash/zsh only) — loads the generated alias file so shortcuts like `web01` work immediately in every new shell. Fish uses `conf.d/` auto-sourcing instead, so no explicit source line is needed.
The alias file is also generated immediately during `hook install`, so aliases are available as soon as you reload your profile — no `cache refresh` needed first (though the cache must not be empty).
### Install
```sh
netssh hook install # auto-detects $SHELL
netssh hook install --shell bash
netssh hook install --shell zsh
netssh hook install --shell fish
```
Example output:
```
Hook installed → /home/user/.bashrc
Reload with: source /home/user/.bashrc
42 aliases written to /home/user/.cache/netssh/aliases.sh
```
What gets written to the profile:
**bash / zsh** (`~/.bashrc` or `~/.zshrc`):
```bash
netssh shell-init # netssh cache auto-refresh
source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases
```
**fish** (`~/.config/fish/config.fish`):
```fish
netssh shell-init # netssh cache auto-refresh
```
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
| Shell | Profile file | Alias file |
|-------|-------------|------------|
| bash | `~/.bashrc` | `~/.cache/netssh/aliases.sh` |
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` |
The install is idempotent — running it again changes nothing if the lines are already present.
Reload your profile after installation to activate immediately:
```sh
source ~/.bashrc # bash
source ~/.zshrc # zsh
source ~/.config/fish/config.fish # fish
```
### Alias file updates
The alias file is regenerated automatically — no manual action needed:
| Event | Aliases updated |
|-------|----------------|
| New shell session (`shell-init`) | Yes — from the current local cache |
| `netssh cache refresh` | Yes — after the full refresh completes |
| Background 24h auto-refresh | Yes — after the background refresh completes |
Aliases for newly added hosts appear in the next shell session after a `cache refresh` has run.
### Uninstall
```sh
netssh hook uninstall # auto-detects $SHELL
netssh hook uninstall --shell zsh
```
Removes both the `netssh shell-init` line and the `source aliases.sh` line from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk — delete them manually if desired.
### Relation to the connect-time auto-refresh
The shell hook and the connect-time trigger (which fires on each `netssh` invocation) are independent but complementary:
| Trigger | When it fires |
|---------|--------------|
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed |
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
## Development
```sh
@@ -179,12 +423,17 @@ go test ./... # run all tests
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, setup wizard, shortcut normalization, and shell hook install/uninstall.
## 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
1. `netssh` checks whether the first argument is a known subcommand (`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`, …).
3. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
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.
5. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
4. It resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made.
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.
+420 -23
View File
@@ -4,7 +4,10 @@ import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"text/tabwriter"
"time"
@@ -14,9 +17,11 @@ import (
"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/hook"
"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/setup"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
)
@@ -26,6 +31,9 @@ var managedSubcommands = map[string]bool{
"configure": true,
"search": true,
"cache": true,
"alias": true,
"hook": true,
"shell-init": true,
"completion": true,
"__complete": true,
"help": true,
@@ -86,36 +94,84 @@ func runSSHWrapper(args []string) {
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
parsed.DestIdx += 2
}
// Inject the configured default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
parsed.DestIdx += 2
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
// Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(parsed.Host); fresh {
c.MarkUsed(parsed.Host)
_ = c.Save()
connect(entry.IP, parsed, args)
return
}
// Shortcut cache lookup: scan entries whose normalized name matches the input.
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
lookupHost := parsed.Host
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
if shortcutsEnabled {
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
if entry, found, fresh := c.GetByShortcut(lookupHost, normalize); found {
if fresh {
c.MarkUsed(entry.Name)
_ = c.Save()
connect(entry.IP, parsed, args)
return
}
// Stale shortcut match — use canonical name for NetBox re-fetch.
lookupHost = entry.Name
}
}
if cfg.NetBox.URL == "" {
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)
defer cancel()
entries, err := nbClient.Search(ctx, parsed.Host)
entries, err := nbClient.Search(ctx, lookupHost, netbox.SearchOptions{})
if err != nil {
fatalf("NetBox search failed: %v", err)
}
var target *netbox.HostEntry
for i, e := range entries {
if strings.EqualFold(e.Name, parsed.Host) {
if strings.EqualFold(e.Name, lookupHost) {
target = &entries[i]
break
}
}
// Domain expansion: if still no match and the input has no dots, try appending
// each configured domain suffix and re-querying NetBox.
if target == nil && !strings.Contains(parsed.Host, ".") && len(cfg.Shortcuts.Domains) > 0 {
for _, domain := range cfg.Shortcuts.Domains {
expanded := parsed.Host + domain
expandedEntries, searchErr := nbClient.Search(ctx, expanded, netbox.SearchOptions{})
if searchErr != nil {
continue
}
for i, e := range expandedEntries {
if strings.EqualFold(e.Name, expanded) {
target = &expandedEntries[i]
break
}
}
if target != nil {
break
}
}
}
if target == nil {
fatalf("host %q not found in NetBox", parsed.Host)
}
@@ -131,6 +187,7 @@ func runSSHWrapper(args []string) {
}
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
c.MarkUsed(target.Name)
_ = c.Save()
connect(ip, parsed, args)
@@ -149,13 +206,14 @@ func runTUI() {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
maybeBackgroundRefresh(cfg, c)
var nbClient *netbox.Client
if cfg.NetBox.URL != "" {
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
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())
final, err := p.Run()
if err != nil {
@@ -174,11 +232,29 @@ func runTUI() {
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
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)
}
port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort)
}
if port != "" {
sshArgs = append(sshArgs, "-p", port)
}
sshArgs = append(sshArgs, host.IP)
c.MarkUsed(host.Name)
_ = c.Save()
if err := internalssh.Exec(sshArgs); err != nil {
fatalf("%v", err)
}
@@ -198,20 +274,103 @@ func rootCmd() *cobra.Command {
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
entries := c.Search(toComplete)
names := make([]cobra.Completion, len(entries))
for i, e := range entries {
names[i] = cobra.Completion(e.Name)
var completions []cobra.Completion
for _, e := range c.Search(toComplete) {
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(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
// 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
}
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",
@@ -308,7 +467,8 @@ func cacheClearCmd() *cobra.Command {
}
func cacheRefreshCmd() *cobra.Command {
return &cobra.Command{
var filterTag, filterKind string
cmd := &cobra.Command{
Use: "refresh",
Short: "Re-fetch all known hosts from NetBox",
RunE: func(cmd *cobra.Command, args []string) error {
@@ -320,12 +480,12 @@ func cacheRefreshCmd() *cobra.Command {
return fmt.Errorf("netbox.url is not configured")
}
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// An empty query returns up to 50 entries per type.
entries, err := nbClient.Search(ctx, "")
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
entries, err := nbClient.SearchAll(ctx, "", opts)
if err != nil {
return fmt.Errorf("NetBox: %w", err)
}
@@ -336,13 +496,20 @@ func cacheRefreshCmd() *cobra.Command {
chain, _ := resolver.New(cfg.Resolver)
for i := range entries {
e := &entries[i]
ip := e.PrimaryIP4
if ip == "" && chain != nil {
resolved, err := chain.Resolve(ctx, e, nbClient)
if err == nil {
var ip string
if chain != nil {
if resolved, err := chain.Resolve(ctx, e, nbClient); err == nil {
ip = resolved
}
}
if ip == "" {
ip = e.PrimaryIP4
}
if ip == "" {
ip = e.PrimaryIP6
}
if ip != "" {
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
}
@@ -351,10 +518,240 @@ func cacheRefreshCmd() *cobra.Command {
if err := c.Save(); err != nil {
return err
}
if err := c.SetRefreshed(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
}
if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 {
if err := hook.WriteAliasFiles(aliasEntries); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
}
}
fmt.Printf("%d entries written to cache.\n", len(entries))
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
}
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
all := c.All()
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
seen := make(map[string]string)
var out []hook.AliasEntry
for _, e := range all {
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
if name == "" {
continue
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = e.Name
out = append(out, hook.AliasEntry{Name: name, Host: e.Name})
}
return out
}
func aliasCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "alias",
Short: "Print shell aliases for all cached hosts",
Long: `Print shell alias definitions for all cached hosts.
The alias name is the shortened form derived from the configured shortcuts
(domain suffixes stripped, hyphens optionally stripped).
Source the output in your shell profile:
bash/zsh: eval "$(netssh alias)"
fish: netssh alias --shell fish | source
Or use in a script:
source <(netssh alias)`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
if err := c.Load(); err != nil {
return err
}
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
entries := buildAliasEntries(c, *cfg)
if len(entries) == 0 {
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
return nil
}
switch shell {
case "fish":
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
for _, e := range entries {
fmt.Printf("alias %s 'netssh %s'\n", e.Name, e.Host)
}
default:
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
for _, e := range entries {
fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host)
}
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
return cmd
}
// shellInitCmd is called at shell startup to update alias files and trigger a
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
func shellInitCmd() *cobra.Command {
return &cobra.Command{
Use: "shell-init",
Short: "Regenerate alias files and trigger a background cache refresh if stale",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil || cfg.NetBox.URL == "" {
return nil
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
// Regenerate alias files from the current cache every shell start
// so aliases are always up-to-date even before a refresh completes.
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
_ = hook.WriteAliasFiles(entries)
}
maybeBackgroundRefresh(cfg, c)
return nil
},
}
}
func hookCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "hook",
Short: "Manage shell hooks for automatic cache refresh at shell startup",
}
cmd.AddCommand(hookInstallCmd(), hookUninstallCmd())
return cmd
}
func hookInstallCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Add netssh shell-init to your shell profile",
Long: `Appends a single line to your shell profile that runs 'netssh shell-init'
on every new shell session. shell-init checks whether the cache is older
than 24 hours and, if so, starts a background refresh — no delay to your prompt.
After installation, reload your profile or open a new shell.`,
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
profile, err := hook.ProfilePath(shell)
if err != nil {
return err
}
installed, err := hook.Install(profile)
if err != nil {
return err
}
// For bash/zsh: also add the source line so aliases are loaded on startup.
// Fish uses conf.d auto-sourcing — no explicit source line needed.
if shell != "fish" {
if _, err := hook.InstallAliasesSource(profile); err != nil {
return err
}
}
if !installed {
fmt.Printf("Hook already installed in %s\n", profile)
} else {
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
}
// Generate the aliases file immediately so it is ready on the next shell start.
cfg, cfgErr := config.Load()
if cfgErr == nil && cfg.NetBox.URL != "" {
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
if _ = c.Load(); true {
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
if err := hook.WriteAliasFiles(entries); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
} else {
fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath())
}
} else {
fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.")
}
}
}
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func hookUninstallCmd() *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "uninstall",
Short: "Remove netssh shell-init from your shell profile",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
profile, err := hook.ProfilePath(shell)
if err != nil {
return err
}
removed, err := hook.Uninstall(profile)
if err != nil {
return err
}
if !removed {
fmt.Printf("No hook found in %s\n", profile)
return nil
}
fmt.Printf("Hook removed from %s\n", profile)
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if cfg.NetBox.URL == "" {
return
}
if !c.NeedsRefresh(24 * time.Hour) {
return
}
self, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(self, "cache", "refresh")
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
_ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh
}
func fatalf(format string, args ...any) {
+96 -8
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -15,13 +16,15 @@ type Entry struct {
Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"`
CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"`
}
type Cache struct {
mu sync.RWMutex
entries map[string]Entry
path string
ttl time.Duration
mu sync.RWMutex
entries map[string]Entry
path string
ttl time.Duration
refreshStamp string // path to last_refresh timestamp file
}
type diskFormat struct {
@@ -29,11 +32,48 @@ type diskFormat struct {
}
func New(path string, ttlSeconds int) *Cache {
return &Cache{
entries: make(map[string]Entry),
path: path,
ttl: time.Duration(ttlSeconds) * time.Second,
stamp := ""
if path != "" {
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
}
return &Cache{
entries: make(map[string]Entry),
path: path,
ttl: time.Duration(ttlSeconds) * time.Second,
refreshStamp: stamp,
}
}
// NeedsRefresh reports whether the last full cache refresh (via `cache refresh`)
// is older than d, or has never happened. Always returns false when no path is set.
func (c *Cache) NeedsRefresh(d time.Duration) bool {
if c.refreshStamp == "" {
return false
}
data, err := os.ReadFile(c.refreshStamp)
if err != nil {
return true // file missing → never refreshed
}
var t time.Time
if err := t.UnmarshalText(data); err != nil {
return true
}
return time.Since(t) >= d
}
// SetRefreshed records the current time as the last successful full refresh.
func (c *Cache) SetRefreshed() error {
if c.refreshStamp == "" {
return nil
}
data, err := time.Now().MarshalText()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(c.refreshStamp), 0o755); err != nil {
return err
}
return os.WriteFile(c.refreshStamp, data, 0o644)
}
func (c *Cache) Load() error {
@@ -86,6 +126,39 @@ func (c *Cache) Upsert(e Entry) {
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).
// TTL is intentionally ignored — this is used for shell completion.
func (c *Cache) Search(prefix string) []Entry {
@@ -102,6 +175,21 @@ func (c *Cache) Search(prefix string) []Entry {
return out
}
// GetByShortcut scans all entries and returns the first whose normalized name matches
// the normalized shortcut. Returns (entry, found, fresh).
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool) {
c.mu.RLock()
defer c.mu.RUnlock()
norm := normalize(shortcut)
for _, e := range c.entries {
if normalize(e.Name) == norm {
isFresh := c.ttl > 0 && time.Since(e.CachedAt) < c.ttl
return e, true, isFresh
}
}
return Entry{}, false, false
}
// Get returns an entry and reports whether it is still within the TTL.
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
c.mu.RLock()
+238
View File
@@ -220,6 +220,244 @@ 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)
}
}
// --- GetByShortcut tests ---
func TestGetByShortcut_MatchFresh(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device"})
normalize := func(s string) string {
// strip .example.com
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
e, found, fresh := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found")
}
if !fresh {
t.Error("entry just inserted should be fresh")
}
if e.IP != "10.0.0.1" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
}
}
func TestGetByShortcut_MatchStale(t *testing.T) {
c := New("", 1) // 1 second TTL
stale := Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
c.mu.Lock()
c.entries["web01.example.com"] = stale
c.mu.Unlock()
normalize := func(s string) string {
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
s = s[:len(s)-len(".example.com")]
}
return s
}
_, found, fresh := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("expected entry to be found even when stale")
}
if fresh {
t.Error("entry older than TTL should not be fresh")
}
}
func TestGetByShortcut_NotFound(t *testing.T) {
c := New("", 3600)
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("should not find an entry that does not match the shortcut")
}
}
func TestGetByShortcut_EmptyCache(t *testing.T) {
c := New("", 3600)
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
if found {
t.Error("empty cache should return found=false")
}
}
func TestGetByShortcut_MultiDomain(t *testing.T) {
c := New("", 3600)
// Entry uses second domain (.example.de)
c.Upsert(Entry{Name: "web01.example.de", IP: "10.0.0.5", Kind: "vm"})
normalize := func(s string) string {
for _, suffix := range []string{".example.com", ".example.de"} {
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
return s[:len(s)-len(suffix)]
}
}
return s
}
e, found, _ := c.GetByShortcut("web01", normalize)
if !found {
t.Fatal("should match entry with second configured domain")
}
if e.IP != "10.0.0.5" {
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.5")
}
}
// --- NeedsRefresh / SetRefreshed tests ---
// These tests require NeedsRefresh(time.Duration) bool and SetRefreshed() error
// to be implemented on *Cache. The refreshStamp field (path to the timestamp file)
// must be set before calling these methods.
func TestNeedsRefresh_NeverRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh file does not exist")
}
}
func TestNeedsRefresh_JustRefreshed(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return false immediately after SetRefreshed")
}
}
func TestNeedsRefresh_Stale(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
// Write a timestamp 25 hours ago
oldTime := time.Now().Add(-25 * time.Hour).Format(time.RFC3339)
if err := os.WriteFile(stampPath, []byte(oldTime), 0o644); err != nil {
t.Fatalf("writing stamp: %v", err)
}
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if !c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh should return true when last_refresh is older than duration")
}
}
func TestSetRefreshed_Roundtrip(t *testing.T) {
dir := t.TempDir()
stampPath := filepath.Join(dir, "last_refresh")
c := New(filepath.Join(dir, "cache.json"), 3600)
c.refreshStamp = stampPath
if err := c.SetRefreshed(); err != nil {
t.Fatalf("SetRefreshed: %v", err)
}
// Just refreshed: 24h window → not stale
if c.NeedsRefresh(24 * time.Hour) {
t.Error("NeedsRefresh(24h) should be false right after SetRefreshed")
}
// Zero duration: everything is stale
if !c.NeedsRefresh(0) {
t.Error("NeedsRefresh(0) should always return true")
}
}
// tempFile writes content to a temp file and returns its path.
func tempFile(t *testing.T, content []byte) string {
t.Helper()
+23 -6
View File
@@ -4,20 +4,23 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
type Config struct {
NetBox NetBoxConfig `mapstructure:"netbox"`
Resolver ResolverConfig `mapstructure:"resolver"`
Cache CacheConfig `mapstructure:"cache"`
SSH SSHConfig `mapstructure:"ssh"`
NetBox NetBoxConfig `mapstructure:"netbox"`
Resolver ResolverConfig `mapstructure:"resolver"`
Cache CacheConfig `mapstructure:"cache"`
SSH SSHConfig `mapstructure:"ssh"`
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
}
type NetBoxConfig struct {
URL string `mapstructure:"url"`
Token string `mapstructure:"token"`
URL string `mapstructure:"url"`
Token string `mapstructure:"token"`
TokenVersion int `mapstructure:"token_version"`
}
type ResolverConfig struct {
@@ -33,6 +36,12 @@ type CacheConfig struct {
type SSHConfig struct {
DefaultUser string `mapstructure:"default_user"`
DefaultPort int `mapstructure:"default_port"`
}
type ShortcutsConfig struct {
Domains []string `mapstructure:"domains"`
StripHyphens bool `mapstructure:"strip_hyphens"`
}
// Path returns the canonical config file path.
@@ -73,6 +82,14 @@ func Load() (*Config, error) {
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 == "" {
cacheDir, err := os.UserCacheDir()
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))
}
}
+82
View File
@@ -0,0 +1,82 @@
package hook
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// AliasEntry holds a single shell alias mapping.
type AliasEntry struct {
Name string // short alias name (e.g. "web01")
Host string // canonical NetBox hostname (e.g. "web01.example.com")
}
// AliasesPath returns the path for the bash/zsh aliases file.
func AliasesPath() string {
cacheDir, err := os.UserCacheDir()
if err != nil {
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
}
return filepath.Join(cacheDir, "netssh", "aliases.sh")
}
// FishAliasesPath returns the path for the fish conf.d aliases file.
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
func FishAliasesPath() string {
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(configDir, "fish", "conf.d", "netssh.fish")
}
// WriteAliasFiles writes alias definitions to disk for all supported shells.
//
// The bash/zsh file (~/.cache/netssh/aliases.sh) is always written.
// The fish file (~/.config/fish/conf.d/netssh.fish) is written only when
// ~/.config/fish/ already exists (i.e. fish is configured on this system).
func WriteAliasFiles(entries []AliasEntry) error {
if err := writeShAliasFile(entries); err != nil {
return err
}
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
return nil
}
func writeShAliasFile(entries []AliasEntry) error {
p := AliasesPath()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return fmt.Errorf("creating aliases dir: %w", err)
}
var b strings.Builder
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
for _, e := range entries {
fmt.Fprintf(&b, "alias %s='netssh %s'\n", e.Name, e.Host)
}
return os.WriteFile(p, []byte(b.String()), 0o644)
}
func writeFishAliasFile(entries []AliasEntry) {
configDir, err := os.UserConfigDir()
if err != nil {
return
}
// Only write if fish is configured on this system.
if _, err := os.Stat(filepath.Join(configDir, "fish")); err != nil {
return
}
p := FishAliasesPath()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return
}
var b strings.Builder
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
for _, e := range entries {
fmt.Fprintf(&b, "alias %s 'netssh %s'\n", e.Name, e.Host)
}
os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck
}
+210
View File
@@ -0,0 +1,210 @@
package hook_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
func TestAliasesPath_UnderCacheDir(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", dir)
defer os.Setenv("XDG_CACHE_HOME", orig)
p := hook.AliasesPath()
if !strings.HasPrefix(p, dir) {
t.Errorf("AliasesPath should be under XDG_CACHE_HOME, got %q", p)
}
if !strings.HasSuffix(p, "aliases.sh") {
t.Errorf("AliasesPath should end with aliases.sh, got %q", p)
}
}
func TestFishAliasesPath_UnderConfigDir(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)
p := hook.FishAliasesPath()
if !strings.HasPrefix(p, dir) {
t.Errorf("FishAliasesPath should be under XDG_CONFIG_HOME, got %q", p)
}
if !strings.HasSuffix(p, "netssh.fish") {
t.Errorf("FishAliasesPath should end with netssh.fish, got %q", p)
}
}
// TestWriteAliasFiles_ShFile verifies the bash/zsh file is always written.
func TestWriteAliasFiles_ShFile(t *testing.T) {
cacheDir := t.TempDir()
orig := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", cacheDir)
defer os.Setenv("XDG_CACHE_HOME", orig)
entries := []hook.AliasEntry{
{Name: "web01", Host: "web01.example.com"},
{Name: "db01", Host: "db01.example.com"},
}
if err := hook.WriteAliasFiles(entries); err != nil {
t.Fatalf("WriteAliasFiles: %v", err)
}
data, err := os.ReadFile(hook.AliasesPath())
if err != nil {
t.Fatalf("aliases.sh not created: %v", err)
}
content := string(data)
for _, want := range []string{
"alias web01='netssh web01.example.com'",
"alias db01='netssh db01.example.com'",
} {
if !strings.Contains(content, want) {
t.Errorf("aliases.sh missing %q\ncontent:\n%s", want, content)
}
}
}
// TestWriteAliasFiles_FishFile verifies the fish file is written when fish config dir exists.
func TestWriteAliasFiles_FishFile(t *testing.T) {
configDir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", configDir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
// Create the fish config directory so WriteAliasFiles knows fish is set up.
fishDir := filepath.Join(configDir, "fish")
os.MkdirAll(fishDir, 0o755)
cacheDir := t.TempDir()
origCache := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", cacheDir)
defer os.Setenv("XDG_CACHE_HOME", origCache)
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
if err := hook.WriteAliasFiles(entries); err != nil {
t.Fatalf("WriteAliasFiles: %v", err)
}
data, err := os.ReadFile(hook.FishAliasesPath())
if err != nil {
t.Fatalf("netssh.fish not created: %v", err)
}
if !strings.Contains(string(data), "alias web01 'netssh web01.example.com'") {
t.Errorf("fish alias file missing expected line\ncontent:\n%s", string(data))
}
}
// TestWriteAliasFiles_SkipsFishWhenNotConfigured verifies that fish file is NOT written
// when fish is not set up (no ~/.config/fish directory).
func TestWriteAliasFiles_SkipsFishWhenNotConfigured(t *testing.T) {
configDir := t.TempDir() // no fish subdir
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", configDir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cacheDir := t.TempDir()
origCache := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", cacheDir)
defer os.Setenv("XDG_CACHE_HOME", origCache)
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
if err := hook.WriteAliasFiles(entries); err != nil {
t.Fatalf("WriteAliasFiles: %v", err)
}
if _, err := os.Stat(hook.FishAliasesPath()); err == nil {
t.Error("fish alias file should not be created when fish is not configured")
}
}
// TestWriteAliasFiles_EmptyEntries verifies an empty (but valid) file is written.
func TestWriteAliasFiles_EmptyEntries(t *testing.T) {
cacheDir := t.TempDir()
orig := os.Getenv("XDG_CACHE_HOME")
os.Setenv("XDG_CACHE_HOME", cacheDir)
defer os.Setenv("XDG_CACHE_HOME", orig)
if err := hook.WriteAliasFiles(nil); err != nil {
t.Fatalf("WriteAliasFiles with nil entries: %v", err)
}
data, err := os.ReadFile(hook.AliasesPath())
if err != nil {
t.Fatalf("aliases.sh not created: %v", err)
}
// Should contain only the header comment, no alias lines.
if strings.Contains(string(data), "alias ") {
t.Error("aliases.sh should have no alias lines for empty entries")
}
}
// --- InstallAliasesSource ---
func TestInstallAliasesSource_Fresh(t *testing.T) {
profile := writeProfile(t, "existing line\n")
installed, err := hook.InstallAliasesSource(profile)
if err != nil {
t.Fatalf("InstallAliasesSource: %v", err)
}
if !installed {
t.Error("should report installed=true on first install")
}
data, _ := os.ReadFile(profile)
if !strings.Contains(string(data), hook.AliasesMarker) {
t.Error("profile should contain aliases source line")
}
}
func TestInstallAliasesSource_Idempotent(t *testing.T) {
profile := writeProfile(t, "")
hook.InstallAliasesSource(profile)
installed, err := hook.InstallAliasesSource(profile)
if err != nil {
t.Fatalf("second InstallAliasesSource: %v", err)
}
if installed {
t.Error("second call should report installed=false")
}
data, _ := os.ReadFile(profile)
if count := strings.Count(string(data), hook.AliasesMarker); count != 1 {
t.Errorf("aliases source line should appear once, got %d", count)
}
}
// TestUninstall_AlsoRemovesAliasesSourceLine verifies that Uninstall removes both lines.
func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
content := "export PATH=$PATH\n" +
hook.Line + "\n" +
hook.AliasesSourceLine + "\n" +
"export FOO=bar\n"
profile := writeProfile(t, content)
removed, err := hook.Uninstall(profile)
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if !removed {
t.Error("should report removed=true")
}
data, _ := os.ReadFile(profile)
s := string(data)
if strings.Contains(s, hook.Marker) {
t.Error("shell-init line should be removed")
}
if strings.Contains(s, hook.AliasesMarker) {
t.Error("aliases source line should be removed")
}
if !strings.Contains(s, "export PATH") || !strings.Contains(s, "export FOO") {
t.Error("other content should be preserved")
}
}
+128
View File
@@ -0,0 +1,128 @@
package hook
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// Marker is the unique string used to detect an existing hook line.
Marker = "netssh shell-init"
// Line is the full line written into the shell profile.
Line = "netssh shell-init # netssh cache auto-refresh"
// AliasesMarker is the unique string used to detect the aliases source line.
AliasesMarker = "netssh/aliases.sh"
// AliasesSourceLine is the bash/zsh line that sources the generated aliases file.
AliasesSourceLine = "source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases"
)
// ProfilePath returns the canonical shell profile path for the given shell name.
func ProfilePath(shell string) (string, error) {
switch shell {
case "bash":
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
case "zsh":
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
case "fish":
configDir, err := os.UserConfigDir()
if err != nil {
configDir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(configDir, "fish", "config.fish"), nil
default:
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
}
}
// ReloadNote returns the shell-specific hint for reloading the profile.
func ReloadNote(profilePath string) string {
return "Reload with: source " + profilePath
}
// IsInstalled reports whether the hook marker is present in the profile file.
// Returns false if the file does not exist or cannot be read.
func IsInstalled(profilePath string) bool {
data, err := os.ReadFile(profilePath)
if err != nil {
return false
}
return strings.Contains(string(data), Marker)
}
// Install appends the hook line to the profile if it is not already present.
// Returns (true, nil) when newly installed, (false, nil) when already present.
// Creates the profile file (and any parent directories) if they do not exist.
func Install(profilePath string) (installed bool, err error) {
if IsInstalled(profilePath) {
return false, nil
}
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
return false, fmt.Errorf("creating directory: %w", err)
}
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false, fmt.Errorf("opening profile: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "\n%s\n", Line); err != nil {
return false, err
}
return true, nil
}
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
// Returns (true, nil) when newly added, (false, nil) when already present.
func InstallAliasesSource(profilePath string) (installed bool, err error) {
data, err := os.ReadFile(profilePath)
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("reading profile: %w", err)
}
if strings.Contains(string(data), AliasesMarker) {
return false, nil
}
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false, fmt.Errorf("opening profile: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s\n", AliasesSourceLine); err != nil {
return false, err
}
return true, nil
}
// Uninstall removes the hook line and the aliases source line from the profile.
// Returns (true, nil) when something was removed, (false, nil) when nothing was found.
// Returns (false, nil) — not an error — when the file does not exist.
func Uninstall(profilePath string) (removed bool, err error) {
data, err := os.ReadFile(profilePath)
if os.IsNotExist(err) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("reading profile: %w", err)
}
content := string(data)
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
return false, nil
}
var kept []string
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
continue
}
kept = append(kept, line)
}
// Collapse triple blank lines that the removal may leave.
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
return false, fmt.Errorf("writing profile: %w", err)
}
return true, nil
}
+263
View File
@@ -0,0 +1,263 @@
package hook_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
)
// --- ProfilePath ---
func TestProfilePath_Bash(t *testing.T) {
orig := os.Getenv("HOME")
os.Setenv("HOME", t.TempDir())
defer os.Setenv("HOME", orig)
p, err := hook.ProfilePath("bash")
if err != nil {
t.Fatalf("ProfilePath(bash): %v", err)
}
if !strings.HasSuffix(p, ".bashrc") {
t.Errorf("bash profile should end with .bashrc, got %q", p)
}
}
func TestProfilePath_Zsh(t *testing.T) {
orig := os.Getenv("HOME")
os.Setenv("HOME", t.TempDir())
defer os.Setenv("HOME", orig)
p, err := hook.ProfilePath("zsh")
if err != nil {
t.Fatalf("ProfilePath(zsh): %v", err)
}
if !strings.HasSuffix(p, ".zshrc") {
t.Errorf("zsh profile should end with .zshrc, got %q", p)
}
}
func TestProfilePath_Fish(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)
p, err := hook.ProfilePath("fish")
if err != nil {
t.Fatalf("ProfilePath(fish): %v", err)
}
if !strings.HasSuffix(p, "config.fish") {
t.Errorf("fish profile should end with config.fish, got %q", p)
}
}
func TestProfilePath_UnknownShell(t *testing.T) {
if _, err := hook.ProfilePath("ksh"); err == nil {
t.Error("unknown shell should return an error")
}
}
// --- IsInstalled ---
func TestIsInstalled_Present(t *testing.T) {
profile := writeProfile(t, hook.Line+"\n")
if !hook.IsInstalled(profile) {
t.Error("IsInstalled should return true when marker is present")
}
}
func TestIsInstalled_Absent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH\n")
if hook.IsInstalled(profile) {
t.Error("IsInstalled should return false when marker is absent")
}
}
func TestIsInstalled_MissingFile(t *testing.T) {
if hook.IsInstalled("/nonexistent/path/.bashrc") {
t.Error("IsInstalled should return false for missing file")
}
}
// --- Install ---
func TestInstall_Fresh(t *testing.T) {
profile := filepath.Join(t.TempDir(), ".bashrc")
installed, err := hook.Install(profile)
if err != nil {
t.Fatalf("Install: %v", err)
}
if !installed {
t.Error("should report installed=true on fresh install")
}
if !hook.IsInstalled(profile) {
t.Error("profile should contain the hook line after Install")
}
}
func TestInstall_Idempotent(t *testing.T) {
profile := filepath.Join(t.TempDir(), ".bashrc")
hook.Install(profile)
installed, err := hook.Install(profile)
if err != nil {
t.Fatalf("second Install: %v", err)
}
if installed {
t.Error("second Install should report installed=false")
}
data, _ := os.ReadFile(profile)
if count := strings.Count(string(data), hook.Marker); count != 1 {
t.Errorf("hook line should appear exactly once, got %d", count)
}
}
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
if _, err := hook.Install(profile); err != nil {
t.Fatalf("Install should create missing directories: %v", err)
}
if _, err := os.Stat(profile); err != nil {
t.Error("profile file should have been created")
}
}
func TestInstall_PreservesExistingContent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
hook.Install(profile)
data, _ := os.ReadFile(profile)
content := string(data)
if !strings.Contains(content, "export PATH") {
t.Error("Install should not remove existing content")
}
if !strings.Contains(content, hook.Marker) {
t.Error("hook line should be appended")
}
}
func TestInstall_AppendedAtEnd(t *testing.T) {
profile := writeProfile(t, "existing line\n")
hook.Install(profile)
data, _ := os.ReadFile(profile)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
last := lines[len(lines)-1]
if last != hook.Line {
t.Errorf("hook line should be the last line, got %q", last)
}
}
// --- Uninstall ---
func TestUninstall_Removes(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH\n"+hook.Line+"\nexport FOO=bar\n")
removed, err := hook.Uninstall(profile)
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if !removed {
t.Error("should report removed=true")
}
if hook.IsInstalled(profile) {
t.Error("hook line should have been removed")
}
}
func TestUninstall_PreservesOtherContent(t *testing.T) {
profile := writeProfile(t, "export FOO=bar\n"+hook.Line+"\nexport BAZ=qux\n")
hook.Uninstall(profile)
data, _ := os.ReadFile(profile)
content := string(data)
if !strings.Contains(content, "export FOO=bar") {
t.Error("content before hook should be preserved")
}
if !strings.Contains(content, "export BAZ=qux") {
t.Error("content after hook should be preserved")
}
}
func TestUninstall_NotPresent(t *testing.T) {
profile := writeProfile(t, "export PATH=$PATH\n")
removed, err := hook.Uninstall(profile)
if err != nil {
t.Fatalf("Uninstall: %v", err)
}
if removed {
t.Error("should report removed=false when hook was not present")
}
}
func TestUninstall_MissingFile(t *testing.T) {
removed, err := hook.Uninstall("/nonexistent/.bashrc")
if err != nil {
t.Errorf("Uninstall on missing file should not error: %v", err)
}
if removed {
t.Error("should report removed=false for missing file")
}
}
func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
// blank line before hook + hook + blank line after = three consecutive newlines after removal
profile := writeProfile(t, "line1\n\n"+hook.Line+"\n\nline2\n")
hook.Uninstall(profile)
data, _ := os.ReadFile(profile)
if strings.Contains(string(data), "\n\n\n") {
t.Error("three consecutive blank lines should be collapsed after uninstall")
}
}
// --- Roundtrip ---
func TestInstallUninstall_Roundtrip(t *testing.T) {
profile := writeProfile(t, "existing content\n")
hook.Install(profile)
if !hook.IsInstalled(profile) {
t.Fatal("should be installed after Install")
}
hook.Uninstall(profile)
if hook.IsInstalled(profile) {
t.Fatal("should not be installed after Uninstall")
}
data, _ := os.ReadFile(profile)
if !strings.Contains(string(data), "existing content") {
t.Error("original content should survive install+uninstall cycle")
}
}
// --- ReloadNote ---
func TestReloadNote_ContainsPath(t *testing.T) {
note := hook.ReloadNote("/home/user/.bashrc")
if !strings.Contains(note, "/home/user/.bashrc") {
t.Errorf("ReloadNote should contain the profile path, got %q", note)
}
}
// --- helpers ---
func writeProfile(t *testing.T, content string) string {
t.Helper()
f := filepath.Join(t.TempDir(), "profile")
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return f
}
+175 -36
View File
@@ -11,60 +11,131 @@ import (
)
type Client struct {
baseURL string
token string
httpClient *http.Client
baseURL string
token string
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{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{},
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
tokenVersion: tokenVersion,
httpClient: &http.Client{},
}
}
// Search queries devices and VMs in parallel and merges the results.
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) {
// Search queries up to 50 devices and VMs in parallel and merges the results.
// Use SearchOptions to restrict by kind or tag.
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
var (
mu sync.Mutex
results []HostEntry
errs []error
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() {
defer wg.Done()
devices, err := c.searchDevices(ctx, query)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, fmt.Errorf("devices: %w", err))
return
}
results = append(results, devices...)
}()
go func() {
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...)
}()
if opts.Kind != "device" {
started++
wg.Add(1)
go func() {
defer wg.Done()
vms, err := c.searchVMs(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) == 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 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
}
@@ -107,8 +178,11 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
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))
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
@@ -120,8 +194,11 @@ func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry,
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))
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
@@ -133,6 +210,60 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
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 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
@@ -147,6 +278,14 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
}
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 {
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
}
+191 -16
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -58,8 +59,8 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
results, err := c.Search(context.Background(), "")
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil {
t.Fatalf("Search: %v", err)
}
@@ -87,8 +88,8 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
results, _ := c.Search(context.Background(), "")
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "", SearchOptions{})
for _, r := range results {
switch r.Name {
@@ -113,8 +114,8 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
results, _ := c.Search(context.Background(), "host")
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "host", SearchOptions{})
if len(results) == 0 {
t.Fatal("expected at least one result")
}
@@ -138,8 +139,8 @@ func TestSearch_TagsAreMapped(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
results, _ := c.Search(context.Background(), "")
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "", SearchOptions{})
if len(results[0].Tags) != 2 {
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)
defer srv.Close()
c := NewClient(srv.URL, "token")
results, err := c.Search(context.Background(), "")
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "", SearchOptions{})
if err != nil {
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)
defer srv.Close()
c := NewClient(srv.URL, "token")
_, err := c.Search(context.Background(), "")
c := NewClient(srv.URL, "token", 0)
_, err := c.Search(context.Background(), "", SearchOptions{})
if err == nil {
t.Error("both endpoints failing should return an error")
}
@@ -190,7 +191,7 @@ func TestGetIPs_Device(t *testing.T) {
})
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"})
if err != nil {
t.Fatalf("GetIPs: %v", err)
@@ -209,7 +210,7 @@ func TestGetIPs_VM(t *testing.T) {
})
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"})
if err != nil {
t.Fatalf("GetIPs: %v", err)
@@ -220,7 +221,7 @@ func TestGetIPs_VM(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"})
if err == nil {
t.Error("unknown kind should return an error")
@@ -233,7 +234,7 @@ func TestGetIPsWithFilter(t *testing.T) {
})
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")
if err != nil {
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) {
tests := []struct {
in string
+7
View File
@@ -1,5 +1,12 @@
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.
type HostEntry struct {
ID int
+5 -5
View File
@@ -36,7 +36,7 @@ func TestManagementSubnetStrategy_MatchesSubnet(t *testing.T) {
defer srv.Close()
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)
if err != nil {
@@ -52,7 +52,7 @@ func TestManagementSubnetStrategy_NoMatch(t *testing.T) {
defer srv.Close()
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)
if err != ErrNoIP {
@@ -65,7 +65,7 @@ func TestManagementSubnetStrategy_FirstMatchWins(t *testing.T) {
defer srv.Close()
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)
if err != nil {
@@ -81,7 +81,7 @@ func TestManagementSubnetStrategy_VMKind(t *testing.T) {
defer srv.Close()
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)
if err != nil {
@@ -97,7 +97,7 @@ func TestManagementSubnetStrategy_IPv6Subnet(t *testing.T) {
defer srv.Close()
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)
if err != nil {
+117 -13
View File
@@ -11,6 +11,7 @@ import (
"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.
@@ -19,13 +20,19 @@ func RunWizard(cfg *config.Config) error {
url := cfg.NetBox.URL
token := cfg.NetBox.Token
defaultUser := cfg.SSH.DefaultUser
strategies := cfg.Resolver.Strategies
defaultPort := ""
if cfg.SSH.DefaultPort > 0 {
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
}
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
stripHyphens := cfg.Shortcuts.StripHyphens
if len(strategies) == 0 {
strategies = []string{"primary_ip"}
if strategiesRaw == "" {
strategiesRaw = "primary_ip"
}
if cacheTTL == "0" {
cacheTTL = "3600"
@@ -61,18 +68,30 @@ func RunWizard(cfg *config.Config) error {
Title("Default SSH user").
Description("Leave empty to use your system user ($USER).").
Value(&defaultUser),
huh.NewInput().
Title("Default SSH port").
Description("Leave empty to use the standard port (22).").
Placeholder("22").
Value(&defaultPort).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 || n > 65535 {
return errors.New("must be a port number between 1 and 65535")
}
return nil
}),
).Title("SSH defaults"),
huh.NewGroup(
huh.NewMultiSelect[string]().
huh.NewInput().
Title("Resolver strategies").
Description("Order matters: first match wins.").
Options(
huh.NewOption("primary_ip — NetBox primary IPv4/IPv6", "primary_ip"),
huh.NewOption("management_subnet — first IP inside a subnet", "management_subnet"),
huh.NewOption("interface_name — IP on a named interface", "interface_name"),
).
Value(&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.").
@@ -92,6 +111,18 @@ func RunWizard(cfg *config.Config) error {
return nil
}),
).Title("Resolver & cache"),
huh.NewGroup(
huh.NewInput().
Title("Domain suffixes").
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
Placeholder(".example.com").
Value(&shortcutDomains),
huh.NewConfirm().
Title("Strip hyphens").
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
Value(&stripHyphens),
).Title("Shortcuts"),
)
if err := form.Run(); err != nil {
@@ -102,8 +133,20 @@ func RunWizard(cfg *config.Config) error {
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)
port := 0
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
port = p
}
var subnetList []string
for _, s := range strings.Split(subnets, ",") {
if s = strings.TrimSpace(s); s != "" {
@@ -111,13 +154,25 @@ func RunWizard(cfg *config.Config) error {
}
}
var domainList []string
for _, s := range strings.Split(shortcutDomains, ",") {
if s = strings.TrimSpace(s); s != "" {
if !strings.HasPrefix(s, ".") {
s = "." + s
}
domainList = append(domainList, s)
}
}
out := config.Config{
NetBox: config.NetBoxConfig{
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
TokenVersion: tokenVersion,
},
SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser),
DefaultPort: port,
},
Resolver: config.ResolverConfig{
Strategies: strategies,
@@ -127,11 +182,43 @@ func RunWizard(cfg *config.Config) error {
Cache: config.CacheConfig{
TTL: ttl,
},
Shortcuts: config.ShortcutsConfig{
Domains: domainList,
StripHyphens: stripHyphens,
},
}
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 {
@@ -142,6 +229,7 @@ func save(cfg config.Config) error {
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")
@@ -165,6 +253,22 @@ func save(cfg config.Config) error {
if cfg.SSH.DefaultUser != "" {
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
}
if cfg.SSH.DefaultPort > 0 {
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
}
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
b.WriteString("\nshortcuts:\n")
if len(cfg.Shortcuts.Domains) > 0 {
b.WriteString(" domains:\n")
for _, d := range cfg.Shortcuts.Domains {
fmt.Fprintf(&b, " - %s\n", d)
}
}
if cfg.Shortcuts.StripHyphens {
b.WriteString(" strip_hyphens: true\n")
}
}
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
return fmt.Errorf("writing config: %w", err)
+368
View File
@@ -0,0 +1,368 @@
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 TestSave_DefaultPort(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},
SSH: config.SSHConfig{DefaultPort: 2222},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if !strings.Contains(string(data), "default_port: 2222") {
t.Errorf("expected default_port: 2222 in config, got:\n%s", string(data))
}
}
func TestSave_Shortcuts_WritesSection(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},
Shortcuts: config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: true,
},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
content := string(data)
for _, want := range []string{"shortcuts:", "domains:", ".example.com", "strip_hyphens: true"} {
if !strings.Contains(content, want) {
t.Errorf("expected %q in config, got:\n%s", want, content)
}
}
}
func TestSave_OmitsDefaultPortWhenZero(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},
SSH: config.SSHConfig{DefaultPort: 0},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if strings.Contains(string(data), "default_port") {
t.Errorf("default_port should be omitted when zero, got:\n%s", string(data))
}
}
func TestSave_OmitsShortcutsWhenEmpty(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},
Shortcuts: config.ShortcutsConfig{}, // empty
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if strings.Contains(string(data), "shortcuts:") {
t.Errorf("shortcuts section should be omitted when empty, got:\n%s", string(data))
}
}
func TestSave_Roundtrip_WithNewFields(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.example.com",
Token: "nbt_test",
TokenVersion: 2,
},
SSH: config.SSHConfig{
DefaultUser: "admin",
DefaultPort: 2222,
},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip"},
},
Cache: config.CacheConfig{TTL: 3600},
Shortcuts: config.ShortcutsConfig{
Domains: []string{".example.com", ".example.de"},
StripHyphens: true,
},
}
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.SSH.DefaultPort != original.SSH.DefaultPort {
t.Errorf("DefaultPort: got %d, want %d", loaded.SSH.DefaultPort, original.SSH.DefaultPort)
}
if len(loaded.Shortcuts.Domains) != len(original.Shortcuts.Domains) {
t.Errorf("Shortcuts.Domains length: got %d, want %d", len(loaded.Shortcuts.Domains), len(original.Shortcuts.Domains))
} else {
for i, d := range original.Shortcuts.Domains {
if loaded.Shortcuts.Domains[i] != d {
t.Errorf("Shortcuts.Domains[%d]: got %q, want %q", i, loaded.Shortcuts.Domains[i], d)
}
}
}
if loaded.Shortcuts.StripHyphens != original.Shortcuts.StripHyphens {
t.Errorf("StripHyphens: got %v, want %v", loaded.Shortcuts.StripHyphens, original.Shortcuts.StripHyphens)
}
}
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)
}
}
+42
View File
@@ -0,0 +1,42 @@
package shortcuts
import (
"strings"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
// The result is lowercased for case-insensitive comparison.
func Normalize(name string, cfg config.ShortcutsConfig) string {
s := strings.ToLower(name)
for _, domain := range cfg.Domains {
suffix := strings.ToLower(domain)
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
if strings.HasSuffix(s, suffix) {
s = s[:len(s)-len(suffix)]
break
}
}
if cfg.StripHyphens {
s = strings.ReplaceAll(s, "-", "")
}
return s
}
// MakeNormalizer returns a closure bound to cfg, suitable for Cache.GetByShortcut.
func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
return func(name string) string {
return Normalize(name, cfg)
}
}
// AliasName returns a shell-safe alias name for the given host.
// It normalizes the name (strips configured domains and optionally hyphens) then
// replaces any remaining dots with underscores so the result is a valid identifier.
func AliasName(name string, cfg config.ShortcutsConfig) string {
s := Normalize(name, cfg)
return strings.ReplaceAll(s, ".", "_")
}
+80
View File
@@ -0,0 +1,80 @@
package shortcuts_test
import (
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
)
func TestNormalize(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com", ".example.de"},
StripHyphens: true,
}
cases := []struct {
input string
want string
}{
{"fsn1-web01.example.com", "fsn1web01"},
{"fsn1-web01.example.de", "fsn1web01"},
{"FSN1-WEB01.EXAMPLE.COM", "fsn1web01"},
{"fsn1-web01", "fsn1web01"},
{"web01.example.com", "web01"},
{"web01.other.com", "web01.other.com"}, // unknown domain not stripped
}
for _, c := range cases {
got := shortcuts.Normalize(c.input, cfg)
if got != c.want {
t.Errorf("Normalize(%q) = %q, want %q", c.input, got, c.want)
}
}
}
func TestNormalizeNoHyphenStrip(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: false,
}
got := shortcuts.Normalize("fsn1-web01.example.com", cfg)
want := "fsn1-web01"
if got != want {
t.Errorf("Normalize = %q, want %q", got, want)
}
}
func TestAliasName_WithDomainAndHyphens(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com"},
StripHyphens: true,
}
got := shortcuts.AliasName("fsn1-web01.example.com", cfg)
want := "fsn1web01"
if got != want {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
func TestAliasName_DotReplacement(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".example.com"},
}
// "web01.other.com" — domain not stripped, remaining dots → underscores
got := shortcuts.AliasName("web01.other.com", cfg)
want := "web01_other_com"
if got != want {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
func TestAliasName_NoConfig(t *testing.T) {
cfg := config.ShortcutsConfig{}
got := shortcuts.AliasName("web01.example.com", cfg)
want := "web01_example_com"
if got != want {
t.Errorf("AliasName = %q, want %q", got, want)
}
}
+13
View File
@@ -86,6 +86,19 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
return result
}
// HasPortFlag reports whether a port was specified via -p in args.
func HasPortFlag(args []string) bool {
for i, a := range args {
if a == "-p" && i+1 < len(args) {
return true
}
if len(a) > 2 && a[0] == '-' && a[1] == 'p' {
return true
}
}
return false
}
// HasUserFlag reports whether a user was specified via -l in args.
// Used to avoid overriding an explicit -l with the configured default user.
func HasUserFlag(args []string) bool {
+31
View File
@@ -144,6 +144,37 @@ func TestHasUserFlag_LFlagAtEnd(t *testing.T) {
}
}
func TestHasPortFlag_FlagSeparated(t *testing.T) {
if !HasPortFlag([]string{"-p", "2222", "host"}) {
t.Error("should detect -p <port>")
}
}
func TestHasPortFlag_FlagAttached(t *testing.T) {
if !HasPortFlag([]string{"-p2222", "host"}) {
t.Error("should detect -p<port> (attached form)")
}
}
func TestHasPortFlag_NotPresent(t *testing.T) {
if HasPortFlag([]string{"-l", "admin", "host"}) {
t.Error("should not detect port flag when absent")
}
}
func TestHasPortFlag_EmptyArgs(t *testing.T) {
if HasPortFlag([]string{}) {
t.Error("empty args should return false")
}
}
func TestHasPortFlag_PAtEnd(t *testing.T) {
// -p at the very end with no value — should return false
if HasPortFlag([]string{"-p"}) {
t.Error("-p with no value should return false")
}
}
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
t.Helper()
if got == nil {
+260 -32
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"strconv"
"strings"
"time"
@@ -20,6 +21,8 @@ import (
type SelectedHost struct {
Name string
IP string
User string // empty = use config default
Port string // empty = use default port
}
// --- bubbletea messages ---
@@ -30,6 +33,7 @@ type searchResultMsg struct {
query string
entries []netbox.HostEntry
err error
recent bool // true when this is a recently-used list, not a search result
}
// --- list item ---
@@ -38,6 +42,7 @@ type hostItem struct {
name string
ip string
kind string
tags []string
}
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)
}
// --- 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 ---
type Model struct {
input textinput.Model
list list.Model
client *netbox.Client
cache *cache.Cache
lastSent string // last query sent to NetBox (or served from cache)
seq int // sequence number to discard stale results
loading bool
err error
selected *SelectedHost
width int
height int
input textinput.Model
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
editInput textinput.Model // user@host:port inline editor
list list.Model
client *netbox.Client
cache *cache.Cache
defaultUser string
lastSent string // last query sent to NetBox (or served from cache)
lastResults []netbox.HostEntry // raw results before filter applied
seq int // sequence number to discard stale results
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.Placeholder = "Search hostname…"
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.SetShowHelp(false)
l.SetShowTitle(false)
@@ -91,15 +159,34 @@ func New(client *netbox.Client, c *cache.Cache) *Model {
l.SetFilteringEnabled(false)
return &Model{
input: ti,
list: l,
client: client,
cache: c,
input: ti,
filterInput: fi,
editInput: ei,
list: l,
client: client,
cache: c,
defaultUser: defaultUser,
recentMode: true,
}
}
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) {
@@ -108,22 +195,83 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
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
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() {
case "ctrl+c", "esc":
return m, tea.Quit
case "ctrl+f":
m.filterOpen = true
m.input.Blur()
m.filterInput.Focus()
return m, nil
case "enter":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
return m, tea.Quit
}
case "e":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item)
return m, nil
}
case "tab":
// Copy the top result into the search field.
if m.list.Items() != nil && len(m.list.Items()) > 0 {
if item, ok := m.list.Items()[0].(hostItem); ok {
m.input.SetValue(item.name)
@@ -134,18 +282,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case debounceMsg:
// Only query if the input has changed since the last request.
q := m.input.Value()
if q == m.lastSent {
return m, nil
}
m.lastSent = q
m.recentMode = false
m.loading = true
m.seq++
seq := m.seq
return m, m.doSearch(q, seq)
case searchResultMsg:
if msg.recent {
m.recentMode = true
m.lastResults = msg.entries
m.updateListItems(msg.entries)
return m, nil
}
if msg.query != m.lastSent {
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
return m, nil
}
items := make([]list.Item, len(msg.entries))
for i, e := range 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.lastResults = msg.entries
m.updateListItems(msg.entries)
m.err = 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 inputCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg)
@@ -188,14 +335,33 @@ func (m *Model) View() string {
sb.WriteString(title + "\n\n")
sb.WriteString(m.input.View() + "\n")
if m.filterOpen {
sb.WriteString(m.filterInput.View() + "\n")
}
if m.loading {
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 {
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())
}
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()
}
@@ -211,13 +377,14 @@ func (m *Model) startDebounce() tea.Cmd {
}
func (m *Model) doSearch(query string, seq int) tea.Cmd {
opts := m.netboxSearchOpts()
return func() tea.Msg {
// Return cache hits immediately without a network round-trip.
if m.cache != nil {
if cached := m.cache.Search(query); len(cached) > 0 {
entries := make([]netbox.HostEntry, len(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}
}
@@ -230,8 +397,69 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
entries, err := m.client.Search(ctx, query)
entries, err := m.client.Search(ctx, query, opts)
_ = seq
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
}