Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc7896b35 | |||
| da3a280a43 |
@@ -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.
|
||||
|
||||
+75
-1
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
@@ -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())
|
||||
|
||||
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
|
||||
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 {
|
||||
return &cobra.Command{
|
||||
Use: "configure",
|
||||
|
||||
Reference in New Issue
Block a user