diff --git a/CHANGELOG.md b/CHANGELOG.md index ffee5b7..85fbfed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.0.23 + +- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list. +- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving. +- The configurator validates SSH options and rules before writing the config. +- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`. +- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches. +- Add `netssh sftp` and `netssh scp`: host lookup, shortcuts, instances, default user and port, SSH options and rules, jump host, `--dry-run`, and completion work as for ssh. IPv6 addresses are bracketed for host:path. +- Add named port forwards (`ssh.forwards` globally, per instance, and per rule), `netssh tunnel HOST NAME|SPEC...` (ssh -N with ExitOnForwardFailure), and `netssh --forward NAME`. Forwards are editable in `netssh configure` and completed in the shell. +- TUI: `Ctrl+O` opens SFTP, `Ctrl+T` starts a tunnel with configured or ad hoc forwards. +- Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination. + ## v0.0.22 - Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1`) 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. diff --git a/README.md b/README.md index 1b157ea..4be7a66 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,12 @@ netssh -p 2222 admin@app-server-03 uptime - **Shell completion** — install without sudo via `netssh completion install` - **Default SSH user** — set a fallback username once in config instead of typing it every time - **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line -- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding +- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`) - **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` +- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh +- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI - **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 @@ -106,8 +108,19 @@ Re-run it at any time to change settings without editing the file manually: netssh configure ``` -The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias, -then saves to `~/.config/netssh.yaml`. +The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. +A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**: + +| Section | Settings | +|---|---| +| SSH defaults | `ssh.default_user`, `ssh.default_port` | +| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) | +| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options | +| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file | +| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` | +| Completion | `completion.max_results` | + +The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing. ### Manual config @@ -316,6 +329,46 @@ With `target: hostname`, ssh resolves the NetBox name itself (DNS or Switching to `name` stores new known_hosts entries under the device name, so ssh asks once to confirm each host key. +### SFTP and SCP + +`netssh sftp` and `netssh scp` look up the host exactly like a connection does: cache, shortcuts and rewrite rules, `instance/host`, default user and port, SSH options and rules, `--jump`/`--no-jump`, and `--dry-run`. + +```sh +netssh sftp fsnbx1 # interactive sftp +netssh sftp prod/web01:/var/log # start in a directory +netssh scp ./app.tar fsnbx1:/tmp/ # upload +netssh scp -r web01:/etc/nginx ./nginx # download +netssh scp --dry-run app.tar web01:/tmp/ # print the scp command +``` + +sftp and scp options are passed through. Mind their differences from ssh: the port is `-P`, and `-l` is a bandwidth limit, so the default user is passed as `-o User=`. `extra_args` and `forwards` contain ssh flags and apply to ssh only. All remote operands of one `scp` call must name the same host; copy between two NetBox hosts in two steps. Tab completion offers `host:` for scp and local files otherwise. + +### Port forwarding + +Plain `-L`, `-R`, and `-D` flags are passed to ssh as usual (`netssh -L 8080:localhost:80 web01`). For tunnels you use often, name them under `ssh.forwards`, globally, per NetBox instance (`netboxes[].ssh.forwards`), or per rule. A more specific layer replaces a forward of the same name. + +```yaml +ssh: + forwards: + web: {local: "8080:localhost:80"} # -L + socks: {dynamic: "1080"} # -D, SOCKS proxy + back: {remote: "9000:localhost:9000"} # -R + rules: + - match: {tag: postgres} + forwards: + db: {local: "5432:localhost:5432"} +``` + +```sh +netssh tunnel fsnbx1 web socks # only forwards: ssh -N, stops with Ctrl+C +netssh tunnel prod/db01 db 6379:localhost:6379 # names and ad hoc specs mixed +netssh --forward web fsnbx1 # shell plus forward (repeatable) +``` + +A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 `) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards. + +`sftp`, `scp`, and `tunnel` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`. + ### Default username Set `ssh.default_user` in the config to avoid typing a username every time: @@ -355,13 +408,26 @@ shortcuts: - .example.com - .example.de strip_hyphens: false # optional + rewrites: # optional, applied in order + - pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1 + replace: '$1' ``` Resolution order: 1. **Exact match** — the input is looked up as-is in the cache. 2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name. -3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first. +3. **Shortcut match** — the input and every cached hostname are shortened the same way and compared: lowercase, strip the domain suffix, apply each rewrite rule in order, then remove hyphens if `strip_hyphens` is set (`fsn1web01` → `fsn1-web01.example.com`, `fsnbx1` → `fsn1-bx1.hoster.de`). This only works when the host is already cached; run `netssh cache refresh` first. + +**Rewrite rules** use Go regular expressions ([syntax](https://pkg.go.dev/regexp/syntax)); `replace` may refer to groups as `$1` or `${1}`. Every match in the name is replaced. Because the typed shortcut is rewritten too, write rules that leave an already shortened name unchanged: `^([a-z]+)[0-9]+-` matches `fsn1-bx1` but not `fsnbx1`. Shortcuts also become shell aliases and are offered by Tab completion when the full name does not match what you typed (`fsnb` → `fsnbx1`). + +Check what a hostname is shortened to, and whether the shortcut is unique in the cache: + +```sh +$ netssh alias --test fsn1-bx1.hoster.de +fsn1-bx1.hoster.de → fsnbx1 +matches fsn1-bx1.hoster.de +``` Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names. @@ -369,6 +435,7 @@ Domains may be configured with or without a leading dot. If several cached hosts |-------|--------|-------------| | `web01` | `domains: [.example.com]` | `web01.example.com` | | `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) | +| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) | ### Shell aliases @@ -419,6 +486,8 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search | `Tab` | autocomplete top result into the search field | | `↑` / `↓` | navigate results | | `Enter` | connect to selected host | +| `Ctrl+O` | open an SFTP session to the selected host | +| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) | | `Ctrl+E` | open inline editor to override user/port before connecting | | `Ctrl+N` | edit local notes for the selected server | | `Ctrl+B` | edit NetBox comments for the selected server | diff --git a/cmd/netssh/instances.go b/cmd/netssh/instances.go index 2d23f70..206de3a 100644 --- a/cmd/netssh/instances.go +++ b/cmd/netssh/instances.go @@ -41,7 +41,9 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st // 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 { +// Hosts whose full name does not match are offered by their configured +// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com). +func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string { prefix := strings.ToLower(toComplete) qualifiedInput := strings.Contains(prefix, "/") instances := make(map[string]int) @@ -49,21 +51,69 @@ func hostCompletions(entries []cache.Entry, toComplete string) []string { _, name := netbox.SplitTarget(e.Name) instances[strings.ToLower(name)]++ } - var out []string + var unmatched []cache.Entry + var names []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) + names = append(names, name) } else { - out = append(out, e.Name) + names = append(names, e.Name) } case strings.HasPrefix(target, prefix): - out = append(out, e.Name) + names = append(names, e.Name) + default: + unmatched = append(unmatched, e) + } + } + if sc.Enabled() { + names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...) + } + sort.Strings(names) + return names +} + +// shortcutCompletions offers the shortcut of each unmatched host when it starts +// with prefix and selects exactly one host, qualified by instance if needed. +func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string { + bare := make(map[string]int) + qualified := make(map[string]int) + short := func(e cache.Entry) (string, string) { + source, name := netbox.SplitTarget(e.Name) + s := shortcuts.Normalize(name, sc) + if source == "" { + return s, s + } + return s, strings.ToLower(source) + "/" + s + } + for _, e := range all { + b, q := short(e) + bare[b]++ + qualified[q]++ + } + seen := make(map[string]bool) + var out []string + for _, e := range unmatched { + b, q := short(e) + candidate := "" + switch { + case strings.Contains(prefix, "/"): + if qualified[q] == 1 && strings.HasPrefix(q, prefix) { + candidate = q + } + case !strings.HasPrefix(b, prefix): + case bare[b] == 1: + candidate = b + case q != b && qualified[q] == 1: + candidate = q // like full names: zsh accepts it, bash/fish ignore it + } + if candidate != "" && !seen[candidate] { + seen[candidate] = true + out = append(out, candidate) } } - sort.Strings(out) return out } diff --git a/cmd/netssh/instances_test.go b/cmd/netssh/instances_test.go index 02955c6..69412b7 100644 --- a/cmd/netssh/instances_test.go +++ b/cmd/netssh/instances_test.go @@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) { // 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) { + if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !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"}) { + if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) { t.Errorf("single instance: %v", got) } } + +var fsnRewrite = config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, +} + +func TestHostCompletionsOfferShortcuts(t *testing.T) { + entries := []cache.Entry{ + {Name: "prod/fsn1-bx1.hoster.de"}, + {Name: "prod/fsn1-db1.hoster.de"}, + {Name: "stg/fsn1-db1.hoster.de"}, + {Name: "prod/web01.hoster.de"}, + } + for _, tc := range []struct { + input string + want []string + }{ + {"fsnb", []string{"fsnbx1"}}, + // Shortcut shared by two instances stays qualified. + {"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}}, + {"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}}, + {"stg/fsnd", []string{"stg/fsndb1"}}, + // A matching full name is not repeated as shortcut. + {"web", []string{"web01.hoster.de"}}, + } { + if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%q: got %v, want %v", tc.input, got, tc.want) + } + } + // Two hosts of the same instance with one shortcut cannot be selected by it. + same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}} + if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 { + t.Errorf("ambiguous shortcut offered: %v", got) + } +} + +func TestShortcutTestOutput(t *testing.T) { + var out bytes.Buffer + printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite) + want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n" + if out.String() != want { + t.Fatalf("got:\n%s", out.String()) + } +} + +// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable, +// so only the cached host can answer. +func TestWrapperConnectsByRewriteShortcut(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("NETSSH_NETBOX_URL", "") + t.Setenv("NETSSH_NETBOX_TOKEN", "") + t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) + t.Setenv("NETSSH_SHELL_ALIAS", "") + data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n" + if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + c := cache.New(cfg.CachePath(), 3600) + c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"}) + if err := c.Save(); err != nil { + t.Fatal(err) + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$") + cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "10.0.0.9\n") { + t.Fatalf("shortcut not resolved from cache: %s, %v", out, err) + } +} diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index b8af0aa..4540fd2 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -42,6 +42,9 @@ var managedSubcommands = map[string]bool{ "help": true, "version": true, "uninstall": true, + "sftp": true, + "scp": true, + "tunnel": true, } func main() { @@ -91,48 +94,107 @@ func runSSHWrapper(args []string) { if err != nil { fatalf("%v", err) } + runClient(internalssh.SSH, flags, args) +} + +// destination is the NetBox host named in the arguments of a client. +type destination struct { + host string + user string + dests []int // argument indices naming the host; several only for scp +} + +func findDestination(client internalssh.Client, args []string) (*destination, error) { + if client.Name == internalssh.SCP.Name { + operands, err := client.Operands(args) + if err != nil || len(operands) == 0 { + return nil, err + } + dest := &destination{host: operands[0].Host} + for _, operand := range operands { + dest.dests = append(dest.dests, operand.Idx) + if operand.User != "" { + dest.user = operand.User + } + } + return dest, nil + } + parsed := client.Parse(args) + if parsed == nil { + return nil, nil + } + return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil +} + +// runClient resolves the NetBox host in args and execs ssh, sftp, or scp. +func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) { cfg := loadConfigOrSetup() - parsed := internalssh.Parse(args) - if parsed == nil { - // No destination found — pass arguments straight through to ssh. - execSSH(internalssh.Result{Args: args}, flags) - return + dest, err := findDestination(client, args) + if err != nil { + fatalf("%v", err) } - connect := func(name, kind, ip string, tags []string) { - connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args) + if dest == nil { + if len(flags.Forwards) > 0 { + fatalf("--forward needs a destination host") + } + // No destination found — pass arguments straight through. + execClient(client, internalssh.Result{Args: args}, flags) + return } // Inject the configured default user if none was given on the command line. - if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) { - args = append([]string{"-l", cfg.SSH.DefaultUser}, args...) - parsed.DestIdx += 2 + if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) { + args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests) } // Inject the configured default port if none was given on the command line. - if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) { - args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...) - parsed.DestIdx += 2 + if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) { + args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests) } + connectHost(cfg, client, flags, resolveTarget(cfg, dest.host), dest.dests, args) +} + +// prependArgs puts extra before args and shifts the destination indices. +func prependArgs(extra, args []string, dests []int) []string { + for i := range dests { + dests[i] += len(extra) + } + return append(append([]string(nil), extra...), args...) +} + +// resolvedHost is a NetBox host with the address to connect to. +type resolvedHost struct { + Name string // cache/selector name, may carry an instance prefix + Kind string + IP string + Tags []string +} + +// resolveTarget finds host in the cache (also by shortcut) or NetBox and +// resolves its address. It exits on failure. +func resolveTarget(cfg *config.Config, host string) resolvedHost { c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() maybeBackgroundRefresh(cfg, c) - source, _ := netbox.SplitTarget(parsed.Host) + source, _ := netbox.SplitTarget(host) useCache := len(cfg.NetBoxes) == 0 || source != "" + used := func(entry cache.Entry) resolvedHost { + c.MarkUsed(entry.Name) + _ = c.Save() + return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags} + } // Cache hit with a fresh TTL — connect directly without querying NetBox. - if entry, fresh := c.Get(parsed.Host); fresh && useCache { - c.MarkUsed(parsed.Host) - _ = c.Save() - connect(entry.Name, entry.Kind, entry.IP, entry.Tags) - return + if entry, fresh := c.Get(host); fresh && useCache { + return used(entry) } // Shortcut cache lookup: scan entries whose normalized name matches the input. // If a stale match is found, use the canonical name for the NetBox re-fetch below. - lookupHost := parsed.Host - shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens + lookupHost := host + shortcutsEnabled := cfg.Shortcuts.Enabled() if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { normalize := normalizeTarget(cfg.Shortcuts, source != "") entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) @@ -141,10 +203,7 @@ func runSSHWrapper(args []string) { } if found { if fresh { - c.MarkUsed(entry.Name) - _ = c.Save() - connect(entry.Name, entry.Kind, entry.IP, entry.Tags) - return + return used(entry) } // Stale shortcut match — use canonical name for NetBox re-fetch. lookupHost = entry.Name @@ -161,7 +220,7 @@ func runSSHWrapper(args []string) { target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains) if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled { - entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false)) + entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false)) if cacheErr != nil { fatalf("%v", cacheErr) } @@ -174,10 +233,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.Name, cached.Kind, cached.IP, cached.Tags) - return + return used(cached) } chain, err := resolver.New(cfg.Resolver) @@ -187,14 +243,12 @@ func runSSHWrapper(args []string) { ip, err := chain.Resolve(ctx, &target, nbClient) if err != nil { - fatalf("IP resolution for %q: %v", parsed.Host, err) + fatalf("IP resolution for %q: %v", host, err) } - c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}) - c.MarkUsed(target.Target()) - _ = c.Save() - - connect(target.Target(), target.Kind, ip, target.Tags) + entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags} + c.Upsert(entry) + return used(entry) } // findSSHHost uses the paginated, ambiguity-checking lookup for connection @@ -217,24 +271,43 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name) } -// 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) +// connectHost applies the SSH options configured for the host and execs the client. +func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) { + opts := hostOptions(cfg, host) + if len(flags.Forwards) > 0 { + forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards) + if err != nil { + fatalf("%v", err) + } + flags.ForwardArgs = forwardArgs + } + if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) { + available := "none configured (ssh.forwards)" + if names := forwardNames(opts); len(names) > 0 { + available = strings.Join(names, ", ") + } + fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available) + } + _, name := netbox.SplitTarget(host.Name) + execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.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) { +// hostOptions merges the global, instance, and rule SSH options for host. +func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions { + instance, name := netbox.SplitTarget(host.Name) + return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags}) +} + +// execClient replaces the process with the client, or prints the command for --dry-run. +func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) { if flags.DryRun { - fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...))) + fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...))) return } if result.Notice != "" { fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice) } - if err := internalssh.Exec(result.Args); err != nil { + if err := client.Exec(result.Args); err != nil { fatalf("%v", err) } } @@ -254,6 +327,9 @@ func runTUI() { m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) m.SetNotes(notes.New(cfg.NetBox.URL, nbClient)) + m.SetForwards(func(name, kind string, tags []string) []string { + return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags})) + }) p := tea.NewProgram(m) final, err := p.Run() if err != nil { @@ -297,31 +373,54 @@ func runTUI() { } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) - var sshArgs []string + c.MarkUsed(host.Name) + _ = c.Save() + client, flags, args := tuiCommand(cfg, host) + connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args) +} + +// tuiCommand builds the client and arguments for the action chosen in the TUI. +func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) { + client := internalssh.SSH + var flags internalssh.WrapperFlags + switch host.Action { + case tui.ActionSFTP: + client = internalssh.SFTP + case tui.ActionTunnel: + flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true} + } + + var args []string // User override from TUI edit mode takes priority, then config default. user := host.User if user == "" { user = cfg.SSH.DefaultUser } if user != "" { - sshArgs = append(sshArgs, "-l", user) + args = append(args, client.UserArgs(user)...) } - port := host.Port if port == "" && cfg.SSH.DefaultPort > 0 { port = strconv.Itoa(cfg.SSH.DefaultPort) } if port != "" { - sshArgs = append(sshArgs, "-p", port) + args = append(args, client.PortArgs(port)...) } + // Apply replaces the destination; a bare name cannot be mistaken for + // host:path by sftp, unlike an IPv6 address. + _, name := netbox.SplitTarget(host.Name) + return client, flags, append(args, name) +} - sshArgs = append(sshArgs, host.IP) - - c.MarkUsed(host.Name) - _ = c.Save() - - connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs) +// forwardNames lists the forwards configured for a host, sorted. +func forwardNames(opts config.SSHOptions) []string { + names := make([]string, 0, len(opts.Forwards)) + for name := range opts.Forwards { + names = append(names, name) + } + sort.Strings(names) + return names } // --- cobra root + subcommands --- @@ -332,17 +431,14 @@ func rootCmd() *cobra.Command { Short: "SSH wrapper with NetBox hostname resolution", DisableFlagParsing: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - cfg, err := config.Load() - if err != nil { - return nil, cobra.ShellCompDirectiveError - } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) - _ = c.Load() - - hosts := hostCompletions(c.All(), toComplete) - if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { - hosts = hosts[:limit] + if len(args) > 0 && args[len(args)-1] == "--forward" { + cfg, err := config.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp } + hosts := completeHosts(toComplete) completions := make([]cobra.Completion, 0, len(hosts)) for _, host := range hosts { completions = append(completions, cobra.Completion(host)) @@ -357,7 +453,7 @@ func rootCmd() *cobra.Command { }, } - root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd()) + root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd()) // cobra builds the "completion" command lazily; force init so we can extend it. root.InitDefaultCompletionCmd() @@ -645,13 +741,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { } func aliasCmd() *cobra.Command { - var shell string + var shell, test string cmd := &cobra.Command{ Use: "alias", Short: "Print shell aliases for all cached hosts", Long: `Print shell alias definitions for all cached hosts. The alias name is the shortened form derived from the configured shortcuts -(domain suffixes stripped, hyphens optionally stripped). +(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped). + +Check a rule with: netssh alias --test fsn1-bx1.example.com Source the output in your shell profile: bash/zsh: eval "$(netssh alias)" @@ -668,6 +766,10 @@ Or use in a script: if err := c.Load(); err != nil { return err } + if test != "" { + printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts) + return nil + } if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } @@ -694,9 +796,36 @@ Or use in a script: }, } cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)") + cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches") return cmd } +// printShortcutTest helps writing shortcut rules: it shows what a name is +// shortened to and which cached hosts share that shortcut. +func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) { + normalize := normalizeTarget(sc, strings.Contains(name, "/")) + short := normalize(name) + fmt.Fprintf(w, "%s → %s\n", name, short) + var matches []string + for _, e := range entries { + if normalize(e.Name) == short { + matches = append(matches, e.Name) + } + } + sort.Strings(matches) + switch len(matches) { + case 0: + fmt.Fprintln(w, "no cached host has this shortcut") + case 1: + fmt.Fprintf(w, "matches %s\n", matches[0]) + default: + fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches)) + for _, m := range matches { + fmt.Fprintf(w, " %s\n", m) + } + } +} + // shellInitCmd is called at shell startup to update alias files and trigger a // background cache refresh when stale. Intentionally silent — never disrupts the prompt. func shellInitCmd() *cobra.Command { diff --git a/cmd/netssh/transfer.go b/cmd/netssh/transfer.go new file mode 100644 index 0000000..0653961 --- /dev/null +++ b/cmd/netssh/transfer.go @@ -0,0 +1,198 @@ +package main + +import ( + "sort" + "strings" + + "github.com/spf13/cobra" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" + internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh" +) + +func sftpCmd() *cobra.Command { + return transferCmd(internalssh.SFTP, "sftp [--dry-run] [--jump HOST|--no-jump] [sftp options] HOST[:PATH]", + "Open an SFTP session to a NetBox host", + `Resolve HOST like netssh does for ssh (cache, shortcuts, instance/host, +SSH options and rules, jump host) and run sftp. sftp options are passed +through; note that sftp uses -P for the port. + + netssh sftp fsnbx1 + netssh sftp -P 2222 prod/web01:/var/log`) +} + +func scpCmd() *cobra.Command { + return transferCmd(internalssh.SCP, "scp [--dry-run] [--jump HOST|--no-jump] [scp options] SOURCE... TARGET", + "Copy files to or from a NetBox host", + `Resolve the host in HOST:PATH operands like netssh does for ssh and run scp. +All remote operands must name the same host. + + netssh scp ./app.tar fsnbx1:/tmp/ + netssh scp -r prod/web01:/etc/nginx ./nginx`) +} + +func transferCmd(client internalssh.Client, use, short, long string) *cobra.Command { + return &cobra.Command{ + Use: use, + Short: short, + Long: long, + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + _ = cmd.Help() + return + } + flags, args, err := client.ExtractWrapperFlags(args) + if err != nil { + fatalf("%v", err) + } + if len(flags.Forwards) > 0 { + fatalf("--forward is only available for ssh and netssh tunnel") + } + runClient(client, flags, args) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + if client.Name == internalssh.SFTP.Name && client.Parse(args) != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "-") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if client.Name == internalssh.SCP.Name { + if strings.Contains(toComplete, ":") { + return nil, cobra.ShellCompDirectiveNoFileComp // remote paths are not completed + } + // HOST: for remote operands; without a matching host, local files. + hosts := completeHosts(toComplete) + if len(hosts) == 0 { + return nil, cobra.ShellCompDirectiveDefault + } + for i := range hosts { + hosts[i] += ":" + } + return hosts, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp + }, + } +} + +func tunnelCmd() *cobra.Command { + return &cobra.Command{ + Use: "tunnel [--dry-run] [--jump HOST|--no-jump] [ssh options] HOST FORWARD...", + Short: "Open port forwards to a NetBox host without a shell", + Long: `Run ssh -N with the named forwards configured under ssh.forwards (globally, +per NetBox instance, or per rule). A FORWARD that is no configured name but a +port spec such as 8080:localhost:80 is used as a local forward (-L). + + netssh tunnel fsnbx1 web socks + netssh tunnel prod/db01 5432:localhost:5432`, + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 || args[0] == "--help" || args[0] == "-h" { + _ = cmd.Help() + return + } + flags, args, err := internalssh.SSH.ExtractWrapperFlags(args) + if err != nil { + fatalf("%v", err) + } + parsed := internalssh.SSH.Parse(args) + if parsed == nil { + fatalf("usage: netssh tunnel HOST FORWARD...") + } + // Everything after the host names forwards instead of a remote command. + flags.Forwards = append(flags.Forwards, args[parsed.DestIdx+1:]...) + flags.Tunnel = true + runClient(internalssh.SSH, flags, args[:parsed.DestIdx+1]) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + parsed := internalssh.SSH.Parse(args) + if parsed == nil { + return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp + } + cfg, err := config.Load() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + var names []string + if entry, ok := cachedHost(cfg, parsed.Host); ok { + names = forwardNames(hostOptions(cfg, resolvedHost{Name: entry.Name, Kind: entry.Kind, Tags: entry.Tags})) + } else { + names = allForwardNames(cfg) + } + return filterPrefix(names, toComplete, args[parsed.DestIdx+1:]), cobra.ShellCompDirectiveNoFileComp + }, + } +} + +// completeHosts returns the cached hosts for a completion request. +func completeHosts(toComplete string) []string { + cfg, err := config.Load() + if err != nil { + return nil + } + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + _ = c.Load() + hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts) + if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { + hosts = hosts[:limit] + } + return hosts +} + +// cachedHost finds a host for completion by name or shortcut, without NetBox. +func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) { + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + if err := c.Load(); err != nil { + return cache.Entry{}, false + } + if entry, _ := c.Get(host); entry.Name != "" { + return entry, true + } + if !cfg.Shortcuts.Enabled() { + return cache.Entry{}, false + } + entry, found, _, err := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, strings.Contains(host, "/"))) + return entry, found && err == nil +} + +// allForwardNames lists every configured forward name, for when the host is unknown. +func allForwardNames(cfg *config.Config) []string { + seen := map[string]bool{} + add := func(o config.SSHOptions) { + for name := range o.Forwards { + seen[strings.ToLower(name)] = true + } + } + add(cfg.SSH.SSHOptions) + add(cfg.NetBox.SSH) + for _, source := range cfg.NetBoxes { + add(source.SSH) + } + for _, rule := range cfg.SSH.Rules { + add(rule.SSHOptions) + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// filterPrefix keeps the names starting with prefix that were not given yet. +func filterPrefix(names []string, prefix string, given []string) []string { + used := map[string]bool{} + for _, name := range given { + used[strings.ToLower(name)] = true + } + var out []string + for _, name := range names { + if strings.HasPrefix(name, strings.ToLower(prefix)) && !used[name] { + out = append(out, name) + } + } + return out +} diff --git a/cmd/netssh/transfer_test.go b/cmd/netssh/transfer_test.go new file mode 100644 index 0000000..5da6e97 --- /dev/null +++ b/cmd/netssh/transfer_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// TestNetsshHelper runs main() with the arguments in NETSSH_TEST_ARGS. +func TestNetsshHelper(t *testing.T) { + if args := os.Getenv("NETSSH_TEST_ARGS"); args != "" { + os.Args = append([]string{"netssh"}, strings.Split(args, "\x1f")...) + main() + os.Exit(0) + } +} + +// prepareTransferTest writes a config whose only host is cached; NetBox is +// unreachable, so every result must come from cache, shortcuts, and options. +func prepareTransferTest(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("NETSSH_NETBOX_URL", "") + t.Setenv("NETSSH_NETBOX_TOKEN", "") + t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) + t.Setenv("NETSSH_SHELL_ALIAS", "") + data := `netbox: + url: http://127.0.0.1:1 + token: t +ssh: + default_user: ops + default_port: 2222 + extra_args: ["-A"] + forwards: + web: {local: "8080:localhost:80"} + rules: + - match: {tag: dmz} + proxy_jump: jh.example.com + forwards: + socks: {dynamic: "1080"} +shortcuts: + domains: [.hoster.de] + rewrites: + - pattern: '^([a-z]+)[0-9]+-' + replace: '$1' +` + if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + c := cache.New(cfg.CachePath(), 3600) + c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device", Tags: []string{"dmz"}}) + if err := c.Save(); err != nil { + t.Fatal(err) + } +} + +func runNetssh(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestNetsshHelper$") + cmd.Env = append(os.Environ(), "NETSSH_TEST_ARGS="+strings.Join(args, "\x1f")) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func TestTransferAndTunnelCommands(t *testing.T) { + prepareTransferTest(t) + for _, tc := range []struct { + args []string + want string + }{ + // sftp: -P and -o User, rule jump host, path kept, no ssh-only extra_args. + {[]string{"sftp", "--dry-run", "fsnbx1:/var/log"}, "sftp -P 2222 -o User=ops -J jh.example.com 10.0.0.9:/var/log"}, + {[]string{"sftp", "--dry-run", "-P", "22", "root@fsnbx1"}, "sftp -P 22 -J jh.example.com root@10.0.0.9"}, + {[]string{"scp", "--dry-run", "-r", "a.txt", "fsn1-bx1.hoster.de:/tmp/"}, "scp -P 2222 -o User=ops -r -J jh.example.com a.txt 10.0.0.9:/tmp/"}, + {[]string{"scp", "--dry-run", "--no-jump", "fsnbx1:/etc/hosts", "."}, "scp -P 2222 -o User=ops -o ProxyJump=none 10.0.0.9:/etc/hosts ."}, + // Forwards come from the global options and the matching rule. + {[]string{"tunnel", "--dry-run", "fsnbx1", "web", "socks", "5432:db:5432"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 -D 1080 -L 5432:db:5432 -o ExitOnForwardFailure=yes -N 10.0.0.9"}, + {[]string{"--dry-run", "--forward", "web", "fsnbx1"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 10.0.0.9"}, + } { + out, err := runNetssh(t, tc.args...) + if err != nil || out != tc.want { + t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want) + } + } + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"tunnel", "--dry-run", "fsnbx1"}, "forwards for fsn1-bx1.hoster.de: socks, web"}, + {[]string{"tunnel", "--dry-run", "fsnbx1", "db"}, `unknown forward "db"; available: socks, web`}, + {[]string{"sftp", "--forward", "web", "fsnbx1"}, "only available for ssh"}, + {[]string{"scp", "fsnbx1:a", "other:b"}, "not supported"}, + } { + out, err := runNetssh(t, tc.args...) + if err == nil || !strings.Contains(out, tc.want) { + t.Errorf("%v: got %s (%v), want error containing %q", tc.args, out, err, tc.want) + } + } +} + +func TestTransferCompletion(t *testing.T) { + prepareTransferTest(t) + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"__complete", "sftp", "fsnb"}, "fsnbx1\n"}, + {[]string{"__complete", "scp", "a.txt", "fsnb"}, "fsnbx1:\n"}, + {[]string{"__complete", "tunnel", "fsnbx1", ""}, "socks\nweb\n"}, + {[]string{"__complete", "tunnel", "fsnbx1", "web", ""}, "socks\n"}, + {[]string{"__complete", "--forward", ""}, "socks\nweb\n"}, + } { + out, err := runNetssh(t, tc.args...) + if err != nil || !strings.HasPrefix(out+"\n", tc.want) { + t.Errorf("%v: got %q (%v), want prefix %q", tc.args, out, err, tc.want) + } + } +} diff --git a/go.mod b/go.mod index 241edf7..8ba23bf 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,15 @@ module git.zb-server.de/Sebi/ssh-netbox-wrapper -go 1.27.3 +go 1.27.4 require ( charm.land/bubbles/v2 v2.2.1 - charm.land/bubbletea/v2 v2.1.0 + charm.land/bubbletea/v2 v2.1.1 charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.6 + charm.land/lipgloss/v2 v2.1.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - golang.org/x/sys v0.49.0 + golang.org/x/sys v0.50.0 ) require ( @@ -48,6 +48,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v1.2.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/sync v0.24.0 // indirect - golang.org/x/text v0.43.0 // indirect + golang.org/x/sync v0.25.0 // indirect + golang.org/x/text v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index 382c38d..6ef791f 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= -charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= -charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= +charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU= +charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= -charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +charm.land/lipgloss/v2 v2.1.0 h1:RLCNLhSQST+NPOmvUSbRWZ3UFOXsf3ZXFmJ5E/2tHEU= +charm.land/lipgloss/v2 v2.1.0/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -100,10 +100,10 @@ github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTB go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= -golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= -golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= -golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs= +golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs= +golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo= +golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index c498b9c..04d7ea5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,8 +57,34 @@ type CompletionConfig struct { } type ShortcutsConfig struct { - Domains []string `mapstructure:"domains"` - StripHyphens bool `mapstructure:"strip_hyphens"` + Domains []string `mapstructure:"domains"` + StripHyphens bool `mapstructure:"strip_hyphens"` + Rewrites []ShortcutRewrite `mapstructure:"rewrites"` +} + +// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with +// Replace, which may reference groups as $1. Applied after domain stripping. +type ShortcutRewrite struct { + Pattern string `mapstructure:"pattern"` + Replace string `mapstructure:"replace"` +} + +// Enabled reports whether any shortcut normalization is configured. +func (s ShortcutsConfig) Enabled() bool { + return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0 +} + +// Validate rejects rewrite rules that cannot be compiled. +func (s ShortcutsConfig) Validate() error { + for i, rewrite := range s.Rewrites { + if rewrite.Pattern == "" { + return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i) + } + if _, err := regexp.Compile(rewrite.Pattern); err != nil { + return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err) + } + } + return nil } // Path returns the canonical config file path. @@ -128,6 +154,9 @@ func Load() (*Config, error) { if err := cfg.ValidateSSH(); err != nil { return nil, err } + if err := cfg.Shortcuts.Validate(); err != nil { + return nil, err + } for i := range cfg.NetBoxes { source := &cfg.NetBoxes[i] source.Name = strings.ToLower(source.Name) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a5594a4..f9e6997 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -177,3 +178,21 @@ func TestCommandAliasEnvironmentAndValidation(t *testing.T) { } } } + +func TestLoad_ShortcutRewrites(t *testing.T) { + dir := writeConfig(t, "shortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n") + cfg := loadFromDir(t, dir) + want := []ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}} + if len(cfg.Shortcuts.Rewrites) != 1 || cfg.Shortcuts.Rewrites[0] != want[0] || !cfg.Shortcuts.Enabled() { + t.Fatalf("rewrites: %+v", cfg.Shortcuts) + } +} + +func TestLoad_InvalidShortcutRewrite(t *testing.T) { + for _, rule := range []string{"pattern: '[a-'", "replace: x"} { + t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "shortcuts:\n rewrites:\n - "+rule+"\n")) + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "shortcuts.rewrites[0]") { + t.Errorf("%s: got %v", rule, err) + } + } +} diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 0e55e09..2cbc49f 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -22,6 +22,48 @@ type SSHOptions struct { ExtraArgs []string `mapstructure:"extra_args"` // Options are passed as -o Key=Value. Options map[string]string `mapstructure:"options"` + // Forwards are named port forwards, selected with --forward or netssh tunnel. + Forwards map[string]Forward `mapstructure:"forwards"` +} + +// Forward is one port forward; exactly one of the fields is set. +type Forward struct { + Local string `mapstructure:"local"` // -L [bind:]port:host:hostport + Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport + Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy) +} + +// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80. +func (f Forward) Flag() []string { + switch { + case f.Local != "": + return []string{"-L", f.Local} + case f.Remote != "": + return []string{"-R", f.Remote} + case f.Dynamic != "": + return []string{"-D", f.Dynamic} + } + return nil +} + +// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80". +func (f Forward) String() string { return strings.Join(f.Flag(), " ") } + +func (f Forward) validate() error { + set := 0 + for _, spec := range []string{f.Local, f.Remote, f.Dynamic} { + if spec == "" { + continue + } + set++ + if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") { + return fmt.Errorf("invalid forward %q", spec) + } + } + if set != 1 { + return fmt.Errorf("set exactly one of local, remote, dynamic") + } + return nil } // SSHRule applies SSH options to hosts that match every non-empty field. @@ -50,32 +92,46 @@ 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 { + 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 { + if err := rule.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 { + 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 { + if err := source.SSH.Validate("netboxes." + source.Name + ".ssh"); err != nil { return err } } return nil } -func (o SSHOptions) validate(scope string) error { +// Validate checks the match and options of a single rule. +func (r SSHRule) Validate(scope string) error { + if err := r.SSHOptions.Validate(scope); err != nil { + return err + } + return r.Match.Validate(scope) +} + +// Validate requires at least one selector and a well-formed name pattern. +func (m SSHMatch) Validate(scope string) error { + if m == (SSHMatch{}) { + return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope) + } + if _, err := path.Match(m.Name, ""); err != nil { + return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name) + } + return nil +} + +// Validate rejects values that ssh would not accept or would misinterpret. +func (o SSHOptions) Validate(scope string) error { switch o.HostKeyChecking { case "", "strict", "accept-new", "off": default: @@ -99,6 +155,14 @@ func (o SSHOptions) validate(scope string) error { return fmt.Errorf("%s.options: invalid option %q", scope, key) } } + for name, forward := range o.Forwards { + if !instanceName.MatchString(name) { + return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name) + } + if err := forward.validate(); err != nil { + return fmt.Errorf("%s.forwards.%s: %v", scope, name, err) + } + } return nil } @@ -133,6 +197,14 @@ func (o SSHOptions) merge(override SSHOptions) SSHOptions { for key, value := range override.Options { out.Options[strings.ToLower(key)] = value } + // Forwards override per name, so a rule can redefine a global "web" forward. + out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards)) + for name, forward := range o.Forwards { + out.Forwards[strings.ToLower(name)] = forward + } + for name, forward := range override.Forwards { + out.Forwards[strings.ToLower(name)] = forward + } if override.ProxyJump != "" { out.ProxyJump = override.ProxyJump } diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index a6780bc..4e8cf25 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -88,3 +88,50 @@ func TestSSHOptionsValidation(t *testing.T) { } } } + +func TestForwardsLayering(t *testing.T) { + cfg, err := loadYAML(t, ` +netboxes: + - name: prod + url: https://prod.example + token: t + ssh: + forwards: + db: {local: "5432:localhost:5432"} +ssh: + forwards: + web: {local: "8080:localhost:80"} + socks: {dynamic: "1080"} + rules: + - match: {tag: dmz} + forwards: + web: {local: "8443:localhost:443"} +`) + if err != nil { + t.Fatal(err) + } + got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards + want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v", got) + } + if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" { + t.Fatalf("other instance: %+v", got) + } + if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" { + t.Fatal(f.String()) + } +} + +func TestForwardsValidation(t *testing.T) { + for _, forwards := range []string{ + `web: {local: "8080:a:80", dynamic: "1080"}`, + `web: {local: "-oProxyCommand=x"}`, + `web: {local: "8080 a:80"}`, + `"bad name": {local: "8080:a:80"}`, + } { + if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") { + t.Errorf("%s: got %v", forwards, err) + } + } +} diff --git a/internal/hook/aliases_test.go b/internal/hook/aliases_test.go index 2facfa2..e7f4b3c 100644 --- a/internal/hook/aliases_test.go +++ b/internal/hook/aliases_test.go @@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) { t.Fatalf("alias lost source: %+v", entries) } } + +func TestAliasesUseRewriteRules(t *testing.T) { + cfg := config.Config{Shortcuts: config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + }} + entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg) + if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" { + t.Fatalf("entries: %+v", entries) + } +} diff --git a/internal/setup/instances.go b/internal/setup/instances.go index a4a4b8e..2540506 100644 --- a/internal/setup/instances.go +++ b/internal/setup/instances.go @@ -30,6 +30,7 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { var options []huh.Option[string] for _, source := range sources { options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name)) + options = append(options, huh.NewOption("SSH options for "+source.Name, "ssh:"+source.Name)) options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name)) } options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done")) @@ -53,13 +54,20 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { if source.Name != name { continue } - if verb == "edit" { + switch verb { + case "edit": edited, err := editInstance(source, sources, name) if err != nil { return nil, err } sources[i] = edited - } else { + case "ssh": + options, err := editSSHOptions("SSH options for "+name, source.SSH) + if err != nil { + return nil, err + } + sources[i].SSH = options + default: remove := false if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil { return nil, err diff --git a/internal/setup/ssh.go b/internal/setup/ssh.go new file mode 100644 index 0000000..ec6d569 --- /dev/null +++ b/internal/setup/ssh.go @@ -0,0 +1,284 @@ +package setup + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "charm.land/huh/v2" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides. +func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") } + +// editSSHOptions edits one SSH option layer: global, per instance, or per rule. +func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) { + proxyJump := o.ProxyJump + hostKeyChecking := o.HostKeyChecking + target := o.Target + addressFamily := o.AddressFamily + extraArgs := strings.Join(o.ExtraArgs, "\n") + options := formatOptions(o.Options) + forwards := formatForwards(o.Forwards) + + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Jump host"). + Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere."). + Value(&proxyJump). + Validate(func(s string) error { + return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh") + }), + huh.NewSelect[string](). + Title("Host key checking"). + Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")). + Value(&hostKeyChecking), + huh.NewSelect[string](). + Title("Connection target"). + Description("What is passed to ssh as destination."). + Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")). + Value(&target), + huh.NewSelect[string](). + Title("Address family"). + Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")). + Value(&addressFamily), + huh.NewText(). + Title("Extra arguments"). + Description("One argument per line, inserted before the destination, e.g. -A"). + Lines(3). + Value(&extraArgs), + huh.NewText(). + Title("SSH options"). + Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60"). + Lines(4). + Value(&options). + Validate(func(s string) error { + _, err := parseOptions(s) + return err + }), + huh.NewText(). + Title("Port forwards"). + Description("One per line: NAME L|R|D SPEC, e.g. web L 8080:localhost:80 or socks D 1080.\n"+ + "Use with netssh tunnel HOST NAME or netssh --forward NAME HOST."). + Lines(3). + Value(&forwards). + Validate(func(s string) error { + _, err := parseForwards(s) + return err + }), + ).Title(title)).Run() + if err != nil { + return o, err + } + + parsed, _ := parseOptions(options) + parsedForwards, _ := parseForwards(forwards) + return config.SSHOptions{ + Forwards: parsedForwards, + ProxyJump: strings.TrimSpace(proxyJump), + HostKeyChecking: hostKeyChecking, + Target: target, + AddressFamily: addressFamily, + ExtraArgs: parseLines(extraArgs), + Options: parsed, + }, nil +} + +// parseLines returns the non-empty, trimmed lines of s. +func parseLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} + +// parseOptions reads Key=Value lines into the map stored under ssh.options. +func parseOptions(s string) (map[string]string, error) { + var out map[string]string + for _, line := range parseLines(s) { + key, value, ok := strings.Cut(line, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("%q: expected Key=Value", line) + } + if out == nil { + out = make(map[string]string) + } + out[key] = strings.TrimSpace(value) + } + if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil { + return nil, err + } + return out, nil +} + +// forwardKinds maps the letters used in the form to the ssh flags. +var forwardKinds = map[string]func(spec string) config.Forward{ + "L": func(spec string) config.Forward { return config.Forward{Local: spec} }, + "R": func(spec string) config.Forward { return config.Forward{Remote: spec} }, + "D": func(spec string) config.Forward { return config.Forward{Dynamic: spec} }, +} + +// parseForwards reads "NAME L|R|D SPEC" lines into ssh.forwards. +func parseForwards(s string) (map[string]config.Forward, error) { + var out map[string]config.Forward + for _, line := range parseLines(s) { + fields := strings.Fields(line) + if len(fields) != 3 || forwardKinds[strings.ToUpper(fields[1])] == nil { + return nil, fmt.Errorf("%q: expected NAME L|R|D SPEC", line) + } + if out == nil { + out = make(map[string]config.Forward) + } + out[strings.ToLower(fields[0])] = forwardKinds[strings.ToUpper(fields[1])](fields[2]) + } + if err := (config.SSHOptions{Forwards: out}).Validate("ssh"); err != nil { + return nil, err + } + return out, nil +} + +func formatForwards(forwards map[string]config.Forward) string { + names := make([]string, 0, len(forwards)) + for name := range forwards { + names = append(names, name) + } + sort.Strings(names) + lines := make([]string, 0, len(names)) + for _, name := range names { + flag := forwards[name].Flag() + if len(flag) == 2 { + lines = append(lines, name+" "+strings.TrimPrefix(flag[0], "-")+" "+flag[1]) + } + } + return strings.Join(lines, "\n") +} + +func formatOptions(options map[string]string) string { + keys := make([]string, 0, len(options)) + for key := range options { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, key+"="+options[key]) + } + return strings.Join(lines, "\n") +} + +// configureRules edits a copy of the rules; the caller decides whether to keep it. +func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) { + rules = append([]config.SSHRule(nil), rules...) + for { + action := "done" + var options []huh.Option[string] + for i, rule := range rules { + index := strconv.Itoa(i) + options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index)) + options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index)) + } + options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done")) + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("SSH rules"). + Description("Rules apply SSH options to hosts matching every set field. Later rules win."). + Options(options...). + Value(&action))).Run() + if err != nil { + return nil, err + } + if action == "done" { + return rules, nil + } + if action == "add" { + rule, err := editRule(config.SSHRule{}, sources) + if err != nil { + return nil, err + } + rules = append(rules, rule) + continue + } + verb, index, _ := strings.Cut(action, ":") + i, _ := strconv.Atoi(index) + if verb == "edit" { + rule, err := editRule(rules[i], sources) + if err != nil { + return nil, err + } + rules[i] = rule + continue + } + remove := false + if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil { + return nil, err + } + if remove { + rules = append(rules[:i], rules[i+1:]...) + } + } +} + +func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) { + match := rule.Match + instances := []huh.Option[string]{huh.NewOption("(any)", "")} + known := false + for _, source := range sources { + instances = append(instances, huh.NewOption(source.Name, source.Name)) + known = known || strings.EqualFold(source.Name, match.Instance) + } + if match.Instance != "" && !known { + instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance)) + } + + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Tag"). + Description("NetBox tag name or slug, e.g. dmz"). + Value(&match.Tag), + huh.NewSelect[string](). + Title("Instance"). + Options(instances...). + Value(&match.Instance), + huh.NewSelect[string](). + Title("Kind"). + Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")). + Value(&match.Kind), + huh.NewInput(). + Title("Name pattern"). + Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com"). + Value(&match.Name). + Validate(func(s string) error { + candidate := match + candidate.Tag = strings.TrimSpace(candidate.Tag) + candidate.Name = strings.TrimSpace(s) + return candidate.Validate("rule") + }), + ).Title("Rule match")).Run() + if err != nil { + return rule, err + } + match.Tag = strings.TrimSpace(match.Tag) + match.Name = strings.TrimSpace(match.Name) + + options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions) + if err != nil { + return rule, err + } + return config.SSHRule{Match: match, SSHOptions: options}, nil +} + +func describeMatch(m config.SSHMatch) string { + var parts []string + for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} { + if field[1] != "" { + parts = append(parts, field[0]+"="+field[1]) + } + } + return "[" + strings.Join(parts, " ") + "]" +} diff --git a/internal/setup/ssh_test.go b/internal/setup/ssh_test.go new file mode 100644 index 0000000..c6c7853 --- /dev/null +++ b/internal/setup/ssh_test.go @@ -0,0 +1,92 @@ +package setup + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestParseLines(t *testing.T) { + got := parseLines(" -A \n\n\t-C\n \n") + if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %q, want %q", got, want) + } + if got := parseLines(" \n "); got != nil { + t.Fatalf("blank input: got %q, want nil", got) + } +} + +func TestParseOptionsRoundtrip(t *testing.T) { + text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n" + got, err := parseOptions(text) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + again, err := parseOptions(formatOptions(got)) + if err != nil || !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: got %v (%v)", again, err) + } + if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" { + t.Fatalf("formatOptions not sorted: %q", formatOptions(got)) + } +} + +func TestParseOptionsEmpty(t *testing.T) { + got, err := parseOptions("\n \n") + if err != nil || got != nil { + t.Fatalf("got %v, %v; want nil, nil", got, err) + } +} + +func TestParseOptionsInvalid(t *testing.T) { + for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} { + if _, err := parseOptions(text); err == nil { + t.Errorf("%q: expected error", text) + } + } +} + +func TestDescribeMatch(t *testing.T) { + got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"}) + if got != "[tag=dmz name=*.dmz]" { + t.Fatalf("got %q", got) + } +} + +func TestRuleValidation(t *testing.T) { + if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") { + t.Fatalf("empty match: got %v", err) + } + if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil { + t.Fatal("invalid glob accepted") + } + if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil { + t.Fatalf("valid match rejected: %v", err) + } +} + +func TestParseForwardsRoundtrip(t *testing.T) { + text := "Web L 8080:localhost:80\n\nsocks d 1080\nback R 9000:localhost:9000\n" + got, err := parseForwards(text) + if err != nil { + t.Fatal(err) + } + want := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v", got) + } + if formatForwards(got) != "back R 9000:localhost:9000\nsocks D 1080\nweb L 8080:localhost:80" { + t.Fatalf("format: %q", formatForwards(got)) + } + for _, bad := range []string{"web 8080:localhost:80", "web X 8080", "web L -oFoo", "bad/name L 1:a:2"} { + if _, err := parseForwards(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 9b78b26..5293376 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -17,163 +17,19 @@ import ( "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) -// RunWizard runs the interactive setup form, pre-filled with any existing cfg values. +// RunWizard runs the interactive configurator, pre-filled with any existing cfg values. // It saves the result to the config file on success. func RunWizard(cfg *config.Config) error { sources, err := configureInstances(cfg) if err != nil { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Setup cancelled.") - os.Exit(0) - } - return err + return cancelled(err) } - defaultUser := cfg.SSH.DefaultUser - defaultPort := "" - if cfg.SSH.DefaultPort > 0 { - defaultPort = strconv.Itoa(cfg.SSH.DefaultPort) - } - strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") - subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") - interfaceName := cfg.Resolver.InterfaceName - cacheTTL := strconv.Itoa(cfg.Cache.TTL) - shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") - stripHyphens := cfg.Shortcuts.StripHyphens - commandAlias := cfg.Shell.Alias - - if strategiesRaw == "" { - strategiesRaw = "primary_ip" - } - if cacheTTL == "0" { - cacheTTL = "3600" + work := prepare(*cfg, sources) + if err := runMenu(&work); err != nil { + return cancelled(err) } - form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("Default SSH user"). - Description("Leave empty to use your system user ($USER)."). - Value(&defaultUser), - huh.NewInput(). - Title("Default SSH port"). - Description("Leave empty to use the standard port (22)."). - Placeholder("22"). - Value(&defaultPort). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - n, err := strconv.Atoi(strings.TrimSpace(s)) - if err != nil || n < 1 || n > 65535 { - return errors.New("must be a port number between 1 and 65535") - } - return nil - }), - ).Title("SSH defaults"), - - huh.NewGroup( - huh.NewInput(). - Title("Resolver strategies"). - Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name"). - Placeholder("primary_ip, management_subnet"). - Value(&strategiesRaw). - Validate(validateStrategies), - huh.NewInput(). - Title("Management subnets"). - Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). - Value(&subnets), - huh.NewInput(). - Title("Interface name"). - Description("Only used when interface_name strategy is active."). - Placeholder("eth0"). - Value(&interfaceName), - huh.NewInput(). - Title("Cache TTL (seconds)"). - Value(&cacheTTL). - Validate(func(s string) error { - if _, err := strconv.Atoi(s); err != nil { - return errors.New("must be a number") - } - return nil - }), - ).Title("Resolver & cache"), - - huh.NewGroup( - huh.NewInput(). - Title("Domain suffixes"). - Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'."). - Placeholder(".example.com"). - Value(&shortcutDomains), - huh.NewConfirm(). - Title("Strip hyphens"). - Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). - Value(&stripHyphens), - huh.NewInput(). - Title("Command alias"). - Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). - Placeholder("j"). - Value(&commandAlias). - Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }), - ).Title("Shortcuts"), - ) - - if err := form.Run(); err != nil { - if errors.Is(err, huh.ErrUserAborted) { - fmt.Fprintln(os.Stderr, "Setup cancelled.") - os.Exit(0) - } - return err - } - - strategies := parseStrategies(strategiesRaw) - - ttl, _ := strconv.Atoi(cacheTTL) - - port := 0 - if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil { - port = p - } - - var subnetList []string - for _, s := range strings.Split(subnets, ",") { - if s = strings.TrimSpace(s); s != "" { - subnetList = append(subnetList, s) - } - } - - var domainList []string - for _, s := range strings.Split(shortcutDomains, ",") { - if s = strings.TrimSpace(s); s != "" { - if !strings.HasPrefix(s, ".") { - s = "." + s - } - domainList = append(domainList, s) - } - } - - out := config.Config{ - Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)}, - NetBoxes: sources, - SSH: config.SSHConfig{ - DefaultUser: strings.TrimSpace(defaultUser), - DefaultPort: port, - }, - Resolver: config.ResolverConfig{ - Strategies: strategies, - ManagementSubnets: subnetList, - InterfaceName: strings.TrimSpace(interfaceName), - }, - Cache: config.CacheConfig{ - TTL: ttl, - Path: cfg.Cache.Path, - }, - Shortcuts: config.ShortcutsConfig{ - Domains: domainList, - StripHyphens: stripHyphens, - }, - } - - if err := save(out); err != nil { + if err := save(work); err != nil { return err } loaded, err := config.Load() @@ -202,6 +58,265 @@ func RunWizard(cfg *config.Config) error { return nil } +func cancelled(err error) error { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(os.Stderr, "Setup cancelled.") + os.Exit(0) + } + return err +} + +// prepare starts from the loaded config so settings without a menu entry survive +// a rewrite, migrates a legacy single instance, and fills in usable defaults. +func prepare(cfg config.Config, sources []config.NetBoxConfig) config.Config { + cfg.NetBox = config.NetBoxConfig{} + cfg.NetBoxes = sources + if len(cfg.Resolver.Strategies) == 0 { + cfg.Resolver.Strategies = []string{"primary_ip"} + } + if cfg.Cache.TTL == 0 { + cfg.Cache.TTL = 3600 + } + return cfg +} + +func runMenu(cfg *config.Config) error { + for { + action := "save" + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("netssh configuration"). + Description("Choose a section to edit, then save."). + Options( + huh.NewOption("SSH defaults — user, port", "defaults"), + huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"), + huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"), + huh.NewOption("Resolver & cache", "resolver"), + huh.NewOption("Shortcuts & command alias", "shortcuts"), + huh.NewOption("Completion", "completion"), + huh.NewOption("Save and exit", "save"), + ). + Value(&action))).Run() + if err != nil { + return err + } + switch action { + case "defaults": + err = editSSHDefaults(cfg) + case "ssh": + cfg.SSH.SSHOptions, err = editSSHOptions("SSH options (all hosts)", cfg.SSH.SSHOptions) + case "rules": + var rules []config.SSHRule + if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil { + cfg.SSH.Rules = rules + } + case "resolver": + err = editResolverCache(cfg) + case "shortcuts": + err = editShortcuts(cfg) + case "completion": + err = editCompletion(cfg) + case "save": + return nil + } + if err != nil { + return err + } + } +} + +func editSSHDefaults(cfg *config.Config) error { + defaultUser := cfg.SSH.DefaultUser + defaultPort := "" + if cfg.SSH.DefaultPort > 0 { + defaultPort = strconv.Itoa(cfg.SSH.DefaultPort) + } + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Default SSH user"). + Description("Leave empty to use your system user ($USER)."). + Value(&defaultUser), + huh.NewInput(). + Title("Default SSH port"). + Description("Leave empty to use the standard port (22)."). + Placeholder("22"). + Value(&defaultPort). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 || n > 65535 { + return errors.New("must be a port number between 1 and 65535") + } + return nil + }), + ).Title("SSH defaults")).Run() + if err != nil { + return err + } + cfg.SSH.DefaultUser = strings.TrimSpace(defaultUser) + cfg.SSH.DefaultPort, _ = strconv.Atoi(strings.TrimSpace(defaultPort)) + return nil +} + +func editResolverCache(cfg *config.Config) error { + strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") + subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") + interfaceName := cfg.Resolver.InterfaceName + cacheTTL := strconv.Itoa(cfg.Cache.TTL) + cachePath := cfg.Cache.Path + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Resolver strategies"). + Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name"). + Placeholder("primary_ip, management_subnet"). + Value(&strategiesRaw). + Validate(validateStrategies), + huh.NewInput(). + Title("Management subnets"). + Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active."). + Value(&subnets), + huh.NewInput(). + Title("Interface name"). + Description("Only used when interface_name strategy is active."). + Placeholder("eth0"). + Value(&interfaceName), + huh.NewInput(). + Title("Cache TTL (seconds)"). + Value(&cacheTTL). + Validate(func(s string) error { + if _, err := strconv.Atoi(s); err != nil { + return errors.New("must be a number") + } + return nil + }), + huh.NewInput(). + Title("Cache file"). + Description("Where resolved hosts are stored. Leave empty for the default location."). + Value(&cachePath), + ).Title("Resolver & cache")).Run() + if err != nil { + return err + } + cfg.Resolver.Strategies = parseStrategies(strategiesRaw) + cfg.Resolver.ManagementSubnets = splitList(subnets) + cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName) + cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL) + cfg.Cache.Path = strings.TrimSpace(cachePath) + return nil +} + +func editShortcuts(cfg *config.Config) error { + shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ") + stripHyphens := cfg.Shortcuts.StripHyphens + rewrites := formatRewrites(cfg.Shortcuts.Rewrites) + commandAlias := cfg.Shell.Alias + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Domain suffixes"). + Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'."). + Placeholder(".example.com"). + Value(&shortcutDomains), + huh.NewConfirm(). + Title("Strip hyphens"). + Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache."). + Value(&stripHyphens), + huh.NewText(). + Title("Rewrite rules"). + Description("One rule per line: REGEX => REPLACEMENT, applied after the domain suffix is removed.\n"+ + "Example: ^([a-z]+)[0-9]+- => $1 turns fsn1-bx1.example.com into fsnbx1.\n"+ + "Check rules with: netssh alias --test HOSTNAME"). + Lines(3). + Value(&rewrites). + Validate(func(s string) error { + _, err := parseRewrites(s) + return err + }), + huh.NewInput(). + Title("Command alias"). + Description("Optional alias for netssh itself, e.g. j. Leave empty to disable."). + Placeholder("j"). + Value(&commandAlias). + Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }), + ).Title("Shortcuts")).Run() + if err != nil { + return err + } + cfg.Shortcuts.Domains = nil + for _, s := range splitList(shortcutDomains) { + if !strings.HasPrefix(s, ".") { + s = "." + s + } + cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s) + } + cfg.Shortcuts.StripHyphens = stripHyphens + cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites) + cfg.Shell.Alias = strings.TrimSpace(commandAlias) + return nil +} + +func editCompletion(cfg *config.Config) error { + maxResults := "" + if cfg.Completion.MaxResults > 0 { + maxResults = strconv.Itoa(cfg.Completion.MaxResults) + } + err := huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Maximum completion results"). + Description("Limits the hostnames offered per Tab completion. Leave empty for unlimited."). + Value(&maxResults). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return nil + } + if n, err := strconv.Atoi(strings.TrimSpace(s)); err != nil || n < 0 { + return errors.New("must be a non-negative number") + } + return nil + }), + ).Title("Completion")).Run() + if err != nil { + return err + } + cfg.Completion.MaxResults, _ = strconv.Atoi(strings.TrimSpace(maxResults)) + return nil +} + +// parseRewrites reads "REGEX => REPLACEMENT" lines; the replacement may be empty. +func parseRewrites(s string) ([]config.ShortcutRewrite, error) { + var out []config.ShortcutRewrite + for _, line := range parseLines(s) { + pattern, replace, ok := strings.Cut(line, "=>") + if !ok { + return nil, fmt.Errorf("%q: expected REGEX => REPLACEMENT", line) + } + out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)}) + } + if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil { + return nil, err + } + return out, nil +} + +func formatRewrites(rewrites []config.ShortcutRewrite) string { + lines := make([]string, 0, len(rewrites)) + for _, r := range rewrites { + lines = append(lines, r.Pattern+" => "+r.Replace) + } + return strings.Join(lines, "\n") +} + +// splitList returns the non-empty, trimmed entries of a comma-separated list. +func splitList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + var knownStrategies = map[string]bool{ "primary_ip": true, "management_subnet": true, @@ -220,15 +335,7 @@ func validateStrategies(s string) error { return nil } -func parseStrategies(s string) []string { - var out []string - for _, part := range strings.Split(s, ",") { - if name := strings.TrimSpace(part); name != "" { - out = append(out, name) - } - } - return out -} +func parseStrategies(s string) []string { return splitList(s) } func save(cfg config.Config) error { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { @@ -237,6 +344,12 @@ func save(cfg config.Config) error { if err := cfg.ValidateNetBoxes(); err != nil { return err } + if err := cfg.ValidateSSH(); err != nil { + return err + } + if err := cfg.Shortcuts.Validate(); err != nil { + return err + } path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config dir: %w", err) @@ -307,7 +420,7 @@ func save(cfg config.Config) error { fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) } - if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens { + if cfg.Shortcuts.Enabled() { b.WriteString("\nshortcuts:\n") if len(cfg.Shortcuts.Domains) > 0 { b.WriteString(" domains:\n") @@ -318,6 +431,12 @@ func save(cfg config.Config) error { if cfg.Shortcuts.StripHyphens { b.WriteString(" strip_hyphens: true\n") } + if len(cfg.Shortcuts.Rewrites) > 0 { + b.WriteString(" rewrites:\n") + for _, r := range cfg.Shortcuts.Rewrites { + fmt.Fprintf(&b, " - pattern: %q\n replace: %q\n", r.Pattern, r.Replace) + } + } } if cfg.Shell.Alias != "" { @@ -333,7 +452,7 @@ func save(cfg config.Config) error { } func sshOptionsEmpty(o config.SSHOptions) bool { - return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 + return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 && len(o.Forwards) == 0 } // writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config. @@ -360,4 +479,20 @@ func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) { fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key]) } } + if len(o.Forwards) > 0 { + names := make([]string, 0, len(o.Forwards)) + for name := range o.Forwards { + names = append(names, name) + } + sort.Strings(names) + fmt.Fprintf(b, "%sforwards:\n", indent) + for _, name := range names { + f := o.Forwards[name] + for _, field := range [][2]string{{"local", f.Local}, {"remote", f.Remote}, {"dynamic", f.Dynamic}} { + if field[1] != "" { + fmt.Fprintf(b, "%s %s:\n%s %s: %q\n", indent, name, indent, field[0], field[1]) + } + } + } + } } diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 4469300..d968c8c 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -455,3 +455,116 @@ func TestSaveKeepsSSHOptions(t *testing.T) { t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH) } } + +// Re-running the configurator must keep settings that have no form field of +// their own or were not touched in the menu. +func TestPrepareKeepsUnmanagedSettings(t *testing.T) { + cfg := config.Config{ + NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"}, + SSH: config.SSHConfig{ + DefaultUser: "root", + SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}}, + Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}}, + }, + Completion: config.CompletionConfig{MaxResults: 50}, + Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"}, + } + sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}} + got := prepare(cfg, sources) + + if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path { + t.Fatalf("settings lost: %+v", got) + } + if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) { + t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes) + } + if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 { + t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache) + } +} + +func TestSaveRejectsInvalidSSHOptions(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}}, + } + if err := save(cfg); err == nil { + t.Fatal("rule without match was saved") + } + if _, err := os.Stat(config.Path()); !os.IsNotExist(err) { + t.Fatalf("config file written despite error: %v", err) + } +} + +func TestParseRewrites(t *testing.T) { + text := `^([a-z]+)[0-9]+- => $1 + +\.lab$ => +` + got, err := parseRewrites(text) + if err != nil { + t.Fatal(err) + } + want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v", got) + } + if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) { + t.Fatalf("roundtrip: %+v", again) + } + for _, bad := range []string{"no arrow", "[a- => x", " => x"} { + if _, err := parseRewrites(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestSaveShortcutRewritesRoundtrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}} + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + Shortcuts: config.ShortcutsConfig{Rewrites: rewrites}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) { + t.Fatalf("got %+v", loaded.Shortcuts.Rewrites) + } +} + +func TestSaveForwardsRoundtrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}} + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}}, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + SSH: config.SSHConfig{ + SSHOptions: config.SSHOptions{Forwards: global}, + Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}}, + }, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.SSH.Forwards, global) || + !reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) || + !reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) { + t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules) + } +} diff --git a/internal/shortcuts/shortcuts.go b/internal/shortcuts/shortcuts.go index bfae38c..f1d5ffb 100644 --- a/internal/shortcuts/shortcuts.go +++ b/internal/shortcuts/shortcuts.go @@ -1,13 +1,30 @@ package shortcuts import ( + "regexp" "strings" + "sync" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" ) -// Normalize strips configured domain suffixes and optionally hyphens from a hostname. -// The result is lowercased for case-insensitive comparison. +// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup. +var compiled sync.Map // pattern → *regexp.Regexp + +func rewritePattern(pattern string) *regexp.Regexp { + if re, ok := compiled.Load(pattern); ok { + return re.(*regexp.Regexp) + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil // rejected by config.Load already + } + compiled.Store(pattern, re) + return re +} + +// Normalize strips configured domain suffixes, applies rewrite rules in order, and +// optionally strips hyphens. The result is lowercased for case-insensitive comparison. func Normalize(name string, cfg config.ShortcutsConfig) string { s := strings.ToLower(name) for _, domain := range cfg.Domains { @@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string { break } } + for _, rewrite := range cfg.Rewrites { + if re := rewritePattern(rewrite.Pattern); re != nil { + s = re.ReplaceAllString(s, rewrite.Replace) + } + } if cfg.StripHyphens { s = strings.ReplaceAll(s, "-", "") } @@ -34,7 +56,7 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string { } // AliasName returns a shell-safe alias name for the given host. -// It normalizes the name (strips configured domains and optionally hyphens) then +// It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then // replaces any remaining dots with underscores so the result is a valid identifier. func AliasName(name string, cfg config.ShortcutsConfig) string { s := Normalize(name, cfg) diff --git a/internal/shortcuts/shortcuts_test.go b/internal/shortcuts/shortcuts_test.go index 2ae5aa4..51e96c0 100644 --- a/internal/shortcuts/shortcuts_test.go +++ b/internal/shortcuts/shortcuts_test.go @@ -78,3 +78,40 @@ func TestAliasName_NoConfig(t *testing.T) { t.Errorf("AliasName = %q, want %q", got, want) } } + +func TestNormalizeRewrites(t *testing.T) { + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + } + for input, want := range map[string]string{ + "fsn1-bx1.hoster.de": "fsnbx1", + "FSN1-BX1.hoster.de": "fsnbx1", + "fsn1-bx1": "fsnbx1", + "fsnbx1": "fsnbx1", // the shortcut itself is left alone + "web01.hoster.de": "web01", + } { + if got := shortcuts.Normalize(input, cfg); got != want { + t.Errorf("Normalize(%q) = %q, want %q", input, got, want) + } + } + if got := shortcuts.AliasName("fsn1-bx1.hoster.de", cfg); got != "fsnbx1" { + t.Errorf("AliasName = %q", got) + } +} + +func TestNormalizeRewritesOrder(t *testing.T) { + // Rules run in order after domain stripping, before hyphen stripping. + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.de"}, + StripHyphens: true, + Rewrites: []config.ShortcutRewrite{ + {Pattern: `\.hoster\.de$`, Replace: "-never"}, + {Pattern: `^fsn`, Replace: "f"}, + {Pattern: `^f1`, Replace: "falkenstein"}, + }, + } + if got := shortcuts.Normalize("fsn1-bx-1.hoster.de", cfg); got != "falkensteinbx1" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/ssh/args.go b/internal/ssh/args.go index f83916b..b36ea5a 100644 --- a/internal/ssh/args.go +++ b/internal/ssh/args.go @@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{ // ParsedArgs holds the result of parsing SSH arguments. type ParsedArgs struct { - Host string // hostname without the user@ prefix + Host string // hostname without the user@ prefix or :path User string // empty if not specified + Path string // sftp only: the remote path after host: DestIdx int // index in Args where [user@]host sits Args []string } // Parse scans SSH arguments and extracts the destination ([user@]host). // Returns nil if no destination is found. -func Parse(args []string) *ParsedArgs { +func Parse(args []string) *ParsedArgs { return SSH.Parse(args) } + +// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp. +func (c Client) Parse(args []string) *ParsedArgs { i := 0 for i < len(args) { arg := args[i] @@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs { if strings.HasPrefix(arg, "-") && len(arg) > 1 { // OpenSSH accepts clusters such as -vp 2222 and -vladmin. - for j := 1; j < len(arg); j++ { - if flagsWithArg[arg[j]] { - if j == len(arg)-1 { - i++ - } - break - } + if c.takesSeparateValue(arg) { + i++ } i++ continue } // first non-flag argument is the destination - host, user := splitUserHost(arg) - return &ParsedArgs{ - Host: host, - User: user, - DestIdx: i, - Args: args, - } + return c.parsed(args, i) } // handle arguments after "--" if i < len(args) { - host, user := splitUserHost(args[i]) - return &ParsedArgs{ - Host: host, - User: user, - DestIdx: i, - Args: args, - } + return c.parsed(args, i) } return nil } +func (c Client) parsed(args []string, i int) *ParsedArgs { + dest, path := args[i], "" + if c.paths { + if d, p, remote := splitRemote(dest); remote { + dest, path = d, p + } + } + host, user := splitUserHost(dest) + return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args} +} + // ReplaceHost returns a copy of args with the destination replaced by newHost, // preserving any user@ prefix. func ReplaceHost(args []string, destIdx int, newHost string) []string { + return SSH.replaceHost(args, []int{destIdx}, newHost) +} + +// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read +// host:path, so an IPv6 address must be bracketed. +func (c Client) replaceHost(args []string, dests []int, newHost string) []string { result := make([]string, len(args)) copy(result, args) - - original := args[destIdx] - if at := strings.Index(original, "@"); at != -1 { - result[destIdx] = original[:at+1] + newHost - } else { - result[destIdx] = newHost + if c.paths && strings.Contains(newHost, ":") { + newHost = "[" + newHost + "]" + } + for _, destIdx := range dests { + original := args[destIdx] + prefix, suffix := "", "" + if at := strings.Index(original, "@"); at != -1 { + prefix, original = original[:at+1], original[at+1:] + } + if c.paths { + if _, path, remote := splitRemote(original); remote { + suffix = ":" + path + } + } + result[destIdx] = prefix + newHost + suffix } return result } // HasPortFlag reports whether a port was explicitly supplied. -func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") } +func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) } // HasUserFlag reports whether a login user was explicitly supplied. -func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") } +func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) } + +// HasPortFlag reports whether a port was explicitly supplied. +func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") } + +// HasUserFlag reports whether a login user was explicitly supplied. +func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") } func hasOption(args []string, wanted byte, keyword string) bool { + return SSH.hasOption(args, wanted, keyword) +} + +func (c Client) hasOption(args []string, wanted byte, keyword string) bool { for i := 0; i < len(args); i++ { arg := args[i] if arg == "--" || len(arg) < 2 || arg[0] != '-' { @@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool { } for j := 1; j < len(arg); j++ { flag := arg[j] - if !flagsWithArg[flag] { + if !c.withArg[flag] { continue } value := arg[j+1:] @@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool { } value = args[i] } - if flag == wanted { + if wanted != 0 && flag == wanted { return true } if flag == 'o' { diff --git a/internal/ssh/client.go b/internal/ssh/client.go new file mode 100644 index 0000000..3ffb926 --- /dev/null +++ b/internal/ssh/client.go @@ -0,0 +1,137 @@ +package ssh + +import ( + "fmt" + "strings" +) + +// Client describes one OpenSSH program netssh can run. The programs share +// ssh's connection options but differ in flags and destination syntax. +type Client struct { + Name string // binary name: ssh, sftp, or scp + withArg map[byte]bool // short flags that consume a value + port byte // flag for the port: -p (ssh) or -P (sftp, scp) + user byte // flag for the login user; 0 when only user@ or -o User work + paths bool // destinations are host[:path] +} + +var ( + // SSH is the default client; its flag table is flagsWithArg. + SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'} + // SFTP follows sftp(1): -l is a bandwidth limit, -P the port. + SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true} + // SCP follows scp(1); unlike sftp, -B and -R take no value. + SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true} +) + +func flagSet(flags string) map[byte]bool { + set := make(map[byte]bool, len(flags)) + for i := 0; i < len(flags); i++ { + set[flags[i]] = true + } + return set +} + +// PortArgs returns the arguments that set port for this client. +func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} } + +// UserArgs returns the arguments that set the login user for this client. +func (c Client) UserArgs(user string) []string { + if c.user == 0 { + return []string{"-o", "User=" + user} + } + return []string{"-" + string(c.user), user} +} + +// Operand is a remote scp argument such as user@host:/path. +type Operand struct { + Idx int + User string + Host string + Path string +} + +// Operands returns the remote operands of scp arguments. All remote operands +// must name the same host, because one set of options applies per call. +func (c Client) Operands(args []string) ([]Operand, error) { + start := c.optionsEnd(args) + var out []Operand + for i := start; i < len(args); i++ { + arg := args[i] + if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") { + return nil, fmt.Errorf("%q: use host:path instead of a URI", arg) + } + dest, path, remote := splitRemote(arg) + if !remote { + continue + } + host, user := splitUserHost(dest) + if host == "" { + continue + } + if len(out) > 0 && !strings.EqualFold(out[0].Host, host) { + return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host) + } + out = append(out, Operand{Idx: i, User: user, Host: host, Path: path}) + } + return out, nil +} + +// optionsEnd returns the index of the first operand; scp, like ssh, stops +// option parsing at the first non-option or after "--". +func (c Client) optionsEnd(args []string) int { + end := c.insertionPoint(args) + if end < len(args) && args[end] == "--" { + end++ + } + return end +} + +// insertionPoint is where added options still count as options: before the +// first operand or before "--". +func (c Client) insertionPoint(args []string) int { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + return i + } + if !strings.HasPrefix(arg, "-") || len(arg) < 2 { + return i + } + if c.takesSeparateValue(arg) { + i++ + } + } + return len(args) +} + +// splitRemote mirrors scp's colon(): an argument is remote when a ':' comes +// before any '/'. A leading "[" starts a bracketed IPv6 address. +func splitRemote(arg string) (dest, path string, remote bool) { + if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") { + return "", "", false + } + inBracket := false + for i := 0; i < len(arg); i++ { + switch arg[i] { + case '[': + inBracket = true + case ']': + inBracket = false + case '/': + if !inBracket { + return "", "", false + } + case ':': + if !inBracket { + return arg[:i], arg[i+1:], true + } + } + } + return "", "", false +} + +// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given. +func (c Client) HasForwardFlag(args []string) bool { + return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward") +} diff --git a/internal/ssh/client_test.go b/internal/ssh/client_test.go new file mode 100644 index 0000000..3825a0a --- /dev/null +++ b/internal/ssh/client_test.go @@ -0,0 +1,156 @@ +package ssh + +import ( + "reflect" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +func TestSFTPParse(t *testing.T) { + // -l is a bandwidth limit and -s takes a value for sftp, unlike ssh. + parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"}) + if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 { + t.Fatalf("got %+v", parsed) + } + if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" { + t.Fatalf("instance-qualified host: %+v", p) + } + if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) { + t.Fatal("sftp port flag is -P") + } + if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) { + t.Fatal("sftp has no -l user flag") + } +} + +func TestSCPOperands(t *testing.T) { + ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"}) + if err != nil { + t.Fatal(err) + } + want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}} + if !reflect.DeepEqual(ops, want) { + t.Fatalf("got %+v", ops) + } + if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 { + t.Fatalf("local copy has remote operands: %+v", ops) + } + if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil { + t.Fatal("two remote hosts accepted") + } + if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil { + t.Fatal("URI accepted") + } +} + +func TestSFTPApply(t *testing.T) { + args := []string{"-P", "2222", "ops@web01:/var/log"} + opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}} + got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args + want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want) + } +} + +func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) { + args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"} + got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args + want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v", got) + } + // sftp needs brackets even without a path; ssh never does. + if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" { + t.Fatalf("sftp: %v", got) + } + if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" { + t.Fatalf("ssh: %v", got.Args) + } +} + +func TestSFTPTargetName(t *testing.T) { + got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args + want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v", got) + } +} + +func TestClientUserAndPortArgs(t *testing.T) { + if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) { + t.Fatal(got) + } + if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) { + t.Fatal(got) + } + if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) { + t.Fatal(got) + } +} + +func TestClientExtractWrapperFlags(t *testing.T) { + // For scp, -X takes a value; for ssh it does not. + flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"}) + if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) { + t.Fatalf("got %+v %v %v", flags, rest, err) + } + flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"}) + if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) { + t.Fatalf("got %+v %v %v", flags, rest, err) + } + for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} { + if _, _, err := ExtractWrapperFlags(args); err == nil { + t.Errorf("%v accepted", args) + } + } +} + +func TestForwardArgs(t *testing.T) { + available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}} + got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available) + want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("got %v %v", got, err) + } + if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") { + t.Fatalf("unknown name: %v", err) + } + if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") { + t.Fatalf("no forwards: %v", err) + } +} + +func TestApplyForwardsAndTunnel(t *testing.T) { + flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}} + if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) { + t.Fatalf("forward: %v", got) + } + flags.Tunnel = true + result := apply([]string{"web"}, config.SSHOptions{}, flags) + want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"} + if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") { + t.Fatalf("tunnel: %v %q", result.Args, result.Notice) + } + // The user's own ExitOnForwardFailure wins. + if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") { + t.Fatalf("overrode user option: %v", got) + } +} + +func TestHasForwardFlag(t *testing.T) { + for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} { + if got := SSH.HasForwardFlag(strings.Fields(args)); got != want { + t.Errorf("%q: got %v", args, got) + } + } +} + +func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) { + got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args + if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/internal/ssh/exec.go b/internal/ssh/exec.go index fd55bb5..2c7e169 100644 --- a/internal/ssh/exec.go +++ b/internal/ssh/exec.go @@ -9,11 +9,14 @@ import ( // Exec replaces the current process with the native ssh client via syscall.Exec. // All existing SSH configs, keys, and agent forwarding remain intact. -func Exec(args []string) error { - sshPath, err := exec.LookPath("ssh") +func Exec(args []string) error { return SSH.Exec(args) } + +// Exec replaces the current process with the client's binary. +func (c Client) Exec(args []string) error { + path, err := exec.LookPath(c.Name) if err != nil { - return fmt.Errorf("ssh not found in PATH: %w", err) + return fmt.Errorf("%s not found in PATH: %w", c.Name, err) } - argv := append([]string{"ssh"}, args...) - return syscall.Exec(sshPath, argv, os.Environ()) + argv := append([]string{c.Name}, args...) + return syscall.Exec(path, argv, os.Environ()) } diff --git a/internal/ssh/options.go b/internal/ssh/options.go index 0542140..34babb2 100644 --- a/internal/ssh/options.go +++ b/internal/ssh/options.go @@ -12,14 +12,24 @@ import ( // 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 + DryRun bool + Jump string // --jump HOST overrides the configured jump host + NoJump bool // --no-jump disables any jump host, including ~/.ssh/config + Forwards []string // --forward NAME|SPEC, repeatable (ssh only) + // ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them. + ForwardArgs []string + // Tunnel runs ssh without a remote command (-N), for netssh tunnel. + Tunnel bool } // 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) { + return SSH.ExtractWrapperFlags(args) +} + +// ExtractWrapperFlags removes netssh's long options before the destination. +func (c Client) ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) { var flags WrapperFlags out := make([]string, 0, len(args)) for i := 0; i < len(args); i++ { @@ -37,6 +47,14 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) { flags.Jump = args[i] case strings.HasPrefix(arg, "--jump="): flags.Jump = strings.TrimPrefix(arg, "--jump=") + case arg == "--forward": + if i+1 >= len(args) { + return flags, nil, fmt.Errorf("--forward requires a name or spec") + } + i++ + flags.Forwards = append(flags.Forwards, args[i]) + case strings.HasPrefix(arg, "--forward="): + flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward=")) 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) @@ -45,7 +63,7 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) { 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) { + if c.takesSeparateValue(arg) && i+1 < len(args) { i++ out = append(out, args[i]) } @@ -64,14 +82,48 @@ func validateJump(flags WrapperFlags) error { if strings.HasPrefix(flags.Jump, "-") { return fmt.Errorf("--jump: invalid host %q", flags.Jump) } + for _, forward := range flags.Forwards { + if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") { + return fmt.Errorf("--forward: invalid value %q", forward) + } + } return nil } +var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`) + +// ForwardArgs resolves forward names against the configured forwards. A value +// that is no configured name but looks like a port or [bind:]port:host:port +// is used as a local forward (-L). +func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) { + var out []string + for _, name := range names { + if forward, ok := available[strings.ToLower(name)]; ok { + out = append(out, forward.Flag()...) + continue + } + if strings.Contains(name, ":") && adHocForward.MatchString(name) { + out = append(out, "-L", name) + continue + } + known := make([]string, 0, len(available)) + for key := range available { + known = append(known, key) + } + sort.Strings(known) + if len(known) == 0 { + return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name) + } + return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", ")) + } + return out, 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 { +func (c Client) takesSeparateValue(arg string) bool { for j := 1; j < len(arg); j++ { - if flagsWithArg[arg[j]] { + if c.withArg[arg[j]] { return j == len(arg)-1 } } @@ -96,9 +148,17 @@ var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9]) // 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 { + return SSH.Apply(args, []int{destIdx}, host, opts, flags) +} + +// Apply replaces the host in every destination (several only for scp) and +// inserts the configured options before the first one. extra_args and +// forwards are ssh flags and apply to ssh only. +func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result { + destIdx := dests[0] var inserted []string add := func(keyword, value string) { - if !hasOption(args[:destIdx], 0, keyword) { + if !c.hasOption(args[:destIdx], 0, keyword) { inserted = append(inserted, "-o", keyword+"="+value) } } @@ -114,7 +174,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags } } - userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand") + userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand") switch { case userJump: case flags.NoJump: @@ -140,7 +200,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags } } - if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') { + if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') { add("AddressFamily", opts.AddressFamily) } @@ -153,19 +213,42 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags add(key, opts.Options[key]) } + var extra []string + if c.Name == SSH.Name { + extra = opts.ExtraArgs + inserted = append(inserted, flags.ForwardArgs...) + if flags.Tunnel { + add("ExitOnForwardFailure", "yes") + inserted = append(inserted, "-N") + forwards := strings.Join(flags.ForwardArgs, " ") + if forwards == "" { + forwards = "the given ports" + } + tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name) + if notice == "" { + notice = tunnel + } else { + notice += "\nnetssh: " + tunnel + } + } + } + // 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]...) + // Options go before the first operand: scp stops reading options there, + // and after "--" they would be taken as the destination. + at := c.insertionPoint(args) + replaced := c.replaceHost(args, dests, dest) + out := make([]string, 0, len(extra)+len(replaced)+len(inserted)) + out = append(out, extra...) + out = append(out, replaced[:at]...) out = append(out, inserted...) - out = append(out, replaced[destIdx:]...) + out = append(out, replaced[at:]...) 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 { +func (c Client) hasBoolFlag(args []string, wanted byte) bool { for i := 0; i < len(args); i++ { arg := args[i] if arg == "--" || len(arg) < 2 || arg[0] != '-' { @@ -175,7 +258,7 @@ func hasBoolFlag(args []string, wanted byte) bool { if arg[j] == wanted { return true } - if flagsWithArg[arg[j]] { + if c.withArg[arg[j]] { if j == len(arg)-1 { i++ } diff --git a/internal/ssh/options_test.go b/internal/ssh/options_test.go index d7ed209..fc749c9 100644 --- a/internal/ssh/options_test.go +++ b/internal/ssh/options_test.go @@ -20,7 +20,7 @@ func TestExtractWrapperFlags(t *testing.T) { {[]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) { + if err != nil || !reflect.DeepEqual(flags, tc.flags) || !reflect.DeepEqual(rest, tc.rest) { t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index a8b903c..4b369b4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -22,13 +22,22 @@ import ( // SelectedHost is returned when the user confirms a host in the TUI. type SelectedHost struct { - Kind string - Name string - IP string - User string // empty = use config default - Port string // empty = use default port + Kind string + Name string + IP string + User string // empty = use config default + Port string // empty = use default port + Action string // ActionSSH, ActionSFTP, or ActionTunnel + Forwards []string // forward names or specs for ActionTunnel } +// Actions the user can start on a host. +const ( + ActionSSH = "" + ActionSFTP = "sftp" + ActionTunnel = "tunnel" +) + // --- bubbletea messages --- type debounceMsg struct { @@ -145,6 +154,9 @@ type Model struct { input textinput.Model filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f editInput textinput.Model // user@host:port inline editor + forwardInput textinput.Model // forward names/specs, ctrl+t + forwardHost *hostItem + forwardsFor func(name, kind string, tags []string) []string list list.Model client *netbox.Client cache *cache.Cache @@ -160,6 +172,7 @@ type Model struct { recentMode bool // true when showing recent hosts (empty search, initial state) filterOpen bool // Ctrl+F toggles editMode bool // Ctrl+E on the selected item + forwardMode bool // Ctrl+T on the selected item } func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model { @@ -174,6 +187,10 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ei := textinput.New() ei.Prompt = "" + fwd := textinput.New() + fwd.Prompt = "" + fwd.Placeholder = "web, 8080:localhost:80" + l := list.New(nil, compactDelegate{}, 0, 0) l.SetShowHelp(false) l.SetShowTitle(false) @@ -189,16 +206,17 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ni.CharLimit = 0 ni.SetHeight(10) return &Model{ - noteInput: ni, - defaultPort: port, - input: ti, - filterInput: fi, - editInput: ei, - list: l, - client: client, - cache: c, - defaultUser: defaultUser, - recentMode: true, + noteInput: ni, + defaultPort: port, + input: ti, + filterInput: fi, + editInput: ei, + forwardInput: fwd, + list: l, + client: client, + cache: c, + defaultUser: defaultUser, + recentMode: true, } } @@ -281,6 +299,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.noteInput, cmd = m.noteInput.Update(msg) return m, cmd } + // --- Forward mode --- + if m.forwardMode { + switch msg.String() { + case "esc": + m.forwardMode = false + m.forwardInput.Blur() + m.input.Focus() + m.err = nil + return m, nil + case "enter": + item := *m.forwardHost + forwards, err := parseForwards(m.forwardInput.Value(), m.availableForwards(item)) + if err != nil { + m.err = err + return m, nil + } + m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionTunnel, Forwards: forwards} + return m, tea.Quit + } + var cmd tea.Cmd + m.forwardInput, cmd = m.forwardInput.Update(msg) + return m, cmd + } + // --- Edit mode --- if m.editMode { switch msg.String() { @@ -355,6 +397,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + case "ctrl+o": + if item, ok := m.list.SelectedItem().(hostItem); ok { + m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionSFTP} + return m, tea.Quit + } + + case "ctrl+t": + if item, ok := m.list.SelectedItem().(hostItem); ok { + m.forwardMode = true + m.forwardHost = &item + m.err = nil + m.input.Blur() + m.forwardInput.SetValue("") + m.forwardInput.Focus() + return m, nil + } + case "ctrl+n", "ctrl+b": if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil { m.noteMode = true @@ -509,13 +568,23 @@ func (m *Model) viewContent() string { sb.WriteString(m.list.View()) } - if m.editMode { + if m.forwardMode { + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + sb.WriteString("\n" + dim.Render("Forward via "+m.forwardHost.name+": ")) + sb.WriteString(m.forwardInput.View() + "\n") + available := "none configured — enter specs such as 8080:localhost:80" + if names := m.availableForwards(*m.forwardHost); len(names) > 0 { + available = strings.Join(names, ", ") + } + sb.WriteString(dim.Render(" forwards: "+available) + "\n") + sb.WriteString(dim.Render(" enter start tunnel esc cancel") + "\n") + } else if m.editMode { sb.WriteString("\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: ")) sb.WriteString(m.editInput.View() + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n") } else { - hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox" + hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox" sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n") } @@ -654,3 +723,34 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost { } func (m *Model) SetNotes(service *notes.Service) { m.noteService = service } + +// SetForwards provides the forward names configured for a host (ctrl+t). +func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) { + m.forwardsFor = forwardsFor +} + +func (m *Model) availableForwards(item hostItem) []string { + if m.forwardsFor == nil { + return nil + } + return m.forwardsFor(item.name, item.kind, item.tags) +} + +// parseForwards accepts configured names and specs containing a colon, +// separated by commas or spaces. +func parseForwards(input string, available []string) ([]string, error) { + known := make(map[string]bool, len(available)) + for _, name := range available { + known[strings.ToLower(name)] = true + } + fields := strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) + if len(fields) == 0 { + return nil, fmt.Errorf("enter a forward name or a spec such as 8080:localhost:80") + } + for _, field := range fields { + if !known[strings.ToLower(field)] && !strings.Contains(field, ":") { + return nil, fmt.Errorf("unknown forward %q", field) + } + } + return fields, nil +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 10514f4..21127b2 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,6 +1,8 @@ package tui import ( + "reflect" + "strings" "testing" tea "charm.land/bubbletea/v2" @@ -70,3 +72,53 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) { t.Fatalf("edited host changed: %+v", m.selected) } } + +func TestCtrlOSelectsSFTP(t *testing.T) { + m := New(nil, nil, "") + m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1"}}) + m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl}) + if m.selected == nil || m.selected.Action != ActionSFTP || m.selected.Name != "web01" { + t.Fatalf("got %+v", m.selected) + } +} + +func TestCtrlTStartsTunnelWithForwards(t *testing.T) { + m := New(nil, nil, "") + m.SetForwards(func(name, kind string, tags []string) []string { + if name != "web01" || len(tags) != 1 { + t.Errorf("forwards asked for %s %v", name, tags) + } + return []string{"web"} + }) + m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1", Tags: []string{"dmz"}}}) + m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl}) + if !m.forwardMode || m.selected != nil { + t.Fatal("forward input not opened") + } + if !strings.Contains(m.viewContent(), "forwards: web") { + t.Fatalf("configured forwards not shown:\n%s", m.viewContent()) + } + + m.forwardInput.SetValue("nope") + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.selected != nil || m.err == nil { + t.Fatal("unknown forward accepted") + } + + m.forwardInput.SetValue("WEB, 9000:localhost:9000") + m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + want := []string{"WEB", "9000:localhost:9000"} + if m.selected == nil || m.selected.Action != ActionTunnel || !reflect.DeepEqual(m.selected.Forwards, want) { + t.Fatalf("got %+v", m.selected) + } +} + +func TestForwardInputEscCancels(t *testing.T) { + m := New(nil, nil, "") + m.updateListItems([]netbox.HostEntry{{Name: "web01"}}) + m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl}) + m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.forwardMode || m.selected != nil { + t.Fatal("esc did not cancel") + } +}