Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cc64fdd9 | ||
|
|
2852dff2c4 | ||
|
|
2d11303014 | ||
|
|
5c6cc7dbbf | ||
|
|
b17ace7fc5 | ||
|
|
8241a064f4 | ||
|
|
86fc2f19eb | ||
|
|
fa646f25a6 | ||
|
|
7c902cab3a |
No files matched your search
@@ -10,9 +10,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
@@ -20,13 +20,18 @@ netssh -p 2222 admin@app-server-03 uptime
|
||||
- **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
|
||||
- **User/port override** — press `Ctrl+E` in the TUI to override the SSH user or port before connecting
|
||||
- **Server notes** — multiline local notes or optional NetBox comments, available in the TUI and CLI
|
||||
- **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`
|
||||
- **Shell completion** — install without sudo via `netssh completion install`
|
||||
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
||||
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
|
||||
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
|
||||
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
||||
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -94,6 +99,12 @@ cache:
|
||||
|
||||
ssh:
|
||||
default_user: admin # used when no user is specified on the command line
|
||||
default_port: 2222 # optional; injected as -p if not specified on the command line
|
||||
|
||||
shortcuts:
|
||||
domains: # strip these suffixes to create short aliases
|
||||
- .example.com
|
||||
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
||||
```
|
||||
|
||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||
@@ -139,6 +150,76 @@ netssh root@my-router # user@ prefix wins → ssh root@10.0.0.1
|
||||
netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1
|
||||
```
|
||||
|
||||
### Default SSH port
|
||||
|
||||
Set `ssh.default_port` in the config to use a non-standard port without specifying it every time:
|
||||
|
||||
```sh
|
||||
netssh my-router # → ssh -p 2222 -l admin 10.0.0.1
|
||||
```
|
||||
|
||||
The default is only applied when `-p` is not already present in the SSH arguments. An explicit `-p` always takes precedence:
|
||||
|
||||
```sh
|
||||
netssh -p 22 my-router # -p flag wins → ssh -p 22 10.0.0.1
|
||||
```
|
||||
|
||||
### Host shortcuts
|
||||
|
||||
Configure short aliases so you can type `web01` instead of `web01.example.com`:
|
||||
|
||||
```yaml
|
||||
shortcuts:
|
||||
domains:
|
||||
- .example.com
|
||||
- .example.de
|
||||
strip_hyphens: false # optional
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. **Exact match** — the input is looked up as-is in the cache.
|
||||
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
||||
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
||||
|
||||
| Input | Config | Resolved as |
|
||||
|-------|--------|-------------|
|
||||
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
||||
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
||||
|
||||
### Shell aliases
|
||||
|
||||
Generate shell aliases for all cached hosts based on the shortcut settings:
|
||||
|
||||
```sh
|
||||
netssh alias # bash/zsh syntax
|
||||
netssh alias --shell fish # fish syntax
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```bash
|
||||
# netssh aliases (3 hosts) — source with: eval "$(netssh alias)"
|
||||
alias db01='netssh db01.example.com'
|
||||
alias fsn1web01='netssh fsn1-web01.example.com'
|
||||
alias web01='netssh web01.example.com'
|
||||
```
|
||||
|
||||
Add to your shell startup file:
|
||||
|
||||
```sh
|
||||
# bash / zsh — in ~/.bashrc or ~/.zshrc
|
||||
eval "$(netssh alias)"
|
||||
|
||||
# fish — in ~/.config/fish/config.fish
|
||||
netssh alias --shell fish | source
|
||||
|
||||
# scripts
|
||||
source <(netssh alias)
|
||||
```
|
||||
|
||||
Without a `shortcuts` config, the alias name is derived from the full hostname with dots replaced by underscores (e.g. `web01_example_com`). If two hosts would produce the same alias name, the first entry wins and a warning is printed to stderr.
|
||||
|
||||
### Interactive TUI
|
||||
|
||||
Run without arguments to open the interactive search:
|
||||
@@ -155,7 +236,9 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
|
||||
| `Tab` | autocomplete top result into the search field |
|
||||
| `↑` / `↓` | navigate results |
|
||||
| `Enter` | connect to selected host |
|
||||
| `e` | open inline editor to override user/port before connecting |
|
||||
| `Ctrl+E` | open inline editor to override user/port before connecting |
|
||||
| `Ctrl+N` | edit local notes for the selected server |
|
||||
| `Ctrl+B` | edit NetBox comments for the selected server |
|
||||
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
|
||||
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
||||
|
||||
@@ -167,13 +250,48 @@ 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:
|
||||
**User/port override** — press `Ctrl+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.
|
||||
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. The configured default port is pre-filled. An explicit port 22 overrides a different configured default.
|
||||
|
||||
### Server notes
|
||||
|
||||
Select a server in the TUI and press `Ctrl+N` for local notes, or `Ctrl+B` for
|
||||
NetBox comments. Both open a multiline editor: `Ctrl+S` saves, `Esc` cancels.
|
||||
Existing text is loaded before editing. A failed save keeps your draft in the editor.
|
||||
Local notes and NetBox comments are independent; there is no automatic synchronization.
|
||||
|
||||
The CLI also supports reading, replacing, and appending notes:
|
||||
|
||||
```sh
|
||||
netssh notes set web01 "Maintenance on Friday"
|
||||
netssh notes append web01 "Reboot completed"
|
||||
netssh notes show web01
|
||||
printf 'First line\nSecond line\n' | netssh notes set web01
|
||||
netssh notes append web01 --netbox --kind vm "Maintenance completed"
|
||||
netssh notes show web01 --netbox --kind vm
|
||||
```
|
||||
|
||||
`set` replaces the selected note; `append` preserves existing text and adds a new line.
|
||||
Omitting the text reads it from standard input. An empty string clears the note.
|
||||
For an uncached local host, specify `--kind device` or `--kind vm`; local operations
|
||||
work offline. Use the full server name as displayed in the TUI.
|
||||
|
||||
Local notes live under `~/.config/netssh/notes/` (`$XDG_CONFIG_HOME` is respected),
|
||||
with file permissions `0600` and atomic file replacement. They are keyed by NetBox
|
||||
URL, host kind, and hostname, so cache refreshes and `cache clear` preserve them.
|
||||
Changing the hostname or NetBox URL creates a separate note identity.
|
||||
|
||||
`--netbox` explicitly selects the device/VM's shared `comments` field and requires
|
||||
a token with write permissions. Existing comments are loaded before modification;
|
||||
ambiguous names are rejected (specify `--kind` where appropriate).
|
||||
The client uses the API's ETag/If-Match conflict protection when the server provides
|
||||
it; older NetBox versions without ETags use the server's last-write-wins behavior.
|
||||
See the [NetBox REST API documentation](https://netbox.readthedocs.io/en/stable/integrations/rest-api/).
|
||||
|
||||
### Cache management
|
||||
|
||||
@@ -186,6 +304,25 @@ netssh cache refresh --tag prod --kind vm # combine filters
|
||||
netssh cache clear # wipe the cache
|
||||
```
|
||||
|
||||
The cache is refreshed automatically in the background every 24 hours. The trigger fires on the next SSH connect or TUI start after the interval has elapsed — there is no delay to the connection itself. The timestamp of the last refresh is stored in `~/.cache/netssh/last_refresh`.
|
||||
|
||||
To also trigger the check at shell startup (before you run any SSH command), install the shell hook:
|
||||
|
||||
```sh
|
||||
netssh hook install # auto-detects $SHELL
|
||||
netssh hook install --shell bash
|
||||
netssh hook install --shell zsh
|
||||
netssh hook install --shell fish
|
||||
```
|
||||
|
||||
This appends a single line (`netssh shell-init`) to your shell profile. To remove it:
|
||||
|
||||
```sh
|
||||
netssh hook uninstall
|
||||
```
|
||||
|
||||
The hook is a no-op when the cache is fresh — it adds no measurable delay to your shell startup.
|
||||
|
||||
### Search (for scripting)
|
||||
|
||||
```sh
|
||||
@@ -228,6 +365,95 @@ autoload -Uz compinit && compinit
|
||||
|
||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
||||
|
||||
## Shell Hook
|
||||
|
||||
`netssh hook install` sets up two things in your shell profile:
|
||||
|
||||
1. **`netssh shell-init`** — runs on every new shell session. It regenerates the alias file from the current cache so your aliases are always up to date, and starts a background cache refresh if the last full refresh was more than 24 hours ago.
|
||||
2. **A `source` line** (bash/zsh only) — loads the generated alias file so shortcuts like `web01` work immediately in every new shell. Fish uses `conf.d/` auto-sourcing instead, so no explicit source line is needed.
|
||||
|
||||
The alias file is also generated immediately during `hook install`, so aliases are available as soon as you reload your profile — no `cache refresh` needed first (though the cache must not be empty).
|
||||
|
||||
### Install
|
||||
|
||||
```sh
|
||||
netssh hook install # auto-detects $SHELL
|
||||
netssh hook install --shell bash
|
||||
netssh hook install --shell zsh
|
||||
netssh hook install --shell fish
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Hook installed → /home/user/.bashrc
|
||||
Reload with: source /home/user/.bashrc
|
||||
42 aliases written to /home/user/.cache/netssh/aliases.sh
|
||||
```
|
||||
|
||||
What gets written to the profile:
|
||||
|
||||
**bash / zsh** (`~/.bashrc` or `~/.zshrc`):
|
||||
```bash
|
||||
netssh shell-init # netssh cache auto-refresh
|
||||
source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases
|
||||
```
|
||||
|
||||
**fish** (`~/.config/fish/config.fish`):
|
||||
```fish
|
||||
netssh shell-init # netssh cache auto-refresh
|
||||
```
|
||||
|
||||
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
|
||||
|
||||
| Shell | Profile file | Alias file |
|
||||
|-------|-------------|------------|
|
||||
| bash | `~/.bashrc` | `~/.cache/netssh/aliases.sh` |
|
||||
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
|
||||
| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` |
|
||||
|
||||
The install is idempotent — running it again changes nothing if the lines are already present.
|
||||
|
||||
Reload your profile after installation to activate immediately:
|
||||
|
||||
```sh
|
||||
source ~/.bashrc # bash
|
||||
source ~/.zshrc # zsh
|
||||
source ~/.config/fish/config.fish # fish
|
||||
```
|
||||
|
||||
### Alias file updates
|
||||
|
||||
The alias file is regenerated automatically — no manual action needed:
|
||||
|
||||
| Event | Aliases updated |
|
||||
|-------|----------------|
|
||||
| New shell session (`shell-init`) | Yes — from the current local cache |
|
||||
| `netssh cache refresh` | Yes — after the full refresh completes |
|
||||
| Background 24h auto-refresh | Yes — after the background refresh completes |
|
||||
|
||||
Aliases for newly added hosts appear in the next shell session after a `cache refresh` has run.
|
||||
|
||||
### Uninstall
|
||||
|
||||
```sh
|
||||
netssh hook uninstall # auto-detects $SHELL
|
||||
netssh hook uninstall --shell zsh
|
||||
```
|
||||
|
||||
Removes both the `netssh shell-init` line and the `source aliases.sh` line from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk — delete them manually if desired.
|
||||
|
||||
### Relation to the connect-time auto-refresh
|
||||
|
||||
The shell hook and the connect-time trigger (which fires on each `netssh` invocation) are independent but complementary:
|
||||
|
||||
| Trigger | When it fires |
|
||||
|---------|--------------|
|
||||
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
|
||||
| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed |
|
||||
|
||||
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
@@ -235,7 +461,7 @@ go test ./... # run all tests
|
||||
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
|
||||
|
||||
@@ -246,6 +472,6 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
|
||||
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
|
||||
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
|
||||
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
||||
4. It 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.
|
||||
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
|
||||
+326
-8
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -15,9 +17,12 @@ import (
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
||||
)
|
||||
@@ -25,8 +30,12 @@ import (
|
||||
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
||||
var managedSubcommands = map[string]bool{
|
||||
"configure": true,
|
||||
"notes": true,
|
||||
"search": true,
|
||||
"cache": true,
|
||||
"alias": true,
|
||||
"hook": true,
|
||||
"shell-init": true,
|
||||
"completion": true,
|
||||
"__complete": true,
|
||||
"help": true,
|
||||
@@ -87,9 +96,15 @@ func runSSHWrapper(args []string) {
|
||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
||||
parsed.DestIdx += 2
|
||||
}
|
||||
// Inject the configured default port if none was given on the command line.
|
||||
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
|
||||
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
|
||||
parsed.DestIdx += 2
|
||||
}
|
||||
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
||||
@@ -99,6 +114,24 @@ func runSSHWrapper(args []string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||
lookupHost := parsed.Host
|
||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
||||
if shortcutsEnabled {
|
||||
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
|
||||
if entry, found, fresh := c.GetByShortcut(lookupHost, normalize); found {
|
||||
if fresh {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
connect(entry.IP, parsed, args)
|
||||
return
|
||||
}
|
||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||
lookupHost = entry.Name
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.NetBox.URL == "" {
|
||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||
}
|
||||
@@ -107,18 +140,40 @@ func runSSHWrapper(args []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := nbClient.Search(ctx, parsed.Host, netbox.SearchOptions{})
|
||||
entries, err := nbClient.Search(ctx, lookupHost, netbox.SearchOptions{})
|
||||
if err != nil {
|
||||
fatalf("NetBox search failed: %v", err)
|
||||
}
|
||||
|
||||
var target *netbox.HostEntry
|
||||
for i, e := range entries {
|
||||
if strings.EqualFold(e.Name, parsed.Host) {
|
||||
if strings.EqualFold(e.Name, lookupHost) {
|
||||
target = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Domain expansion: if still no match and the input has no dots, try appending
|
||||
// each configured domain suffix and re-querying NetBox.
|
||||
if target == nil && !strings.Contains(parsed.Host, ".") && len(cfg.Shortcuts.Domains) > 0 {
|
||||
for _, domain := range cfg.Shortcuts.Domains {
|
||||
expanded := parsed.Host + domain
|
||||
expandedEntries, searchErr := nbClient.Search(ctx, expanded, netbox.SearchOptions{})
|
||||
if searchErr != nil {
|
||||
continue
|
||||
}
|
||||
for i, e := range expandedEntries {
|
||||
if strings.EqualFold(e.Name, expanded) {
|
||||
target = &expandedEntries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if target == nil {
|
||||
fatalf("host %q not found in NetBox", parsed.Host)
|
||||
}
|
||||
@@ -153,13 +208,15 @@ func runTUI() {
|
||||
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
var nbClient *netbox.Client
|
||||
if cfg.NetBox.URL != "" {
|
||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
}
|
||||
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser)
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
@@ -175,6 +232,29 @@ func runTUI() {
|
||||
return
|
||||
}
|
||||
|
||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||
if cached, fresh := c.Get(host.Name); fresh {
|
||||
host.IP = cached.IP
|
||||
} else {
|
||||
if nbClient == nil {
|
||||
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
target, err := nbClient.FindHost(ctx, host.Name, host.Kind)
|
||||
if err != nil {
|
||||
fatalf("NetBox: %v", err)
|
||||
}
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
if err != nil {
|
||||
fatalf("resolver: %v", err)
|
||||
}
|
||||
host.IP, err = chain.Resolve(ctx, &target, nbClient)
|
||||
if err != nil {
|
||||
fatalf("IP resolution: %v", err)
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
|
||||
var sshArgs []string
|
||||
@@ -188,8 +268,12 @@ func runTUI() {
|
||||
sshArgs = append(sshArgs, "-l", user)
|
||||
}
|
||||
|
||||
if host.Port != "" {
|
||||
sshArgs = append(sshArgs, "-p", host.Port)
|
||||
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)
|
||||
@@ -231,7 +315,7 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
@@ -435,7 +519,11 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
chain, _ := resolver.New(cfg.Resolver)
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolver: %w", err)
|
||||
}
|
||||
written := 0
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
|
||||
@@ -454,13 +542,24 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
|
||||
if ip != "" {
|
||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||
written++
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
||||
if filterTag == "" && filterKind == "" {
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||
}
|
||||
}
|
||||
if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 {
|
||||
if err := hook.WriteAliasFiles(aliasEntries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d entries written to cache.\n", written)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -469,6 +568,225 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
|
||||
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
||||
all := c.All()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
|
||||
|
||||
seen := make(map[string]string)
|
||||
var out []hook.AliasEntry
|
||||
for _, e := range all {
|
||||
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = e.Name
|
||||
out = append(out, hook.AliasEntry{Name: name, Host: e.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func aliasCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "alias",
|
||||
Short: "Print shell aliases for all cached hosts",
|
||||
Long: `Print shell alias definitions for all cached hosts.
|
||||
The alias name is the shortened form derived from the configured shortcuts
|
||||
(domain suffixes stripped, hyphens optionally stripped).
|
||||
|
||||
Source the output in your shell profile:
|
||||
bash/zsh: eval "$(netssh alias)"
|
||||
fish: netssh alias --shell fish | source
|
||||
|
||||
Or use in a script:
|
||||
source <(netssh alias)`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
|
||||
entries := buildAliasEntries(c, *cfg)
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
|
||||
return nil
|
||||
}
|
||||
|
||||
switch shell {
|
||||
case "fish":
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
|
||||
for _, e := range entries {
|
||||
fmt.Printf("alias %s 'netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
default:
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
||||
for _, e := range entries {
|
||||
fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||
func shellInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "shell-init",
|
||||
Short: "Regenerate alias files and trigger a background cache refresh if stale",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil || cfg.NetBox.URL == "" {
|
||||
return nil
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
// Regenerate alias files from the current cache every shell start
|
||||
// so aliases are always up-to-date even before a refresh completes.
|
||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||
_ = hook.WriteAliasFiles(entries)
|
||||
}
|
||||
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func hookCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "hook",
|
||||
Short: "Manage shell hooks for automatic cache refresh at shell startup",
|
||||
}
|
||||
cmd.AddCommand(hookInstallCmd(), hookUninstallCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hookInstallCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Add netssh shell-init to your shell profile",
|
||||
Long: `Appends a single line to your shell profile that runs 'netssh shell-init'
|
||||
on every new shell session. shell-init checks whether the cache is older
|
||||
than 24 hours and, if so, starts a background refresh — no delay to your prompt.
|
||||
|
||||
After installation, reload your profile or open a new shell.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installed, err := hook.Install(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For bash/zsh: also add the source line so aliases are loaded on startup.
|
||||
// Fish uses conf.d auto-sourcing — no explicit source line needed.
|
||||
if shell != "fish" {
|
||||
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !installed {
|
||||
fmt.Printf("Hook already installed in %s\n", profile)
|
||||
} else {
|
||||
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
|
||||
}
|
||||
|
||||
// Generate the aliases file immediately so it is ready on the next shell start.
|
||||
cfg, cfgErr := config.Load()
|
||||
if cfgErr == nil && cfg.NetBox.URL != "" {
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
if _ = c.Load(); true {
|
||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath())
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hookUninstallCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove netssh shell-init from your shell profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !removed {
|
||||
fmt.Printf("No hook found in %s\n", profile)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Hook removed from %s\n", profile)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
||||
// has not been fully refreshed within the last 24 hours.
|
||||
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
||||
if cfg.NetBox.URL == "" {
|
||||
return
|
||||
}
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
return
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(self, "cache", "refresh")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
_ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func notesCmd() *cobra.Command {
|
||||
var remote bool
|
||||
var kind string
|
||||
cmd := &cobra.Command{Use: "notes", Short: "Read and write server notes (local by default)"}
|
||||
cmd.PersistentFlags().BoolVar(&remote, "netbox", false, "Use the NetBox comments field instead of local notes")
|
||||
cmd.PersistentFlags().StringVar(&kind, "kind", "", "Host kind: device or vm (required for uncached local hosts)")
|
||||
for _, action := range []string{"show", "set", "append"} {
|
||||
sub := &cobra.Command{Use: action + " <host> [text]", Short: action + " a server note", Args: cobra.RangeArgs(1, 2)}
|
||||
if action == "show" {
|
||||
sub.Args = cobra.ExactArgs(1)
|
||||
}
|
||||
sub.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if kind != "" && kind != "vm" && kind != "device" {
|
||||
return fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var client *netbox.Client
|
||||
if cfg.NetBox.URL != "" {
|
||||
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
}
|
||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
var cached []cache.Entry
|
||||
if !remote {
|
||||
cached = c.All()
|
||||
}
|
||||
for _, entry := range cached {
|
||||
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
|
||||
host.Name = entry.Name
|
||||
host.Kind = entry.Kind
|
||||
break
|
||||
}
|
||||
}
|
||||
if !remote && host.Kind == "" {
|
||||
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
service := notes.New(cfg.NetBox.URL, client)
|
||||
note, err := service.Read(ctx, host, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == "show" {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), note.Text)
|
||||
return err
|
||||
}
|
||||
var text string
|
||||
if len(args) == 2 {
|
||||
text = args[1]
|
||||
} else {
|
||||
data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1024*1024+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > 1024*1024 {
|
||||
return fmt.Errorf("note exceeds 1 MiB")
|
||||
}
|
||||
text = string(data)
|
||||
}
|
||||
if action == "append" && note.Text != "" {
|
||||
note.Text = strings.TrimRight(note.Text, "\n") + "\n" + text
|
||||
} else {
|
||||
note.Text = text
|
||||
}
|
||||
return service.Write(ctx, note, remote)
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalNotesCLI(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||
run := func(args []string, input string) (string, error) {
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetIn(strings.NewReader(input))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
if _, err := run([]string{"notes", "set", "web01", "--kind", "vm"}, "First\nSecond"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := run([]string{"notes", "append", "web01", "Third", "--kind", "vm"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||
if err != nil || out != "First\nSecond\nThird" {
|
||||
t.Fatalf("show: %q, %v", out, err)
|
||||
}
|
||||
if _, err := run([]string{"notes", "set", "web01", "", "--kind", "vm"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||
if err != nil || out != "" {
|
||||
t.Fatalf("clear: %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
Vendored
+66
-9
@@ -2,6 +2,7 @@ package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -20,10 +21,11 @@ type Entry struct {
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]Entry
|
||||
path string
|
||||
ttl time.Duration
|
||||
mu sync.RWMutex
|
||||
entries map[string]Entry
|
||||
path string
|
||||
ttl time.Duration
|
||||
refreshStamp string // path to last_refresh timestamp file
|
||||
}
|
||||
|
||||
type diskFormat struct {
|
||||
@@ -31,11 +33,48 @@ type diskFormat struct {
|
||||
}
|
||||
|
||||
func New(path string, ttlSeconds int) *Cache {
|
||||
return &Cache{
|
||||
entries: make(map[string]Entry),
|
||||
path: path,
|
||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||
stamp := ""
|
||||
if path != "" {
|
||||
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
|
||||
}
|
||||
return &Cache{
|
||||
entries: make(map[string]Entry),
|
||||
path: path,
|
||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||
refreshStamp: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the last full cache refresh (via `cache refresh`)
|
||||
// is older than d, or has never happened. Always returns false when no path is set.
|
||||
func (c *Cache) NeedsRefresh(d time.Duration) bool {
|
||||
if c.refreshStamp == "" {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(c.refreshStamp)
|
||||
if err != nil {
|
||||
return true // file missing → never refreshed
|
||||
}
|
||||
var t time.Time
|
||||
if err := t.UnmarshalText(data); err != nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(t) >= d
|
||||
}
|
||||
|
||||
// SetRefreshed records the current time as the last successful full refresh.
|
||||
func (c *Cache) SetRefreshed() error {
|
||||
if c.refreshStamp == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := time.Now().MarshalText()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(c.refreshStamp), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.refreshStamp, data, 0o644)
|
||||
}
|
||||
|
||||
func (c *Cache) Load() error {
|
||||
@@ -78,12 +117,15 @@ func (c *Cache) Save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0o644)
|
||||
return storage.WriteFile(c.path, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Cache) Upsert(e Entry) {
|
||||
e.CachedAt = time.Now()
|
||||
c.mu.Lock()
|
||||
if old, ok := c.entries[e.Name]; ok && e.LastUsed.IsZero() {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
c.entries[e.Name] = e
|
||||
c.mu.Unlock()
|
||||
}
|
||||
@@ -137,6 +179,21 @@ func (c *Cache) Search(prefix string) []Entry {
|
||||
return out
|
||||
}
|
||||
|
||||
// GetByShortcut scans all entries and returns the first whose normalized name matches
|
||||
// the normalized shortcut. Returns (entry, found, fresh).
|
||||
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
norm := normalize(shortcut)
|
||||
for _, e := range c.entries {
|
||||
if normalize(e.Name) == norm {
|
||||
isFresh := c.ttl > 0 && time.Since(e.CachedAt) < c.ttl
|
||||
return e, true, isFresh
|
||||
}
|
||||
}
|
||||
return Entry{}, false, false
|
||||
}
|
||||
|
||||
// Get returns an entry and reports whether it is still within the TTL.
|
||||
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||
c.mu.RLock()
|
||||
|
||||
Vendored
+170
@@ -300,6 +300,164 @@ func TestMarkUsed_RoundtripViaSave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetByShortcut tests ---
|
||||
|
||||
func TestGetByShortcut_MatchFresh(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
normalize := func(s string) string {
|
||||
// strip .example.com
|
||||
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
|
||||
s = s[:len(s)-len(".example.com")]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
e, found, fresh := c.GetByShortcut("web01", normalize)
|
||||
if !found {
|
||||
t.Fatal("expected entry to be found")
|
||||
}
|
||||
if !fresh {
|
||||
t.Error("entry just inserted should be fresh")
|
||||
}
|
||||
if e.IP != "10.0.0.1" {
|
||||
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_MatchStale(t *testing.T) {
|
||||
c := New("", 1) // 1 second TTL
|
||||
stale := Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
|
||||
c.mu.Lock()
|
||||
c.entries["web01.example.com"] = stale
|
||||
c.mu.Unlock()
|
||||
|
||||
normalize := func(s string) string {
|
||||
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
|
||||
s = s[:len(s)-len(".example.com")]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
_, found, fresh := c.GetByShortcut("web01", normalize)
|
||||
if !found {
|
||||
t.Fatal("expected entry to be found even when stale")
|
||||
}
|
||||
if fresh {
|
||||
t.Error("entry older than TTL should not be fresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_NotFound(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
|
||||
|
||||
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
||||
if found {
|
||||
t.Error("should not find an entry that does not match the shortcut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_EmptyCache(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
_, found, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
||||
if found {
|
||||
t.Error("empty cache should return found=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_MultiDomain(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
// Entry uses second domain (.example.de)
|
||||
c.Upsert(Entry{Name: "web01.example.de", IP: "10.0.0.5", Kind: "vm"})
|
||||
|
||||
normalize := func(s string) string {
|
||||
for _, suffix := range []string{".example.com", ".example.de"} {
|
||||
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
|
||||
return s[:len(s)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
e, found, _ := c.GetByShortcut("web01", normalize)
|
||||
if !found {
|
||||
t.Fatal("should match entry with second configured domain")
|
||||
}
|
||||
if e.IP != "10.0.0.5" {
|
||||
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
// --- NeedsRefresh / SetRefreshed tests ---
|
||||
// These tests require NeedsRefresh(time.Duration) bool and SetRefreshed() error
|
||||
// to be implemented on *Cache. The refreshStamp field (path to the timestamp file)
|
||||
// must be set before calling these methods.
|
||||
|
||||
func TestNeedsRefresh_NeverRefreshed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh should return true when last_refresh file does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRefresh_JustRefreshed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
t.Fatalf("SetRefreshed: %v", err)
|
||||
}
|
||||
if c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh should return false immediately after SetRefreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRefresh_Stale(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
// Write a timestamp 25 hours ago
|
||||
oldTime := time.Now().Add(-25 * time.Hour).Format(time.RFC3339)
|
||||
if err := os.WriteFile(stampPath, []byte(oldTime), 0o644); err != nil {
|
||||
t.Fatalf("writing stamp: %v", err)
|
||||
}
|
||||
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh should return true when last_refresh is older than duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRefreshed_Roundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
t.Fatalf("SetRefreshed: %v", err)
|
||||
}
|
||||
|
||||
// Just refreshed: 24h window → not stale
|
||||
if c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh(24h) should be false right after SetRefreshed")
|
||||
}
|
||||
|
||||
// Zero duration: everything is stale
|
||||
if !c.NeedsRefresh(0) {
|
||||
t.Error("NeedsRefresh(0) should always return true")
|
||||
}
|
||||
}
|
||||
|
||||
// tempFile writes content to a temp file and returns its path.
|
||||
func tempFile(t *testing.T, content []byte) string {
|
||||
t.Helper()
|
||||
@@ -313,3 +471,15 @@ func tempFile(t *testing.T, content []byte) string {
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestUpsertPreservesUsageHistory(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1"})
|
||||
c.MarkUsed("web01")
|
||||
before, _ := c.Get("web01")
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.2"})
|
||||
after, _ := c.Get("web01")
|
||||
if after.LastUsed != before.LastUsed || after.IP != "10.0.0.2" {
|
||||
t.Fatalf("history lost: %+v", after)
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
}
|
||||
|
||||
type NetBoxConfig struct {
|
||||
@@ -35,6 +36,12 @@ type CacheConfig struct {
|
||||
|
||||
type SSHConfig struct {
|
||||
DefaultUser string `mapstructure:"default_user"`
|
||||
DefaultPort int `mapstructure:"default_port"`
|
||||
}
|
||||
|
||||
type ShortcutsConfig struct {
|
||||
Domains []string `mapstructure:"domains"`
|
||||
StripHyphens bool `mapstructure:"strip_hyphens"`
|
||||
}
|
||||
|
||||
// Path returns the canonical config file path.
|
||||
@@ -62,7 +69,13 @@ func Load() (*Config, error) {
|
||||
}
|
||||
|
||||
v.SetEnvPrefix("NETSSH")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
|
||||
@@ -143,3 +143,17 @@ func TestPath_ReturnsNonEmpty(t *testing.T) {
|
||||
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_NETBOX_URL", "https://env.example")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "nbt_env")
|
||||
t.Setenv("NETSSH_SSH_DEFAULT_PORT", "2222")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.NetBox.URL != "https://env.example" || cfg.NetBox.Token != "nbt_env" || cfg.NetBox.TokenVersion != 2 || cfg.SSH.DefaultPort != 2222 {
|
||||
t.Fatalf("env ignored: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AliasEntry holds a single shell alias mapping.
|
||||
type AliasEntry struct {
|
||||
Name string // short alias name (e.g. "web01")
|
||||
Host string // canonical NetBox hostname (e.g. "web01.example.com")
|
||||
}
|
||||
|
||||
// AliasesPath returns the path for the bash/zsh aliases file.
|
||||
func AliasesPath() string {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
|
||||
}
|
||||
return filepath.Join(cacheDir, "netssh", "aliases.sh")
|
||||
}
|
||||
|
||||
// FishAliasesPath returns the path for the fish conf.d aliases file.
|
||||
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
|
||||
func FishAliasesPath() string {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(configDir, "fish", "conf.d", "netssh.fish")
|
||||
}
|
||||
|
||||
// WriteAliasFiles writes alias definitions to disk for all supported shells.
|
||||
//
|
||||
// The bash/zsh file (~/.cache/netssh/aliases.sh) is always written.
|
||||
// The fish file (~/.config/fish/conf.d/netssh.fish) is written only when
|
||||
// ~/.config/fish/ already exists (i.e. fish is configured on this system).
|
||||
func WriteAliasFiles(entries []AliasEntry) error {
|
||||
if err := writeShAliasFile(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeShAliasFile(entries []AliasEntry) error {
|
||||
p := AliasesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return fmt.Errorf("creating aliases dir: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "alias %s='netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
return os.WriteFile(p, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeFishAliasFile(entries []AliasEntry) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Only write if fish is configured on this system.
|
||||
if _, err := os.Stat(filepath.Join(configDir, "fish")); err != nil {
|
||||
return
|
||||
}
|
||||
p := FishAliasesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, "alias %s 'netssh %s'\n", e.Name, e.Host)
|
||||
}
|
||||
os.WriteFile(p, []byte(b.String()), 0o644) //nolint:errcheck
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
func TestAliasesPath_UnderCacheDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", dir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
p := hook.AliasesPath()
|
||||
if !strings.HasPrefix(p, dir) {
|
||||
t.Errorf("AliasesPath should be under XDG_CACHE_HOME, got %q", p)
|
||||
}
|
||||
if !strings.HasSuffix(p, "aliases.sh") {
|
||||
t.Errorf("AliasesPath should end with aliases.sh, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFishAliasesPath_UnderConfigDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
p := hook.FishAliasesPath()
|
||||
if !strings.HasPrefix(p, dir) {
|
||||
t.Errorf("FishAliasesPath should be under XDG_CONFIG_HOME, got %q", p)
|
||||
}
|
||||
if !strings.HasSuffix(p, "netssh.fish") {
|
||||
t.Errorf("FishAliasesPath should end with netssh.fish, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_ShFile verifies the bash/zsh file is always written.
|
||||
func TestWriteAliasFiles_ShFile(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
entries := []hook.AliasEntry{
|
||||
{Name: "web01", Host: "web01.example.com"},
|
||||
{Name: "db01", Host: "db01.example.com"},
|
||||
}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("aliases.sh not created: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"alias web01='netssh web01.example.com'",
|
||||
"alias db01='netssh db01.example.com'",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("aliases.sh missing %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_FishFile verifies the fish file is written when fish config dir exists.
|
||||
func TestWriteAliasFiles_FishFile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
// Create the fish config directory so WriteAliasFiles knows fish is set up.
|
||||
fishDir := filepath.Join(configDir, "fish")
|
||||
os.MkdirAll(fishDir, 0o755)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
origCache := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", origCache)
|
||||
|
||||
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hook.FishAliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("netssh.fish not created: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "alias web01 'netssh web01.example.com'") {
|
||||
t.Errorf("fish alias file missing expected line\ncontent:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_SkipsFishWhenNotConfigured verifies that fish file is NOT written
|
||||
// when fish is not set up (no ~/.config/fish directory).
|
||||
func TestWriteAliasFiles_SkipsFishWhenNotConfigured(t *testing.T) {
|
||||
configDir := t.TempDir() // no fish subdir
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
origCache := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", origCache)
|
||||
|
||||
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(hook.FishAliasesPath()); err == nil {
|
||||
t.Error("fish alias file should not be created when fish is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_EmptyEntries verifies an empty (but valid) file is written.
|
||||
func TestWriteAliasFiles_EmptyEntries(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||
t.Fatalf("WriteAliasFiles with nil entries: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("aliases.sh not created: %v", err)
|
||||
}
|
||||
// Should contain only the header comment, no alias lines.
|
||||
if strings.Contains(string(data), "alias ") {
|
||||
t.Error("aliases.sh should have no alias lines for empty entries")
|
||||
}
|
||||
}
|
||||
|
||||
// --- InstallAliasesSource ---
|
||||
|
||||
func TestInstallAliasesSource_Fresh(t *testing.T) {
|
||||
profile := writeProfile(t, "existing line\n")
|
||||
|
||||
installed, err := hook.InstallAliasesSource(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAliasesSource: %v", err)
|
||||
}
|
||||
if !installed {
|
||||
t.Error("should report installed=true on first install")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if !strings.Contains(string(data), hook.AliasesMarker) {
|
||||
t.Error("profile should contain aliases source line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAliasesSource_Idempotent(t *testing.T) {
|
||||
profile := writeProfile(t, "")
|
||||
|
||||
hook.InstallAliasesSource(profile)
|
||||
installed, err := hook.InstallAliasesSource(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("second InstallAliasesSource: %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Error("second call should report installed=false")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if count := strings.Count(string(data), hook.AliasesMarker); count != 1 {
|
||||
t.Errorf("aliases source line should appear once, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUninstall_AlsoRemovesAliasesSourceLine verifies that Uninstall removes both lines.
|
||||
func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
|
||||
content := "export PATH=$PATH\n" +
|
||||
hook.Line + "\n" +
|
||||
hook.AliasesSourceLine + "\n" +
|
||||
"export FOO=bar\n"
|
||||
profile := writeProfile(t, content)
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("should report removed=true")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
s := string(data)
|
||||
if strings.Contains(s, hook.Marker) {
|
||||
t.Error("shell-init line should be removed")
|
||||
}
|
||||
if strings.Contains(s, hook.AliasesMarker) {
|
||||
t.Error("aliases source line should be removed")
|
||||
}
|
||||
if !strings.Contains(s, "export PATH") || !strings.Contains(s, "export FOO") {
|
||||
t.Error("other content should be preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Marker is the unique string used to detect an existing hook line.
|
||||
Marker = "netssh shell-init"
|
||||
// Line is the full line written into the shell profile.
|
||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
||||
|
||||
// AliasesMarker is the unique string used to detect the aliases source line.
|
||||
AliasesMarker = "netssh/aliases.sh"
|
||||
// AliasesSourceLine is the bash/zsh line that sources the generated aliases file.
|
||||
AliasesSourceLine = "source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases"
|
||||
)
|
||||
|
||||
// ProfilePath returns the canonical shell profile path for the given shell name.
|
||||
func ProfilePath(shell string) (string, error) {
|
||||
switch shell {
|
||||
case "bash":
|
||||
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
|
||||
case "zsh":
|
||||
return filepath.Join(os.Getenv("HOME"), ".zshrc"), nil
|
||||
case "fish":
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(configDir, "fish", "config.fish"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadNote returns the shell-specific hint for reloading the profile.
|
||||
func ReloadNote(profilePath string) string {
|
||||
return "Reload with: source " + profilePath
|
||||
}
|
||||
|
||||
// IsInstalled reports whether the hook marker is present in the profile file.
|
||||
// Returns false if the file does not exist or cannot be read.
|
||||
func IsInstalled(profilePath string) bool {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(data), Marker)
|
||||
}
|
||||
|
||||
// Install appends the hook line to the profile if it is not already present.
|
||||
// Returns (true, nil) when newly installed, (false, nil) when already present.
|
||||
// Creates the profile file (and any parent directories) if they do not exist.
|
||||
func Install(profilePath string) (installed bool, err error) {
|
||||
if IsInstalled(profilePath) {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
return false, fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("opening profile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := fmt.Fprintf(f, "\n%s\n", Line); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
|
||||
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
|
||||
// Returns (true, nil) when newly added, (false, nil) when already present.
|
||||
func InstallAliasesSource(profilePath string) (installed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
if strings.Contains(string(data), AliasesMarker) {
|
||||
return false, nil
|
||||
}
|
||||
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("opening profile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := fmt.Fprintf(f, "%s\n", AliasesSourceLine); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Uninstall removes the hook line and the aliases source line from the profile.
|
||||
// Returns (true, nil) when something was removed, (false, nil) when nothing was found.
|
||||
// Returns (false, nil) — not an error — when the file does not exist.
|
||||
func Uninstall(profilePath string) (removed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var kept []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
// Collapse triple blank lines that the removal may leave.
|
||||
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||
|
||||
if err := os.WriteFile(profilePath, []byte(cleaned), 0o644); err != nil {
|
||||
return false, fmt.Errorf("writing profile: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+99
-17
@@ -1,13 +1,17 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -27,13 +31,16 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
tokenVersion: tokenVersion,
|
||||
httpClient: &http.Client{},
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||
// Use SearchOptions to restrict by kind or tag.
|
||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
@@ -88,16 +95,17 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
|
||||
// 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) {
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
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()
|
||||
@@ -113,7 +121,6 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
}
|
||||
|
||||
if opts.Kind != "device" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -130,11 +137,8 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
|
||||
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])
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -265,16 +269,31 @@ func TokenVersion(token string) int {
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
_, err := c.request(ctx, http.MethodGet, apiURL, nil, "", out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method, apiURL string, body io.Reader, etag string, out any) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, apiURL, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
scheme := "Token "
|
||||
if c.tokenVersion == 2 {
|
||||
scheme = "Bearer "
|
||||
}
|
||||
req.Header.Set("Authorization", scheme+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-Match", etag)
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
return "", fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -283,17 +302,17 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||
if c.tokenVersion == 1 {
|
||||
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
|
||||
}
|
||||
return fmt.Errorf("%s: %s", apiURL, hint)
|
||||
return "", fmt.Errorf("%s: %s", apiURL, hint)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
return "", fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
return "", fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return nil
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
func deviceToEntry(d netboxDevice) HostEntry {
|
||||
@@ -331,3 +350,66 @@ func stripPrefix(cidr string) string {
|
||||
}
|
||||
return cidr
|
||||
}
|
||||
|
||||
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
|
||||
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
|
||||
if kind != "" && kind != "device" && kind != "vm" {
|
||||
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||
if err != nil {
|
||||
return HostEntry{}, err
|
||||
}
|
||||
var matches []HostEntry
|
||||
for _, e := range entries {
|
||||
if strings.EqualFold(e.Name, name) {
|
||||
matches = append(matches, e)
|
||||
}
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (c *Client) hostURL(host HostEntry) (string, error) {
|
||||
var path string
|
||||
switch host.Kind {
|
||||
case "device":
|
||||
path = "dcim/devices"
|
||||
case "vm":
|
||||
path = "virtualization/virtual-machines"
|
||||
default:
|
||||
return "", fmt.Errorf("invalid host kind %q", host.Kind)
|
||||
}
|
||||
if host.ID <= 0 {
|
||||
return "", fmt.Errorf("invalid host ID")
|
||||
}
|
||||
return fmt.Sprintf("%s/api/%s/%d/", c.baseURL, path, host.ID), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var out struct {
|
||||
Comments string `json:"comments"`
|
||||
}
|
||||
etag, err := c.request(ctx, http.MethodGet, u, nil, "", &out)
|
||||
return out.Comments, etag, err
|
||||
}
|
||||
|
||||
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(map[string]string{"comments": comments})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
_, err = c.request(ctx, http.MethodPatch, u, bytes.NewReader(data), etag, &out)
|
||||
return err
|
||||
}
|
||||
@@ -318,21 +318,26 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
b, _ := json.Marshal(deviceListResponse())
|
||||
w.Write(b)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
||||
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
|
||||
|
||||
want := "Token nbt_mytoken"
|
||||
if gotAuth != want {
|
||||
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
|
||||
for _, token := range []string{"nbt_mytoken", "legacytoken"} {
|
||||
t.Run(token, func(t *testing.T) {
|
||||
want := "Token " + token
|
||||
if strings.HasPrefix(token, "nbt_") {
|
||||
want = "Bearer " + token
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != want {
|
||||
t.Errorf("Authorization: got %q, want %q", got, want)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
b, _ := json.Marshal(deviceListResponse())
|
||||
w.Write(b)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, token, 0)
|
||||
if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,3 +439,63 @@ func TestStripPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentsPreserveFieldAndSendConflictHeader(t *testing.T) {
|
||||
for _, kind := range []string{"device", "vm"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer nbt_test" {
|
||||
t.Error("wrong authentication")
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
if r.Header.Get("If-Match") != `W/"123"` {
|
||||
t.Error("missing conflict protection")
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if len(payload) != 1 || payload["comments"] != "new\nnotes" {
|
||||
t.Errorf("unexpected patch: %v", payload)
|
||||
}
|
||||
}
|
||||
w.Header().Set("ETag", `W/"123"`)
|
||||
w.Write([]byte(`{"comments":"existing notes"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "nbt_test", 0)
|
||||
host := HostEntry{ID: 1, Kind: kind}
|
||||
text, etag, err := c.GetComments(context.Background(), host)
|
||||
if err != nil || text != "existing notes" {
|
||||
t.Fatalf("read: %q, %v", text, err)
|
||||
}
|
||||
if err := c.SetComments(context.Background(), host, "new\nnotes", etag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullRefreshRejectsPartialFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "virtualization") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"count":0,"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
if _, err := c.SearchAll(context.Background(), "", SearchOptions{}); err == nil {
|
||||
t.Fatal("partial refresh reported as successful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentConflictIsReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPreconditionFailed) }))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
if err := c.SetComments(context.Background(), HostEntry{ID: 1, Kind: "device"}, "draft", "old"); err == nil {
|
||||
t.Fatal("conflict ignored")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package notes stores private server notes independently of the host cache.
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
Text, ETag string
|
||||
Host netbox.HostEntry
|
||||
}
|
||||
type Service struct {
|
||||
Dir, Namespace string
|
||||
Client *netbox.Client
|
||||
}
|
||||
|
||||
func New(namespace string, client *netbox.Client) *Service {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return &Service{Dir: filepath.Join(dir, "netssh", "notes"), Namespace: strings.TrimRight(namespace, "/"), Client: client}
|
||||
}
|
||||
|
||||
func (s *Service) path(host netbox.HostEntry) string {
|
||||
key := fmt.Sprintf("%s\x00%s\x00%s", s.Namespace, host.Kind, strings.ToLower(host.Name))
|
||||
return filepath.Join(s.Dir, fmt.Sprintf("%x.txt", sha256.Sum256([]byte(key))))
|
||||
}
|
||||
|
||||
func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool) (Note, error) {
|
||||
note := Note{Host: host}
|
||||
if remote {
|
||||
if s.Client == nil {
|
||||
return note, fmt.Errorf("NetBox is not configured")
|
||||
}
|
||||
if host.ID == 0 {
|
||||
resolved, err := s.Client.FindHost(ctx, host.Name, host.Kind)
|
||||
if err != nil {
|
||||
return note, err
|
||||
}
|
||||
note.Host = resolved
|
||||
}
|
||||
var err error
|
||||
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||
return note, err
|
||||
}
|
||||
data, err := os.ReadFile(s.path(host))
|
||||
if os.IsNotExist(err) {
|
||||
return note, nil
|
||||
}
|
||||
note.Text = string(data)
|
||||
return note, err
|
||||
}
|
||||
|
||||
func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
||||
if remote {
|
||||
if s.Client == nil {
|
||||
return fmt.Errorf("NetBox is not configured")
|
||||
}
|
||||
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||
}
|
||||
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalNotesPersistAndStayIsolated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"}
|
||||
host := netbox.HostEntry{Name: "web01", Kind: "device"}
|
||||
n := Note{Host: host, Text: "Wartung\nGrüße 🔧"}
|
||||
if err := s.Write(ctx, n, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded := &Service{Dir: s.Dir, Namespace: s.Namespace}
|
||||
got, err := reloaded.Read(ctx, host, false)
|
||||
if err != nil || got.Text != n.Text {
|
||||
t.Fatalf("round trip: %+v, %v", got, err)
|
||||
}
|
||||
info, err := os.Stat(s.path(host))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("permissions: %v", info.Mode())
|
||||
}
|
||||
host.Kind = "vm"
|
||||
got, err = s.Read(ctx, host, false)
|
||||
if err != nil || got.Text != "" {
|
||||
t.Fatalf("kind isolation: %+v, %v", got, err)
|
||||
}
|
||||
host.Kind = "device"
|
||||
reloaded.Namespace = "https://other.example"
|
||||
got, err = reloaded.Read(ctx, host, false)
|
||||
if err != nil || got.Text != "" {
|
||||
t.Fatalf("instance isolation: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
||||
s := &Service{Dir: t.TempDir()}
|
||||
host := netbox.HostEntry{Name: "web01", Kind: "vm"}
|
||||
if err := os.Mkdir(s.path(host), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Read(context.Background(), host, false); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,16 @@ func RunWizard(cfg *config.Config) error {
|
||||
url := cfg.NetBox.URL
|
||||
token := cfg.NetBox.Token
|
||||
defaultUser := cfg.SSH.DefaultUser
|
||||
defaultPort := ""
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||
}
|
||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
||||
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||
interfaceName := cfg.Resolver.InterfaceName
|
||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||
|
||||
if strategiesRaw == "" {
|
||||
strategiesRaw = "primary_ip"
|
||||
@@ -62,6 +68,21 @@ func RunWizard(cfg *config.Config) error {
|
||||
Title("Default SSH user").
|
||||
Description("Leave empty to use your system user ($USER).").
|
||||
Value(&defaultUser),
|
||||
huh.NewInput().
|
||||
Title("Default SSH port").
|
||||
Description("Leave empty to use the standard port (22).").
|
||||
Placeholder("22").
|
||||
Value(&defaultPort).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return errors.New("must be a port number between 1 and 65535")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("SSH defaults"),
|
||||
|
||||
huh.NewGroup(
|
||||
@@ -90,6 +111,18 @@ func RunWizard(cfg *config.Config) error {
|
||||
return nil
|
||||
}),
|
||||
).Title("Resolver & cache"),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Domain suffixes").
|
||||
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
||||
Placeholder(".example.com").
|
||||
Value(&shortcutDomains),
|
||||
huh.NewConfirm().
|
||||
Title("Strip hyphens").
|
||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||
Value(&stripHyphens),
|
||||
).Title("Shortcuts"),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
@@ -109,6 +142,11 @@ func RunWizard(cfg *config.Config) error {
|
||||
|
||||
ttl, _ := strconv.Atoi(cacheTTL)
|
||||
|
||||
port := 0
|
||||
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
|
||||
port = p
|
||||
}
|
||||
|
||||
var subnetList []string
|
||||
for _, s := range strings.Split(subnets, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
@@ -116,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{
|
||||
NetBox: config.NetBoxConfig{
|
||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||
@@ -124,6 +172,7 @@ func RunWizard(cfg *config.Config) error {
|
||||
},
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: strings.TrimSpace(defaultUser),
|
||||
DefaultPort: port,
|
||||
},
|
||||
Resolver: config.ResolverConfig{
|
||||
Strategies: strategies,
|
||||
@@ -133,6 +182,10 @@ func RunWizard(cfg *config.Config) error {
|
||||
Cache: config.CacheConfig{
|
||||
TTL: ttl,
|
||||
},
|
||||
Shortcuts: config.ShortcutsConfig{
|
||||
Domains: domainList,
|
||||
StripHyphens: stripHyphens,
|
||||
},
|
||||
}
|
||||
|
||||
return save(out)
|
||||
@@ -200,6 +253,22 @@ func save(cfg config.Config) error {
|
||||
if cfg.SSH.DefaultUser != "" {
|
||||
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
|
||||
}
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
|
||||
}
|
||||
|
||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString("\nshortcuts:\n")
|
||||
if len(cfg.Shortcuts.Domains) > 0 {
|
||||
b.WriteString(" domains:\n")
|
||||
for _, d := range cfg.Shortcuts.Domains {
|
||||
fmt.Fprintf(&b, " - %s\n", d)
|
||||
}
|
||||
}
|
||||
if cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString(" strip_hyphens: true\n")
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
|
||||
@@ -116,6 +116,148 @@ 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
|
||||
|
||||
@@ -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,9 +86,28 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// HasPortFlag reports whether a port was specified via -p in args.
|
||||
func HasPortFlag(args []string) bool {
|
||||
if parsed := Parse(args); parsed != nil {
|
||||
args = args[:parsed.DestIdx]
|
||||
}
|
||||
for i, a := range args {
|
||||
if a == "-p" && i+1 < len(args) {
|
||||
return true
|
||||
}
|
||||
if len(a) > 2 && a[0] == '-' && a[1] == 'p' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasUserFlag reports whether a user was specified via -l in args.
|
||||
// Used to avoid overriding an explicit -l with the configured default user.
|
||||
func HasUserFlag(args []string) bool {
|
||||
if parsed := Parse(args); parsed != nil {
|
||||
args = args[:parsed.DestIdx]
|
||||
}
|
||||
for i, a := range args {
|
||||
if a == "-l" && i+1 < len(args) {
|
||||
return true
|
||||
|
||||
@@ -144,6 +144,37 @@ func TestHasUserFlag_LFlagAtEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_FlagSeparated(t *testing.T) {
|
||||
if !HasPortFlag([]string{"-p", "2222", "host"}) {
|
||||
t.Error("should detect -p <port>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_FlagAttached(t *testing.T) {
|
||||
if !HasPortFlag([]string{"-p2222", "host"}) {
|
||||
t.Error("should detect -p<port> (attached form)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_NotPresent(t *testing.T) {
|
||||
if HasPortFlag([]string{"-l", "admin", "host"}) {
|
||||
t.Error("should not detect port flag when absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_EmptyArgs(t *testing.T) {
|
||||
if HasPortFlag([]string{}) {
|
||||
t.Error("empty args should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_PAtEnd(t *testing.T) {
|
||||
// -p at the very end with no value — should return false
|
||||
if HasPortFlag([]string{"-p"}) {
|
||||
t.Error("-p with no value should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
@@ -159,3 +190,10 @@ func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int)
|
||||
t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommandFlagsDoNotOverrideDefaults(t *testing.T) {
|
||||
args := []string{"web01", "command", "-p", "1234", "-lroot"}
|
||||
if HasPortFlag(args) || HasUserFlag(args) {
|
||||
t.Fatal("remote command interpreted as SSH options")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package storage provides atomic file replacement in the destination directory.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".netssh-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
if err := f.Chmod(mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
+180
-40
@@ -8,7 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
|
||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||
type SelectedHost struct {
|
||||
Kind string
|
||||
Name string
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
@@ -27,10 +30,19 @@ type SelectedHost struct {
|
||||
|
||||
// --- bubbletea messages ---
|
||||
|
||||
type debounceMsg struct{ query string }
|
||||
type debounceMsg struct {
|
||||
query string
|
||||
seq int
|
||||
}
|
||||
type noteResultMsg struct {
|
||||
note notes.Note
|
||||
err error
|
||||
saved bool
|
||||
}
|
||||
|
||||
type searchResultMsg struct {
|
||||
query string
|
||||
seq int
|
||||
entries []netbox.HostEntry
|
||||
err error
|
||||
recent bool // true when this is a recently-used list, not a search result
|
||||
@@ -40,6 +52,7 @@ type searchResultMsg struct {
|
||||
|
||||
type hostItem struct {
|
||||
name string
|
||||
id int
|
||||
ip string
|
||||
kind string
|
||||
tags []string
|
||||
@@ -120,27 +133,35 @@ func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
||||
// --- Model ---
|
||||
|
||||
type Model struct {
|
||||
input textinput.Model
|
||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||
editInput textinput.Model // user@host:port inline editor
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
defaultUser string
|
||||
lastSent string // last query sent to NetBox (or served from cache)
|
||||
lastResults []netbox.HostEntry // raw results before filter applied
|
||||
seq int // sequence number to discard stale results
|
||||
loading bool
|
||||
err error
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // 'e' on selected item
|
||||
noteErr error
|
||||
editHost *hostItem
|
||||
noteInput textarea.Model
|
||||
noteService *notes.Service
|
||||
note notes.Note
|
||||
noteMode, noteRemote, noteBusy bool
|
||||
noteLoaded bool
|
||||
defaultPort int
|
||||
input textinput.Model
|
||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||
editInput textinput.Model // user@host:port inline editor
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
defaultUser string
|
||||
lastSent string // last query sent to NetBox (or served from cache)
|
||||
lastResults []netbox.HostEntry // raw results before filter applied
|
||||
seq int // sequence number to discard stale results
|
||||
loading bool
|
||||
err error
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // 'e' on selected item
|
||||
}
|
||||
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hostname…"
|
||||
ti.Focus()
|
||||
@@ -157,8 +178,18 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.DisableQuitKeybindings()
|
||||
port := 22
|
||||
if len(defaultPorts) > 0 && defaultPorts[0] > 0 {
|
||||
port = defaultPorts[0]
|
||||
}
|
||||
|
||||
ni := textarea.New()
|
||||
ni.CharLimit = 0
|
||||
ni.SetHeight(10)
|
||||
return &Model{
|
||||
noteInput: ni,
|
||||
defaultPort: port,
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
@@ -191,6 +222,20 @@ func (m *Model) loadRecent() tea.Cmd {
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case noteResultMsg:
|
||||
m.noteBusy = false
|
||||
m.noteErr = msg.err
|
||||
if msg.err == nil {
|
||||
m.noteLoaded = true
|
||||
m.note = msg.note
|
||||
m.noteInput.SetValue(msg.note.Text)
|
||||
if msg.saved {
|
||||
m.noteMode = false
|
||||
m.noteInput.Blur()
|
||||
m.input.Focus()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
@@ -202,10 +247,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.editMode {
|
||||
extraRows += 2
|
||||
}
|
||||
m.list.SetSize(msg.Width, msg.Height-extraRows)
|
||||
m.list.SetSize(msg.Width, max(1, msg.Height-extraRows))
|
||||
m.noteInput.SetWidth(max(10, msg.Width-4))
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.noteMode {
|
||||
if m.noteBusy {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.noteMode = false
|
||||
m.noteInput.Blur()
|
||||
m.input.Focus()
|
||||
m.noteErr = nil
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
if !m.noteLoaded {
|
||||
return m, nil
|
||||
}
|
||||
note := m.note
|
||||
note.Text = m.noteInput.Value()
|
||||
remote, service := m.noteRemote, m.noteService
|
||||
m.noteBusy = true
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// --- Edit mode ---
|
||||
if m.editMode {
|
||||
switch msg.String() {
|
||||
@@ -215,7 +293,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.input.Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
if m.editHost != nil {
|
||||
item := *m.editHost
|
||||
value := m.editInput.Value()
|
||||
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
||||
port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:]))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
m.err = fmt.Errorf("port must be between 1 and 65535")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.selected = m.buildSelected(item)
|
||||
}
|
||||
return m, tea.Quit
|
||||
@@ -261,21 +348,44 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "enter":
|
||||
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, Kind: item.kind}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "e":
|
||||
case "ctrl+e":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.openEdit(item)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+n", "ctrl+b":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||
m.noteMode = true
|
||||
m.noteLoaded = false
|
||||
m.noteBusy = true
|
||||
m.noteRemote = msg.String() == "ctrl+b"
|
||||
m.input.Blur()
|
||||
m.noteInput.Focus()
|
||||
m.noteInput.SetValue("")
|
||||
m.noteErr = nil
|
||||
host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind}
|
||||
m.note = notes.Note{Host: host}
|
||||
remote, service := m.noteRemote, m.noteService
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
note, err := service.Read(ctx, host, remote)
|
||||
return noteResultMsg{note: note, err: err}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "tab":
|
||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||
m.input.SetValue(item.name)
|
||||
m.input.CursorEnd()
|
||||
m.seq++
|
||||
return m, m.startDebounce()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -283,24 +393,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case debounceMsg:
|
||||
q := m.input.Value()
|
||||
if q == m.lastSent {
|
||||
if msg.seq != m.seq || msg.query != q {
|
||||
return m, nil
|
||||
}
|
||||
m.lastSent = q
|
||||
m.recentMode = false
|
||||
m.loading = true
|
||||
m.seq++
|
||||
seq := m.seq
|
||||
return m, m.doSearch(q, seq)
|
||||
|
||||
case searchResultMsg:
|
||||
if msg.recent {
|
||||
if m.input.Value() != "" || m.seq > 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.recentMode = true
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
return m, nil
|
||||
}
|
||||
if msg.query != m.lastSent {
|
||||
if msg.seq != m.seq || msg.query != m.input.Value() {
|
||||
return m, nil // discard stale result
|
||||
}
|
||||
m.loading = false
|
||||
@@ -314,12 +426,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.noteMode {
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// Forward to search input and restart debounce timer.
|
||||
var cmds []tea.Cmd
|
||||
var inputCmd tea.Cmd
|
||||
oldQuery := m.input.Value()
|
||||
m.input, inputCmd = m.input.Update(msg)
|
||||
cmds = append(cmds, inputCmd)
|
||||
cmds = append(cmds, m.startDebounce())
|
||||
if m.input.Value() != oldQuery {
|
||||
m.seq++
|
||||
cmds = append(cmds, m.startDebounce())
|
||||
}
|
||||
|
||||
var listCmd tea.Cmd
|
||||
m.list, listCmd = m.list.Update(msg)
|
||||
@@ -329,6 +450,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
if m.noteMode {
|
||||
target := "local"
|
||||
if m.noteRemote {
|
||||
target = "NetBox comments"
|
||||
}
|
||||
status := "ctrl+s save esc cancel"
|
||||
if m.noteBusy {
|
||||
status = "Loading / saving…"
|
||||
}
|
||||
errText := ""
|
||||
if m.noteErr != nil {
|
||||
errText = "\nError: " + m.noteErr.Error()
|
||||
}
|
||||
return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText)
|
||||
}
|
||||
var sb strings.Builder
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
||||
@@ -358,7 +494,7 @@ func (m *Model) View() string {
|
||||
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"
|
||||
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
@@ -371,8 +507,9 @@ func (m *Model) Selected() *SelectedHost {
|
||||
}
|
||||
|
||||
func (m *Model) startDebounce() tea.Cmd {
|
||||
query, seq := m.input.Value(), m.seq
|
||||
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
||||
return debounceMsg{query: m.input.Value()}
|
||||
return debounceMsg{query: query, seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -386,20 +523,19 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||
for i, c := range cached {
|
||||
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, seq: seq, entries: entries}
|
||||
}
|
||||
}
|
||||
|
||||
if m.client == nil {
|
||||
return searchResultMsg{query: query, entries: nil}
|
||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := m.client.Search(ctx, query, opts)
|
||||
_ = seq
|
||||
return searchResultMsg{query: query, entries: entries, err: err}
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +559,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
}
|
||||
@@ -431,13 +567,15 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
// openEdit switches to edit mode for the given list item.
|
||||
func (m *Model) openEdit(item hostItem) {
|
||||
m.editMode = true
|
||||
m.editHost = &item
|
||||
m.input.Blur()
|
||||
|
||||
user := m.defaultUser
|
||||
if user == "" {
|
||||
user = item.name
|
||||
prefix := ""
|
||||
if user != "" {
|
||||
prefix = user + "@"
|
||||
}
|
||||
m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name))
|
||||
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
|
||||
m.editInput.CursorEnd()
|
||||
m.editInput.Focus()
|
||||
}
|
||||
@@ -445,7 +583,7 @@ func (m *Model) openEdit(item hostItem) {
|
||||
// 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}
|
||||
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||
|
||||
// Extract user: everything before @
|
||||
if idx := strings.Index(s, "@"); idx != -1 {
|
||||
@@ -456,10 +594,12 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
// 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" {
|
||||
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
|
||||
sel.Port = portStr
|
||||
}
|
||||
}
|
||||
|
||||
return sel
|
||||
}
|
||||
|
||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||
@@ -0,0 +1,70 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
||||
if m.editMode || m.input.Value() != "e" {
|
||||
t.Fatal("e intercepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleResultsAndDebounceAreIgnored(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.input.SetValue("web")
|
||||
m.seq = 3
|
||||
m.Update(searchResultMsg{query: "web", seq: 1, entries: []netbox.HostEntry{{Name: "old"}}})
|
||||
if len(m.list.Items()) != 0 {
|
||||
t.Fatal("stale result accepted")
|
||||
}
|
||||
_, cmd := m.Update(debounceMsg{query: "w", seq: 1})
|
||||
if cmd != nil {
|
||||
t.Fatal("stale debounce accepted")
|
||||
}
|
||||
m.Update(searchResultMsg{query: "web", seq: 3, entries: []netbox.HostEntry{{Name: "web01"}}})
|
||||
if len(m.list.Items()) != 1 {
|
||||
t.Fatal("current result ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditPreservesDefaultAndExplicitPort22(t *testing.T) {
|
||||
m := New(nil, nil, "", 2222)
|
||||
item := hostItem{name: "web01", ip: "10.0.0.1"}
|
||||
m.openEdit(item)
|
||||
if m.editInput.Value() != "web01:2222" {
|
||||
t.Fatal(m.editInput.Value())
|
||||
}
|
||||
m.editInput.SetValue("ops@web01:22")
|
||||
selected := m.buildSelected(item)
|
||||
if selected.Port != "22" || selected.User != "ops" {
|
||||
t.Fatalf("bad override: %+v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedNoteLoadCannotBeSaved(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
||||
m.noteMode = true
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
if cmd != nil {
|
||||
t.Fatal("save allowed before successful load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||
m := New(nil, nil, "admin")
|
||||
first := hostItem{name: "first", ip: "10.0.0.1"}
|
||||
m.openEdit(first)
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "first" {
|
||||
t.Fatalf("edited host changed: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user