Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091f5cc518 | ||
|
|
6243514def | ||
|
|
6f00d69b6f | ||
|
|
6711b610f1 | ||
|
|
c2e590bf52 | ||
|
|
786768ec53 | ||
|
|
2b46c4f74d | ||
|
|
129a7201bc | ||
|
|
8fb7633783 |
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
||||
out="dist/netssh_${GOOS}_${GOARCH}"
|
||||
echo "→ $out"
|
||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||
go build -trimpath -ldflags="-s -w" \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${GITHUB_REF_NAME}" \
|
||||
-o "$out" ./cmd/netssh
|
||||
done
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v0.0.22
|
||||
|
||||
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
|
||||
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
|
||||
|
||||
## v0.0.21
|
||||
|
||||
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
|
||||
- Add `completion.max_results` to limit the number of hostnames offered per completion.
|
||||
- Register zsh completion in the managed `.zshrc` block (`fpath` and `compdef`), also when `compinit` already ran, e.g. with oh-my-zsh. The installer reports which completions it set up.
|
||||
- Add `netssh uninstall [--keep-config] [--dry-run] [--yes]` to remove the binary, profile entries of all versions, completions, cache, and config (with a private backup).
|
||||
- Write marked `# >>> netssh >>>` blocks from `netssh hook install` and the wizard, replacing unmarked entries of older versions instead of appending.
|
||||
- Add jump host support: `ssh.proxy_jump` globally, per NetBox instance, and per rule, plus `--jump HOST` and `--no-jump`.
|
||||
- Add `ssh.host_key_checking` (`strict`, `accept-new`, `off`) for netssh connections only.
|
||||
- Add `ssh.target: name|hostname` so `~/.ssh/config` host patterns apply and known_hosts entries belong to the device, and `ssh.address_family`.
|
||||
- Add `ssh.options` and `ssh.extra_args`, and `ssh.rules` that match by tag, instance, kind, or name pattern.
|
||||
- Add `netssh --version` / `netssh version` and `netssh --dry-run` to print the final ssh command.
|
||||
|
||||
## v0.0.20
|
||||
|
||||
- Configure multiple named NetBox instances and search them together in parallel.
|
||||
|
||||
@@ -32,6 +32,9 @@ netssh -p 2222 admin@app-server-03 uptime
|
||||
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
|
||||
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
||||
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
||||
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
|
||||
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
|
||||
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -135,6 +138,11 @@ cache:
|
||||
ssh:
|
||||
default_user: admin # used when no user is specified on the command line
|
||||
default_port: 2222 # optional; injected as -p if not specified on the command line
|
||||
# See "SSH options" below for proxy_jump, host_key_checking, target,
|
||||
# address_family, extra_args, options, and rules.
|
||||
|
||||
completion:
|
||||
max_results: 0 # hostnames offered per <Tab>; 0 = unlimited
|
||||
|
||||
shortcuts:
|
||||
domains: # strip these suffixes to create short aliases
|
||||
@@ -240,6 +248,74 @@ netssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no db-primary
|
||||
|
||||
The process is replaced by `ssh` with the resolved IP — your `~/.ssh/config`, agent, and keys all work as normal.
|
||||
|
||||
### Dry run and version
|
||||
|
||||
```sh
|
||||
netssh --dry-run web01 # print the final ssh command instead of connecting
|
||||
netssh --version # or: netssh version
|
||||
```
|
||||
|
||||
Long options (`--dry-run`, `--jump`, `--no-jump`) are netssh's own; OpenSSH has
|
||||
no long options. They must come before the hostname — everything after the
|
||||
hostname is the remote command.
|
||||
|
||||
### SSH options
|
||||
|
||||
All settings below go under `ssh:`. The same keys (except `rules`) can be set
|
||||
per NetBox instance under `netboxes[].ssh` (or `netbox.ssh`), and per rule.
|
||||
More specific settings win: global → instance → matching rules (in order) →
|
||||
command-line flags. Options you pass on the command line always take precedence
|
||||
over the config.
|
||||
|
||||
```yaml
|
||||
ssh:
|
||||
proxy_jump: "jumphost.example.com" # passed as -J; "none" disables an inherited jump host
|
||||
host_key_checking: accept-new # strict | accept-new | off; unset = ssh's own setting
|
||||
target: ip # ip (default) | name | hostname
|
||||
address_family: inet # any | inet (-4) | inet6 (-6)
|
||||
extra_args: ["-A"] # inserted before your own arguments
|
||||
options: # passed as -o Key=Value
|
||||
ServerAliveInterval: 60
|
||||
ServerAliveCountMax: 5
|
||||
rules: # every set match field must match
|
||||
- match: {tag: dmz} # tag name or slug
|
||||
proxy_jump: "dmz-jump.example.com"
|
||||
- match: {instance: lab, name: "*.lab.example.com", kind: vm}
|
||||
host_key_checking: off
|
||||
|
||||
netboxes:
|
||||
- name: prod
|
||||
url: "https://netbox.example.com"
|
||||
token: "nbt_…"
|
||||
ssh:
|
||||
proxy_jump: "jumphost.example.com"
|
||||
- name: staging
|
||||
url: "https://netbox-staging.example.com"
|
||||
token: "nbt_…"
|
||||
ssh:
|
||||
host_key_checking: off
|
||||
```
|
||||
|
||||
**Jump hosts.** Override the configured jump host for a single call with
|
||||
`--jump other.example.com` (or ssh's own `-J`), or disable all jump hosts —
|
||||
including `ProxyJump` from `~/.ssh/config` — with `--no-jump`.
|
||||
|
||||
**Host key checking.** `strict` sets `StrictHostKeyChecking=yes`, `accept-new`
|
||||
accepts keys of new hosts but rejects changed ones. `off` sets
|
||||
`StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`, and `LogLevel=ERROR`
|
||||
for netssh connections only and prints a short notice. Use it only for hosts
|
||||
that are reinstalled often, such as staging; it disables protection against
|
||||
man-in-the-middle attacks.
|
||||
|
||||
**Connection target.** By default netssh calls `ssh <ip>`. With `target: name`,
|
||||
it calls `ssh -o HostName=<ip> -o HostKeyAlias=<name> <name>`: the resolved IP
|
||||
is still used, but `Host *.example.com` rules in `~/.ssh/config` apply, and
|
||||
known_hosts entries belong to the device instead of an IP that may be reused.
|
||||
With `target: hostname`, ssh resolves the NetBox name itself (DNS or
|
||||
`~/.ssh/config`). Names that are not valid hostnames fall back to the IP.
|
||||
Switching to `name` stores new known_hosts entries under the device name, so
|
||||
ssh asks once to confirm each host key.
|
||||
|
||||
### Default username
|
||||
|
||||
Set `ssh.default_user` in the config to avoid typing a username every time:
|
||||
@@ -422,7 +498,7 @@ netssh hook install --shell zsh
|
||||
netssh hook install --shell fish
|
||||
```
|
||||
|
||||
This appends a single line (`netssh shell-init`) to your shell profile. To remove it:
|
||||
This adds a marked block (`# >>> netssh >>>` … `# <<< netssh <<<`) to your shell profile. To remove it:
|
||||
|
||||
```sh
|
||||
netssh hook uninstall
|
||||
@@ -465,14 +541,24 @@ netssh completion install --shell fish
|
||||
| zsh | `~/.zfunc/_netssh` |
|
||||
| fish | `~/.config/fish/completions/netssh.fish` |
|
||||
|
||||
For zsh, make sure `~/.zfunc` is in your `fpath` (add to `~/.zshrc`):
|
||||
For zsh, the netssh block in `~/.zshrc` (written by the installer and by
|
||||
`netssh hook install --shell zsh`) adds `~/.zfunc` to `fpath` and registers the
|
||||
completion with `compdef`, also when `compinit` already ran earlier — for
|
||||
example via oh-my-zsh. Without the block, add `fpath=(~/.zfunc $fpath)` before
|
||||
`compinit`. If `<Tab>` still completes files, clear the completion cache:
|
||||
|
||||
```zsh
|
||||
fpath=(~/.zfunc $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
rm -f ~/.zcompdump* && exec zsh
|
||||
```
|
||||
|
||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
||||
With several NetBox instances, a hostname that exists in only one instance is
|
||||
completed without the instance prefix (`web01`); names that exist in several
|
||||
instances are offered as `prod/web01`, `staging/web01`. In bash, type the
|
||||
instance prefix for those; zsh and fish offer them for the hostname alone.
|
||||
The zsh script that `netssh completion zsh` prints is built for large caches:
|
||||
5000 hosts complete in well under a second. To limit the number of hostnames
|
||||
offered, set `completion.max_results`.
|
||||
|
||||
## Shell Hook
|
||||
|
||||
@@ -502,15 +588,25 @@ Reload with: source /home/user/.bashrc
|
||||
|
||||
What gets written to the profile:
|
||||
|
||||
**bash / zsh** (`~/.bashrc` or `~/.zshrc`):
|
||||
**bash** (`~/.bashrc`):
|
||||
```bash
|
||||
netssh shell-init # netssh cache auto-refresh
|
||||
source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases
|
||||
# >>> netssh >>>
|
||||
netssh shell-init
|
||||
[ ! -f '/home/user/.cache/netssh/aliases.sh' ] || . '/home/user/.cache/netssh/aliases.sh'
|
||||
# <<< netssh <<<
|
||||
```
|
||||
|
||||
**zsh** (`~/.zshrc`) additionally registers the completion:
|
||||
```zsh
|
||||
fpath=('/home/user/.zfunc' $fpath)
|
||||
if (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi
|
||||
```
|
||||
|
||||
**fish** (`~/.config/fish/config.fish`):
|
||||
```fish
|
||||
netssh shell-init # netssh cache auto-refresh
|
||||
# >>> netssh >>>
|
||||
netssh shell-init
|
||||
# <<< netssh <<<
|
||||
```
|
||||
|
||||
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
|
||||
@@ -521,7 +617,7 @@ Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically a
|
||||
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
|
||||
| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` |
|
||||
|
||||
The install is idempotent — running it again changes nothing if the lines are already present.
|
||||
The install is idempotent — running it again changes nothing if the block is already present. Unmarked lines written by older versions are replaced by the block.
|
||||
|
||||
Reload your profile after installation to activate immediately:
|
||||
|
||||
@@ -550,7 +646,7 @@ netssh hook uninstall # auto-detects $SHELL
|
||||
netssh hook uninstall --shell zsh
|
||||
```
|
||||
|
||||
Removes both the `netssh shell-init` line and the `source aliases.sh` line from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk — delete them manually if desired.
|
||||
Removes the netssh block and unmarked entries of older versions from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk. To remove netssh completely, use `netssh uninstall`.
|
||||
|
||||
### Relation to the connect-time auto-refresh
|
||||
|
||||
@@ -563,6 +659,24 @@ The shell hook and the connect-time trigger (which fires on each `netssh` invoca
|
||||
|
||||
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```sh
|
||||
netssh uninstall --dry-run # show what would be removed
|
||||
netssh uninstall # asks for confirmation
|
||||
netssh uninstall --keep-config # keep config and notes for a reinstall
|
||||
```
|
||||
|
||||
`netssh uninstall` removes:
|
||||
|
||||
- netssh blocks and unmarked entries of older versions from `~/.bashrc`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`, `~/.zshrc`, `~/.zprofile`, `~/.zshenv`, and `config.fish`
|
||||
- completion files for bash, zsh (also the oh-my-zsh cache), fish, and the command alias
|
||||
- the cache directory `~/.cache/netssh/` including per-instance caches and aliases
|
||||
- `~/.config/netssh.yaml` (a private backup `netssh.yaml.backup-<timestamp>` is kept, because it contains API tokens) and local notes
|
||||
- the netssh binary; if it is in a protected directory, netssh prints the `sudo rm` command to run
|
||||
|
||||
Afterwards open a new terminal; for zsh also run `rm -f ~/.zcompdump*`.
|
||||
|
||||
## Development
|
||||
|
||||
Renovate monitors the release workflow's actions, the Go version in `go.mod`, and
|
||||
@@ -582,9 +696,9 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
|
||||
|
||||
## How it works
|
||||
|
||||
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `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`, `version`, `uninstall`, …). 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 resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made.
|
||||
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.
|
||||
6. It adds the configured SSH options for the host (jump host, host key checking, `-o` options) before the destination and calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP (or the name, with `ssh.target: name`).
|
||||
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
@@ -30,3 +34,36 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// hostCompletions returns the cached hosts matching toComplete in a form that
|
||||
// starts with the typed text, because bash and fish only accept such
|
||||
// candidates. Without an instance prefix, a hostname that is unique across
|
||||
// all instances is offered bare ("web01"), so it completes like with a single
|
||||
// NetBox; connecting to it still checks every instance for ambiguity.
|
||||
// Names present in several instances stay qualified ("prod/web01").
|
||||
func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
||||
prefix := strings.ToLower(toComplete)
|
||||
qualifiedInput := strings.Contains(prefix, "/")
|
||||
instances := make(map[string]int)
|
||||
for _, e := range entries {
|
||||
_, name := netbox.SplitTarget(e.Name)
|
||||
instances[strings.ToLower(name)]++
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
source, name := netbox.SplitTarget(e.Name)
|
||||
target := strings.ToLower(e.Name)
|
||||
switch {
|
||||
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
|
||||
if instances[strings.ToLower(name)] == 1 {
|
||||
out = append(out, name)
|
||||
} else {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
case strings.HasPrefix(target, prefix):
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -206,3 +207,33 @@ func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCompletionsMatchTypedText(t *testing.T) {
|
||||
entries := []cache.Entry{
|
||||
{Name: "prod/fsn1-bx1.example.com"},
|
||||
{Name: "prod/fsn1-bx2.example.com"},
|
||||
{Name: "stg/fsn1-bx2.example.com"},
|
||||
{Name: "stg/hel1-tb1.example.com"},
|
||||
{Name: "prod/HEL1-TB2"},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
// Unique names are bare; names in both instances stay qualified.
|
||||
{"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}},
|
||||
{"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}},
|
||||
{"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}},
|
||||
{"stg/fsn", []string{"stg/fsn1-bx2.example.com"}},
|
||||
// An instance name typed without the slash completes qualified targets.
|
||||
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
|
||||
} {
|
||||
if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
// Single-instance caches are unchanged.
|
||||
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) {
|
||||
t.Errorf("single instance: %v", got)
|
||||
}
|
||||
}
|
||||
+70
-34
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -39,6 +40,8 @@ var managedSubcommands = map[string]bool{
|
||||
"completion": true,
|
||||
"__complete": true,
|
||||
"help": true,
|
||||
"version": true,
|
||||
"uninstall": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -48,6 +51,10 @@ func main() {
|
||||
runTUI()
|
||||
return
|
||||
}
|
||||
if args[0] == "--version" {
|
||||
fmt.Printf("netssh %s\n", buildVersion())
|
||||
return
|
||||
}
|
||||
|
||||
if managedSubcommands[args[0]] {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
@@ -80,16 +87,21 @@ func loadConfigOrSetup() *config.Config {
|
||||
|
||||
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
|
||||
func runSSHWrapper(args []string) {
|
||||
flags, args, err := internalssh.ExtractWrapperFlags(args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
cfg := loadConfigOrSetup()
|
||||
|
||||
parsed := internalssh.Parse(args)
|
||||
if parsed == nil {
|
||||
// No destination found — pass arguments straight through to ssh.
|
||||
if err := internalssh.Exec(args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
execSSH(internalssh.Result{Args: args}, flags)
|
||||
return
|
||||
}
|
||||
connect := func(name, kind, ip string, tags []string) {
|
||||
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
|
||||
}
|
||||
|
||||
// Inject the configured default user if none was given on the command line.
|
||||
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
||||
@@ -113,7 +125,7 @@ func runSSHWrapper(args []string) {
|
||||
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||
c.MarkUsed(parsed.Host)
|
||||
_ = c.Save()
|
||||
connect(entry.IP, parsed, args)
|
||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +143,7 @@ func runSSHWrapper(args []string) {
|
||||
if fresh {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
connect(entry.IP, parsed, args)
|
||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||
return
|
||||
}
|
||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||
@@ -164,7 +176,7 @@ func runSSHWrapper(args []string) {
|
||||
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
connect(cached.IP, parsed, args)
|
||||
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,7 +194,7 @@ func runSSHWrapper(args []string) {
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
|
||||
connect(ip, parsed, args)
|
||||
connect(target.Target(), target.Kind, ip, target.Tags)
|
||||
}
|
||||
|
||||
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||
@@ -205,9 +217,24 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain
|
||||
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
||||
}
|
||||
|
||||
func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
||||
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip)
|
||||
if err := internalssh.Exec(newArgs); err != nil {
|
||||
// connectHost applies the SSH options configured for the host and execs ssh.
|
||||
// target is the cache/selector name, which may carry an instance prefix.
|
||||
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
|
||||
instance, name := netbox.SplitTarget(target)
|
||||
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
|
||||
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
|
||||
}
|
||||
|
||||
// execSSH replaces the process with ssh, or prints the command for --dry-run.
|
||||
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||
if flags.DryRun {
|
||||
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
|
||||
return
|
||||
}
|
||||
if result.Notice != "" {
|
||||
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
||||
}
|
||||
if err := internalssh.Exec(result.Args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -243,8 +270,10 @@ func runTUI() {
|
||||
}
|
||||
|
||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||
var tags []string
|
||||
if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind {
|
||||
host.IP = cached.IP
|
||||
tags = cached.Tags
|
||||
} else {
|
||||
if nbClient == nil {
|
||||
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
|
||||
@@ -264,6 +293,7 @@ func runTUI() {
|
||||
fatalf("IP resolution: %v", err)
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||
tags = target.Tags
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
|
||||
@@ -291,9 +321,7 @@ func runTUI() {
|
||||
c.MarkUsed(host.Name)
|
||||
_ = c.Save()
|
||||
|
||||
if err := internalssh.Exec(sshArgs); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
|
||||
}
|
||||
|
||||
// --- cobra root + subcommands ---
|
||||
@@ -311,9 +339,13 @@ func rootCmd() *cobra.Command {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
var completions []cobra.Completion
|
||||
for _, e := range c.Search(toComplete) {
|
||||
completions = append(completions, cobra.Completion(e.Name))
|
||||
hosts := hostCompletions(c.All(), toComplete)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
}
|
||||
completions := make([]cobra.Completion, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
completions = append(completions, cobra.Completion(host))
|
||||
}
|
||||
// Subcommands at the end, after all hostnames.
|
||||
for _, sub := range cmd.Commands() {
|
||||
@@ -325,13 +357,24 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
|
||||
|
||||
// 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))
|
||||
// Cobra's zsh script spawns a subshell per line and sends every host
|
||||
// through _describe, which hangs with thousands of cached hosts.
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == "zsh" {
|
||||
sub.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
_, err := io.WriteString(cmd.OutOrStdout(), hook.ZshCompletionScript)
|
||||
return err
|
||||
}
|
||||
sub.Run = nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -369,12 +412,8 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
||||
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"
|
||||
gen = func() ([]byte, error) { return []byte(hook.ZshCompletionScript), nil }
|
||||
note = hook.ZshCompletionNote(dir)
|
||||
case "fish":
|
||||
dir = filepath.Join(hook.FishConfigDir(), "completions")
|
||||
file = filepath.Join(dir, "netssh.fish")
|
||||
@@ -697,9 +736,11 @@ func hookInstallCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Add netssh shell-init to your shell profile",
|
||||
Long: `Appends a single line to your shell profile that runs 'netssh shell-init'
|
||||
on every new shell session. shell-init checks whether the cache is older
|
||||
than 24 hours and, if so, starts a background refresh — no delay to your prompt.
|
||||
Long: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell
|
||||
profile that runs 'netssh shell-init' on every new shell session and, for zsh,
|
||||
registers the completion. Unmarked entries from older versions are replaced.
|
||||
shell-init checks whether the cache is older than 24 hours and, if so, starts
|
||||
a background refresh — no delay to your prompt.
|
||||
|
||||
After installation, reload your profile or open a new shell.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -711,17 +752,12 @@ After installation, reload your profile or open a new shell.`,
|
||||
return err
|
||||
}
|
||||
|
||||
installed, err := hook.Install(profile)
|
||||
// The managed block also sources the aliases (bash/zsh) and registers
|
||||
// zsh completion; fish uses conf.d auto-sourcing instead.
|
||||
installed, err := hook.Install(profile, shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For bash/zsh: also add the source line so aliases are loaded on startup.
|
||||
// Fish uses conf.d auto-sourcing — no explicit source line needed.
|
||||
if shell != "fish" {
|
||||
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !installed {
|
||||
fmt.Printf("Hook already installed in %s\n", profile)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
// uninstallPlan lists everything netssh has written to this machine.
|
||||
type uninstallPlan struct {
|
||||
Profiles []string // shell profiles with a netssh block or legacy lines
|
||||
Paths []string // files and directories to delete
|
||||
Config string // config file to back up and delete; empty when kept
|
||||
Binaries []string
|
||||
}
|
||||
|
||||
func buildUninstallPlan(cfg *config.Config, keepConfig bool) uninstallPlan {
|
||||
var plan uninstallPlan
|
||||
seen := make(map[string]bool)
|
||||
addPath := func(path string) {
|
||||
if path == "" || seen[path] {
|
||||
return
|
||||
}
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
seen[path] = true
|
||||
plan.Paths = append(plan.Paths, path)
|
||||
}
|
||||
}
|
||||
|
||||
for _, profile := range hook.ProfileCandidates() {
|
||||
target, err := filepath.EvalSymlinks(profile)
|
||||
if err != nil || seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
if hook.HasEntries(profile) {
|
||||
plan.Profiles = append(plan.Profiles, profile)
|
||||
}
|
||||
}
|
||||
|
||||
aliases := []string{hook.CompletionAliasState()}
|
||||
if cfg != nil {
|
||||
aliases = append(aliases, cfg.Shell.Alias)
|
||||
}
|
||||
for _, path := range hook.CompletionFiles(aliases...) {
|
||||
if hook.IsGenerated(path) {
|
||||
addPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache directory holds hosts, aliases, refresh stamps, and instance caches.
|
||||
cacheDir := filepath.Dir(hook.AliasesPath())
|
||||
addPath(cacheDir)
|
||||
if cfg != nil {
|
||||
for _, path := range []string{cfg.Cache.Path, cfg.CachePath()} {
|
||||
if path == "" || strings.HasPrefix(path, cacheDir+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
addPath(path)
|
||||
addPath(path + ".lock")
|
||||
addPath(filepath.Join(filepath.Dir(path), "last_refresh"))
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") {
|
||||
addPath(filepath.Dir(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !keepConfig {
|
||||
if _, err := os.Stat(config.Path()); err == nil {
|
||||
plan.Config = config.Path()
|
||||
}
|
||||
// Local server notes live next to the config file.
|
||||
addPath(filepath.Join(filepath.Dir(config.Path()), "netssh"))
|
||||
}
|
||||
|
||||
plan.Binaries = installedBinaries()
|
||||
return plan
|
||||
}
|
||||
|
||||
// installedBinaries finds the running binary and any other netssh in PATH.
|
||||
func installedBinaries() []string {
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
add := func(path string) {
|
||||
real, err := filepath.EvalSymlinks(path)
|
||||
if err != nil || seen[real] {
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(real); err != nil || info.IsDir() || filepath.Base(real) != "netssh" {
|
||||
return
|
||||
}
|
||||
seen[real] = true
|
||||
out = append(out, real)
|
||||
}
|
||||
if self, err := os.Executable(); err == nil {
|
||||
add(self)
|
||||
}
|
||||
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||
if dir != "" {
|
||||
add(filepath.Join(dir, "netssh"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p uninstallPlan) empty() bool {
|
||||
return len(p.Profiles) == 0 && len(p.Paths) == 0 && p.Config == "" && len(p.Binaries) == 0
|
||||
}
|
||||
|
||||
func (p uninstallPlan) print(w io.Writer) {
|
||||
for _, profile := range p.Profiles {
|
||||
fmt.Fprintf(w, " edit %s (remove netssh entries)\n", profile)
|
||||
}
|
||||
for _, path := range p.Paths {
|
||||
fmt.Fprintf(w, " delete %s\n", path)
|
||||
}
|
||||
if p.Config != "" {
|
||||
fmt.Fprintf(w, " delete %s (a backup is kept)\n", p.Config)
|
||||
}
|
||||
for _, binary := range p.Binaries {
|
||||
fmt.Fprintf(w, " delete %s\n", binary)
|
||||
}
|
||||
}
|
||||
|
||||
// execute applies the plan. It continues after errors so that one protected
|
||||
// file does not leave the rest behind, and returns commands that need sudo.
|
||||
func (p uninstallPlan) execute(w io.Writer) (manual []string, err error) {
|
||||
var errs []error
|
||||
for _, profile := range p.Profiles {
|
||||
if _, err := hook.Uninstall(profile); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "cleaned %s\n", profile)
|
||||
}
|
||||
for _, path := range p.Paths {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "removed %s\n", path)
|
||||
}
|
||||
if p.Config != "" {
|
||||
backup, err := backupConfig(p.Config)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("config not removed: %w", err))
|
||||
} else if err := os.Remove(p.Config); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
fmt.Fprintf(w, "removed %s (backup: %s)\n", p.Config, backup)
|
||||
}
|
||||
}
|
||||
for _, binary := range p.Binaries {
|
||||
if err := os.Remove(binary); err != nil {
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
manual = append(manual, "sudo rm -f "+shellQuote(binary))
|
||||
continue
|
||||
}
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "removed %s\n", binary)
|
||||
}
|
||||
return manual, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// backupConfig keeps a private copy, because the config holds API tokens.
|
||||
func backupConfig(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backup := path + ".backup-" + time.Now().Format("20060102-150405")
|
||||
if err := storage.WriteFile(backup, data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func uninstallCmd() *cobra.Command {
|
||||
var keepConfig, yes, dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove netssh, its shell integration, cache, and config",
|
||||
Long: `Removes everything netssh and install.sh have set up:
|
||||
|
||||
- netssh blocks and unmarked entries of older versions in bash, zsh, and fish profiles
|
||||
- shell completion files (bash, zsh, fish, oh-my-zsh cache, command alias)
|
||||
- the cache directory with hosts, aliases, and per-instance caches
|
||||
- the config file (a private backup is kept) and local server notes
|
||||
- the netssh binary
|
||||
|
||||
Use --keep-config to keep the config file and notes for a reinstall.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
// A broken config must not block the uninstall.
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
cfg = nil
|
||||
}
|
||||
plan := buildUninstallPlan(cfg, keepConfig)
|
||||
out := cmd.OutOrStdout()
|
||||
if plan.empty() {
|
||||
fmt.Fprintln(out, "Nothing to remove.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(out, "netssh uninstall will:")
|
||||
plan.print(out)
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
if !yes {
|
||||
fmt.Fprint(out, "Proceed? [y/N] ")
|
||||
answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
|
||||
if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
|
||||
return fmt.Errorf("aborted")
|
||||
}
|
||||
}
|
||||
manual, err := plan.execute(out)
|
||||
for _, command := range manual {
|
||||
fmt.Fprintf(out, "Permission denied. Run: %s\n", command)
|
||||
}
|
||||
if len(plan.Profiles) > 0 {
|
||||
fmt.Fprintln(out, "Open a new terminal. For zsh, also run: rm -f ~/.zcompdump*")
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&keepConfig, "keep-config", false, "Keep the config file and local notes")
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Do not ask for confirmation")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would be removed")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
func uninstallFixture(t *testing.T) (home string) {
|
||||
t.Helper()
|
||||
home = t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("ZDOTDIR", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
t.Setenv("ZSH", "")
|
||||
t.Setenv("ZSH_CACHE_DIR", "")
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||
t.Setenv("NETSSH_CACHE_PATH", "")
|
||||
t.Setenv("PATH", filepath.Join(home, "nobin"))
|
||||
files := map[string]string{
|
||||
".bashrc": "export KEEP=1\n# >>> netssh >>>\nnetssh shell-init\n# <<< netssh <<<\n",
|
||||
".zshrc": "plugins=(git)\nnetssh shell-init # netssh cache auto-refresh\nsource ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases\n",
|
||||
".profile": "export UNRELATED=1\n",
|
||||
".config/netssh.yaml": "netbox:\n url: \"https://netbox.example.com\"\n token: \"secret\"\nshell:\n alias: \"j\"\n",
|
||||
".config/netssh/notes/abc.txt": "note",
|
||||
".cache/netssh/hosts.json": "{}",
|
||||
".cache/netssh/instances-0011/hosts.json": "{}",
|
||||
".local/share/bash-completion/completions/netssh": "# bash completion for netssh",
|
||||
".zfunc/_netssh": hook.ZshCompletionScript,
|
||||
".zfunc/_j": "#compdef j\n# netssh command alias completion — generated automatically\n",
|
||||
".zfunc/_other": "#compdef other\n",
|
||||
}
|
||||
for name, content := range files {
|
||||
path := filepath.Join(home, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func runUninstall(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := rootCmd()
|
||||
var out strings.Builder
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(append([]string{"uninstall"}, args...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("uninstall: %v\n%s", err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestUninstallRemovesEverythingAndBacksUpConfig(t *testing.T) {
|
||||
home := uninstallFixture(t)
|
||||
if out := runUninstall(t, "--dry-run"); !strings.Contains(out, ".zshrc") || strings.Contains(out, ".profile") {
|
||||
t.Fatalf("plan:\n%s", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); err != nil {
|
||||
t.Fatal("dry run removed files")
|
||||
}
|
||||
runUninstall(t, "--yes")
|
||||
|
||||
for name, want := range map[string]string{".bashrc": "export KEEP=1\n", ".zshrc": "plugins=(git)\n", ".profile": "export UNRELATED=1\n"} {
|
||||
data, _ := os.ReadFile(filepath.Join(home, name))
|
||||
if string(data) != want {
|
||||
t.Errorf("%s: %q", name, data)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{".cache/netssh", ".config/netssh.yaml", ".config/netssh", ".zfunc/_netssh", ".zfunc/_j", ".local/share/bash-completion/completions/netssh"} {
|
||||
if _, err := os.Stat(filepath.Join(home, gone)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s not removed: %v", gone, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".zfunc", "_other")); err != nil {
|
||||
t.Error("unrelated completion removed")
|
||||
}
|
||||
backups, _ := filepath.Glob(filepath.Join(home, ".config", "netssh.yaml.backup-*"))
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("backups: %v", backups)
|
||||
}
|
||||
if info, _ := os.Stat(backups[0]); info.Mode().Perm() != 0o600 {
|
||||
t.Error("backup with token is not private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallKeepConfig(t *testing.T) {
|
||||
home := uninstallFixture(t)
|
||||
runUninstall(t, "--yes", "--keep-config")
|
||||
for _, kept := range []string{".config/netssh.yaml", ".config/netssh/notes/abc.txt"} {
|
||||
if _, err := os.Stat(filepath.Join(home, kept)); err != nil {
|
||||
t.Errorf("%s removed", kept)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); !os.IsNotExist(err) {
|
||||
t.Error("cache kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallAbortsWithoutConfirmation(t *testing.T) {
|
||||
home := uninstallFixture(t)
|
||||
cmd := rootCmd()
|
||||
cmd.SetOut(&strings.Builder{})
|
||||
cmd.SetIn(strings.NewReader("n\n"))
|
||||
cmd.SetArgs([]string{"uninstall"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("no confirmation required")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".config", "netssh.yaml")); err != nil {
|
||||
t.Fatal("config removed without confirmation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// version is set at build time: -ldflags "-X main.version=v1.2.3".
|
||||
var version = ""
|
||||
|
||||
// buildVersion falls back to the module version for `go install` builds.
|
||||
func buildVersion() string {
|
||||
if version != "" {
|
||||
return version
|
||||
}
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
return info.Main.Version
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the netssh version",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "netssh %s\n", buildVersion())
|
||||
},
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -168,6 +168,11 @@ plan_profile() {
|
||||
printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")"
|
||||
printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")"
|
||||
fi
|
||||
if [[ "$kind" == zsh ]]; then
|
||||
# Register completion also when compinit already ran (e.g. oh-my-zsh).
|
||||
printf 'fpath=(%s $fpath)\n' "$(quote_sh "$HOME/.zfunc")"
|
||||
printf 'if (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n'
|
||||
fi
|
||||
printf '# <<< netssh <<<\n'
|
||||
} >> "$data"
|
||||
fi
|
||||
@@ -231,10 +236,26 @@ for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do
|
||||
mv -f "$staged" "$target"
|
||||
info "updated : ${PROFILE_PATHS[$index]}"
|
||||
done
|
||||
COMPLETIONS=()
|
||||
for kind in "${SHELLS[@]}"; do
|
||||
if ! "$INSTALL_DIR/$BINARY" completion install --shell "$kind"; then
|
||||
if "$INSTALL_DIR/$BINARY" completion install --shell "$kind" >/dev/null; then
|
||||
COMPLETIONS+=("$kind")
|
||||
else
|
||||
info "warning: could not update $kind completion"
|
||||
fi
|
||||
done
|
||||
printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY"
|
||||
for kind in "${COMPLETIONS[@]}"; do
|
||||
case "$kind" in
|
||||
bash) info "completion: bash -> $HOME/.local/share/bash-completion/completions/netssh" ;;
|
||||
zsh) info "completion: zsh -> $HOME/.zfunc/_netssh (registered in ${ZSH_DIR}/.zshrc)" ;;
|
||||
fish) info "completion: fish -> $CONFIG_DIR/fish/completions/netssh.fish" ;;
|
||||
esac
|
||||
done
|
||||
info "Open a new terminal to load the current hooks and aliases."
|
||||
for kind in "${COMPLETIONS[@]}"; do
|
||||
if [[ "$kind" == zsh ]]; then
|
||||
info "zsh: if <Tab> still shows files, run: rm -f $(quote_sh "$ZSH_DIR")/.zcompdump* && exec zsh"
|
||||
fi
|
||||
done
|
||||
info "Remove netssh again with: netssh uninstall"
|
||||
@@ -108,7 +108,14 @@ func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) {
|
||||
t.Fatalf("bad managed blocks: %s", data)
|
||||
}
|
||||
}
|
||||
zshrc, _ := os.ReadFile(filepath.Join(f.home, ".zshrc"))
|
||||
if !strings.Contains(string(zshrc), "compdef _netssh netssh") || !strings.Contains(string(zshrc), "/.zfunc' $fpath)") {
|
||||
t.Fatalf("zsh completion not registered: %s", zshrc)
|
||||
}
|
||||
data, _ := os.ReadFile(bashrc)
|
||||
if strings.Contains(string(data), "compdef") {
|
||||
t.Fatalf("zsh code in bash profile: %s", data)
|
||||
}
|
||||
for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} {
|
||||
if !strings.Contains(string(data), kept) {
|
||||
t.Fatalf("unrelated line lost: %s", kept)
|
||||
|
||||
@@ -18,6 +18,7 @@ type Config struct {
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
@@ -29,6 +30,7 @@ type NetBoxConfig struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
TokenVersion int `mapstructure:"token_version"`
|
||||
SSH SSHOptions `mapstructure:"ssh"`
|
||||
}
|
||||
|
||||
type ResolverConfig struct {
|
||||
@@ -45,6 +47,13 @@ type CacheConfig struct {
|
||||
type SSHConfig struct {
|
||||
DefaultUser string `mapstructure:"default_user"`
|
||||
DefaultPort int `mapstructure:"default_port"`
|
||||
SSHOptions `mapstructure:",squash"`
|
||||
Rules []SSHRule `mapstructure:"rules"`
|
||||
}
|
||||
|
||||
type CompletionConfig struct {
|
||||
// MaxResults limits the number of hostnames offered per completion; 0 = unlimited.
|
||||
MaxResults int `mapstructure:"max_results"`
|
||||
}
|
||||
|
||||
type ShortcutsConfig struct {
|
||||
@@ -79,7 +88,7 @@ func Load() (*Config, error) {
|
||||
v.SetEnvPrefix("NETSSH")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias"} {
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,6 +125,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateSSH(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHOptions are connection settings that can be set globally, per NetBox
|
||||
// instance, and per rule. More specific layers override less specific ones.
|
||||
type SSHOptions struct {
|
||||
// ProxyJump is passed as -J; "none" disables a jump host set by a broader layer.
|
||||
ProxyJump string `mapstructure:"proxy_jump"`
|
||||
// HostKeyChecking is strict, accept-new, or off. Empty keeps ssh's own setting.
|
||||
HostKeyChecking string `mapstructure:"host_key_checking"`
|
||||
// Target selects the ssh destination: ip (default), name, or hostname.
|
||||
Target string `mapstructure:"target"`
|
||||
// AddressFamily is any, inet, or inet6.
|
||||
AddressFamily string `mapstructure:"address_family"`
|
||||
// ExtraArgs are inserted verbatim before the destination.
|
||||
ExtraArgs []string `mapstructure:"extra_args"`
|
||||
// Options are passed as -o Key=Value.
|
||||
Options map[string]string `mapstructure:"options"`
|
||||
}
|
||||
|
||||
// SSHRule applies SSH options to hosts that match every non-empty field.
|
||||
type SSHRule struct {
|
||||
Match SSHMatch `mapstructure:"match"`
|
||||
SSHOptions `mapstructure:",squash"`
|
||||
}
|
||||
|
||||
// SSHMatch selects hosts by NetBox attributes available in the cache.
|
||||
type SSHMatch struct {
|
||||
Tag string `mapstructure:"tag"`
|
||||
Instance string `mapstructure:"instance"`
|
||||
Kind string `mapstructure:"kind"`
|
||||
Name string `mapstructure:"name"` // shell-style glob, e.g. "*.dmz.example.com"
|
||||
}
|
||||
|
||||
// SSHHost identifies the resolved host that SSH options are computed for.
|
||||
type SSHHost struct {
|
||||
Instance string
|
||||
Name string // NetBox name without the instance prefix
|
||||
Kind string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
|
||||
|
||||
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
|
||||
func (c *Config) ValidateSSH() error {
|
||||
if err := c.SSH.SSHOptions.validate("ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rule := range c.SSH.Rules {
|
||||
if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.Match == (SSHMatch{}) {
|
||||
return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i)
|
||||
}
|
||||
if _, err := path.Match(rule.Match.Name, ""); err != nil {
|
||||
return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name)
|
||||
}
|
||||
}
|
||||
if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o SSHOptions) validate(scope string) error {
|
||||
switch o.HostKeyChecking {
|
||||
case "", "strict", "accept-new", "off":
|
||||
default:
|
||||
return fmt.Errorf("%s.host_key_checking must be strict, accept-new, or off", scope)
|
||||
}
|
||||
switch o.Target {
|
||||
case "", "ip", "name", "hostname":
|
||||
default:
|
||||
return fmt.Errorf("%s.target must be ip, name, or hostname", scope)
|
||||
}
|
||||
switch o.AddressFamily {
|
||||
case "", "any", "inet", "inet6":
|
||||
default:
|
||||
return fmt.Errorf("%s.address_family must be any, inet, or inet6", scope)
|
||||
}
|
||||
if strings.HasPrefix(o.ProxyJump, "-") || strings.ContainsAny(o.ProxyJump, " \t\n") {
|
||||
return fmt.Errorf("%s.proxy_jump %q is not a valid jump host", scope, o.ProxyJump)
|
||||
}
|
||||
for key, value := range o.Options {
|
||||
if !sshOptionKey.MatchString(key) || strings.ContainsAny(value, "\n\r") {
|
||||
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHOptionsFor merges global, instance, and matching rule options for host.
|
||||
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
|
||||
merged := c.SSH.SSHOptions.merge(SSHOptions{})
|
||||
if host.Instance == "" {
|
||||
merged = merged.merge(c.NetBox.SSH)
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
if strings.EqualFold(source.Name, host.Instance) {
|
||||
merged = merged.merge(source.SSH)
|
||||
}
|
||||
}
|
||||
for _, rule := range c.SSH.Rules {
|
||||
if rule.Match.matches(host) {
|
||||
merged = merged.merge(rule.SSHOptions)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// merge returns o overridden by the non-empty fields of override.
|
||||
// Extra arguments accumulate; options override per key.
|
||||
func (o SSHOptions) merge(override SSHOptions) SSHOptions {
|
||||
out := o
|
||||
out.ExtraArgs = append(append([]string(nil), o.ExtraArgs...), override.ExtraArgs...)
|
||||
out.Options = make(map[string]string, len(o.Options)+len(override.Options))
|
||||
for key, value := range o.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
for key, value := range override.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
if override.ProxyJump != "" {
|
||||
out.ProxyJump = override.ProxyJump
|
||||
}
|
||||
if override.HostKeyChecking != "" {
|
||||
out.HostKeyChecking = override.HostKeyChecking
|
||||
}
|
||||
if override.Target != "" {
|
||||
out.Target = override.Target
|
||||
}
|
||||
if override.AddressFamily != "" {
|
||||
out.AddressFamily = override.AddressFamily
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m SSHMatch) matches(host SSHHost) bool {
|
||||
if m.Instance != "" && !strings.EqualFold(m.Instance, host.Instance) {
|
||||
return false
|
||||
}
|
||||
if m.Kind != "" && !strings.EqualFold(m.Kind, host.Kind) {
|
||||
return false
|
||||
}
|
||||
if m.Name != "" {
|
||||
if ok, _ := path.Match(strings.ToLower(m.Name), strings.ToLower(host.Name)); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if m.Tag != "" {
|
||||
found := false
|
||||
for _, tag := range host.Tags {
|
||||
if strings.EqualFold(tag, m.Tag) || strings.EqualFold(tagSlug(tag), m.Tag) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tagSlug approximates NetBox's slug so rules may use either the tag name or slug.
|
||||
func tagSlug(name string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(name)), "-")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadYAML(t *testing.T, data string) (*Config, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Load()
|
||||
}
|
||||
|
||||
func TestSSHOptionsLayering(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
netboxes:
|
||||
- name: prod
|
||||
url: https://prod.example.com
|
||||
ssh:
|
||||
proxy_jump: jh-prod.example.com
|
||||
host_key_checking: strict
|
||||
- name: staging
|
||||
url: https://staging.example.com
|
||||
ssh:
|
||||
host_key_checking: off
|
||||
ssh:
|
||||
proxy_jump: jh.example.com
|
||||
extra_args: ["-4"]
|
||||
options:
|
||||
ServerAliveInterval: 60
|
||||
ServerAliveCountMax: 5
|
||||
rules:
|
||||
- match: {tag: dmz}
|
||||
proxy_jump: jh-dmz.example.com
|
||||
options: {ServerAliveInterval: 10}
|
||||
- match: {instance: prod, name: "db*"}
|
||||
proxy_jump: none
|
||||
completion:
|
||||
max_results: 200
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Completion.MaxResults != 200 {
|
||||
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
host SSHHost
|
||||
jump, keys, alive string
|
||||
}{
|
||||
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
|
||||
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
|
||||
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
|
||||
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
|
||||
} {
|
||||
got := cfg.SSHOptionsFor(tc.host)
|
||||
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
|
||||
t.Errorf("%+v: got %+v", tc.host, got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
|
||||
t.Errorf("extra args: %v", got.ExtraArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHOptionsValidation(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"ssh:\n host_key_checking: maybe\n",
|
||||
"ssh:\n target: dns\n",
|
||||
"ssh:\n address_family: ipx\n",
|
||||
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
|
||||
"ssh:\n options:\n \"bad key\": 1\n",
|
||||
"ssh:\n rules:\n - proxy_jump: jh\n",
|
||||
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
|
||||
} {
|
||||
if _, err := loadYAML(t, data); err == nil {
|
||||
t.Errorf("accepted invalid config:\n%s", data)
|
||||
} else if !strings.Contains(err.Error(), "ssh") {
|
||||
t.Errorf("unclear error %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func commandCompletionPath(shell, alias string) string {
|
||||
case "bash":
|
||||
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
||||
case "zsh":
|
||||
return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias)
|
||||
return filepath.Join(ZshFuncDir(), "_"+alias)
|
||||
default:
|
||||
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
||||
}
|
||||
@@ -114,3 +114,46 @@ complete --command %s --wraps netssh
|
||||
`, completionMarker, alias)
|
||||
}
|
||||
}
|
||||
|
||||
// CompletionFiles lists the completion and alias files netssh may have
|
||||
// installed for itself and for the given command aliases.
|
||||
func CompletionFiles(aliases ...string) []string {
|
||||
files := []string{
|
||||
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "netssh"),
|
||||
filepath.Join(ZshFuncDir(), "_netssh"),
|
||||
filepath.Join(FishConfigDir(), "completions", "netssh.fish"),
|
||||
FishAliasesPath(),
|
||||
}
|
||||
// oh-my-zsh caches generated completions separately.
|
||||
for _, dir := range []string{os.Getenv("ZSH_CACHE_DIR"), filepath.Join(os.Getenv("ZSH"), "cache"), filepath.Join(os.Getenv("HOME"), ".oh-my-zsh", "cache")} {
|
||||
if dir != "" && dir != "cache" {
|
||||
files = append(files, filepath.Join(dir, "completions", "_netssh"))
|
||||
}
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if config.ValidateCommandAlias(alias) != nil || alias == "" {
|
||||
continue
|
||||
}
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
files = append(files, commandCompletionPath(shell, alias))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// CompletionAliasState returns the command alias whose completion files were
|
||||
// last generated, or "" when there is none.
|
||||
func CompletionAliasState() string {
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(AliasesPath()), "completion_alias"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// IsGenerated reports whether a completion file was written by netssh,
|
||||
// so uninstall never removes a user's own file of the same name.
|
||||
func IsGenerated(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
return err == nil && strings.Contains(string(data), "netssh")
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
|
||||
case "bash":
|
||||
err = root.GenBashCompletionV2(&canonical, true)
|
||||
case "zsh":
|
||||
err = root.GenZshCompletion(&canonical)
|
||||
_, err = canonical.WriteString(hook.ZshCompletionScript)
|
||||
case "fish":
|
||||
err = root.GenFishCompletion(&canonical, true)
|
||||
}
|
||||
@@ -149,7 +149,8 @@ compinit -u -D
|
||||
alias j=netssh
|
||||
autoload -Uz _j
|
||||
# Capture matches without requiring an interactive ZLE completion widget.
|
||||
_describe() { print -rl -- "${completions[@]}"; }
|
||||
_describe() { print -rl -- "${(P)4}"; }
|
||||
compadd() { print -rl -- "$@"; }
|
||||
words=(j ser)
|
||||
CURRENT=2
|
||||
_j
|
||||
@@ -169,3 +170,38 @@ complete --do-complete 'j ser'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZshCompletionSplitsHostsAndCommands(t *testing.T) {
|
||||
zsh, err := exec.LookPath("zsh")
|
||||
if err != nil {
|
||||
t.Skip("zsh unavailable")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
backend := "#!/bin/sh\nprintf 'web01\\nweb:02\\ncache\\tManage the cache\\n:36\\n'\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh"), []byte(backend), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := `compdef() { :; }
|
||||
source "$DIR/_netssh"
|
||||
_describe() { print -r -- "describe ${(P)4}"; }
|
||||
compadd() { print -r -- "compadd $*"; }
|
||||
_files() { print -r -- files; }
|
||||
words=(netssh "")
|
||||
CURRENT=2
|
||||
_netssh
|
||||
`
|
||||
cmd := exec.Command(zsh, "-f", "-c", script)
|
||||
cmd.Env = append(os.Environ(), "DIR="+dir, "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
// Directive 36 = NoFileComp|KeepOrder: unsorted host group, no file fallback.
|
||||
want := "describe cache:Manage the cache\ncompadd -V hosts -X %Bhosts%b -M l:|=* -- web01 web:02\n"
|
||||
if string(out) != want {
|
||||
t.Fatalf("got:\n%s\nwant:\n%s", out, want)
|
||||
}
|
||||
}
|
||||
+135
-25
@@ -62,27 +62,63 @@ func IsInstalled(profilePath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Install appends the hook line to the profile if it is not already present.
|
||||
// Returns (true, nil) when newly installed, (false, nil) when already present.
|
||||
// Install writes the managed netssh block into the profile. Entries written by
|
||||
// older versions without markers are replaced instead of duplicated; an
|
||||
// existing managed block (e.g. from install.sh) is left untouched.
|
||||
// Returns (true, nil) when the profile changed, (false, nil) when already present.
|
||||
// Creates the profile file (and any parent directories) if they do not exist.
|
||||
func Install(profilePath string) (installed bool, err error) {
|
||||
if IsInstalled(profilePath) {
|
||||
func Install(profilePath, shell string) (installed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == BlockStart {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
cleaned, _, err := clean(string(data), profilePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
||||
cleaned += "\n"
|
||||
}
|
||||
if cleaned != "" {
|
||||
cleaned += "\n"
|
||||
}
|
||||
cleaned += Block(shell)
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
return false, fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("opening profile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := fmt.Fprintf(f, "\n%s\n", Line); err != nil {
|
||||
if err := writeProfile(profilePath, cleaned); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Block returns the managed profile block for shell.
|
||||
func Block(shell string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(BlockStart + "\n")
|
||||
b.WriteString("netssh shell-init\n")
|
||||
if shell != "fish" {
|
||||
aliases := shellQuote(AliasesPath(), "bash")
|
||||
fmt.Fprintf(&b, "[ ! -f %s ] || . %s\n", aliases, aliases)
|
||||
}
|
||||
if shell == "zsh" {
|
||||
b.WriteString(ZshRegistration(ZshFuncDir()))
|
||||
}
|
||||
b.WriteString(BlockEnd + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ZshRegistration adds dir to fpath and registers the completion even when
|
||||
// compinit already ran earlier in .zshrc (e.g. via oh-my-zsh).
|
||||
func ZshRegistration(dir string) string {
|
||||
return fmt.Sprintf("fpath=(%s $fpath)\nif (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n", shellQuote(dir, "bash"))
|
||||
}
|
||||
|
||||
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
|
||||
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
|
||||
// Returns (true, nil) when newly added, (false, nil) when already present.
|
||||
@@ -110,6 +146,36 @@ func InstallAliasesSource(profilePath string) (installed bool, err error) {
|
||||
var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`)
|
||||
var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`)
|
||||
|
||||
// legacyPatterns match the unmarked lines written by older versions and by
|
||||
// manual setup instructions. They mirror the patterns in install.sh.
|
||||
var legacyPatterns = []*regexp.Regexp{
|
||||
hookLinePattern,
|
||||
aliasesLinePattern,
|
||||
regexp.MustCompile(`^eval\s+["']?\$\((?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+(?:bash|zsh))?\)["']?\s*(?:#.*)?$`),
|
||||
regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+fish)?\s*\|\s*source\s*(?:#.*)?$`),
|
||||
regexp.MustCompile(`^source\s+<\((?:[^\s]*/)?netssh\s+alias\)\s*(?:#.*)?$`),
|
||||
regexp.MustCompile(`^alias\s+[A-Za-z0-9_.-]+(?:=|\s+)["']netssh(?:\s+[^"']+)?["']\s*(?:#.*)?$`),
|
||||
}
|
||||
|
||||
func isLegacyLine(line string) bool {
|
||||
for _, pattern := range legacyPatterns {
|
||||
if pattern.MatchString(line) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasEntries reports whether the profile contains a managed block or legacy lines.
|
||||
func HasEntries(profilePath string) bool {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, removed, err := clean(string(data), profilePath)
|
||||
return removed || err != nil
|
||||
}
|
||||
|
||||
// Uninstall removes legacy hook lines and complete installer-managed blocks.
|
||||
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
|
||||
func Uninstall(profilePath string) (removed bool, err error) {
|
||||
@@ -120,21 +186,33 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
cleaned, removed, err := clean(string(data), profilePath)
|
||||
if err != nil || !removed {
|
||||
return false, err
|
||||
}
|
||||
return true, writeProfile(profilePath, cleaned)
|
||||
}
|
||||
|
||||
// clean strips managed blocks and legacy lines from profile content.
|
||||
func clean(data, profilePath string) (cleaned string, removed bool, err error) {
|
||||
if data == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
var kept []string
|
||||
inside := false
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
value := strings.TrimSpace(line)
|
||||
switch value {
|
||||
case BlockStart:
|
||||
if inside {
|
||||
return false, fmt.Errorf("nested netssh block in %s", profilePath)
|
||||
return "", false, fmt.Errorf("nested netssh block in %s", profilePath)
|
||||
}
|
||||
inside = true
|
||||
removed = true
|
||||
continue
|
||||
case BlockEnd:
|
||||
if !inside {
|
||||
return false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
||||
return "", false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
||||
}
|
||||
inside = false
|
||||
continue
|
||||
@@ -142,29 +220,61 @@ func Uninstall(profilePath string) (removed bool, err error) {
|
||||
if inside {
|
||||
continue
|
||||
}
|
||||
if hookLinePattern.MatchString(value) || aliasesLinePattern.MatchString(value) {
|
||||
if isLegacyLine(value) {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
if inside {
|
||||
return false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
||||
return "", false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
||||
}
|
||||
if !removed {
|
||||
return false, nil
|
||||
return data, false, nil
|
||||
}
|
||||
cleaned := strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||
cleaned = strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||
// Drop the blank separator line Install puts in front of the block.
|
||||
if strings.HasSuffix(cleaned, "\n\n") {
|
||||
cleaned = strings.TrimRight(cleaned, "\n") + "\n"
|
||||
}
|
||||
return cleaned, true, nil
|
||||
}
|
||||
|
||||
// writeProfile replaces the profile atomically, following dotfile symlinks
|
||||
// and keeping the permissions of an existing file.
|
||||
func writeProfile(profilePath, content string) error {
|
||||
target, err := filepath.EvalSymlinks(profilePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
if os.IsNotExist(err) {
|
||||
target = profilePath
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
mode := os.FileMode(0o644)
|
||||
if info, err := os.Stat(target); err == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
if err := storage.WriteFile(target, []byte(cleaned), info.Mode().Perm()); err != nil {
|
||||
return false, fmt.Errorf("writing profile: %w", err)
|
||||
if err := storage.WriteFile(target, []byte(content), mode); err != nil {
|
||||
return fmt.Errorf("writing profile: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfileCandidates lists every shell profile netssh or install.sh may have
|
||||
// written to, in the same order as the installer.
|
||||
func ProfileCandidates() []string {
|
||||
home := os.Getenv("HOME")
|
||||
zdot := zshDir()
|
||||
paths := []string{
|
||||
filepath.Join(home, ".bashrc"),
|
||||
filepath.Join(home, ".bash_profile"),
|
||||
filepath.Join(home, ".bash_login"),
|
||||
filepath.Join(home, ".profile"),
|
||||
filepath.Join(zdot, ".zshrc"),
|
||||
filepath.Join(zdot, ".zprofile"),
|
||||
filepath.Join(zdot, ".zshenv"),
|
||||
}
|
||||
if zdot != home {
|
||||
paths = append(paths, filepath.Join(home, ".zshrc"), filepath.Join(home, ".zprofile"), filepath.Join(home, ".zshenv"))
|
||||
}
|
||||
return append(paths, filepath.Join(FishConfigDir(), "config.fish"))
|
||||
}
|
||||
+77
-10
@@ -2,6 +2,7 @@ package hook_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -87,7 +88,7 @@ func TestIsInstalled_MissingFile(t *testing.T) {
|
||||
func TestInstall_Fresh(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||
|
||||
installed, err := hook.Install(profile)
|
||||
installed, err := hook.Install(profile, "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
@@ -102,8 +103,8 @@ func TestInstall_Fresh(t *testing.T) {
|
||||
func TestInstall_Idempotent(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||
|
||||
hook.Install(profile)
|
||||
installed, err := hook.Install(profile)
|
||||
hook.Install(profile, "bash")
|
||||
installed, err := hook.Install(profile, "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("second Install: %v", err)
|
||||
}
|
||||
@@ -120,7 +121,7 @@ func TestInstall_Idempotent(t *testing.T) {
|
||||
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
|
||||
|
||||
if _, err := hook.Install(profile); err != nil {
|
||||
if _, err := hook.Install(profile, "bash"); err != nil {
|
||||
t.Fatalf("Install should create missing directories: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(profile); err != nil {
|
||||
@@ -131,7 +132,7 @@ func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
||||
func TestInstall_PreservesExistingContent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
|
||||
|
||||
hook.Install(profile)
|
||||
hook.Install(profile, "bash")
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
@@ -145,13 +146,13 @@ func TestInstall_PreservesExistingContent(t *testing.T) {
|
||||
|
||||
func TestInstall_AppendedAtEnd(t *testing.T) {
|
||||
profile := writeProfile(t, "existing line\n")
|
||||
hook.Install(profile)
|
||||
hook.Install(profile, "bash")
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
last := lines[len(lines)-1]
|
||||
if last != hook.Line {
|
||||
t.Errorf("hook line should be the last line, got %q", last)
|
||||
if last != hook.BlockEnd {
|
||||
t.Errorf("managed block should end the profile, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +227,7 @@ func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
|
||||
func TestInstallUninstall_Roundtrip(t *testing.T) {
|
||||
profile := writeProfile(t, "existing content\n")
|
||||
|
||||
hook.Install(profile)
|
||||
hook.Install(profile, "bash")
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Fatal("should be installed after Install")
|
||||
}
|
||||
@@ -265,7 +266,7 @@ func writeProfile(t *testing.T, content string) string {
|
||||
func TestManagedBlockInstallAndUninstall(t *testing.T) {
|
||||
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
|
||||
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
|
||||
if installed, err := hook.Install(profile); err != nil || installed {
|
||||
if installed, err := hook.Install(profile, "bash"); err != nil || installed {
|
||||
t.Fatalf("duplicate install: %v, %v", installed, err)
|
||||
}
|
||||
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
|
||||
@@ -288,3 +289,69 @@ func TestProfilePathRespectsZdotdir(t *testing.T) {
|
||||
t.Fatalf("zsh path: %s, %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallReplacesLegacyEntriesWithBlock(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
profile := writeProfile(t, "export KEEP=yes\nnetssh shell-init\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias j='netssh'\necho 'netssh shell-init'\n")
|
||||
installed, err := hook.Install(profile, "zsh")
|
||||
if err != nil || !installed {
|
||||
t.Fatalf("install: %v, %v", installed, err)
|
||||
}
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
for _, gone := range []string{"\nnetssh shell-init\nsource", "eval ", "alias j="} {
|
||||
if strings.Contains(content, gone) {
|
||||
t.Fatalf("legacy entry %q kept:\n%s", gone, content)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{"export KEEP=yes", "echo 'netssh shell-init'", hook.BlockStart, "compdef _netssh netssh", "fpath=("} {
|
||||
if !strings.Contains(content, kept) {
|
||||
t.Fatalf("missing %q:\n%s", kept, content)
|
||||
}
|
||||
}
|
||||
if strings.Count(content, hook.BlockStart) != 1 {
|
||||
t.Fatalf("duplicate blocks:\n%s", content)
|
||||
}
|
||||
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
||||
t.Fatalf("uninstall: %v, %v", removed, err)
|
||||
}
|
||||
data, _ = os.ReadFile(profile)
|
||||
if string(data) != "export KEEP=yes\necho 'netssh shell-init'\n" {
|
||||
t.Fatalf("unexpected remainder: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPerShell(t *testing.T) {
|
||||
if b := hook.Block("fish"); strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
|
||||
t.Fatalf("fish block: %s", b)
|
||||
}
|
||||
if b := hook.Block("bash"); !strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
|
||||
t.Fatalf("bash block: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZshBlockRegistersCompletionAfterCompinit(t *testing.T) {
|
||||
zsh, err := exec.LookPath("zsh")
|
||||
if err != nil {
|
||||
t.Skip("zsh unavailable")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("ZDOTDIR", home)
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
||||
if err := os.MkdirAll(hook.ZshFuncDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(hook.ZshFuncDir(), "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// compinit runs before the block, as with oh-my-zsh.
|
||||
rc := "autoload -Uz compinit && compinit -u -d \"$HOME/.zcompdump\"\nnetssh() { :; }\n" + hook.Block("zsh")
|
||||
if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(rc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command(zsh, "-i", "-c", `print -r -- $_comps[netssh]`).CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "_netssh") {
|
||||
t.Fatalf("completion not registered: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ZshCompletionScript replaces Cobra's generated zsh completion. Cobra's script
|
||||
// spawns a subshell per candidate and passes every host through _describe,
|
||||
// which takes tens of seconds for thousands of hosts. This script splits the
|
||||
// candidates with parameter expansion only: entries with a description
|
||||
// (subcommands, flags) go through _describe, plain hostnames through compadd.
|
||||
//
|
||||
// The file works both from fpath (autoloaded as _netssh) and when sourced.
|
||||
const ZshCompletionScript = `#compdef netssh
|
||||
compdef _netssh netssh
|
||||
|
||||
# netssh zsh completion — generated by netssh, do not edit.
|
||||
_netssh() {
|
||||
local -a lines described plain
|
||||
local out directive=0 entry ret=1
|
||||
local -a nospace
|
||||
|
||||
out=$(netssh __complete "${(@)words[2,CURRENT]}" 2>/dev/null) || return 1
|
||||
lines=("${(@f)out}")
|
||||
if [[ ${lines[-1]} == :<-> ]]; then
|
||||
directive=${lines[-1]#:}
|
||||
lines[-1]=()
|
||||
fi
|
||||
# Directive 1: error.
|
||||
(( directive & 1 )) && return 1
|
||||
lines=(${lines:#_activeHelp_*})
|
||||
lines=(${lines:#})
|
||||
# Directive 2: no trailing space.
|
||||
(( directive & 2 )) && nospace=(-S '')
|
||||
|
||||
# Directive 8: the lines are file extensions to filter by.
|
||||
if (( directive & 8 )); then
|
||||
local -a globs
|
||||
for entry in $lines; do globs+=("*.${entry}"); done
|
||||
_files -g "(${(j:|:)globs})"
|
||||
return
|
||||
fi
|
||||
# Directive 16: complete directories only.
|
||||
if (( directive & 16 )); then
|
||||
_files -/
|
||||
return
|
||||
fi
|
||||
|
||||
described=(${(M)lines:#*$'\t'*})
|
||||
plain=(${lines:#*$'\t'*})
|
||||
if (( $#described )); then
|
||||
local -a items
|
||||
for entry in $described; do
|
||||
items+=("${${entry%%$'\t'*}//:/\\:}:${entry#*$'\t'}")
|
||||
done
|
||||
_describe -t commands 'netssh command' items $nospace && ret=0
|
||||
fi
|
||||
if (( $#plain )); then
|
||||
# netssh already filtered the hosts, also by the name after an instance
|
||||
# prefix: "fsn1" offers "prod/fsn1-…". 'l:|=*' lets zsh accept text
|
||||
# before the typed word instead of discarding these matches.
|
||||
local -a group=(-J hosts)
|
||||
# Directive 32: keep the order supplied by netssh.
|
||||
(( directive & 32 )) && group=(-V hosts)
|
||||
compadd $group -X '%Bhosts%b' -M 'l:|=*' $nospace -- $plain && ret=0
|
||||
fi
|
||||
# Directive 4: no file completion as a fallback.
|
||||
if (( ! $#lines && ! (directive & 4) )); then
|
||||
_files && ret=0
|
||||
fi
|
||||
return ret
|
||||
}
|
||||
|
||||
if [ "$funcstack[1]" = "_netssh" ]; then
|
||||
_netssh "$@"
|
||||
fi
|
||||
`
|
||||
|
||||
// ZshCompletionNote tells the user how zsh picks up a completion file in dir.
|
||||
func ZshCompletionNote(dir string) string {
|
||||
dump := filepath.Join(zshDir(), ".zcompdump")
|
||||
return fmt.Sprintf(`zsh loads it from fpath. The netssh block in your .zshrc adds %s
|
||||
(netssh hook install). Otherwise add this before compinit:
|
||||
fpath=(%s $fpath)
|
||||
Then refresh the completion cache: rm -f %s* && exec zsh`, dir, dir, dump)
|
||||
}
|
||||
|
||||
func zshDir() string {
|
||||
if dir := os.Getenv("ZDOTDIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return os.Getenv("HOME")
|
||||
}
|
||||
|
||||
// ZshFuncDir is the user directory netssh installs zsh completion files into.
|
||||
func ZshFuncDir() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".zfunc")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -193,14 +194,9 @@ func RunWizard(cfg *config.Config) error {
|
||||
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
|
||||
return nil
|
||||
}
|
||||
if _, err := hook.Install(profile); err != nil {
|
||||
if _, err := hook.Install(profile, shell); err != nil {
|
||||
return err
|
||||
}
|
||||
if shell != "fish" {
|
||||
if _, err := hook.InstallAliasesSource(profile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile))
|
||||
}
|
||||
return nil
|
||||
@@ -251,10 +247,18 @@ func save(cfg config.Config) error {
|
||||
b.WriteString("netboxes:\n")
|
||||
for _, source := range cfg.NetBoxes {
|
||||
fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion)
|
||||
if !sshOptionsEmpty(source.SSH) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", source.SSH)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.WriteString("netbox:\n")
|
||||
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
if !sshOptionsEmpty(cfg.NetBox.SSH) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", cfg.NetBox.SSH)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nresolver:\n")
|
||||
@@ -285,6 +289,23 @@ func save(cfg config.Config) error {
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
|
||||
}
|
||||
writeSSHOptions(&b, " ", cfg.SSH.SSHOptions)
|
||||
if len(cfg.SSH.Rules) > 0 {
|
||||
b.WriteString(" rules:\n")
|
||||
for _, rule := range cfg.SSH.Rules {
|
||||
b.WriteString(" - match:\n")
|
||||
for _, field := range [][2]string{{"tag", rule.Match.Tag}, {"instance", rule.Match.Instance}, {"kind", rule.Match.Kind}, {"name", rule.Match.Name}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(&b, " %s: %q\n", field[0], field[1])
|
||||
}
|
||||
}
|
||||
writeSSHOptions(&b, " ", rule.SSHOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString("\nshortcuts:\n")
|
||||
@@ -310,3 +331,33 @@ func save(cfg config.Config) error {
|
||||
fmt.Printf("\nConfig saved → %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshOptionsEmpty(o config.SSHOptions) bool {
|
||||
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
|
||||
}
|
||||
|
||||
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
||||
func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
|
||||
for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1])
|
||||
}
|
||||
}
|
||||
if len(o.ExtraArgs) > 0 {
|
||||
fmt.Fprintf(b, "%sextra_args:\n", indent)
|
||||
for _, arg := range o.ExtraArgs {
|
||||
fmt.Fprintf(b, "%s - %q\n", indent, arg)
|
||||
}
|
||||
}
|
||||
if len(o.Options) > 0 {
|
||||
keys := make([]string, 0, len(o.Options))
|
||||
for key := range o.Options {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Fprintf(b, "%soptions:\n", indent)
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package setup
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -408,7 +409,7 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||
t.Fatalf("instances lost during save: %+v", loaded)
|
||||
}
|
||||
for i, want := range cfg.NetBoxes {
|
||||
if loaded.NetBoxes[i] != want {
|
||||
if !reflect.DeepEqual(loaded.NetBoxes[i], want) {
|
||||
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
|
||||
}
|
||||
}
|
||||
@@ -420,3 +421,37 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||
t.Fatal("credentials are not private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKeepsSSHOptions(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
opts := config.SSHOptions{
|
||||
ProxyJump: "jh.example.com",
|
||||
HostKeyChecking: "accept-new",
|
||||
Target: "name",
|
||||
AddressFamily: "inet",
|
||||
ExtraArgs: []string{"-A"},
|
||||
Options: map[string]string{"serveraliveinterval": "60"},
|
||||
}
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: "root",
|
||||
SSHOptions: opts,
|
||||
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
|
||||
},
|
||||
Completion: config.CompletionConfig{MaxResults: 100},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion {
|
||||
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
|
||||
// so these never collide with flags meant for ssh.
|
||||
type WrapperFlags struct {
|
||||
DryRun bool
|
||||
Jump string // --jump HOST overrides the configured jump host
|
||||
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
||||
}
|
||||
|
||||
// ExtractWrapperFlags removes netssh's long options that appear before the
|
||||
// destination. Anything after the destination belongs to the remote command.
|
||||
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||
var flags WrapperFlags
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--dry-run":
|
||||
flags.DryRun = true
|
||||
case arg == "--no-jump":
|
||||
flags.NoJump = true
|
||||
case arg == "--jump":
|
||||
if i+1 >= len(args) {
|
||||
return flags, nil, fmt.Errorf("--jump requires a host")
|
||||
}
|
||||
i++
|
||||
flags.Jump = args[i]
|
||||
case strings.HasPrefix(arg, "--jump="):
|
||||
flags.Jump = strings.TrimPrefix(arg, "--jump=")
|
||||
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
|
||||
// Destination or end of options: keep the rest unchanged.
|
||||
return flags, append(out, args[i:]...), validateJump(flags)
|
||||
case strings.HasPrefix(arg, "--"):
|
||||
return flags, nil, fmt.Errorf("unknown option %s", arg)
|
||||
default:
|
||||
out = append(out, arg)
|
||||
// Keep the value of an ssh option such as -l USER together with it.
|
||||
if takesSeparateValue(arg) && i+1 < len(args) {
|
||||
i++
|
||||
out = append(out, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags, out, validateJump(flags)
|
||||
}
|
||||
|
||||
func validateJump(flags WrapperFlags) error {
|
||||
if flags.Jump == "" && flags.NoJump {
|
||||
return nil
|
||||
}
|
||||
if flags.Jump != "" && flags.NoJump {
|
||||
return fmt.Errorf("--jump and --no-jump are mutually exclusive")
|
||||
}
|
||||
if strings.HasPrefix(flags.Jump, "-") {
|
||||
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// takesSeparateValue reports whether a short option cluster ends with a flag
|
||||
// whose value is the next argument (e.g. "-l" or "-vp").
|
||||
func takesSeparateValue(arg string) bool {
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if flagsWithArg[arg[j]] {
|
||||
return j == len(arg)-1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Host is a resolved NetBox host.
|
||||
type Host struct {
|
||||
Name string // NetBox name without the instance prefix
|
||||
IP string
|
||||
}
|
||||
|
||||
// Result is the final ssh argument list and an optional notice for stderr.
|
||||
type Result struct {
|
||||
Args []string
|
||||
Notice string
|
||||
}
|
||||
|
||||
var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
|
||||
|
||||
// Apply replaces the destination at destIdx and inserts the configured -o
|
||||
// options directly before it. Explicit options given by the user come first,
|
||||
// and ssh uses the first -o value it obtains, so the user's choice wins.
|
||||
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
var inserted []string
|
||||
add := func(keyword, value string) {
|
||||
if !hasOption(args[:destIdx], 0, keyword) {
|
||||
inserted = append(inserted, "-o", keyword+"="+value)
|
||||
}
|
||||
}
|
||||
|
||||
dest := host.IP
|
||||
if opts.Target == "name" || opts.Target == "hostname" {
|
||||
if plainHostname.MatchString(host.Name) {
|
||||
dest = host.Name
|
||||
if opts.Target == "name" {
|
||||
add("HostName", host.IP)
|
||||
add("HostKeyAlias", host.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
|
||||
switch {
|
||||
case userJump:
|
||||
case flags.NoJump:
|
||||
inserted = append(inserted, "-o", "ProxyJump=none")
|
||||
case flags.Jump != "":
|
||||
inserted = append(inserted, "-J", flags.Jump)
|
||||
case opts.ProxyJump != "" && opts.ProxyJump != "none":
|
||||
inserted = append(inserted, "-J", opts.ProxyJump)
|
||||
}
|
||||
|
||||
var notice string
|
||||
switch opts.HostKeyChecking {
|
||||
case "strict":
|
||||
add("StrictHostKeyChecking", "yes")
|
||||
case "accept-new":
|
||||
add("StrictHostKeyChecking", "accept-new")
|
||||
case "off":
|
||||
if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") {
|
||||
add("StrictHostKeyChecking", "no")
|
||||
add("UserKnownHostsFile", "/dev/null")
|
||||
add("LogLevel", "ERROR")
|
||||
notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)"
|
||||
}
|
||||
}
|
||||
|
||||
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
|
||||
add("AddressFamily", opts.AddressFamily)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(opts.Options))
|
||||
for key := range opts.Options {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
add(key, opts.Options[key])
|
||||
}
|
||||
|
||||
// Extra arguments go first: short flags such as -4 or -A take the last
|
||||
// value given, so the user's own flags later on the line still win.
|
||||
replaced := ReplaceHost(args, destIdx, dest)
|
||||
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
|
||||
out = append(out, opts.ExtraArgs...)
|
||||
out = append(out, replaced[:destIdx]...)
|
||||
out = append(out, inserted...)
|
||||
out = append(out, replaced[destIdx:]...)
|
||||
return Result{Args: out, Notice: notice}
|
||||
}
|
||||
|
||||
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
||||
func hasBoolFlag(args []string, wanted byte) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||
break
|
||||
}
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if arg[j] == wanted {
|
||||
return true
|
||||
}
|
||||
if flagsWithArg[arg[j]] {
|
||||
if j == len(arg)-1 {
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Quote renders an argument list as a copy-pasteable POSIX shell command.
|
||||
func Quote(argv []string) string {
|
||||
quoted := make([]string, len(argv))
|
||||
for i, arg := range argv {
|
||||
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
|
||||
quoted[i] = arg
|
||||
continue
|
||||
}
|
||||
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestExtractWrapperFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
flags WrapperFlags
|
||||
rest []string
|
||||
}{
|
||||
{[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}},
|
||||
{[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}},
|
||||
{[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}},
|
||||
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
||||
} {
|
||||
flags, rest, err := ExtractWrapperFlags(tc.args)
|
||||
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
|
||||
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} {
|
||||
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||
t.Errorf("%v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags)
|
||||
}
|
||||
|
||||
func TestApplyDefaultConnectsToIP(t *testing.T) {
|
||||
got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{})
|
||||
if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTargetName(t *testing.T) {
|
||||
got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"}
|
||||
if !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{})
|
||||
if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
// Names that are not plain hostnames fall back to the IP address.
|
||||
got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||
if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJumpPrecedence(t *testing.T) {
|
||||
opts := config.SSHOptions{ProxyJump: "jh.example.com"}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
flags WrapperFlags
|
||||
want string
|
||||
}{
|
||||
{[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"},
|
||||
{[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"},
|
||||
{[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"},
|
||||
{[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"},
|
||||
{[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"},
|
||||
} {
|
||||
if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want {
|
||||
t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 {
|
||||
t.Fatalf("proxy_jump none still jumps: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostKeyChecking(t *testing.T) {
|
||||
got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||
want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10"
|
||||
if strings.Join(got.Args, " ") != want || got.Notice == "" {
|
||||
t.Fatalf("got %v, notice %q", got.Args, got.Notice)
|
||||
}
|
||||
// An explicit user choice disables the whole "off" bundle.
|
||||
got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" {
|
||||
t.Fatalf("user choice overridden: %v", got.Args)
|
||||
}
|
||||
got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOptionsAndExtraArgs(t *testing.T) {
|
||||
opts := config.SSHOptions{
|
||||
AddressFamily: "inet",
|
||||
ExtraArgs: []string{"-A"},
|
||||
Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"},
|
||||
}
|
||||
got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{})
|
||||
want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10"
|
||||
if strings.Join(got.Args, " ") != want {
|
||||
t.Fatalf("got %q", strings.Join(got.Args, " "))
|
||||
}
|
||||
got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-v6 192.0.2.10" {
|
||||
t.Fatalf("address family overrides -6: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKeepsRemoteCommand(t *testing.T) {
|
||||
got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{})
|
||||
if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote(t *testing.T) {
|
||||
got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"})
|
||||
if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user