Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cc64fdd9 | ||
|
|
2852dff2c4 | ||
|
|
2d11303014 | ||
|
|
5c6cc7dbbf | ||
|
|
b17ace7fc5 | ||
|
|
8241a064f4 | ||
|
|
86fc2f19eb | ||
|
|
fa646f25a6 |
No files matched your search
@@ -10,9 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ 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)
|
- **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
|
- **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`
|
- **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
|
- **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)
|
- **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
|
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
|
||||||
@@ -235,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 |
|
| `Tab` | autocomplete top result into the search field |
|
||||||
| `↑` / `↓` | navigate results |
|
| `↑` / `↓` | navigate results |
|
||||||
| `Enter` | connect to selected host |
|
| `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`) |
|
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
|
||||||
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
||||||
|
|
||||||
@@ -247,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.
|
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
|
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
|
### Cache management
|
||||||
|
|
||||||
@@ -329,7 +367,12 @@ Completions are served from the local cache — no network request on every `<Ta
|
|||||||
|
|
||||||
## Shell Hook
|
## Shell Hook
|
||||||
|
|
||||||
The shell hook runs `netssh shell-init` at the start of every new shell session. It checks whether the cache is older than 24 hours and, if so, starts a background refresh. The check reads a single small file and adds no measurable delay to your shell startup.
|
`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
|
### Install
|
||||||
|
|
||||||
@@ -340,21 +383,38 @@ netssh hook install --shell zsh
|
|||||||
netssh hook install --shell fish
|
netssh hook install --shell fish
|
||||||
```
|
```
|
||||||
|
|
||||||
This appends exactly one line to your shell profile:
|
Example output:
|
||||||
|
|
||||||
```sh
|
```
|
||||||
|
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
|
netssh shell-init # netssh cache auto-refresh
|
||||||
```
|
```
|
||||||
|
|
||||||
| Shell | Profile file |
|
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
|
||||||
|-------|-------------|
|
|
||||||
| bash | `~/.bashrc` |
|
|
||||||
| zsh | `~/.zshrc` |
|
|
||||||
| fish | `~/.config/fish/config.fish` |
|
|
||||||
|
|
||||||
The install is idempotent — running it again does nothing if the hook is already present.
|
| 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` |
|
||||||
|
|
||||||
Reload your profile after installation:
|
The install is idempotent — running it again changes nothing if the lines are already present.
|
||||||
|
|
||||||
|
Reload your profile after installation to activate immediately:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
source ~/.bashrc # bash
|
source ~/.bashrc # bash
|
||||||
@@ -362,6 +422,18 @@ source ~/.zshrc # zsh
|
|||||||
source ~/.config/fish/config.fish # fish
|
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
|
### Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -369,16 +441,18 @@ netssh hook uninstall # auto-detects $SHELL
|
|||||||
netssh hook uninstall --shell zsh
|
netssh hook uninstall --shell zsh
|
||||||
```
|
```
|
||||||
|
|
||||||
Removes the `netssh shell-init` line from the profile and collapses any blank lines left behind.
|
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.
|
||||||
|
|
||||||
### How it differs from the connect-time trigger
|
### 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 |
|
| Trigger | When it fires |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
| Connect / TUI start | On the next SSH command or `netssh` TUI after 24 h |
|
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
|
||||||
| Shell hook | On the first new shell session after 24 h |
|
| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed |
|
||||||
|
|
||||||
Both triggers are non-blocking: the refresh runs in the background and your SSH connection (or prompt) is not delayed. You can install both — they share the same `~/.cache/netssh/last_refresh` timestamp, so the background process runs at most once per 24 hours regardless of how many shells or connections you open.
|
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
|
||||||
|
|
||||||
|
|||||||
+114
-44
@@ -19,6 +19,7 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
"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/notes"
|
||||||
"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"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
||||||
var managedSubcommands = map[string]bool{
|
var managedSubcommands = map[string]bool{
|
||||||
"configure": true,
|
"configure": true,
|
||||||
|
"notes": true,
|
||||||
"search": true,
|
"search": true,
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"alias": true,
|
"alias": true,
|
||||||
@@ -213,7 +215,8 @@ func runTUI() {
|
|||||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.New(nbClient, c, 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())
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
final, err := p.Run()
|
final, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -229,6 +232,29 @@ func runTUI() {
|
|||||||
return
|
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)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
var sshArgs []string
|
var sshArgs []string
|
||||||
@@ -289,7 +315,7 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
root.AddCommand(notesCmd(), 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()
|
||||||
@@ -493,7 +519,11 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = 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 {
|
for i := range entries {
|
||||||
e := &entries[i]
|
e := &entries[i]
|
||||||
|
|
||||||
@@ -512,16 +542,24 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
|
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||||
|
written++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := c.SetRefreshed(); err != nil {
|
if filterTag == "" && filterKind == "" {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
if err := c.SetRefreshed(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -530,6 +568,27 @@ 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 {
|
func aliasCmd() *cobra.Command {
|
||||||
var shell string
|
var shell string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -558,46 +617,23 @@ Or use in a script:
|
|||||||
shell = filepath.Base(os.Getenv("SHELL"))
|
shell = filepath.Base(os.Getenv("SHELL"))
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := c.All()
|
entries := buildAliasEntries(c, *cfg)
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
if len(entries) == 0 {
|
||||||
return entries[i].Name < entries[j].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
// Deduplicate: first host wins when two normalize to the same alias.
|
|
||||||
seen := make(map[string]string) // alias name → canonical name
|
|
||||||
var lines []string
|
|
||||||
for _, e := range entries {
|
|
||||||
aliasName := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
|
||||||
if aliasName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if prev, exists := seen[aliasName]; exists {
|
|
||||||
fmt.Fprintf(os.Stderr, "netssh: alias %q conflict: %s and %s — skipping %s\n", aliasName, prev, e.Name, e.Name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[aliasName] = e.Name
|
|
||||||
|
|
||||||
switch shell {
|
|
||||||
case "fish":
|
|
||||||
lines = append(lines, fmt.Sprintf("alias %s 'netssh %s'", aliasName, e.Name))
|
|
||||||
default:
|
|
||||||
lines = append(lines, fmt.Sprintf("alias %s='netssh %s'", aliasName, e.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(lines) == 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
|
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch shell {
|
switch shell {
|
||||||
case "fish":
|
case "fish":
|
||||||
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(lines))
|
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:
|
default:
|
||||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(lines))
|
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
||||||
}
|
for _, e := range entries {
|
||||||
for _, l := range lines {
|
fmt.Printf("alias %s='netssh %s'\n", e.Name, e.Host)
|
||||||
fmt.Println(l)
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -606,12 +642,12 @@ Or use in a script:
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// shellInitCmd is called at shell startup to trigger a background cache refresh when stale.
|
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||||
// It is intentionally silent — no output on success so it never disrupts shell startup.
|
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||||
func shellInitCmd() *cobra.Command {
|
func shellInitCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "shell-init",
|
Use: "shell-init",
|
||||||
Short: "Trigger a background cache refresh if stale (add to shell profile via 'hook install')",
|
Short: "Regenerate alias files and trigger a background cache refresh if stale",
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
@@ -619,6 +655,14 @@ func shellInitCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
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)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -652,15 +696,41 @@ After installation, reload your profile or open a new shell.`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
installed, err := hook.Install(profile)
|
installed, err := hook.Install(profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if !installed {
|
||||||
fmt.Printf("Hook already installed in %s\n", profile)
|
fmt.Printf("Hook already installed in %s\n", profile)
|
||||||
return nil
|
} 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
+5
-1
@@ -2,6 +2,7 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -116,12 +117,15 @@ func (c *Cache) Save() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(c.path, data, 0o644)
|
return storage.WriteFile(c.path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cache) Upsert(e Entry) {
|
func (c *Cache) Upsert(e Entry) {
|
||||||
e.CachedAt = time.Now()
|
e.CachedAt = time.Now()
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
if old, ok := c.entries[e.Name]; ok && e.LastUsed.IsZero() {
|
||||||
|
e.LastUsed = old.LastUsed
|
||||||
|
}
|
||||||
c.entries[e.Name] = e
|
c.entries[e.Name] = e
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+12
@@ -471,3 +471,15 @@ func tempFile(t *testing.T, content []byte) string {
|
|||||||
f.Close()
|
f.Close()
|
||||||
return f.Name()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,13 @@ func Load() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
v.SetEnvPrefix("NETSSH")
|
v.SetEnvPrefix("NETSSH")
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
v.AutomaticEnv()
|
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 err := v.ReadInConfig(); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
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))
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-5
@@ -8,10 +8,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Marker is the unique string used to detect an existing hook installation.
|
// Marker is the unique string used to detect an existing hook line.
|
||||||
Marker = "netssh shell-init"
|
Marker = "netssh shell-init"
|
||||||
// Line is the full line written into the shell profile.
|
// Line is the full line written into the shell profile.
|
||||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
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.
|
// ProfilePath returns the canonical shell profile path for the given shell name.
|
||||||
@@ -68,8 +73,30 @@ func Install(profilePath string) (installed bool, err error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uninstall removes the hook line from the profile.
|
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
|
||||||
// Returns (true, nil) when removed, (false, nil) when the hook was not found.
|
// 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.
|
// Returns (false, nil) — not an error — when the file does not exist.
|
||||||
func Uninstall(profilePath string) (removed bool, err error) {
|
func Uninstall(profilePath string) (removed bool, err error) {
|
||||||
data, err := os.ReadFile(profilePath)
|
data, err := os.ReadFile(profilePath)
|
||||||
@@ -80,13 +107,13 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
|||||||
return false, fmt.Errorf("reading profile: %w", err)
|
return false, fmt.Errorf("reading profile: %w", err)
|
||||||
}
|
}
|
||||||
content := string(data)
|
content := string(data)
|
||||||
if !strings.Contains(content, Marker) {
|
if !strings.Contains(content, Marker) && !strings.Contains(content, AliasesMarker) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var kept []string
|
var kept []string
|
||||||
for _, line := range strings.Split(content, "\n") {
|
for _, line := range strings.Split(content, "\n") {
|
||||||
if strings.Contains(line, Marker) {
|
if strings.Contains(line, Marker) || strings.Contains(line, AliasesMarker) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
kept = append(kept, line)
|
kept = append(kept, line)
|
||||||
|
|||||||
+99
-17
@@ -1,13 +1,17 @@
|
|||||||
package netbox
|
package netbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -27,13 +31,16 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
|||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
tokenVersion: tokenVersion,
|
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.
|
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||||
// Use SearchOptions to restrict by kind or tag.
|
// Use SearchOptions to restrict by kind or tag.
|
||||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
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 (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results []HostEntry
|
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.
|
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||||
// Intended for cache refresh; use Search for interactive queries.
|
// Intended for cache refresh; use Search for interactive queries.
|
||||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
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 (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results []HostEntry
|
results []HostEntry
|
||||||
errs []error
|
errs []error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
started int
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if opts.Kind != "vm" {
|
if opts.Kind != "vm" {
|
||||||
started++
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -113,7 +121,6 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.Kind != "device" {
|
if opts.Kind != "device" {
|
||||||
started++
|
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
@@ -130,11 +137,8 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if len(errs) == started {
|
if len(errs) > 0 {
|
||||||
if started == 1 {
|
return nil, errors.Join(errs...)
|
||||||
return nil, errs[0]
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
@@ -265,16 +269,31 @@ func TokenVersion(token string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
_, 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 {
|
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")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -283,17 +302,17 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
|||||||
if c.tokenVersion == 1 {
|
if c.tokenVersion == 1 {
|
||||||
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
|
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 {
|
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 {
|
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 {
|
func deviceToEntry(d netboxDevice) HostEntry {
|
||||||
@@ -331,3 +350,66 @@ func stripPrefix(cidr string) string {
|
|||||||
}
|
}
|
||||||
return cidr
|
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) {
|
func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||||
var gotAuth string
|
for _, token := range []string{"nbt_mytoken", "legacytoken"} {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
t.Run(token, func(t *testing.T) {
|
||||||
gotAuth = r.Header.Get("Authorization")
|
want := "Token " + token
|
||||||
w.Header().Set("Content-Type", "application/json")
|
if strings.HasPrefix(token, "nbt_") {
|
||||||
b, _ := json.Marshal(deviceListResponse())
|
want = "Bearer " + token
|
||||||
w.Write(b)
|
}
|
||||||
}))
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer srv.Close()
|
if got := r.Header.Get("Authorization"); got != want {
|
||||||
|
t.Errorf("Authorization: got %q, want %q", got, want)
|
||||||
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
}
|
||||||
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
b, _ := json.Marshal(deviceListResponse())
|
||||||
want := "Token nbt_mytoken"
|
w.Write(b)
|
||||||
if gotAuth != want {
|
}))
|
||||||
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,9 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
|||||||
|
|
||||||
// HasPortFlag reports whether a port was specified via -p in args.
|
// HasPortFlag reports whether a port was specified via -p in args.
|
||||||
func HasPortFlag(args []string) bool {
|
func HasPortFlag(args []string) bool {
|
||||||
|
if parsed := Parse(args); parsed != nil {
|
||||||
|
args = args[:parsed.DestIdx]
|
||||||
|
}
|
||||||
for i, a := range args {
|
for i, a := range args {
|
||||||
if a == "-p" && i+1 < len(args) {
|
if a == "-p" && i+1 < len(args) {
|
||||||
return true
|
return true
|
||||||
@@ -102,6 +105,9 @@ func HasPortFlag(args []string) bool {
|
|||||||
// 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 {
|
||||||
|
if parsed := Parse(args); parsed != nil {
|
||||||
|
args = args[:parsed.DestIdx]
|
||||||
|
}
|
||||||
for i, a := range args {
|
for i, a := range args {
|
||||||
if a == "-l" && i+1 < len(args) {
|
if a == "-l" && i+1 < len(args) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -190,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)
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
|
|
||||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||||
type SelectedHost struct {
|
type SelectedHost struct {
|
||||||
|
Kind string
|
||||||
Name string
|
Name string
|
||||||
IP string
|
IP string
|
||||||
User string // empty = use config default
|
User string // empty = use config default
|
||||||
@@ -27,10 +30,19 @@ type SelectedHost struct {
|
|||||||
|
|
||||||
// --- bubbletea messages ---
|
// --- 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 {
|
type searchResultMsg struct {
|
||||||
query string
|
query string
|
||||||
|
seq int
|
||||||
entries []netbox.HostEntry
|
entries []netbox.HostEntry
|
||||||
err error
|
err error
|
||||||
recent bool // true when this is a recently-used list, not a search result
|
recent bool // true when this is a recently-used list, not a search result
|
||||||
@@ -40,6 +52,7 @@ type searchResultMsg struct {
|
|||||||
|
|
||||||
type hostItem struct {
|
type hostItem struct {
|
||||||
name string
|
name string
|
||||||
|
id int
|
||||||
ip string
|
ip string
|
||||||
kind string
|
kind string
|
||||||
tags []string
|
tags []string
|
||||||
@@ -120,27 +133,35 @@ func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
|||||||
// --- Model ---
|
// --- Model ---
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
input textinput.Model
|
noteErr error
|
||||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
editHost *hostItem
|
||||||
editInput textinput.Model // user@host:port inline editor
|
noteInput textarea.Model
|
||||||
list list.Model
|
noteService *notes.Service
|
||||||
client *netbox.Client
|
note notes.Note
|
||||||
cache *cache.Cache
|
noteMode, noteRemote, noteBusy bool
|
||||||
defaultUser string
|
noteLoaded bool
|
||||||
lastSent string // last query sent to NetBox (or served from cache)
|
defaultPort int
|
||||||
lastResults []netbox.HostEntry // raw results before filter applied
|
input textinput.Model
|
||||||
seq int // sequence number to discard stale results
|
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||||
loading bool
|
editInput textinput.Model // user@host:port inline editor
|
||||||
err error
|
list list.Model
|
||||||
selected *SelectedHost
|
client *netbox.Client
|
||||||
width int
|
cache *cache.Cache
|
||||||
height int
|
defaultUser string
|
||||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
lastSent string // last query sent to NetBox (or served from cache)
|
||||||
filterOpen bool // Ctrl+F toggles
|
lastResults []netbox.HostEntry // raw results before filter applied
|
||||||
editMode bool // 'e' on selected item
|
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 := textinput.New()
|
||||||
ti.Placeholder = "Search hostname…"
|
ti.Placeholder = "Search hostname…"
|
||||||
ti.Focus()
|
ti.Focus()
|
||||||
@@ -157,8 +178,18 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
|||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(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{
|
return &Model{
|
||||||
|
noteInput: ni,
|
||||||
|
defaultPort: port,
|
||||||
input: ti,
|
input: ti,
|
||||||
filterInput: fi,
|
filterInput: fi,
|
||||||
editInput: ei,
|
editInput: ei,
|
||||||
@@ -191,6 +222,20 @@ func (m *Model) loadRecent() tea.Cmd {
|
|||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
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:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
@@ -202,10 +247,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.editMode {
|
if m.editMode {
|
||||||
extraRows += 2
|
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
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
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 ---
|
// --- Edit mode ---
|
||||||
if m.editMode {
|
if m.editMode {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
@@ -215,7 +293,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.input.Focus()
|
m.input.Focus()
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
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)
|
m.selected = m.buildSelected(item)
|
||||||
}
|
}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -261,21 +348,44 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
case "e":
|
case "ctrl+e":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
m.openEdit(item)
|
m.openEdit(item)
|
||||||
return m, nil
|
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":
|
case "tab":
|
||||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||||
m.input.SetValue(item.name)
|
m.input.SetValue(item.name)
|
||||||
m.input.CursorEnd()
|
m.input.CursorEnd()
|
||||||
|
m.seq++
|
||||||
|
return m, m.startDebounce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -283,24 +393,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case debounceMsg:
|
case debounceMsg:
|
||||||
q := m.input.Value()
|
q := m.input.Value()
|
||||||
if q == m.lastSent {
|
if msg.seq != m.seq || msg.query != q {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.lastSent = q
|
m.lastSent = q
|
||||||
m.recentMode = false
|
m.recentMode = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
m.seq++
|
|
||||||
seq := m.seq
|
seq := m.seq
|
||||||
return m, m.doSearch(q, seq)
|
return m, m.doSearch(q, seq)
|
||||||
|
|
||||||
case searchResultMsg:
|
case searchResultMsg:
|
||||||
if msg.recent {
|
if msg.recent {
|
||||||
|
if m.input.Value() != "" || m.seq > 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.recentMode = true
|
m.recentMode = true
|
||||||
m.lastResults = msg.entries
|
m.lastResults = msg.entries
|
||||||
m.updateListItems(msg.entries)
|
m.updateListItems(msg.entries)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if msg.query != m.lastSent {
|
if msg.seq != m.seq || msg.query != m.input.Value() {
|
||||||
return m, nil // discard stale result
|
return m, nil // discard stale result
|
||||||
}
|
}
|
||||||
m.loading = false
|
m.loading = false
|
||||||
@@ -314,12 +426,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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.
|
// Forward to search input and restart debounce timer.
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
var inputCmd tea.Cmd
|
var inputCmd tea.Cmd
|
||||||
|
oldQuery := m.input.Value()
|
||||||
m.input, inputCmd = m.input.Update(msg)
|
m.input, inputCmd = m.input.Update(msg)
|
||||||
cmds = append(cmds, inputCmd)
|
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
|
var listCmd tea.Cmd
|
||||||
m.list, listCmd = m.list.Update(msg)
|
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 {
|
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
|
var sb strings.Builder
|
||||||
|
|
||||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
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(m.editInput.View() + "\n")
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||||
} else {
|
} 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")
|
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 {
|
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 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 {
|
for i, c := range cached {
|
||||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
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 {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := m.client.Search(ctx, query, opts)
|
entries, err := m.client.Search(ctx, query, opts)
|
||||||
_ = seq
|
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||||
return searchResultMsg{query: query, entries: entries, err: err}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +559,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = e.PrimaryIP6
|
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)
|
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.
|
// openEdit switches to edit mode for the given list item.
|
||||||
func (m *Model) openEdit(item hostItem) {
|
func (m *Model) openEdit(item hostItem) {
|
||||||
m.editMode = true
|
m.editMode = true
|
||||||
|
m.editHost = &item
|
||||||
m.input.Blur()
|
m.input.Blur()
|
||||||
|
|
||||||
user := m.defaultUser
|
user := m.defaultUser
|
||||||
if user == "" {
|
prefix := ""
|
||||||
user = item.name
|
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.CursorEnd()
|
||||||
m.editInput.Focus()
|
m.editInput.Focus()
|
||||||
}
|
}
|
||||||
@@ -445,7 +583,7 @@ func (m *Model) openEdit(item hostItem) {
|
|||||||
// buildSelected parses the editInput to extract user/port overrides.
|
// buildSelected parses the editInput to extract user/port overrides.
|
||||||
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||||
s := strings.TrimSpace(m.editInput.Value())
|
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 @
|
// Extract user: everything before @
|
||||||
if idx := strings.Index(s, "@"); idx != -1 {
|
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
|
// Extract port: after the last colon, if it's a number
|
||||||
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
||||||
portStr := strings.TrimSpace(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
|
sel.Port = portStr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sel
|
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