6 Commits

Author SHA1 Message Date
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 605 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
- **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.
+82 -4
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)
@@ -207,11 +208,88 @@ func rootCmd() *cobra.Command {
},
}
// 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 +398,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)
}
}