Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c902cab3a | |||
| d127a3b957 | |||
| cdf750081e | |||
| 574c4dbf58 |
@@ -1,5 +1,7 @@
|
|||||||
# netssh
|
# netssh
|
||||||
|
|
||||||
|
> **Vibe-coded project** — this codebase was written entirely by an AI assistant (Claude) without human code review. Use in production at your own risk.
|
||||||
|
|
||||||
A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting.
|
A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting.
|
||||||
|
|
||||||
Instead of looking up an IP manually, you just type the hostname as it appears in NetBox:
|
Instead of looking up an IP manually, you just type the hostname as it appears in NetBox:
|
||||||
@@ -16,10 +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
|
- **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options
|
||||||
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
|
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
|
||||||
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
|
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
|
||||||
|
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
|
||||||
|
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
|
||||||
|
- **User/port override** — press `e` in the TUI to override the SSH user or port before connecting
|
||||||
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
|
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
|
||||||
|
- **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`
|
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
|
||||||
- **Shell completion** — install without sudo via `netssh completion install`
|
- **Shell completion** — install without sudo via `netssh completion install`
|
||||||
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
||||||
|
- **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
|
## Installation
|
||||||
|
|
||||||
@@ -87,6 +98,12 @@ cache:
|
|||||||
|
|
||||||
ssh:
|
ssh:
|
||||||
default_user: admin # used when no user is specified on the command line
|
default_user: admin # used when no user is specified on the command line
|
||||||
|
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.).
|
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
@@ -132,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
|
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
|
### Interactive TUI
|
||||||
|
|
||||||
Run without arguments to open the interactive search:
|
Run without arguments to open the interactive search:
|
||||||
@@ -140,22 +227,64 @@ Run without arguments to open the interactive search:
|
|||||||
netssh
|
netssh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The TUI opens with your 10 most-recently-connected hosts. Start typing to search all cached hosts or query NetBox live.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| type | filter hosts (300 ms debounce → NetBox query) |
|
| type | filter hosts (300 ms debounce → NetBox query) |
|
||||||
| `Tab` | autocomplete top result into the search field |
|
| `Tab` | autocomplete top result into the search field |
|
||||||
| `↑` / `↓` | navigate results |
|
| `↑` / `↓` | navigate results |
|
||||||
| `Enter` | connect to selected host |
|
| `Enter` | connect to selected host |
|
||||||
| `Esc` / `Ctrl+C` | quit |
|
| `e` | open inline editor to override user/port before connecting |
|
||||||
|
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
|
||||||
|
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
||||||
|
|
||||||
|
**Tag and kind filter** — press `Ctrl+F` to open a second input line:
|
||||||
|
|
||||||
|
```
|
||||||
|
Filter: tag:prod kind:vm
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter.
|
||||||
|
|
||||||
|
**User/port override** — press `e` on any highlighted host:
|
||||||
|
|
||||||
|
```
|
||||||
|
Connect as: admin@my-router:22
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. Port 22 is treated as default and omitted from the ssh command.
|
||||||
|
|
||||||
### Cache management
|
### Cache management
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
netssh cache list # show all cached entries
|
netssh cache list # show all cached entries
|
||||||
netssh cache refresh # re-fetch all hosts from NetBox
|
netssh cache refresh # re-fetch ALL hosts from NetBox (paginated)
|
||||||
netssh cache clear # wipe the cache
|
netssh cache refresh --tag prod # only hosts with the "prod" tag
|
||||||
|
netssh cache refresh --kind vm # only virtual machines
|
||||||
|
netssh cache refresh --tag prod --kind vm # combine filters
|
||||||
|
netssh cache clear # wipe the cache
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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)
|
### Search (for scripting)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -198,6 +327,59 @@ autoload -Uz compinit && compinit
|
|||||||
|
|
||||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
Completions are served from the local cache — no network request on every `<Tab>`.
|
||||||
|
|
||||||
|
## Shell Hook
|
||||||
|
|
||||||
|
The shell hook runs `netssh shell-init` at the start of every new shell session. It checks whether the cache is older than 24 hours and, if so, starts a background refresh. The check reads a single small file and adds no measurable delay to your shell startup.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh hook install # auto-detects $SHELL
|
||||||
|
netssh hook install --shell bash
|
||||||
|
netssh hook install --shell zsh
|
||||||
|
netssh hook install --shell fish
|
||||||
|
```
|
||||||
|
|
||||||
|
This appends exactly one line to your shell profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh shell-init # netssh cache auto-refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
| Shell | Profile file |
|
||||||
|
|-------|-------------|
|
||||||
|
| bash | `~/.bashrc` |
|
||||||
|
| zsh | `~/.zshrc` |
|
||||||
|
| fish | `~/.config/fish/config.fish` |
|
||||||
|
|
||||||
|
The install is idempotent — running it again does nothing if the hook is already present.
|
||||||
|
|
||||||
|
Reload your profile after installation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source ~/.bashrc # bash
|
||||||
|
source ~/.zshrc # zsh
|
||||||
|
source ~/.config/fish/config.fish # fish
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh hook uninstall # auto-detects $SHELL
|
||||||
|
netssh hook uninstall --shell zsh
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes the `netssh shell-init` line from the profile and collapses any blank lines left behind.
|
||||||
|
|
||||||
|
### How it differs from the connect-time trigger
|
||||||
|
|
||||||
|
| Trigger | When it fires |
|
||||||
|
|---------|--------------|
|
||||||
|
| Connect / TUI start | On the next SSH command or `netssh` TUI after 24 h |
|
||||||
|
| Shell hook | On the first new shell session after 24 h |
|
||||||
|
|
||||||
|
Both triggers are non-blocking: the refresh runs in the background and your SSH connection (or prompt) is not delayed. You can install both — they share the same `~/.cache/netssh/last_refresh` timestamp, so the background process runs at most once per 24 hours regardless of how many shells or connections you open.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -205,13 +387,17 @@ go test ./... # run all tests
|
|||||||
go build ./... # build all packages
|
go build ./... # build all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, and the setup wizard.
|
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
|
## How it works
|
||||||
|
|
||||||
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
|
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
|
||||||
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
|
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`, …).
|
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
||||||
4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
|
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.
|
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.
|
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
|
||||||
|
|||||||
+309
-23
@@ -4,8 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,9 +17,11 @@ import (
|
|||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -27,6 +31,9 @@ var managedSubcommands = map[string]bool{
|
|||||||
"configure": true,
|
"configure": true,
|
||||||
"search": true,
|
"search": true,
|
||||||
"cache": true,
|
"cache": true,
|
||||||
|
"alias": true,
|
||||||
|
"hook": true,
|
||||||
|
"shell-init": true,
|
||||||
"completion": true,
|
"completion": true,
|
||||||
"__complete": true,
|
"__complete": true,
|
||||||
"help": true,
|
"help": true,
|
||||||
@@ -87,16 +94,42 @@ func runSSHWrapper(args []string) {
|
|||||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
||||||
parsed.DestIdx += 2
|
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 := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
if entry, fresh := c.Get(parsed.Host); fresh {
|
||||||
|
c.MarkUsed(parsed.Host)
|
||||||
|
_ = c.Save()
|
||||||
connect(entry.IP, parsed, args)
|
connect(entry.IP, parsed, args)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 == "" {
|
if cfg.NetBox.URL == "" {
|
||||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||||
}
|
}
|
||||||
@@ -105,18 +138,40 @@ func runSSHWrapper(args []string) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := nbClient.Search(ctx, parsed.Host)
|
entries, err := nbClient.Search(ctx, lookupHost, netbox.SearchOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("NetBox search failed: %v", err)
|
fatalf("NetBox search failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var target *netbox.HostEntry
|
var target *netbox.HostEntry
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
if strings.EqualFold(e.Name, parsed.Host) {
|
if strings.EqualFold(e.Name, lookupHost) {
|
||||||
target = &entries[i]
|
target = &entries[i]
|
||||||
break
|
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 {
|
if target == nil {
|
||||||
fatalf("host %q not found in NetBox", parsed.Host)
|
fatalf("host %q not found in NetBox", parsed.Host)
|
||||||
}
|
}
|
||||||
@@ -132,6 +187,7 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
|
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||||
|
c.MarkUsed(target.Name)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
|
|
||||||
connect(ip, parsed, args)
|
connect(ip, parsed, args)
|
||||||
@@ -150,13 +206,14 @@ func runTUI() {
|
|||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
var nbClient *netbox.Client
|
var nbClient *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.NetBox.URL != "" {
|
||||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.New(nbClient, c)
|
m := tui.New(nbClient, c, cfg.SSH.DefaultUser)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
final, err := p.Run()
|
final, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,11 +232,29 @@ func runTUI() {
|
|||||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
var sshArgs []string
|
var sshArgs []string
|
||||||
if cfg.SSH.DefaultUser != "" {
|
|
||||||
sshArgs = append(sshArgs, "-l", cfg.SSH.DefaultUser)
|
// User override from TUI edit mode takes priority, then config default.
|
||||||
|
user := host.User
|
||||||
|
if user == "" {
|
||||||
|
user = cfg.SSH.DefaultUser
|
||||||
}
|
}
|
||||||
|
if user != "" {
|
||||||
|
sshArgs = append(sshArgs, "-l", user)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
sshArgs = append(sshArgs, host.IP)
|
||||||
|
|
||||||
|
c.MarkUsed(host.Name)
|
||||||
|
_ = c.Save()
|
||||||
|
|
||||||
if err := internalssh.Exec(sshArgs); err != nil {
|
if err := internalssh.Exec(sshArgs); err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
}
|
}
|
||||||
@@ -199,18 +274,25 @@ func rootCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
entries := c.Search(toComplete)
|
|
||||||
names := make([]cobra.Completion, len(entries))
|
var completions []cobra.Completion
|
||||||
for i, e := range entries {
|
for _, e := range c.Search(toComplete) {
|
||||||
names[i] = cobra.Completion(e.Name)
|
completions = append(completions, cobra.Completion(e.Name))
|
||||||
}
|
}
|
||||||
return names, cobra.ShellCompDirectiveNoFileComp
|
// Subcommands at the end, after all hostnames.
|
||||||
|
for _, sub := range cmd.Commands() {
|
||||||
|
if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) {
|
||||||
|
completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// cobra automatically adds a "completion" subcommand; we extend it with "install".
|
root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
|
|
||||||
|
|
||||||
|
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||||
|
root.InitDefaultCompletionCmd()
|
||||||
for _, cmd := range root.Commands() {
|
for _, cmd := range root.Commands() {
|
||||||
if cmd.Name() == "completion" {
|
if cmd.Name() == "completion" {
|
||||||
cmd.AddCommand(completionInstallCmd(root))
|
cmd.AddCommand(completionInstallCmd(root))
|
||||||
@@ -244,7 +326,8 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
file = filepath.Join(dir, "netssh")
|
file = filepath.Join(dir, "netssh")
|
||||||
gen = func() ([]byte, error) {
|
gen = func() ([]byte, error) {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
return []byte(buf.String()), root.GenBashCompletionV2(&buf, true)
|
err := root.GenBashCompletionV2(&buf, true)
|
||||||
|
return []byte(buf.String()), err
|
||||||
}
|
}
|
||||||
note = "Reload your shell or run: source " + file
|
note = "Reload your shell or run: source " + file
|
||||||
case "zsh":
|
case "zsh":
|
||||||
@@ -252,7 +335,8 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
file = filepath.Join(dir, "_netssh")
|
file = filepath.Join(dir, "_netssh")
|
||||||
gen = func() ([]byte, error) {
|
gen = func() ([]byte, error) {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
return []byte(buf.String()), root.GenZshCompletion(&buf)
|
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"
|
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
|
||||||
case "fish":
|
case "fish":
|
||||||
@@ -261,7 +345,8 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
|||||||
file = filepath.Join(dir, "netssh.fish")
|
file = filepath.Join(dir, "netssh.fish")
|
||||||
gen = func() ([]byte, error) {
|
gen = func() ([]byte, error) {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
return []byte(buf.String()), root.GenFishCompletion(&buf, true)
|
err := root.GenFishCompletion(&buf, true)
|
||||||
|
return []byte(buf.String()), err
|
||||||
}
|
}
|
||||||
note = "Reload your shell or start a new fish session."
|
note = "Reload your shell or start a new fish session."
|
||||||
default:
|
default:
|
||||||
@@ -382,7 +467,8 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cacheRefreshCmd() *cobra.Command {
|
func cacheRefreshCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
var filterTag, filterKind string
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "refresh",
|
Use: "refresh",
|
||||||
Short: "Re-fetch all known hosts from NetBox",
|
Short: "Re-fetch all known hosts from NetBox",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -395,11 +481,11 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// An empty query returns up to 50 entries per type.
|
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
||||||
entries, err := nbClient.Search(ctx, "")
|
entries, err := nbClient.SearchAll(ctx, "", opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("NetBox: %w", err)
|
return fmt.Errorf("NetBox: %w", err)
|
||||||
}
|
}
|
||||||
@@ -410,13 +496,20 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
chain, _ := resolver.New(cfg.Resolver)
|
chain, _ := resolver.New(cfg.Resolver)
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
e := &entries[i]
|
e := &entries[i]
|
||||||
ip := e.PrimaryIP4
|
|
||||||
if ip == "" && chain != nil {
|
var ip string
|
||||||
resolved, err := chain.Resolve(ctx, e, nbClient)
|
if chain != nil {
|
||||||
if err == nil {
|
if resolved, err := chain.Resolve(ctx, e, nbClient); err == nil {
|
||||||
ip = resolved
|
ip = resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ip == "" {
|
||||||
|
ip = e.PrimaryIP4
|
||||||
|
}
|
||||||
|
if ip == "" {
|
||||||
|
ip = e.PrimaryIP6
|
||||||
|
}
|
||||||
|
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||||
}
|
}
|
||||||
@@ -425,10 +518,203 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := c.SetRefreshed(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
|
}
|
||||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
fmt.Printf("%d entries written to cache.\n", len(entries))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
||||||
|
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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 := c.All()
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].Name < entries[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deduplicate: first host wins when two normalize to the same alias.
|
||||||
|
seen := make(map[string]string) // alias name → canonical name
|
||||||
|
var lines []string
|
||||||
|
for _, e := range entries {
|
||||||
|
aliasName := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
||||||
|
if aliasName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prev, exists := seen[aliasName]; exists {
|
||||||
|
fmt.Fprintf(os.Stderr, "netssh: alias %q conflict: %s and %s — skipping %s\n", aliasName, prev, e.Name, e.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[aliasName] = e.Name
|
||||||
|
|
||||||
|
switch shell {
|
||||||
|
case "fish":
|
||||||
|
lines = append(lines, fmt.Sprintf("alias %s 'netssh %s'", aliasName, e.Name))
|
||||||
|
default:
|
||||||
|
lines = append(lines, fmt.Sprintf("alias %s='netssh %s'", aliasName, e.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 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(lines))
|
||||||
|
default:
|
||||||
|
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(lines))
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
fmt.Println(l)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellInitCmd is called at shell startup to trigger a background cache refresh when stale.
|
||||||
|
// It is intentionally silent — no output on success so it never disrupts shell startup.
|
||||||
|
func shellInitCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "shell-init",
|
||||||
|
Short: "Trigger a background cache refresh if stale (add to shell profile via 'hook install')",
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if !installed {
|
||||||
|
fmt.Printf("Hook already installed in %s\n", profile)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
|
||||||
|
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) {
|
func fatalf(format string, args ...any) {
|
||||||
|
|||||||
Vendored
+96
-8
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,13 +16,15 @@ type Entry struct {
|
|||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
CachedAt time.Time `json:"cached_at"`
|
CachedAt time.Time `json:"cached_at"`
|
||||||
|
LastUsed time.Time `json:"last_used,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
entries map[string]Entry
|
entries map[string]Entry
|
||||||
path string
|
path string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
refreshStamp string // path to last_refresh timestamp file
|
||||||
}
|
}
|
||||||
|
|
||||||
type diskFormat struct {
|
type diskFormat struct {
|
||||||
@@ -29,11 +32,48 @@ type diskFormat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(path string, ttlSeconds int) *Cache {
|
func New(path string, ttlSeconds int) *Cache {
|
||||||
return &Cache{
|
stamp := ""
|
||||||
entries: make(map[string]Entry),
|
if path != "" {
|
||||||
path: path,
|
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
|
||||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
func (c *Cache) Load() error {
|
||||||
@@ -86,6 +126,39 @@ func (c *Cache) Upsert(e Entry) {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkUsed records the current time as LastUsed for the named entry.
|
||||||
|
// It is a no-op if the entry does not exist.
|
||||||
|
func (c *Cache) MarkUsed(name string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if e, ok := c.entries[name]; ok {
|
||||||
|
e.LastUsed = time.Now()
|
||||||
|
c.entries[name] = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc.
|
||||||
|
// Entries that have never been used (LastUsed zero) are excluded.
|
||||||
|
// If n <= 0, all used entries are returned.
|
||||||
|
func (c *Cache) RecentlyUsed(n int) []Entry {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
var used []Entry
|
||||||
|
for _, e := range c.entries {
|
||||||
|
if !e.LastUsed.IsZero() {
|
||||||
|
used = append(used, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(used, func(i, j int) bool {
|
||||||
|
return used[i].LastUsed.After(used[j].LastUsed)
|
||||||
|
})
|
||||||
|
if n > 0 && len(used) > n {
|
||||||
|
used = used[:n]
|
||||||
|
}
|
||||||
|
return used
|
||||||
|
}
|
||||||
|
|
||||||
// Search returns all entries whose name starts with prefix (case-insensitive).
|
// Search returns all entries whose name starts with prefix (case-insensitive).
|
||||||
// TTL is intentionally ignored — this is used for shell completion.
|
// TTL is intentionally ignored — this is used for shell completion.
|
||||||
func (c *Cache) Search(prefix string) []Entry {
|
func (c *Cache) Search(prefix string) []Entry {
|
||||||
@@ -102,6 +175,21 @@ func (c *Cache) Search(prefix string) []Entry {
|
|||||||
return out
|
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.
|
// Get returns an entry and reports whether it is still within the TTL.
|
||||||
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
|
|||||||
Vendored
+238
@@ -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.
|
// tempFile writes content to a temp file and returns its path.
|
||||||
func tempFile(t *testing.T, content []byte) string {
|
func tempFile(t *testing.T, content []byte) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
SSH SSHConfig `mapstructure:"ssh"`
|
SSH SSHConfig `mapstructure:"ssh"`
|
||||||
|
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
@@ -35,6 +36,12 @@ type CacheConfig struct {
|
|||||||
|
|
||||||
type SSHConfig struct {
|
type SSHConfig struct {
|
||||||
DefaultUser string `mapstructure:"default_user"`
|
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.
|
// Path returns the canonical config file path.
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package hook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Marker is the unique string used to detect an existing hook installation.
|
||||||
|
Marker = "netssh shell-init"
|
||||||
|
// Line is the full line written into the shell profile.
|
||||||
|
Line = "netssh shell-init # netssh cache auto-refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall removes the hook line from the profile.
|
||||||
|
// Returns (true, nil) when removed, (false, nil) when the hook was not 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) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept []string
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.Contains(line, Marker) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+145
-29
@@ -31,47 +31,111 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search queries devices and VMs in parallel and merges the results.
|
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||||
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) {
|
// Use SearchOptions to restrict by kind or tag.
|
||||||
|
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results []HostEntry
|
results []HostEntry
|
||||||
errs []error
|
errs []error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
started int
|
||||||
)
|
)
|
||||||
|
|
||||||
wg.Add(2)
|
if opts.Kind != "vm" {
|
||||||
|
started++
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
devices, err := c.searchDevices(ctx, query, opts.Tag)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results = append(results, devices...)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
if opts.Kind != "device" {
|
||||||
defer wg.Done()
|
started++
|
||||||
devices, err := c.searchDevices(ctx, query)
|
wg.Add(1)
|
||||||
mu.Lock()
|
go func() {
|
||||||
defer mu.Unlock()
|
defer wg.Done()
|
||||||
if err != nil {
|
vms, err := c.searchVMs(ctx, query, opts.Tag)
|
||||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
mu.Lock()
|
||||||
return
|
defer mu.Unlock()
|
||||||
}
|
if err != nil {
|
||||||
results = append(results, devices...)
|
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||||
}()
|
return
|
||||||
|
}
|
||||||
go func() {
|
results = append(results, vms...)
|
||||||
defer wg.Done()
|
}()
|
||||||
vms, err := c.searchVMs(ctx, query)
|
}
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results = append(results, vms...)
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if len(errs) == 2 {
|
if len(errs) == started {
|
||||||
|
if started == 1 {
|
||||||
|
return nil, errs[0]
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||||
}
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||||
|
// Intended for cache refresh; use Search for interactive queries.
|
||||||
|
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
results []HostEntry
|
||||||
|
errs []error
|
||||||
|
wg sync.WaitGroup
|
||||||
|
started int
|
||||||
|
)
|
||||||
|
|
||||||
|
if opts.Kind != "vm" {
|
||||||
|
started++
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results = append(results, devices...)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Kind != "device" {
|
||||||
|
started++
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results = append(results, vms...)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(errs) == started {
|
||||||
|
if started == 1 {
|
||||||
|
return nil, errs[0]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||||
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +178,11 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
|
|||||||
return ips, nil
|
return ips, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry, error) {
|
func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||||
|
if tag != "" {
|
||||||
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||||
|
}
|
||||||
var resp netboxListResponse[netboxDevice]
|
var resp netboxListResponse[netboxDevice]
|
||||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -127,8 +194,11 @@ func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry,
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, error) {
|
func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||||
|
if tag != "" {
|
||||||
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||||
|
}
|
||||||
var resp netboxListResponse[netboxVM]
|
var resp netboxListResponse[netboxVM]
|
||||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -140,6 +210,52 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||||
|
const pageSize = 50
|
||||||
|
var all []HostEntry
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
|
||||||
|
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||||
|
if tag != "" {
|
||||||
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||||
|
}
|
||||||
|
var resp netboxListResponse[netboxDevice]
|
||||||
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, d := range resp.Results {
|
||||||
|
all = append(all, deviceToEntry(d))
|
||||||
|
}
|
||||||
|
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||||
|
const pageSize = 50
|
||||||
|
var all []HostEntry
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
|
||||||
|
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||||
|
if tag != "" {
|
||||||
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||||
|
}
|
||||||
|
var resp netboxListResponse[netboxVM]
|
||||||
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, v := range resp.Results {
|
||||||
|
all = append(all, vmToEntry(v))
|
||||||
|
}
|
||||||
|
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
|
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
|
||||||
func TokenVersion(token string) int {
|
func TokenVersion(token string) int {
|
||||||
if strings.HasPrefix(token, "nbt_") {
|
if strings.HasPrefix(token, "nbt_") {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "token", 0)
|
c := NewClient(srv.URL, "token", 0)
|
||||||
results, err := c.Search(context.Background(), "")
|
results, err := c.Search(context.Background(), "", SearchOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Search: %v", err)
|
t.Fatalf("Search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "token", 0)
|
c := NewClient(srv.URL, "token", 0)
|
||||||
results, _ := c.Search(context.Background(), "")
|
results, _ := c.Search(context.Background(), "", SearchOptions{})
|
||||||
|
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
switch r.Name {
|
switch r.Name {
|
||||||
@@ -115,7 +115,7 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "token", 0)
|
c := NewClient(srv.URL, "token", 0)
|
||||||
results, _ := c.Search(context.Background(), "host")
|
results, _ := c.Search(context.Background(), "host", SearchOptions{})
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
t.Fatal("expected at least one result")
|
t.Fatal("expected at least one result")
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ func TestSearch_TagsAreMapped(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "token", 0)
|
c := NewClient(srv.URL, "token", 0)
|
||||||
results, _ := c.Search(context.Background(), "")
|
results, _ := c.Search(context.Background(), "", SearchOptions{})
|
||||||
if len(results[0].Tags) != 2 {
|
if len(results[0].Tags) != 2 {
|
||||||
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
|
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "token", 0)
|
c := NewClient(srv.URL, "token", 0)
|
||||||
results, err := c.Search(context.Background(), "")
|
results, err := c.Search(context.Background(), "", SearchOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("partial failure should not return error, got: %v", err)
|
t.Fatalf("partial failure should not return error, got: %v", err)
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "token", 0)
|
c := NewClient(srv.URL, "token", 0)
|
||||||
_, err := c.Search(context.Background(), "")
|
_, err := c.Search(context.Background(), "", SearchOptions{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("both endpoints failing should return an error")
|
t.Error("both endpoints failing should return an error")
|
||||||
}
|
}
|
||||||
@@ -289,7 +289,7 @@ func Test403_V1Token_HintsUpgrade(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "legacytoken", 1)
|
c := NewClient(srv.URL, "legacytoken", 1)
|
||||||
_, err := c.Search(context.Background(), "host")
|
_, err := c.Search(context.Background(), "host", SearchOptions{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error on 403")
|
t.Fatal("expected error on 403")
|
||||||
}
|
}
|
||||||
@@ -305,7 +305,7 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "nbt_secret", 2)
|
c := NewClient(srv.URL, "nbt_secret", 2)
|
||||||
_, err := c.Search(context.Background(), "host")
|
_, err := c.Search(context.Background(), "host", SearchOptions{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error on 403")
|
t.Fatal("expected error on 403")
|
||||||
}
|
}
|
||||||
@@ -328,7 +328,7 @@ func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
||||||
c.Search(context.Background(), "") //nolint:errcheck
|
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
|
||||||
|
|
||||||
want := "Token nbt_mytoken"
|
want := "Token nbt_mytoken"
|
||||||
if gotAuth != want {
|
if gotAuth != want {
|
||||||
@@ -336,6 +336,88 @@ func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchAll_PaginatesResults(t *testing.T) {
|
||||||
|
// Simulate a NetBox endpoint with 3 total devices split across 2 pages.
|
||||||
|
// count=3 throughout; first page has 2 results, second has 1.
|
||||||
|
callCount := 0
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
callCount++
|
||||||
|
offset := r.URL.Query().Get("offset")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var resp netboxListResponse[netboxDevice]
|
||||||
|
switch offset {
|
||||||
|
case "", "0":
|
||||||
|
resp = netboxListResponse[netboxDevice]{
|
||||||
|
Count: 3,
|
||||||
|
Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
resp = netboxListResponse[netboxDevice]{
|
||||||
|
Count: 3,
|
||||||
|
Results: []netboxDevice{{ID: 3, Name: "d-03"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(resp)
|
||||||
|
w.Write(b)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
b, _ := json.Marshal(vmListResponse())
|
||||||
|
w.Write(b)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL, "token", 0)
|
||||||
|
results, err := c.SearchAll(context.Background(), "", SearchOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Errorf("SearchAll: got %d results, want 3", len(results))
|
||||||
|
}
|
||||||
|
if callCount < 2 {
|
||||||
|
t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_KindFilterDeviceOnly(t *testing.T) {
|
||||||
|
srv := newTestServer(t, map[string]any{
|
||||||
|
"/api/dcim/devices/": deviceListResponse(
|
||||||
|
netboxDevice{ID: 1, Name: "sw-01"},
|
||||||
|
),
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL, "token", 0)
|
||||||
|
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Name != "sw-01" {
|
||||||
|
t.Errorf("expected 1 device result, got %v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch_KindFilterVMOnly(t *testing.T) {
|
||||||
|
srv := newTestServer(t, map[string]any{
|
||||||
|
"/api/virtualization/virtual-machines/": vmListResponse(
|
||||||
|
netboxVM{ID: 1, Name: "vm-01"},
|
||||||
|
),
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL, "token", 0)
|
||||||
|
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Name != "vm-01" {
|
||||||
|
t.Errorf("expected 1 vm result, got %v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStripPrefix(t *testing.T) {
|
func TestStripPrefix(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
in string
|
in string
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package netbox
|
package netbox
|
||||||
|
|
||||||
|
// SearchOptions filters a Search or SearchAll query.
|
||||||
|
// Zero value means no filtering (return all kinds, no tag filter).
|
||||||
|
type SearchOptions struct {
|
||||||
|
Tag string // filter by tag slug; empty = no filter
|
||||||
|
Kind string // "device" | "vm" | "" (both)
|
||||||
|
}
|
||||||
|
|
||||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||||
type HostEntry struct {
|
type HostEntry struct {
|
||||||
ID int
|
ID int
|
||||||
|
|||||||
+106
-11
@@ -20,13 +20,19 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
url := cfg.NetBox.URL
|
url := cfg.NetBox.URL
|
||||||
token := cfg.NetBox.Token
|
token := cfg.NetBox.Token
|
||||||
defaultUser := cfg.SSH.DefaultUser
|
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, ", ")
|
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||||
interfaceName := cfg.Resolver.InterfaceName
|
interfaceName := cfg.Resolver.InterfaceName
|
||||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||||
|
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||||
|
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||||
|
|
||||||
if len(strategies) == 0 {
|
if strategiesRaw == "" {
|
||||||
strategies = []string{"primary_ip"}
|
strategiesRaw = "primary_ip"
|
||||||
}
|
}
|
||||||
if cacheTTL == "0" {
|
if cacheTTL == "0" {
|
||||||
cacheTTL = "3600"
|
cacheTTL = "3600"
|
||||||
@@ -62,18 +68,30 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Title("Default SSH user").
|
Title("Default SSH user").
|
||||||
Description("Leave empty to use your system user ($USER).").
|
Description("Leave empty to use your system user ($USER).").
|
||||||
Value(&defaultUser),
|
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"),
|
).Title("SSH defaults"),
|
||||||
|
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewMultiSelect[string]().
|
huh.NewInput().
|
||||||
Title("Resolver strategies").
|
Title("Resolver strategies").
|
||||||
Description("Order matters: first match wins.").
|
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
||||||
Options(
|
Placeholder("primary_ip, management_subnet").
|
||||||
huh.NewOption("primary_ip — NetBox primary IPv4/IPv6", "primary_ip"),
|
Value(&strategiesRaw).
|
||||||
huh.NewOption("management_subnet — first IP inside a subnet", "management_subnet"),
|
Validate(validateStrategies),
|
||||||
huh.NewOption("interface_name — IP on a named interface", "interface_name"),
|
|
||||||
).
|
|
||||||
Value(&strategies),
|
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Management subnets").
|
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.").
|
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
|
||||||
@@ -93,6 +111,18 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
).Title("Resolver & cache"),
|
).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 {
|
if err := form.Run(); err != nil {
|
||||||
@@ -103,6 +133,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strategies := parseStrategies(strategiesRaw)
|
||||||
tokenVersion := netbox.TokenVersion(token)
|
tokenVersion := netbox.TokenVersion(token)
|
||||||
if tokenVersion == 1 {
|
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, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
|
||||||
@@ -111,6 +142,11 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
|
|
||||||
ttl, _ := strconv.Atoi(cacheTTL)
|
ttl, _ := strconv.Atoi(cacheTTL)
|
||||||
|
|
||||||
|
port := 0
|
||||||
|
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
|
||||||
var subnetList []string
|
var subnetList []string
|
||||||
for _, s := range strings.Split(subnets, ",") {
|
for _, s := range strings.Split(subnets, ",") {
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
@@ -118,6 +154,16 @@ 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{
|
out := config.Config{
|
||||||
NetBox: config.NetBoxConfig{
|
NetBox: config.NetBoxConfig{
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||||
@@ -126,6 +172,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
},
|
},
|
||||||
SSH: config.SSHConfig{
|
SSH: config.SSHConfig{
|
||||||
DefaultUser: strings.TrimSpace(defaultUser),
|
DefaultUser: strings.TrimSpace(defaultUser),
|
||||||
|
DefaultPort: port,
|
||||||
},
|
},
|
||||||
Resolver: config.ResolverConfig{
|
Resolver: config.ResolverConfig{
|
||||||
Strategies: strategies,
|
Strategies: strategies,
|
||||||
@@ -135,11 +182,43 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Cache: config.CacheConfig{
|
Cache: config.CacheConfig{
|
||||||
TTL: ttl,
|
TTL: ttl,
|
||||||
},
|
},
|
||||||
|
Shortcuts: config.ShortcutsConfig{
|
||||||
|
Domains: domainList,
|
||||||
|
StripHyphens: stripHyphens,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return save(out)
|
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 {
|
func save(cfg config.Config) error {
|
||||||
path := config.Path()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
@@ -174,6 +253,22 @@ func save(cfg config.Config) error {
|
|||||||
if cfg.SSH.DefaultUser != "" {
|
if cfg.SSH.DefaultUser != "" {
|
||||||
fmt.Fprintf(&b, " default_user: %q\n", 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 {
|
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||||
return fmt.Errorf("writing config: %w", err)
|
return fmt.Errorf("writing config: %w", err)
|
||||||
|
|||||||
@@ -116,6 +116,210 @@ func TestSave_CreatesConfigDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestSave_RoundtripViaLoad(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
|||||||
@@ -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, ".", "_")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,19 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
|||||||
return result
|
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.
|
// HasUserFlag reports whether a user was specified via -l in args.
|
||||||
// Used to avoid overriding an explicit -l with the configured default user.
|
// Used to avoid overriding an explicit -l with the configured default user.
|
||||||
func HasUserFlag(args []string) bool {
|
func HasUserFlag(args []string) bool {
|
||||||
|
|||||||
@@ -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) {
|
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got == nil {
|
if got == nil {
|
||||||
|
|||||||
+260
-32
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ import (
|
|||||||
type SelectedHost struct {
|
type SelectedHost struct {
|
||||||
Name string
|
Name string
|
||||||
IP string
|
IP string
|
||||||
|
User string // empty = use config default
|
||||||
|
Port string // empty = use default port
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- bubbletea messages ---
|
// --- bubbletea messages ---
|
||||||
@@ -30,6 +33,7 @@ type searchResultMsg struct {
|
|||||||
query string
|
query string
|
||||||
entries []netbox.HostEntry
|
entries []netbox.HostEntry
|
||||||
err error
|
err error
|
||||||
|
recent bool // true when this is a recently-used list, not a search result
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- list item ---
|
// --- list item ---
|
||||||
@@ -38,6 +42,7 @@ type hostItem struct {
|
|||||||
name string
|
name string
|
||||||
ip string
|
ip string
|
||||||
kind string
|
kind string
|
||||||
|
tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h hostItem) Title() string { return h.name }
|
func (h hostItem) Title() string { return h.name }
|
||||||
@@ -63,27 +68,90 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
|
|||||||
fmt.Fprintln(w, line)
|
fmt.Fprintln(w, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- filter ---
|
||||||
|
|
||||||
|
type filterOpts struct {
|
||||||
|
tags []string
|
||||||
|
kind string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFilter(s string) filterOpts {
|
||||||
|
var f filterOpts
|
||||||
|
for _, part := range strings.Fields(s) {
|
||||||
|
if after, ok := strings.CutPrefix(part, "tag:"); ok {
|
||||||
|
f.tags = append(f.tags, after)
|
||||||
|
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
|
||||||
|
f.kind = after
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
||||||
|
if len(f.tags) == 0 && f.kind == "" {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
out := make([]netbox.HostEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if f.kind != "" && e.Kind != f.kind {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(f.tags) > 0 {
|
||||||
|
tagSet := make(map[string]bool, len(e.Tags))
|
||||||
|
for _, t := range e.Tags {
|
||||||
|
tagSet[strings.ToLower(t)] = true
|
||||||
|
}
|
||||||
|
allMatch := true
|
||||||
|
for _, want := range f.tags {
|
||||||
|
if !tagSet[strings.ToLower(want)] {
|
||||||
|
allMatch = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allMatch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// --- Model ---
|
// --- Model ---
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
list list.Model
|
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||||
client *netbox.Client
|
editInput textinput.Model // user@host:port inline editor
|
||||||
cache *cache.Cache
|
list list.Model
|
||||||
lastSent string // last query sent to NetBox (or served from cache)
|
client *netbox.Client
|
||||||
seq int // sequence number to discard stale results
|
cache *cache.Cache
|
||||||
loading bool
|
defaultUser string
|
||||||
err error
|
lastSent string // last query sent to NetBox (or served from cache)
|
||||||
selected *SelectedHost
|
lastResults []netbox.HostEntry // raw results before filter applied
|
||||||
width int
|
seq int // sequence number to discard stale results
|
||||||
height int
|
loading bool
|
||||||
|
err error
|
||||||
|
selected *SelectedHost
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||||
|
filterOpen bool // Ctrl+F toggles
|
||||||
|
editMode bool // 'e' on selected item
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client *netbox.Client, c *cache.Cache) *Model {
|
func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = "Search hostname…"
|
ti.Placeholder = "Search hostname…"
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
|
|
||||||
|
fi := textinput.New()
|
||||||
|
fi.Placeholder = "tag:prod kind:vm"
|
||||||
|
fi.Prompt = "Filter: "
|
||||||
|
|
||||||
|
ei := textinput.New()
|
||||||
|
ei.Prompt = ""
|
||||||
|
|
||||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
@@ -91,15 +159,34 @@ func New(client *netbox.Client, c *cache.Cache) *Model {
|
|||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(false)
|
||||||
|
|
||||||
return &Model{
|
return &Model{
|
||||||
input: ti,
|
input: ti,
|
||||||
list: l,
|
filterInput: fi,
|
||||||
client: client,
|
editInput: ei,
|
||||||
cache: c,
|
list: l,
|
||||||
|
client: client,
|
||||||
|
cache: c,
|
||||||
|
defaultUser: defaultUser,
|
||||||
|
recentMode: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
return textinput.Blink
|
return tea.Batch(textinput.Blink, m.loadRecent())
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
|
||||||
|
func (m *Model) loadRecent() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if m.cache == nil {
|
||||||
|
return searchResultMsg{query: "", entries: nil, recent: true}
|
||||||
|
}
|
||||||
|
recent := m.cache.RecentlyUsed(10)
|
||||||
|
entries := make([]netbox.HostEntry, len(recent))
|
||||||
|
for i, e := range recent {
|
||||||
|
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
|
||||||
|
}
|
||||||
|
return searchResultMsg{query: "", entries: entries, recent: true}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -108,22 +195,83 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
m.list.SetSize(msg.Width, msg.Height-4)
|
extraRows := 4
|
||||||
|
if m.filterOpen {
|
||||||
|
extraRows++
|
||||||
|
}
|
||||||
|
if m.editMode {
|
||||||
|
extraRows += 2
|
||||||
|
}
|
||||||
|
m.list.SetSize(msg.Width, msg.Height-extraRows)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
// --- Edit mode ---
|
||||||
|
if m.editMode {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.editMode = false
|
||||||
|
m.editInput.Blur()
|
||||||
|
m.input.Focus()
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
|
m.selected = m.buildSelected(item)
|
||||||
|
}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.editInput, cmd = m.editInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filter focused ---
|
||||||
|
if m.filterOpen {
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+f", "esc":
|
||||||
|
m.filterOpen = false
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.filterInput.SetValue("")
|
||||||
|
m.input.Focus()
|
||||||
|
m.updateListItems(m.lastResults)
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
m.filterOpen = false
|
||||||
|
m.filterInput.Blur()
|
||||||
|
m.input.Focus()
|
||||||
|
m.updateListItems(m.lastResults)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||||
|
m.updateListItems(m.lastResults)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Normal mode ---
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case "ctrl+f":
|
||||||
|
m.filterOpen = true
|
||||||
|
m.input.Blur()
|
||||||
|
m.filterInput.Focus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "e":
|
||||||
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
|
m.openEdit(item)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
// Copy the top result into the search field.
|
|
||||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||||
m.input.SetValue(item.name)
|
m.input.SetValue(item.name)
|
||||||
@@ -134,18 +282,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case debounceMsg:
|
case debounceMsg:
|
||||||
// Only query if the input has changed since the last request.
|
|
||||||
q := m.input.Value()
|
q := m.input.Value()
|
||||||
if q == m.lastSent {
|
if q == m.lastSent {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.lastSent = q
|
m.lastSent = q
|
||||||
|
m.recentMode = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
m.seq++
|
m.seq++
|
||||||
seq := m.seq
|
seq := m.seq
|
||||||
return m, m.doSearch(q, seq)
|
return m, m.doSearch(q, seq)
|
||||||
|
|
||||||
case searchResultMsg:
|
case searchResultMsg:
|
||||||
|
if msg.recent {
|
||||||
|
m.recentMode = true
|
||||||
|
m.lastResults = msg.entries
|
||||||
|
m.updateListItems(msg.entries)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if msg.query != m.lastSent {
|
if msg.query != m.lastSent {
|
||||||
return m, nil // discard stale result
|
return m, nil // discard stale result
|
||||||
}
|
}
|
||||||
@@ -154,20 +308,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
items := make([]list.Item, len(msg.entries))
|
m.lastResults = msg.entries
|
||||||
for i, e := range msg.entries {
|
m.updateListItems(msg.entries)
|
||||||
ip := e.PrimaryIP4
|
|
||||||
if ip == "" {
|
|
||||||
ip = e.PrimaryIP6
|
|
||||||
}
|
|
||||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind}
|
|
||||||
}
|
|
||||||
m.list.SetItems(items)
|
|
||||||
m.err = nil
|
m.err = nil
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward to text input and restart the debounce timer.
|
// Forward to search input and restart debounce timer.
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
m.input, inputCmd = m.input.Update(msg)
|
m.input, inputCmd = m.input.Update(msg)
|
||||||
@@ -188,14 +335,33 @@ func (m *Model) View() string {
|
|||||||
sb.WriteString(title + "\n\n")
|
sb.WriteString(title + "\n\n")
|
||||||
sb.WriteString(m.input.View() + "\n")
|
sb.WriteString(m.input.View() + "\n")
|
||||||
|
|
||||||
|
if m.filterOpen {
|
||||||
|
sb.WriteString(m.filterInput.View() + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
||||||
} else if m.err != nil {
|
} else if m.err != nil {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
||||||
} else {
|
} else {
|
||||||
|
if m.recentMode && m.input.Value() == "" {
|
||||||
|
if len(m.list.Items()) > 0 {
|
||||||
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
sb.WriteString(m.list.View())
|
sb.WriteString(m.list.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.editMode {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
||||||
|
sb.WriteString(m.editInput.View() + "\n")
|
||||||
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||||
|
} else {
|
||||||
|
hint := "ctrl+c quit enter connect e edit ctrl+f filter"
|
||||||
|
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,13 +377,14 @@ func (m *Model) startDebounce() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||||
|
opts := m.netboxSearchOpts()
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Return cache hits immediately without a network round-trip.
|
// Return cache hits immediately without a network round-trip.
|
||||||
if m.cache != nil {
|
if m.cache != nil {
|
||||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
if cached := m.cache.Search(query); len(cached) > 0 {
|
||||||
entries := make([]netbox.HostEntry, len(cached))
|
entries := make([]netbox.HostEntry, len(cached))
|
||||||
for i, c := range cached {
|
for i, c := range cached {
|
||||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind}
|
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
||||||
}
|
}
|
||||||
return searchResultMsg{query: query, entries: entries}
|
return searchResultMsg{query: query, entries: entries}
|
||||||
}
|
}
|
||||||
@@ -230,8 +397,69 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := m.client.Search(ctx, query)
|
entries, err := m.client.Search(ctx, query, opts)
|
||||||
_ = seq
|
_ = seq
|
||||||
return searchResultMsg{query: query, entries: entries, err: err}
|
return searchResultMsg{query: query, entries: entries, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// netboxSearchOpts derives SearchOptions from the current filter input.
|
||||||
|
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
|
||||||
|
f := parseFilter(m.filterInput.Value())
|
||||||
|
var tag string
|
||||||
|
if len(f.tags) > 0 {
|
||||||
|
tag = f.tags[0]
|
||||||
|
}
|
||||||
|
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateListItems applies the active filter and sets the list items.
|
||||||
|
func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||||
|
f := parseFilter(m.filterInput.Value())
|
||||||
|
filtered := applyFilter(entries, f)
|
||||||
|
items := make([]list.Item, len(filtered))
|
||||||
|
for i, e := range filtered {
|
||||||
|
ip := e.PrimaryIP4
|
||||||
|
if ip == "" {
|
||||||
|
ip = e.PrimaryIP6
|
||||||
|
}
|
||||||
|
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||||
|
}
|
||||||
|
m.list.SetItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openEdit switches to edit mode for the given list item.
|
||||||
|
func (m *Model) openEdit(item hostItem) {
|
||||||
|
m.editMode = true
|
||||||
|
m.input.Blur()
|
||||||
|
|
||||||
|
user := m.defaultUser
|
||||||
|
if user == "" {
|
||||||
|
user = item.name
|
||||||
|
}
|
||||||
|
m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name))
|
||||||
|
m.editInput.CursorEnd()
|
||||||
|
m.editInput.Focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSelected parses the editInput to extract user/port overrides.
|
||||||
|
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||||
|
s := strings.TrimSpace(m.editInput.Value())
|
||||||
|
sel := &SelectedHost{Name: item.name, IP: item.ip}
|
||||||
|
|
||||||
|
// Extract user: everything before @
|
||||||
|
if idx := strings.Index(s, "@"); idx != -1 {
|
||||||
|
sel.User = strings.TrimSpace(s[:idx])
|
||||||
|
s = s[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract port: after the last colon, if it's a number
|
||||||
|
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
||||||
|
portStr := strings.TrimSpace(s[idx+1:])
|
||||||
|
if _, err := strconv.Atoi(portStr); err == nil && portStr != "22" {
|
||||||
|
sel.Port = portStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user