5 Commits

Author SHA1 Message Date
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 601 additions and 56 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 - **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments) - **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
- **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
- **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 - **Default SSH user** — set a fallback username once in config instead of typing it every time
## Installation ## Installation
@@ -46,12 +47,27 @@ go build -o netssh ./cmd/netssh
## Configuration ## 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 ```yaml
netbox: netbox:
url: https://netbox.example.com 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: resolver:
# Strategies are tried in order; the first to return an IP wins. # 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 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 ## Usage
@@ -146,28 +174,26 @@ Strategies are tried in the configured order; the first to succeed wins.
## Shell Completion ## Shell Completion
### zsh Install completion for the current user (no sudo required):
```sh ```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 ```zsh
source <(netssh completion zsh) fpath=(~/.zfunc $fpath)
``` autoload -Uz compinit && compinit
### bash
```sh
netssh completion bash > /etc/bash_completion.d/netssh
```
### fish
```sh
netssh completion fish > ~/.config/fish/completions/netssh.fish
``` ```
Completions are served from the local cache — no network request on every `<Tab>`. Completions are served from the local cache — no network request on every `<Tab>`.
@@ -179,12 +205,13 @@ go test ./... # run all tests
go build ./... # build all packages go build ./... # build all packages
``` ```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, 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 ## How it works
1. `netssh` checks whether the first argument is a known subcommand (`search`, `cache`, `completion`). If not, it enters SSH wrapper mode. 1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …). 2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
3. It checks the local cache. If the entry exists and is within the TTL, it connects immediately. 3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
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. 4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
5. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP. 5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
+78 -4
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
@@ -99,7 +100,7 @@ func runSSHWrapper(args []string) {
if cfg.NetBox.URL == "" { if cfg.NetBox.URL == "" {
fatalf("netbox.url is not configured (~/.config/netssh.yaml)") fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
} }
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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -152,7 +153,7 @@ func runTUI() {
var nbClient *netbox.Client var nbClient *netbox.Client
if cfg.NetBox.URL != "" { if cfg.NetBox.URL != "" {
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token) nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
} }
m := tui.New(nbClient, c) m := tui.New(nbClient, c)
@@ -207,11 +208,84 @@ func rootCmd() *cobra.Command {
}, },
} }
// cobra automatically adds a "completion" subcommand // cobra automatically adds a "completion" subcommand; we extend it with "install".
root.AddCommand(configureCmd(), searchCmd(), cacheCmd()) root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
for _, cmd := range root.Commands() {
if cmd.Name() == "completion" {
cmd.AddCommand(completionInstallCmd(root))
break
}
}
return root 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
return []byte(buf.String()), root.GenBashCompletionV2(&buf, true)
}
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
return []byte(buf.String()), root.GenZshCompletion(&buf)
}
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
return []byte(buf.String()), root.GenFishCompletion(&buf, true)
}
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 { func configureCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "configure", Use: "configure",
@@ -320,7 +394,7 @@ func cacheRefreshCmd() *cobra.Command {
return fmt.Errorf("netbox.url is not configured") 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
+10
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -18,6 +19,7 @@ type Config struct {
type NetBoxConfig struct { type NetBoxConfig struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`
Token string `mapstructure:"token"` Token string `mapstructure:"token"`
TokenVersion int `mapstructure:"token_version"`
} }
type ResolverConfig struct { type ResolverConfig struct {
@@ -73,6 +75,14 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("parsing config: %w", err) 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 == "" { if cfg.Cache.Path == "" {
cacheDir, err := os.UserCacheDir() cacheDir, err := os.UserCacheDir()
if err != nil { 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))
}
}
+24 -1
View File
@@ -13,13 +13,20 @@ import (
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
tokenVersion int
httpClient *http.Client 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{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
token: token, token: token,
tokenVersion: tokenVersion,
httpClient: &http.Client{}, httpClient: &http.Client{},
} }
} }
@@ -133,6 +140,14 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
return entries, nil 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 { func (c *Client) get(ctx context.Context, apiURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil { if err != nil {
@@ -147,6 +162,14 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
} }
defer resp.Body.Close() 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 { 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)
} }
+103 -10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@@ -58,7 +59,7 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "") results, err := c.Search(context.Background(), "")
if err != nil { if err != nil {
t.Fatalf("Search: %v", err) t.Fatalf("Search: %v", err)
@@ -87,7 +88,7 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "") results, _ := c.Search(context.Background(), "")
for _, r := range results { for _, r := range results {
@@ -113,7 +114,7 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "host") results, _ := c.Search(context.Background(), "host")
if len(results) == 0 { if len(results) == 0 {
t.Fatal("expected at least one result") t.Fatal("expected at least one result")
@@ -138,7 +139,7 @@ func TestSearch_TagsAreMapped(t *testing.T) {
}) })
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, _ := c.Search(context.Background(), "") results, _ := c.Search(context.Background(), "")
if len(results[0].Tags) != 2 { if len(results[0].Tags) != 2 {
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags) t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
@@ -159,7 +160,7 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
results, err := c.Search(context.Background(), "") results, err := c.Search(context.Background(), "")
if err != nil { if err != nil {
t.Fatalf("partial failure should not return error, got: %v", err) t.Fatalf("partial failure should not return error, got: %v", err)
@@ -177,7 +178,7 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token") c := NewClient(srv.URL, "token", 0)
_, err := c.Search(context.Background(), "") _, err := c.Search(context.Background(), "")
if err == nil { if err == nil {
t.Error("both endpoints failing should return an error") t.Error("both endpoints failing should return an error")
@@ -190,7 +191,7 @@ func TestGetIPs_Device(t *testing.T) {
}) })
defer srv.Close() 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"}) ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"})
if err != nil { if err != nil {
t.Fatalf("GetIPs: %v", err) t.Fatalf("GetIPs: %v", err)
@@ -209,7 +210,7 @@ func TestGetIPs_VM(t *testing.T) {
}) })
defer srv.Close() 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"}) ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"})
if err != nil { if err != nil {
t.Fatalf("GetIPs: %v", err) t.Fatalf("GetIPs: %v", err)
@@ -220,7 +221,7 @@ func TestGetIPs_VM(t *testing.T) {
} }
func TestGetIPs_UnknownKind(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"}) _, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"})
if err == nil { if err == nil {
t.Error("unknown kind should return an error") t.Error("unknown kind should return an error")
@@ -233,7 +234,7 @@ func TestGetIPsWithFilter(t *testing.T) {
}) })
defer srv.Close() 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") ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0")
if err != nil { if err != nil {
t.Fatalf("GetIPsWithFilter: %v", err) 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) { func TestStripPrefix(t *testing.T) {
tests := []struct { tests := []struct {
in string in string
+5 -5
View File
@@ -36,7 +36,7 @@ func TestManagementSubnetStrategy_MatchesSubnet(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"}) 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) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil { if err != nil {
@@ -52,7 +52,7 @@ func TestManagementSubnetStrategy_NoMatch(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"}) 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) _, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != ErrNoIP { if err != ErrNoIP {
@@ -65,7 +65,7 @@ func TestManagementSubnetStrategy_FirstMatchWins(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"}) 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) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil { if err != nil {
@@ -81,7 +81,7 @@ func TestManagementSubnetStrategy_VMKind(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"172.16.0.0/12"}) 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) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 2, Kind: "vm"}, client)
if err != nil { if err != nil {
@@ -97,7 +97,7 @@ func TestManagementSubnetStrategy_IPv6Subnet(t *testing.T) {
defer srv.Close() defer srv.Close()
s, _ := NewManagementSubnetStrategy([]string{"fd00::/8"}) 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) ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
if err != nil { if err != nil {
+9
View File
@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"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/netbox"
) )
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values. // 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 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) ttl, _ := strconv.Atoi(cacheTTL)
var subnetList []string var subnetList []string
@@ -115,6 +122,7 @@ func RunWizard(cfg *config.Config) error {
NetBox: config.NetBoxConfig{ NetBox: config.NetBoxConfig{
URL: strings.TrimRight(strings.TrimSpace(url), "/"), URL: strings.TrimRight(strings.TrimSpace(url), "/"),
Token: strings.TrimSpace(token), Token: strings.TrimSpace(token),
TokenVersion: tokenVersion,
}, },
SSH: config.SSHConfig{ SSH: config.SSHConfig{
DefaultUser: strings.TrimSpace(defaultUser), DefaultUser: strings.TrimSpace(defaultUser),
@@ -142,6 +150,7 @@ func save(cfg config.Config) error {
b.WriteString("netbox:\n") b.WriteString("netbox:\n")
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL)) b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token)) 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("\nresolver:\n")
b.WriteString(" strategies:\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)
}
}