7 Commits

Author SHA1 Message Date
Sebastian Unterschütz cdf750081e feat: show subcommands after hostnames in shell completion
Release / release (push) Successful in 48s
Use ShellCompDirectiveKeepOrder so fish/zsh/bash preserve the returned
order instead of sorting alphabetically. ValidArgsFunction now appends
subcommand names (with their short descriptions) after all hostname
entries, so hosts always appear first in the completion list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:42:41 +02:00
Sebastian Unterschütz 574c4dbf58 fix: completion install writes non-empty scripts
Release / release (push) Successful in 47s
GenBashCompletionV2/GenZshCompletion/GenFishCompletion write into the
buffer as a side effect; capturing buf.String() in the return statement
before the Gen* call runs means the buffer is always empty. Separate
the call from the return to fix evaluation order.

Also call InitDefaultCompletionCmd() before iterating root.Commands()
so the lazily-initialized completion subtree is visible before Execute().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:50:53 +02:00
Sebastian Unterschütz 8fc7896b35 docs: update README for wizard, token versions, completion install
Release / release (push) Successful in 46s
2026-05-23 13:31:08 +02:00
Sebastian Unterschütz da3a280a43 feat: netssh completion install — user-space shell completion setup
Adds `netssh completion install` subcommand:
- Auto-detects shell from $SHELL, override with --shell bash|zsh|fish
- bash  → ~/.local/share/bash-completion/completions/netssh
- zsh   → ~/.zfunc/_netssh  (prints fpath hint)
- fish  → ~/.config/fish/completions/netssh.fish
- No sudo required

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:28:43 +02:00
Sebastian Unterschütz a4fa33d224 feat: v2 token support in client + comprehensive tests
Release / release (push) Successful in 51s
API client:
- NewClient now accepts tokenVersion (0 = auto-detect from token prefix)
- tokenVersion stored on Client, used for 403 error hints
- All callers pass cfg.NetBox.TokenVersion

Tests added:
- netbox: TokenVersion, NewClient auto-detect, explicit version,
  403 v1 hint, 403 v2 no-hint, Authorization header verification
- config: token_version preserved/auto-detected, defaults, missing
  file, invalid YAML, Path()
- setup: save roundtrip, file permissions (0600), empty fields
  omitted, dir creation, full save→load roundtrip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:17:34 +02:00
