Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 574c4dbf58 | |||
| 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
|
- **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.
|
||||||
|
|||||||
+79
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -207,11 +208,88 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// cobra automatically adds a "completion" subcommand
|
|
||||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
|
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
|
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 {
|
func configureCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "configure",
|
Use: "configure",
|
||||||
|
|||||||
Reference in New Issue
Block a user