Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa646f25a6 | |||
| 7c902cab3a |
@@ -27,6 +27,10 @@ netssh -p 2222 admin@app-server-03 uptime
|
|||||||
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
|
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
|
||||||
- **Shell completion** — install without sudo via `netssh completion install`
|
- **Shell completion** — install without sudo via `netssh completion install`
|
||||||
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
||||||
|
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
|
||||||
|
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
|
||||||
|
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
||||||
|
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -94,6 +98,12 @@ cache:
|
|||||||
|
|
||||||
ssh:
|
ssh:
|
||||||
default_user: admin # used when no user is specified on the command line
|
default_user: admin # used when no user is specified on the command line
|
||||||
|
default_port: 2222 # optional; injected as -p if not specified on the command line
|
||||||
|
|
||||||
|
shortcuts:
|
||||||
|
domains: # strip these suffixes to create short aliases
|
||||||
|
- .example.com
|
||||||
|
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
||||||
```
|
```
|
||||||
|
|
||||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
@@ -139,6 +149,76 @@ netssh root@my-router # user@ prefix wins → ssh root@10.0.0.1
|
|||||||
netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1
|
netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Default SSH port
|
||||||
|
|
||||||
|
Set `ssh.default_port` in the config to use a non-standard port without specifying it every time:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh my-router # → ssh -p 2222 -l admin 10.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is only applied when `-p` is not already present in the SSH arguments. An explicit `-p` always takes precedence:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh -p 22 my-router # -p flag wins → ssh -p 22 10.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host shortcuts
|
||||||
|
|
||||||
|
Configure short aliases so you can type `web01` instead of `web01.example.com`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shortcuts:
|
||||||
|
domains:
|
||||||
|
- .example.com
|
||||||
|
- .example.de
|
||||||
|
strip_hyphens: false # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. **Exact match** — the input is looked up as-is in the cache.
|
||||||
|
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
||||||
|
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
||||||
|
|
||||||
|
| Input | Config | Resolved as |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
||||||
|
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
||||||
|
|
||||||
|
### Shell aliases
|
||||||
|
|
||||||
|
Generate shell aliases for all cached hosts based on the shortcut settings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh alias # bash/zsh syntax
|
||||||
|
netssh alias --shell fish # fish syntax
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# netssh aliases (3 hosts) — source with: eval "$(netssh alias)"
|
||||||
|
alias db01='netssh db01.example.com'
|
||||||
|
alias fsn1web01='netssh fsn1-web01.example.com'
|
||||||
|
alias web01='netssh web01.example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to your shell startup file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# bash / zsh — in ~/.bashrc or ~/.zshrc
|
||||||
|
eval "$(netssh alias)"
|
||||||
|
|
||||||
|
# fish — in ~/.config/fish/config.fish
|
||||||
|
netssh alias --shell fish | source
|
||||||
|
|
||||||
|
# scripts
|
||||||
|
source <(netssh alias)
|
||||||
|
```
|
||||||
|
|
||||||
|
Without a `shortcuts` config, the alias name is derived from the full hostname with dots replaced by underscores (e.g. `web01_example_com`). If two hosts would produce the same alias name, the first entry wins and a warning is printed to stderr.
|
||||||
|
|
||||||
### Interactive TUI
|
### Interactive TUI
|
||||||
|
|
||||||
Run without arguments to open the interactive search:
|
Run without arguments to open the interactive search:
|
||||||
@@ -186,6 +266,25 @@ netssh cache refresh --tag prod --kind vm # combine filters
|
|||||||
netssh cache clear # wipe the cache
|
netssh cache clear # wipe the cache
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The cache is refreshed automatically in the background every 24 hours. The trigger fires on the next SSH connect or TUI start after the interval has elapsed — there is no delay to the connection itself. The timestamp of the last refresh is stored in `~/.cache/netssh/last_refresh`.
|
||||||
|
|
||||||
|
To also trigger the check at shell startup (before you run any SSH command), install the shell hook:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh hook install # auto-detects $SHELL
|
||||||
|
netssh hook install --shell bash
|
||||||
|
netssh hook install --shell zsh
|
||||||
|
netssh hook install --shell fish
|
||||||
|
```
|
||||||
|
|
||||||
|
This appends a single line (`netssh shell-init`) to your shell profile. To remove it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh hook uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook is a no-op when the cache is fresh — it adds no measurable delay to your shell startup.
|
||||||
|
|
||||||
### Search (for scripting)
|
### Search (for scripting)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -228,6 +327,95 @@ autoload -Uz compinit && compinit
|
|||||||
|
|
||||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
Completions are served from the local cache — no network request on every `<Tab>`.
|
||||||
|
|
||||||
|
## Shell Hook
|
||||||
|
|
||||||
|
`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
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -235,7 +423,7 @@ go test ./... # run all tests
|
|||||||
go build ./... # build all packages
|
go build ./... # build all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, and the setup wizard.
|
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, setup wizard, shortcut normalization, and shell hook install/uninstall.
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@@ -246,6 +434,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.
|
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
|
||||||
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
|
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
|
||||||
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
||||||
4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
|
4. It resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made.
|
||||||
5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
|
5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
|
||||||
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
|
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
|
||||||
|
|||||||
+290
-5
@@ -4,8 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,9 +17,11 @@ import (
|
|||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -27,6 +31,9 @@ var managedSubcommands = map[string]bool{
|
|||||||
"configure": true,
|
"configure": true,
|
||||||
"search": true,
|
"search": true,
|
||||||
"cache": true,
|
"cache": true,
|
||||||
|
"alias": true,
|
||||||
|
"hook": true,
|
||||||
|
"shell-init": true,
|
||||||
"completion": true,
|
"completion": true,
|
||||||
"__complete": true,
|
"__complete": true,
|
||||||
"help": true,
|
"help": true,
|
||||||
@@ -87,9 +94,15 @@ func runSSHWrapper(args []string) {
|
|||||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
||||||
parsed.DestIdx += 2
|
parsed.DestIdx += 2
|
||||||
}
|
}
|
||||||
|
// Inject the configured default port if none was given on the command line.
|
||||||
|
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
|
||||||
|
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
|
||||||
|
parsed.DestIdx += 2
|
||||||
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
if entry, fresh := c.Get(parsed.Host); fresh {
|
||||||
@@ -99,6 +112,24 @@ func runSSHWrapper(args []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||||
|
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||||
|
lookupHost := parsed.Host
|
||||||
|
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
||||||
|
if shortcutsEnabled {
|
||||||
|
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
|
||||||
|
if entry, found, fresh := c.GetByShortcut(lookupHost, normalize); found {
|
||||||
|
if fresh {
|
||||||
|
c.MarkUsed(entry.Name)
|
||||||
|
_ = c.Save()
|
||||||
|
connect(entry.IP, parsed, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||||
|
lookupHost = entry.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.NetBox.URL == "" {
|
if cfg.NetBox.URL == "" {
|
||||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||||
}
|
}
|
||||||
@@ -107,18 +138,40 @@ func runSSHWrapper(args []string) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := nbClient.Search(ctx, parsed.Host, netbox.SearchOptions{})
|
entries, err := nbClient.Search(ctx, lookupHost, netbox.SearchOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("NetBox search failed: %v", err)
|
fatalf("NetBox search failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var target *netbox.HostEntry
|
var target *netbox.HostEntry
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
if strings.EqualFold(e.Name, parsed.Host) {
|
if strings.EqualFold(e.Name, lookupHost) {
|
||||||
target = &entries[i]
|
target = &entries[i]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain expansion: if still no match and the input has no dots, try appending
|
||||||
|
// each configured domain suffix and re-querying NetBox.
|
||||||
|
if target == nil && !strings.Contains(parsed.Host, ".") && len(cfg.Shortcuts.Domains) > 0 {
|
||||||
|
for _, domain := range cfg.Shortcuts.Domains {
|
||||||
|
expanded := parsed.Host + domain
|
||||||
|
expandedEntries, searchErr := nbClient.Search(ctx, expanded, netbox.SearchOptions{})
|
||||||
|
if searchErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i, e := range expandedEntries {
|
||||||
|
if strings.EqualFold(e.Name, expanded) {
|
||||||
|
target = &expandedEntries[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if target == nil {
|
if target == nil {
|
||||||
fatalf("host %q not found in NetBox", parsed.Host)
|
fatalf("host %q not found in NetBox", parsed.Host)
|
||||||
}
|
}
|
||||||
@@ -153,6 +206,7 @@ func runTUI() {
|
|||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
var nbClient *netbox.Client
|
var nbClient *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.NetBox.URL != "" {
|
||||||
@@ -188,8 +242,12 @@ func runTUI() {
|
|||||||
sshArgs = append(sshArgs, "-l", user)
|
sshArgs = append(sshArgs, "-l", user)
|
||||||
}
|
}
|
||||||
|
|
||||||
if host.Port != "" {
|
port := host.Port
|
||||||
sshArgs = append(sshArgs, "-p", host.Port)
|
if port == "" && cfg.SSH.DefaultPort > 0 {
|
||||||
|
port = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
sshArgs = append(sshArgs, "-p", port)
|
||||||
}
|
}
|
||||||
|
|
||||||
sshArgs = append(sshArgs, host.IP)
|
sshArgs = append(sshArgs, host.IP)
|
||||||
@@ -231,7 +289,7 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
|
root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||||
|
|
||||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||||
root.InitDefaultCompletionCmd()
|
root.InitDefaultCompletionCmd()
|
||||||
@@ -460,6 +518,14 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := c.SetRefreshed(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
|
}
|
||||||
|
if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 {
|
||||||
|
if err := hook.WriteAliasFiles(aliasEntries); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
fmt.Printf("%d entries written to cache.\n", len(entries))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -469,6 +535,225 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
return cmd
|
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) {
|
func fatalf(format string, args ...any) {
|
||||||
fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...)
|
fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Vendored
+61
-8
@@ -20,10 +20,11 @@ type Entry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
entries map[string]Entry
|
entries map[string]Entry
|
||||||
path string
|
path string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
|
refreshStamp string // path to last_refresh timestamp file
|
||||||
}
|
}
|
||||||
|
|
||||||
type diskFormat struct {
|
type diskFormat struct {
|
||||||
@@ -31,11 +32,48 @@ type diskFormat struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(path string, ttlSeconds int) *Cache {
|
func New(path string, ttlSeconds int) *Cache {
|
||||||
return &Cache{
|
stamp := ""
|
||||||
entries: make(map[string]Entry),
|
if path != "" {
|
||||||
path: path,
|
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
|
||||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
|
||||||
}
|
}
|
||||||
|
return &Cache{
|
||||||
|
entries: make(map[string]Entry),
|
||||||
|
path: path,
|
||||||
|
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||||
|
refreshStamp: stamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsRefresh reports whether the last full cache refresh (via `cache refresh`)
|
||||||
|
// is older than d, or has never happened. Always returns false when no path is set.
|
||||||
|
func (c *Cache) NeedsRefresh(d time.Duration) bool {
|
||||||
|
if c.refreshStamp == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(c.refreshStamp)
|
||||||
|
if err != nil {
|
||||||
|
return true // file missing → never refreshed
|
||||||
|
}
|
||||||
|
var t time.Time
|
||||||
|
if err := t.UnmarshalText(data); err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(t) >= d
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRefreshed records the current time as the last successful full refresh.
|
||||||
|
func (c *Cache) SetRefreshed() error {
|
||||||
|
if c.refreshStamp == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := time.Now().MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(c.refreshStamp), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(c.refreshStamp, data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Load() error {
|
func (c *Cache) Load() error {
|
||||||
@@ -137,6 +175,21 @@ func (c *Cache) Search(prefix string) []Entry {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByShortcut scans all entries and returns the first whose normalized name matches
|
||||||
|
// the normalized shortcut. Returns (entry, found, fresh).
|
||||||
|
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
norm := normalize(shortcut)
|
||||||
|
for _, e := range c.entries {
|
||||||
|
if normalize(e.Name) == norm {
|
||||||
|
isFresh := c.ttl > 0 && time.Since(e.CachedAt) < c.ttl
|
||||||
|
return e, true, isFresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Entry{}, false, false
|
||||||
|
}
|
||||||
|
|
||||||
// Get returns an entry and reports whether it is still within the TTL.
|
// Get returns an entry and reports whether it is still within the TTL.
|
||||||
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
|
|||||||
Vendored
+158
@@ -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.
|
// tempFile writes content to a temp file and returns its path.
|
||||||
func tempFile(t *testing.T, content []byte) string {
|
func tempFile(t *testing.T, content []byte) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
SSH SSHConfig `mapstructure:"ssh"`
|
SSH SSHConfig `mapstructure:"ssh"`
|
||||||
|
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
@@ -35,6 +36,12 @@ type CacheConfig struct {
|
|||||||
|
|
||||||
type SSHConfig struct {
|
type SSHConfig struct {
|
||||||
DefaultUser string `mapstructure:"default_user"`
|
DefaultUser string `mapstructure:"default_user"`
|
||||||
|
DefaultPort int `mapstructure:"default_port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortcutsConfig struct {
|
||||||
|
Domains []string `mapstructure:"domains"`
|
||||||
|
StripHyphens bool `mapstructure:"strip_hyphens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns the canonical config file path.
|
// Path returns the canonical config file path.
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -20,10 +20,16 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
url := cfg.NetBox.URL
|
url := cfg.NetBox.URL
|
||||||
token := cfg.NetBox.Token
|
token := cfg.NetBox.Token
|
||||||
defaultUser := cfg.SSH.DefaultUser
|
defaultUser := cfg.SSH.DefaultUser
|
||||||
|
defaultPort := ""
|
||||||
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
|
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||||
|
}
|
||||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
||||||
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||||
interfaceName := cfg.Resolver.InterfaceName
|
interfaceName := cfg.Resolver.InterfaceName
|
||||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||||
|
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||||
|
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||||
|
|
||||||
if strategiesRaw == "" {
|
if strategiesRaw == "" {
|
||||||
strategiesRaw = "primary_ip"
|
strategiesRaw = "primary_ip"
|
||||||
@@ -62,6 +68,21 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Title("Default SSH user").
|
Title("Default SSH user").
|
||||||
Description("Leave empty to use your system user ($USER).").
|
Description("Leave empty to use your system user ($USER).").
|
||||||
Value(&defaultUser),
|
Value(&defaultUser),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Default SSH port").
|
||||||
|
Description("Leave empty to use the standard port (22).").
|
||||||
|
Placeholder("22").
|
||||||
|
Value(&defaultPort).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil || n < 1 || n > 65535 {
|
||||||
|
return errors.New("must be a port number between 1 and 65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
).Title("SSH defaults"),
|
).Title("SSH defaults"),
|
||||||
|
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
@@ -90,6 +111,18 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
).Title("Resolver & cache"),
|
).Title("Resolver & cache"),
|
||||||
|
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Domain suffixes").
|
||||||
|
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
||||||
|
Placeholder(".example.com").
|
||||||
|
Value(&shortcutDomains),
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title("Strip hyphens").
|
||||||
|
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||||
|
Value(&stripHyphens),
|
||||||
|
).Title("Shortcuts"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
if err := form.Run(); err != nil {
|
||||||
@@ -109,6 +142,11 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
|
|
||||||
ttl, _ := strconv.Atoi(cacheTTL)
|
ttl, _ := strconv.Atoi(cacheTTL)
|
||||||
|
|
||||||
|
port := 0
|
||||||
|
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
|
||||||
var subnetList []string
|
var subnetList []string
|
||||||
for _, s := range strings.Split(subnets, ",") {
|
for _, s := range strings.Split(subnets, ",") {
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
@@ -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{
|
out := config.Config{
|
||||||
NetBox: config.NetBoxConfig{
|
NetBox: config.NetBoxConfig{
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||||
@@ -124,6 +172,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
},
|
},
|
||||||
SSH: config.SSHConfig{
|
SSH: config.SSHConfig{
|
||||||
DefaultUser: strings.TrimSpace(defaultUser),
|
DefaultUser: strings.TrimSpace(defaultUser),
|
||||||
|
DefaultPort: port,
|
||||||
},
|
},
|
||||||
Resolver: config.ResolverConfig{
|
Resolver: config.ResolverConfig{
|
||||||
Strategies: strategies,
|
Strategies: strategies,
|
||||||
@@ -133,6 +182,10 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
Cache: config.CacheConfig{
|
Cache: config.CacheConfig{
|
||||||
TTL: ttl,
|
TTL: ttl,
|
||||||
},
|
},
|
||||||
|
Shortcuts: config.ShortcutsConfig{
|
||||||
|
Domains: domainList,
|
||||||
|
StripHyphens: stripHyphens,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return save(out)
|
return save(out)
|
||||||
@@ -200,6 +253,22 @@ func save(cfg config.Config) error {
|
|||||||
if cfg.SSH.DefaultUser != "" {
|
if cfg.SSH.DefaultUser != "" {
|
||||||
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
|
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
|
||||||
}
|
}
|
||||||
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
|
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||||
|
b.WriteString("\nshortcuts:\n")
|
||||||
|
if len(cfg.Shortcuts.Domains) > 0 {
|
||||||
|
b.WriteString(" domains:\n")
|
||||||
|
for _, d := range cfg.Shortcuts.Domains {
|
||||||
|
fmt.Fprintf(&b, " - %s\n", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Shortcuts.StripHyphens {
|
||||||
|
b.WriteString(" strip_hyphens: true\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||||
return fmt.Errorf("writing config: %w", err)
|
return fmt.Errorf("writing config: %w", err)
|
||||||
|
|||||||
@@ -116,6 +116,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) {
|
func TestParseStrategies(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
in string
|
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,6 +86,19 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasPortFlag reports whether a port was specified via -p in args.
|
||||||
|
func HasPortFlag(args []string) bool {
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "-p" && i+1 < len(args) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if len(a) > 2 && a[0] == '-' && a[1] == 'p' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// HasUserFlag reports whether a user was specified via -l in args.
|
// HasUserFlag reports whether a user was specified via -l in args.
|
||||||
// Used to avoid overriding an explicit -l with the configured default user.
|
// Used to avoid overriding an explicit -l with the configured default user.
|
||||||
func HasUserFlag(args []string) bool {
|
func HasUserFlag(args []string) bool {
|
||||||
|
|||||||
@@ -144,6 +144,37 @@ func TestHasUserFlag_LFlagAtEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasPortFlag_FlagSeparated(t *testing.T) {
|
||||||
|
if !HasPortFlag([]string{"-p", "2222", "host"}) {
|
||||||
|
t.Error("should detect -p <port>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPortFlag_FlagAttached(t *testing.T) {
|
||||||
|
if !HasPortFlag([]string{"-p2222", "host"}) {
|
||||||
|
t.Error("should detect -p<port> (attached form)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPortFlag_NotPresent(t *testing.T) {
|
||||||
|
if HasPortFlag([]string{"-l", "admin", "host"}) {
|
||||||
|
t.Error("should not detect port flag when absent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPortFlag_EmptyArgs(t *testing.T) {
|
||||||
|
if HasPortFlag([]string{}) {
|
||||||
|
t.Error("empty args should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPortFlag_PAtEnd(t *testing.T) {
|
||||||
|
// -p at the very end with no value — should return false
|
||||||
|
if HasPortFlag([]string{"-p"}) {
|
||||||
|
t.Error("-p with no value should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
|
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got == nil {
|
if got == nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user