Sebastian Unterschütz 8ae28b3474 feat: persist token_version in config, auto-detect on load
- NetBoxConfig.TokenVersion saved to netssh.yaml by the wizard
- config.Load() auto-detects the version from the token prefix if the
  field is missing (backwards-compatible with existing configs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:12:35 +02:00
Sebastian Unterschütz 9334003c9e feat: detect NetBox token version, hint on v1, better 403 message
- TokenVersion() distinguishes nbt_-prefixed v2 tokens from legacy v1
- 403 errors now say "check token permissions" + v1 hint if applicable
- Setup wizard prints a note after saving if a v1 token was entered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:11:01 +02:00
9 changed files with 616 additions and 61 deletions
+53 -26
View File
@@ -17,7 +17,8 @@ netssh -p 2222 admin@app-server-03 uptime
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
- **Shell completion** — tab-complete hostnames from the cache in zsh, bash, and fish
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
- **Shell completion** — install without sudo via `netssh completion install`
- **Default SSH user** — set a fallback username once in config instead of typing it every time
## Installation
@@ -46,12 +47,27 @@ go build -o netssh ./cmd/netssh
## Configuration
Create `~/.config/netssh.yaml`:
### Interactive wizard
On first run (when no config exists), `netssh` automatically starts an interactive setup wizard.
Re-run it at any time to change settings without editing the file manually:
```sh
netssh configure
```
The wizard walks through NetBox connection, SSH defaults, resolver strategies, and cache TTL,
then saves to `~/.config/netssh.yaml`.
### Manual config
`~/.config/netssh.yaml`:
```yaml
netbox:
url: https://netbox.example.com
token: your-api-token-here
token: nbt_your-api-token-here # v2 token (nbt_ prefix) recommended
token_version: 2 # auto-detected from token; 1 = legacy, 2 = nbt_
resolver:
# Strategies are tried in order; the first to return an IP wins.
@@ -73,7 +89,19 @@ ssh:
default_user: admin # used when no user is specified on the command line
```
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.) or will be read from the config file.
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
### API tokens
NetBox supports two token formats:
| Format | Example | Notes |
|--------|---------|-------|
| v2 (recommended) | `nbt_abc123…` | Create in NetBox → Admin → API Tokens |
| v1 (legacy) | `abc123def456…` | Older format; still works, but v2 is preferred |
`netssh` auto-detects the version from the token prefix and stores it as `token_version` in the config.
A hint is shown during `netssh configure` if a legacy v1 token is entered.
## Usage
@@ -146,28 +174,26 @@ Strategies are tried in the configured order; the first to succeed wins.
## Shell Completion
### zsh
Install completion for the current user (no sudo required):
```sh
netssh completion zsh > "${fpath[1]}/_netssh"
netssh completion install # auto-detects $SHELL
netssh completion install --shell bash
netssh completion install --shell zsh
netssh completion install --shell fish
```
Or add to `.zshrc`:
| Shell | Install path |
|-------|-------------|
| bash | `~/.local/share/bash-completion/completions/netssh` |
| zsh | `~/.zfunc/_netssh` |
| fish | `~/.config/fish/completions/netssh.fish` |
For zsh, make sure `~/.zfunc` is in your `fpath` (add to `~/.zshrc`):
```zsh
source <(netssh completion zsh)
```
### bash
```sh
netssh completion bash > /etc/bash_completion.d/netssh
```
### fish
```sh
netssh completion fish > ~/.config/fish/completions/netssh.fish
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
```
Completions are served from the local cache — no network request on every `<Tab>`.
@@ -179,12 +205,13 @@ go test ./... # run all tests
go build ./... # build all packages
```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, and SSH argument parser.
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, and the setup wizard.
## How it works
1. `netssh` checks whether the first argument is a known subcommand (`search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
3. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
4. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
5. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
+93 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
@@ -99,7 +100,7 @@ func runSSHWrapper(args []string) {
if cfg.NetBox.URL == "" {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
}
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -152,7 +153,7 @@ func runTUI() {
var nbClient *netbox.Client
if cfg.NetBox.URL != "" {
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
}
m := tui.New(nbClient, c)
@@ -198,20 +199,103 @@ func rootCmd() *cobra.Command {
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
entries := c.Search(toComplete)
names := make([]cobra.Completion, len(entries))
for i, e := range entries {
names[i] = cobra.Completion(e.Name)
var completions []cobra.Completion
for _, e := range c.Search(toComplete) {
completions = append(completions, cobra.Completion(e.Name))
}
return names, cobra.ShellCompDirectiveNoFileComp
// Subcommands at the end, after all hostnames.
for _, sub := range cmd.Commands() {
if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) {
completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short))
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
},
}
// cobra automatically adds a "completion" subcommand
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
for _, cmd := range root.Commands() {
if cmd.Name() == "completion" {
cmd.AddCommand(completionInstallCmd(root))
break
}
}
return root
}
func completionInstallCmd(root *cobra.Command) *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Install shell completion for the current user (no sudo required)",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
var (
dir string
file string
gen func() ([]byte, error)
note string
)
switch shell {
case "bash":
dir = filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions")
file = filepath.Join(dir, "netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenBashCompletionV2(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or run: source " + file
case "zsh":
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
file = filepath.Join(dir, "_netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenZshCompletion(&buf)
return []byte(buf.String()), err
}
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
case "fish":
configDir, _ := os.UserConfigDir()
dir = filepath.Join(configDir, "fish", "completions")
file = filepath.Join(dir, "netssh.fish")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenFishCompletion(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or start a new fish session."
default:
return fmt.Errorf("unsupported shell %q — use --shell bash|zsh|fish", shell)
}
script, err := gen()
if err != nil {
return fmt.Errorf("generating completion: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}
if err := os.WriteFile(file, script, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", file, err)
}
fmt.Printf("Completion installed → %s\n%s\n", file, note)
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func configureCmd() *cobra.Command {
return &cobra.Command{
Use: "configure",
@@ -320,7 +404,7 @@ func cacheRefreshCmd() *cobra.Command {
return fmt.Errorf("netbox.url is not configured")
}
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
+12 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
@@ -16,8 +17,9 @@ type Config struct {
}
type NetBoxConfig struct {
URL string `mapstructure:"url"`
Token string `mapstructure:"token"`
URL string `mapstructure:"url"`
Token string `mapstructure:"token"`
TokenVersion int `mapstructure:"token_version"`
}
type ResolverConfig struct {
@@ -73,6 +75,14 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("parsing config: %w", err)
}
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
cfg.NetBox.TokenVersion = 2
} else {
cfg.NetBox.TokenVersion = 1
}
}
if cfg.Cache.Path == "" {
cacheDir, err := os.UserCacheDir()
if err != nil {
+145
View File
@@ -0,0 +1,145 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "netssh.yaml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func loadFromDir(t *testing.T, dir string) *Config {
t.Helper()
// Override UserConfigDir by pointing viper at our temp dir via env isn't
// straightforward, so we exercise Load() by temporarily changing XDG_CONFIG_HOME.
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
t.Cleanup(func() { os.Setenv("XDG_CONFIG_HOME", orig) })
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
return cfg
}
func TestLoad_V2TokenVersion_Preserved(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "nbt_abc123"
token_version: 2
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 2 {
t.Errorf("TokenVersion: got %d, want 2", cfg.NetBox.TokenVersion)
}
}
func TestLoad_V1TokenVersion_Preserved(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "legacyhex123"
token_version: 1
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 1 {
t.Errorf("TokenVersion: got %d, want 1", cfg.NetBox.TokenVersion)
}
}
func TestLoad_AutoDetectsV2_WhenFieldMissing(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "nbt_mytoken"
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 2 {
t.Errorf("TokenVersion: got %d, want 2 (auto-detected from nbt_ prefix)", cfg.NetBox.TokenVersion)
}
}
func TestLoad_AutoDetectsV1_WhenFieldMissing(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "abc123def456"
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 1 {
t.Errorf("TokenVersion: got %d, want 1 (auto-detected from plain token)", cfg.NetBox.TokenVersion)
}
}
func TestLoad_TokenVersionZero_WhenNoToken(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
`)
cfg := loadFromDir(t, dir)
if cfg.NetBox.TokenVersion != 0 {
t.Errorf("TokenVersion: got %d, want 0 (no token present)", cfg.NetBox.TokenVersion)
}
}
func TestLoad_Defaults(t *testing.T) {
dir := writeConfig(t, `
netbox:
url: "https://netbox.example.com"
token: "nbt_x"
`)
cfg := loadFromDir(t, dir)
if cfg.Cache.TTL != 3600 {
t.Errorf("default cache.ttl: got %d, want 3600", cfg.Cache.TTL)
}
if cfg.Cache.Path == "" {
t.Error("cache.path should be auto-set when empty")
}
}
func TestLoad_MissingFile_ReturnsEmptyConfig(t *testing.T) {
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", t.TempDir()) // dir exists but no netssh.yaml
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg, err := Load()
if err != nil {
t.Fatalf("Load on missing file should not error: %v", err)
}
if cfg.NetBox.URL != "" {
t.Errorf("expected empty URL, got %q", cfg.NetBox.URL)
}
}
func TestLoad_InvalidYAML_ReturnsError(t *testing.T) {
dir := writeConfig(t, "not: valid: yaml: [[[")
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
_, err := Load()
if err == nil {
t.Error("invalid YAML should return an error")
}
}
func TestPath_ReturnsNonEmpty(t *testing.T) {
p := Path()
if p == "" {
t.Error("Path() should return a non-empty string")
}
if filepath.Base(p) != "netssh.yaml" {
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
}
}
+30 -7
View File
@@ -11,16 +11,23 @@ import (
)
type Client struct {
baseURL string
token string
httpClient *http.Client
baseURL string
token string
tokenVersion int
httpClient *http.Client
}
func NewClient(baseURL, token string) *Client {
// NewClient creates a NetBox API client. Pass tokenVersion=0 to auto-detect
// from the token string (1 for legacy, 2 for nbt_-prefixed tokens).
func NewClient(baseURL, token string, tokenVersion int) *Client {
if tokenVersion == 0 {
tokenVersion = TokenVersion(token)
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
httpClient: &http.Client{},
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
tokenVersion: tokenVersion,
httpClient: &http.Client{},
}
}
@@ -133,6 +140,14 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
return entries, nil
}
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
func TokenVersion(token string) int {
if strings.HasPrefix(token, "nbt_") {
return 2
}
return 1
}
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
@@ -147,6 +162,14 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
hint := "check token permissions in NetBox"
if c.tokenVersion == 1 {
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
}
return fmt.Errorf("%s: %s", apiURL, hint)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
}
+103 -10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -58,7 +59,7 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "")
if err != nil {
t.Fatalf("Search: %v", err)
@@ -87,7 +88,7 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "")
for _, r := range results {
@@ -113,7 +114,7 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "host")
if len(results) == 0 {
t.Fatal("expected at least one result")
@@ -138,7 +139,7 @@ func TestSearch_TagsAreMapped(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "")
if len(results[0].Tags) != 2 {
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
@@ -159,7 +160,7 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "")
if err != nil {
t.Fatalf("partial failure should not return error, got: %v", err)
@@ -177,7 +178,7 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
_, err := c.Search(context.Background(), "")
if err == nil {
t.Error("both endpoints failing should return an error")
@@ -190,7 +191,7 @@ func TestGetIPs_Device(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"})
if err != nil {
t.Fatalf("GetIPs: %v", err)
@@ -209,7 +210,7 @@ func TestGetIPs_VM(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"})
if err != nil {
t.Fatalf("GetIPs: %v", err)
@@ -220,7 +221,7 @@ func TestGetIPs_VM(t *testing.T) {
}
func TestGetIPs_UnknownKind(t *testing.T) {
c := NewClient("http://localhost", "token")
c := NewClient("http://localhost", "token", 0)
_, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"})
if err == nil {
t.Error("unknown kind should return an error")
@@ -233,7 +234,7 @@ func TestGetIPsWithFilter(t *testing.T) {
})
defer srv.Close()
c := NewClient(srv.URL, "token")
c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0")
if err != nil {
t.Fatalf("GetIPsWithFilter: %v", err)
@@ -243,6 +244,98 @@ func TestGetIPsWithFilter(t *testing.T) {
}
}
func TestTokenVersion(t *testing.T) {
tests := []struct {
token string
want int
}{
{"nbt_abc123", 2},
{"nbt_", 2},
{"abc123def456", 1},
{"", 1},
{"Token abc", 1},
}
for _, tt := range tests {
if got := TokenVersion(tt.token); got != tt.want {
t.Errorf("TokenVersion(%q) = %d, want %d", tt.token, got, tt.want)
}
}
}
func TestNewClient_AutoDetectsVersion(t *testing.T) {
c := NewClient("http://localhost", "nbt_secret", 0)
if c.tokenVersion != 2 {
t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion)
}
c2 := NewClient("http://localhost", "legacytoken", 0)
if c2.tokenVersion != 1 {
t.Errorf("tokenVersion: got %d, want 1", c2.tokenVersion)
}
}
func TestNewClient_RespectsExplicitVersion(t *testing.T) {
// Explicit version overrides auto-detection.
c := NewClient("http://localhost", "legacytoken", 2)
if c.tokenVersion != 2 {
t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion)
}
}
func Test403_V1Token_HintsUpgrade(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
c := NewClient(srv.URL, "legacytoken", 1)
_, err := c.Search(context.Background(), "host")
if err == nil {
t.Fatal("expected error on 403")
}
if !strings.Contains(err.Error(), "v1 token") {
t.Errorf("expected v1 hint in error, got: %v", err)
}
}
func Test403_V2Token_NoV1Hint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden)
}))
defer srv.Close()
c := NewClient(srv.URL, "nbt_secret", 2)
_, err := c.Search(context.Background(), "host")
if err == nil {
t.Fatal("expected error on 403")
}
if strings.Contains(err.Error(), "v1 token") {
t.Errorf("v1 hint should not appear for v2 token, got: %v", err)
}
if !strings.Contains(err.Error(), "check token permissions") {
t.Errorf("expected permissions hint in error, got: %v", err)
}
}
func TestGet_SendsAuthorizationHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(deviceListResponse())
w.Write(b)
}))
defer srv.Close()
c := NewClient(srv.URL, "nbt_mytoken", 2)
c.Search(context.Background(), "") //nolint:errcheck
want := "Token nbt_mytoken"
if gotAuth != want {
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
}
}
func TestStripPrefix(t *testing.T) {
tests := []struct {
in string
+5 -5
View File
@@ -36,7 +36,7 @@ func TestManagementSubnetStrategy_MatchesSubnet(t *testing.T) {
defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
client := netbox.NewClient(srv.URL, "token")
client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil {
@@ -52,7 +52,7 @@ func TestManagementSubnetStrategy_NoMatch(t *testing.T) {
defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
client := netbox.NewClient(srv.URL, "token")
client := netbox.NewClient(srv.URL, "token", 0)
_, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != ErrNoIP {
@@ -65,7 +65,7 @@ func TestManagementSubnetStrategy_FirstMatchWins(t *testing.T) {
defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
client := netbox.NewClient(srv.URL, "token")
client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil {
@@ -81,7 +81,7 @@ func TestManagementSubnetStrategy_VMKind(t *testing.T) {
defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"172.16.0.0/12"})
client := netbox.NewClient(srv.URL, "token")
client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 2, Kind: "vm"}, client)
if err != nil {
@@ -97,7 +97,7 @@ func TestManagementSubnetStrategy_IPv6Subnet(t *testing.T) {
defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"fd00::/8"})
client := netbox.NewClient(srv.URL, "token")
client := netbox.NewClient(srv.URL, "token", 0)
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil {
+11 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/huh"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
@@ -102,6 +103,12 @@ func RunWizard(cfg *config.Config) error {
return err
}
tokenVersion := netbox.TokenVersion(token)
if tokenVersion == 1 {
fmt.Fprintln(os.Stderr, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
}
ttl, _ := strconv.Atoi(cacheTTL)
var subnetList []string
@@ -113,8 +120,9 @@ func RunWizard(cfg *config.Config) error {
out := config.Config{
NetBox: config.NetBoxConfig{
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token),
TokenVersion: tokenVersion,
},
SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser),
@@ -142,6 +150,7 @@ func save(cfg config.Config) error {
b.WriteString("netbox:\n")
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
b.WriteString("\nresolver:\n")
b.WriteString(" strategies:\n")
+164
View File
@@ -0,0 +1,164 @@
package setup
import (
"os"
"path/filepath"
"strings"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func TestSave_WritesFile(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.example.com",
Token: "nbt_abc123",
TokenVersion: 2,
},
SSH: config.SSHConfig{DefaultUser: "admin"},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip", "management_subnet"},
ManagementSubnets: []string{"10.0.0.0/8"},
},
Cache: config.CacheConfig{TTL: 3600},
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
if err != nil {
t.Fatalf("reading saved file: %v", err)
}
content := string(data)
for _, want := range []string{
`"https://netbox.example.com"`,
`"nbt_abc123"`,
`token_version: 2`,
`- primary_ip`,
`- management_subnet`,
`- 10.0.0.0/8`,
`ttl: 3600`,
`"admin"`,
} {
if !strings.Contains(content, want) {
t.Errorf("saved config missing %q\nfull content:\n%s", want, content)
}
}
}
func TestSave_FilePermissions(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
if err := save(config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
}); err != nil {
t.Fatalf("save: %v", err)
}
info, err := os.Stat(filepath.Join(dir, "netssh.yaml"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("file permissions: got %o, want 600", perm)
}
}
func TestSave_OmitsEmptyOptionalFields(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
cfg := config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
// No DefaultUser, no ManagementSubnets, no InterfaceName
}
if err := save(cfg); err != nil {
t.Fatalf("save: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
content := string(data)
for _, absent := range []string{"default_user", "management_subnets", "interface_name"} {
if strings.Contains(content, absent) {
t.Errorf("config should not contain %q when field is empty\nfull content:\n%s", absent, content)
}
}
}
func TestSave_CreatesConfigDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "does", "not", "exist")
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
if err := save(config.Config{
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
Cache: config.CacheConfig{TTL: 60},
}); err != nil {
t.Fatalf("save should create missing directories: %v", err)
}
}
func TestSave_RoundtripViaLoad(t *testing.T) {
dir := t.TempDir()
orig := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", dir)
defer os.Setenv("XDG_CONFIG_HOME", orig)
original := config.Config{
NetBox: config.NetBoxConfig{
URL: "https://netbox.zb-server.de",
Token: "nbt_supersecret",
TokenVersion: 2,
},
SSH: config.SSHConfig{DefaultUser: "root"},
Resolver: config.ResolverConfig{
Strategies: []string{"primary_ip"},
ManagementSubnets: []string{"192.168.0.0/16"},
InterfaceName: "eth0",
},
Cache: config.CacheConfig{TTL: 7200},
}
if err := save(original); err != nil {
t.Fatalf("save: %v", err)
}
loaded, err := config.Load()
if err != nil {
t.Fatalf("Load after save: %v", err)
}
if loaded.NetBox.URL != original.NetBox.URL {
t.Errorf("URL: got %q, want %q", loaded.NetBox.URL, original.NetBox.URL)
}
if loaded.NetBox.Token != original.NetBox.Token {
t.Errorf("Token: got %q, want %q", loaded.NetBox.Token, original.NetBox.Token)
}
if loaded.NetBox.TokenVersion != original.NetBox.TokenVersion {
t.Errorf("TokenVersion: got %d, want %d", loaded.NetBox.TokenVersion, original.NetBox.TokenVersion)
}
if loaded.SSH.DefaultUser != original.SSH.DefaultUser {
t.Errorf("DefaultUser: got %q, want %q", loaded.SSH.DefaultUser, original.SSH.DefaultUser)
}
if loaded.Cache.TTL != original.Cache.TTL {
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
}
}