From a950a82552ff2369aa3650fee40c6d91a0619c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Fri, 13 Nov 2026 23:46:13 +0100 Subject: [PATCH] feat: hostname resolver strategy and static hosts Add the hostname resolver strategy. It connects by the NetBox name instead of an IP, with resolver.hostname_domain appended to names without a dot. Its position in the chain decides whether it is always used or only as a fallback (e.g. [primary_ip, hostname]); names that are not valid DNS hostnames fall through to the next strategy. Add static hosts under hosts: for machines that are not in NetBox, with address, user, port, tags, and their own SSH options. They are checked before NetBox (by name or shortcut), never expire, survive cache refresh and clear, and work without any NetBox configured. The cache overlays them after every load and stores only their last use, so they appear in completion, aliases, the TUI and its recent list. SSH options merge global, static host, then rules; rules can match kind: static. Manage them with netssh hosts list|add|remove and in netssh configure, which can now also continue without NetBox. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 81 +++++++++++++--- cmd/netssh/hosts.go | 142 +++++++++++++++++++++++++++++ cmd/netssh/hosts_test.go | 130 ++++++++++++++++++++++++++ cmd/netssh/instances.go | 5 + cmd/netssh/main.go | 82 ++++++++++++----- cmd/netssh/notes.go | 2 +- cmd/netssh/transfer.go | 4 +- internal/cache/cache.go | 97 +++++++++++++++++++- internal/cache/cache_test.go | 62 +++++++++++++ internal/config/config.go | 8 +- internal/config/hosts.go | 62 +++++++++++++ internal/config/instances.go | 3 + internal/config/ssh.go | 6 +- internal/config/ssh_test.go | 55 +++++++++++ internal/resolver/chain.go | 2 + internal/resolver/hostname.go | 31 +++++++ internal/resolver/hostname_test.go | 43 +++++++++ internal/setup/hosts.go | 140 ++++++++++++++++++++++++++++ internal/setup/instances.go | 12 +++ internal/setup/wizard.go | 55 ++++++++++- internal/setup/wizard_test.go | 49 ++++++++++ 22 files changed, 1024 insertions(+), 49 deletions(-) create mode 100644 cmd/netssh/hosts.go create mode 100644 cmd/netssh/hosts_test.go create mode 100644 internal/config/hosts.go create mode 100644 internal/resolver/hostname.go create mode 100644 internal/resolver/hostname_test.go create mode 100644 internal/setup/hosts.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fbfed..831378d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - 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. +- Add the `hostname` resolver strategy: connect by the NetBox name (plus optional `resolver.hostname_domain`) instead of an IP, always or as a fallback after `primary_ip`. +- Add static hosts (`hosts:`) for machines that are not in NetBox, with user, port, tags, and SSH options, managed with `netssh hosts list|add|remove` and in `netssh configure`. They are found before NetBox, work offline and without any NetBox, and support shortcuts, rules (`kind: static`), completion, aliases, sftp, scp, tunnels, and the TUI. - Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination. ## v0.0.22 diff --git a/README.md b/README.md index ca989e4..6f118cb 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int - [Installation](#installation) - [Quick start](#quick-start) - [Configuration](#configuration) — [wizard](#interactive-wizard), [manual config](#manual-config), [reference](#configuration-reference), [environment variables](#environment-variables), [files](#files-and-directories), [multiple instances](#multiple-netbox-instances) -- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management) +- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management) - [Recipes](#recipes) - [IP Resolution Strategies](#ip-resolution-strategies) - [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook) @@ -31,7 +31,8 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int ## Features - **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options -- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface +- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, named interface, or the hostname itself +- **Static hosts** — add hosts that are not in NetBox (`netssh hosts add router 192.168.1.1`); they work everywhere, also without any NetBox - **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments) - **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed - **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm` @@ -139,7 +140,7 @@ Re-run it at any time to change settings without editing the file manually: netssh configure ``` -The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. +The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. Without any instance you can also continue with static hosts only. A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**: | Section | Settings | @@ -147,7 +148,8 @@ A menu then gives access to every other setting; open only the sections you want | 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 | +| Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options | +| Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file | | Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` | | Completion | `completion.max_results` | @@ -211,6 +213,7 @@ Every key, its default, and the section that explains it. Keys marked *layered* | `resolver.strategies` | list | `[management_subnet, primary_ip]` | Tried in order, first IP wins — see [IP Resolution Strategies](#ip-resolution-strategies). The wizard suggests `primary_ip`. | | `resolver.management_subnets` | list of CIDR | `[]` | Used by `management_subnet`. | | `resolver.interface_name` | string | — | Used by `interface_name`, e.g. `mgmt0`. | +| `resolver.hostname_domain` | string | — | Used by `hostname`: appended to NetBox names without a dot. | | `cache.ttl` | seconds | `3600` | How long a cached IP is used without asking NetBox; `0` always asks NetBox when connecting. | | `cache.path` | path | user cache dir `/netssh/hosts.json` | Cache file; named instances use a subdirectory next to it. | | `ssh.default_user` | string | — | Login user when none is given (`-l` for ssh, `-o User=` for sftp/scp). | @@ -228,15 +231,16 @@ Every key, its default, and the section that explains it. Keys marked *layered* | `shortcuts.rewrites[]` | list | `[]` | `pattern` (Go regex) and `replace` — see [Host shortcuts](#host-shortcuts). | | `completion.max_results` | int | `0` | Hostnames offered per ``; `0` = unlimited. | | `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). | +| `hosts[]` | list | `[]` | Static hosts with `name`, `address`, `user`, `port`, `tags`, `ssh` — see [Static hosts](#static-hosts). | ### Environment variables -These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`) can only be set in the file. +These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`) can only be set in the file. | Variable | Key | |---|---| | `NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, `NETSSH_NETBOX_TOKEN_VERSION` | `netbox.*` (single instance only; ignored when the file has only `netboxes:`) | -| `NETSSH_RESOLVER_STRATEGIES`, `NETSSH_RESOLVER_MANAGEMENT_SUBNETS`, `NETSSH_RESOLVER_INTERFACE_NAME` | `resolver.*` | +| `NETSSH_RESOLVER_STRATEGIES`, `NETSSH_RESOLVER_MANAGEMENT_SUBNETS`, `NETSSH_RESOLVER_INTERFACE_NAME`, `NETSSH_RESOLVER_HOSTNAME_DOMAIN` | `resolver.*` | | `NETSSH_CACHE_TTL`, `NETSSH_CACHE_PATH` | `cache.*` | | `NETSSH_SSH_DEFAULT_USER`, `NETSSH_SSH_DEFAULT_PORT` | `ssh.default_*` | | `NETSSH_SSH_PROXY_JUMP`, `NETSSH_SSH_HOST_KEY_CHECKING`, `NETSSH_SSH_TARGET`, `NETSSH_SSH_ADDRESS_FAMILY` | global `ssh.*` options | @@ -354,6 +358,7 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered. | `netssh sftp [netssh flags] [sftp options] HOST[:PATH]` | SFTP session — see [SFTP and SCP](#sftp-and-scp). | | `netssh scp [netssh flags] [scp options] SOURCE... TARGET` | Copy files — see [SFTP and SCP](#sftp-and-scp). | | `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). | +| `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). | | `netssh configure` | Setup wizard. | | `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. | | `netssh search QUERY` | Print matching cached hostnames, one per line. | @@ -452,12 +457,12 @@ ssh asks once to confirm each host key. |---|---|---| | `tag` | NetBox tags of the host | Tag name or slug, case-insensitive (`DMZ Hosts` matches `dmz-hosts`). | | `instance` | Name of the NetBox instance | Only useful with `netboxes:`. | -| `kind` | `device` or `vm` | Devices come from DCIM, VMs from Virtualization. | +| `kind` | `device`, `vm`, or `static` | Devices come from DCIM, VMs from Virtualization, `static` from `hosts:`. | | `name` | Hostname without instance prefix | Shell glob, case-insensitive (`*.dmz.example.com`, `fw-??`). | Tags and kind are taken from the cache or the NetBox answer, so rules work offline for cached hosts. A rule needs at least one match field. All matching rules apply in file order; a later rule overrides an earlier one. -**How layers combine.** netssh merges global → instance → matching rules: +**How layers combine.** netssh merges global → instance (or the static host's own `ssh`) → matching rules: - single values (`proxy_jump`, `host_key_checking`, `target`, `address_family`) — the most specific non-empty value wins; - `options` and `forwards` — merged per key or name, the more specific layer wins; @@ -508,7 +513,39 @@ 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`. +`sftp`, `scp`, `tunnel`, and `hosts` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`. + +### Static hosts + +Hosts that are not in NetBox — a home router, a cloud VM, a customer machine — can be added to the config. netssh checks them before NetBox, so they work offline and even without any NetBox configured. + +```sh +netssh hosts add router 192.168.1.1 --user admin --tag home +netssh hosts add backup backup.example.org --port 2222 --tag offsite +netssh hosts # list +netssh hosts add router 192.168.1.254 --replace # change the address, keep its SSH options +netssh hosts remove backup +``` + +The same in the config (or in `netssh configure` → *Static hosts*): + +```yaml +hosts: + - name: router + address: 192.168.1.1 # IP address or DNS name + user: admin # optional, overrides ssh.default_user + port: 2222 # optional, overrides ssh.default_port + tags: [home] # optional, for ssh.rules + ssh: # optional, same keys as ssh: (proxy_jump, options, forwards, …) + proxy_jump: none +``` + +Static hosts behave like NetBox hosts everywhere: `netssh router`, `sftp`, `scp`, `tunnel`, shortcuts and rewrite rules, Tab completion, shell aliases, the TUI search and its recently-used list, and `ssh.rules` (match `kind: static` or their tags). Their SSH options are merged like an instance's: global → static host → rules. User and port on the command line still win. + +- Names may contain letters, digits, dots, underscores, and hyphens, and are matched case-insensitively. They have no instance prefix. +- A static host wins over a NetBox host of the same name. Give them distinct names to keep both reachable. +- Static hosts never expire and are not touched by `cache refresh` or `cache clear`; removing one from the config removes it everywhere. +- `--netbox` notes and NetBox comments are not available for static hosts; local notes work. ### Default username @@ -815,6 +852,22 @@ Cache refresh uses the same strategies as a direct connection. To allow the prim | `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. | | `management_subnet` | Fetches all IPs for the host and returns the first one matching a configured CIDR. | | `interface_name` | Fetches IPs attached to a specific named interface (e.g. `mgmt0`). | +| `hostname` | Uses the NetBox name instead of an IP, with `resolver.hostname_domain` appended to names without a dot. DNS or `~/.ssh/config` resolves it when ssh connects. No extra API call. | + +**`hostname` strategy.** Its position in the chain decides when it applies: + +```yaml +resolver: + strategies: [primary_ip, hostname] # IP if NetBox has one, otherwise the hostname + hostname_domain: example.com # web01 → web01.example.com +``` + +```yaml +resolver: + strategies: [hostname] # always connect by name, never by IP +``` + +Names that are not valid DNS hostnames (spaces, underscores) are skipped, so the next strategy is tried. The cache then stores the name instead of an IP, and `--dry-run` shows `ssh web01.example.com`. Compared with `ssh.target: hostname`, the strategy works as a fallback for hosts without an IP in NetBox and can be combined with `target: name`. ## Shell Completion @@ -958,6 +1011,8 @@ Both are non-blocking — the refresh runs as a background process and never del | Message / symptom | Cause and fix | |---|---| | `host not found` | The name is not in NetBox (devices and VMs are searched). Check the spelling, or configure `shortcuts.domains` if you type short names. | +| `host "x" is not in hosts: and no NetBox is configured` | Only static hosts are configured. Add the host with `netssh hosts add`, or a NetBox with `netssh configure`. | +| `no strategy resolved an IP` | No resolver strategy found an address. Add `primary_ip` or `hostname` as a fallback at the end of `resolver.strategies`. | | `ambiguous shortcut "x"; use a full hostname` | Several cached hosts shorten to the same name. Use the full name, or check with `netssh alias --test`. | | `found 2 exact matches` | The name exists in several NetBox instances, or as both device and VM. Prefix the instance (`prod/web01`); for notes and the TUI also `--kind`. | | Shortcut works for one host but not another | Shortcuts (hyphen folding, rewrites) only match cached hosts. Run `netssh cache refresh`. | @@ -969,7 +1024,7 @@ Both are non-blocking — the refresh runs as a background process and never del | Saving notes with `--netbox` fails with a permission error | The API token is read-only. Local notes work without write permission. | | `` completes files instead of hosts | Run `netssh completion install` and open a new terminal; for zsh also `rm -f ~/.zcompdump*`. | | Aliases missing in a new shell | `netssh hook install`, then a new terminal. The cache must not be empty. | -| A host is named `sftp`, `scp`, `tunnel`, or like another subcommand | Use `netssh -- sftp`. | +| A host is named `sftp`, `scp`, `tunnel`, `hosts`, or like another subcommand | Use `netssh -- sftp`. | ## Uninstall @@ -1025,9 +1080,9 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen ## How it works 1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI. -2. On first run or when no NetBox is configured, the interactive setup wizard starts automatically. +2. On first run, or when neither a NetBox nor static hosts are configured, the interactive setup wizard starts automatically. 3. It removes its own long options (`--dry-run`, `--jump`, `--no-jump`, `--forward`) and parses the remaining arguments with the flag table of the program it will run (ssh, sftp, or scp differ, e.g. in `-l` and `-P`) to find the destination: `[user@]host` for ssh, `[user@]host[:path]` for sftp, and every `host:path` operand for scp. -4. It adds the default user and port in the program's syntax unless you gave your own. -5. It resolves the host: an exact cache lookup, then a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached. +4. It resolves the host: first the static hosts from `hosts:` (by name or shortcut), then an exact cache lookup, a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached. +5. It adds the user and port (a static host's own, else the defaults) in the program's syntax unless you gave your own. 6. It merges the SSH options for the host (global, instance, matching rules), resolves named forwards, and inserts the options before the first operand: jump host, host key checking, `-o` options, forwards, and for `tunnel` `-N` with `ExitOnForwardFailure`. 7. It replaces the hostname with the resolved IP (or the name, with `ssh.target: name`; IPv6 in brackets for sftp/scp) and calls `syscall.Exec` to become `ssh`, `sftp`, or `scp` — or prints the command with `--dry-run`. diff --git a/cmd/netssh/hosts.go b/cmd/netssh/hosts.go new file mode 100644 index 0000000..b463aaa --- /dev/null +++ b/cmd/netssh/hosts.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup" +) + +func hostsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "hosts", + Short: "Manage static hosts that are not in NetBox", + Long: `Static hosts are defined under hosts: in the config. They are found before +NetBox, work offline, and support shortcuts, rules (kind: static), aliases, +completion, sftp, scp, and tunnels like NetBox hosts.`, + RunE: func(cmd *cobra.Command, args []string) error { return listHosts() }, + } + cmd.AddCommand(hostsListCmd(), hostsAddCmd(), hostsRemoveCmd()) + return cmd +} + +func hostsListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List static hosts", + RunE: func(cmd *cobra.Command, args []string) error { return listHosts() }, + } +} + +func listHosts() error { + cfg, err := config.Load() + if err != nil { + return err + } + if len(cfg.Hosts) == 0 { + fmt.Println("No static hosts. Add one with: netssh hosts add NAME ADDRESS") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tADDRESS\tUSER\tPORT\tTAGS") + for _, host := range cfg.Hosts { + port := "" + if host.Port > 0 { + port = fmt.Sprint(host.Port) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", host.Name, host.Address, host.User, port, strings.Join(host.Tags, ",")) + } + return w.Flush() +} + +func hostsAddCmd() *cobra.Command { + var host config.StaticHost + var replace bool + cmd := &cobra.Command{ + Use: "add NAME ADDRESS", + Short: "Add a static host (or replace one with --replace)", + Example: ` netssh hosts add router 192.168.1.1 --user admin + netssh hosts add backup backup.example.org --port 2222 --tag offsite`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + host.Name, host.Address = args[0], args[1] + if existing := cfg.StaticHost(host.Name); existing != nil { + if !replace { + return fmt.Errorf("static host %q already exists; use --replace to overwrite it", existing.Name) + } + host.SSH = existing.SSH // keep SSH options set in the config or wizard + *existing = host + } else { + cfg.Hosts = append(cfg.Hosts, host) + } + return saveHosts(cfg) + }, + } + cmd.Flags().StringVar(&host.User, "user", "", "Login user (default: ssh.default_user)") + cmd.Flags().IntVar(&host.Port, "port", 0, "SSH port (default: ssh.default_port or 22)") + cmd.Flags().StringSliceVar(&host.Tags, "tag", nil, "Tag for ssh.rules (repeatable)") + cmd.Flags().BoolVar(&replace, "replace", false, "Overwrite an existing static host") + return cmd +} + +func hostsRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove NAME", + Aliases: []string{"rm"}, + Short: "Remove a static host", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + for i, host := range cfg.Hosts { + if strings.EqualFold(host.Name, args[0]) { + cfg.Hosts = append(cfg.Hosts[:i], cfg.Hosts[i+1:]...) + return saveHosts(cfg) + } + } + return fmt.Errorf("no static host %q", args[0]) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + cfg, err := config.Load() + if err != nil || len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + var names []string + for _, host := range cfg.Hosts { + if strings.HasPrefix(strings.ToLower(host.Name), strings.ToLower(toComplete)) { + names = append(names, host.Name) + } + } + return names, cobra.ShellCompDirectiveNoFileComp + }, + } +} + +// saveHosts writes the config and regenerates the shell aliases. +func saveHosts(cfg *config.Config) error { + if err := setup.Save(*cfg); err != nil { + return err + } + loaded, err := config.Load() + if err != nil { + return err + } + c := newCache(loaded) + _ = c.Load() + if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) + } + return nil +} diff --git a/cmd/netssh/hosts_test.go b/cmd/netssh/hosts_test.go new file mode 100644 index 0000000..786f1fe --- /dev/null +++ b/cmd/netssh/hosts_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// prepareConfig writes data as the only config, with empty cache and home. +func prepareConfig(t *testing.T, data string) string { + 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", "") + path := filepath.Join(dir, "netssh.yaml") + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +const staticOnlyConfig = `ssh: + default_user: ops + rules: + - match: {kind: static, tag: home} + host_key_checking: accept-new +shortcuts: + domains: [.lan] +hosts: + - name: router.lan + address: 192.168.1.1 + user: admin + port: 2222 + tags: [home] + - name: nas + address: nas.example.org + ssh: + proxy_jump: jh.example.com +` + +// Static hosts work without any NetBox and with their own user, port, tags, and options. +func TestStaticHostsWithoutNetBox(t *testing.T) { + prepareConfig(t, staticOnlyConfig) + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"--dry-run", "router.lan"}, "ssh -p 2222 -l admin -o StrictHostKeyChecking=accept-new 192.168.1.1"}, + {[]string{"--dry-run", "router"}, "ssh -p 2222 -l admin -o StrictHostKeyChecking=accept-new 192.168.1.1"}, // shortcut + {[]string{"--dry-run", "-l", "root", "nas", "uptime"}, "ssh -l root -J jh.example.com nas.example.org uptime"}, + {[]string{"sftp", "--dry-run", "router:/etc"}, "sftp -P 2222 -o User=admin -o StrictHostKeyChecking=accept-new 192.168.1.1:/etc"}, + {[]string{"scp", "--dry-run", "a.txt", "nas:/tmp/"}, "scp -o User=ops -J jh.example.com a.txt nas.example.org:/tmp/"}, + } { + 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) + } + } + if out, err := runNetssh(t, "--dry-run", "unknown"); err == nil || !strings.Contains(out, "no NetBox is configured") { + t.Errorf("unknown host: %s (%v)", out, err) + } + if out, _ := runNetssh(t, "__complete", "rou"); !strings.HasPrefix(out, "router.lan\n") { + t.Errorf("completion: %q", out) + } + if out, _ := runNetssh(t, "alias", "--shell", "bash"); !strings.Contains(out, "alias router='netssh router.lan'") || !strings.Contains(out, "alias nas='netssh nas'") { + t.Errorf("aliases: %s", out) + } +} + +func TestHostsCommand(t *testing.T) { + path := prepareConfig(t, "hosts:\n - {name: nas, address: nas.example.org, ssh: {proxy_jump: jh}}\n") + steps := []struct { + args []string + wantErr string + }{ + {[]string{"hosts", "add", "router", "192.168.1.1", "--user", "admin", "--port", "2222", "--tag", "home", "--tag", "lab"}, ""}, + {[]string{"hosts", "add", "router", "10.0.0.1"}, "already exists"}, + {[]string{"hosts", "add", "nas", "10.0.0.5", "--replace"}, ""}, + {[]string{"hosts", "add", "bad name", "10.0.0.1"}, "must start with"}, + {[]string{"hosts", "remove", "missing"}, "no static host"}, + } + for _, step := range steps { + out, err := runNetssh(t, step.args...) + if step.wantErr == "" && err != nil || step.wantErr != "" && !strings.Contains(out, step.wantErr) { + t.Fatalf("%v: %s (%v)", step.args, out, err) + } + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + router, nas := cfg.StaticHost("router"), cfg.StaticHost("nas") + if router == nil || router.User != "admin" || router.Port != 2222 || strings.Join(router.Tags, ",") != "home,lab" { + t.Fatalf("router: %+v", router) + } + if nas == nil || nas.Address != "10.0.0.5" || nas.SSH.ProxyJump != "jh" { + t.Fatalf("--replace must keep SSH options: %+v", nas) + } + out, err := runNetssh(t, "hosts", "list") + if err != nil || !strings.Contains(out, "router 192.168.1.1 admin 2222 home,lab") { + t.Fatalf("list:\n%s (%v)", out, err) + } + if out, err := runNetssh(t, "hosts", "rm", "router"); err != nil { + t.Fatalf("remove: %s (%v)", out, err) + } + data, _ := os.ReadFile(path) + if strings.Contains(string(data), "router") || strings.Contains(string(data), "netbox:") { + t.Fatalf("config after remove:\n%s", data) + } +} + +// The hostname strategy passes the NetBox name (plus domain) instead of an IP. +func TestHostnameStrategyConnects(t *testing.T) { + srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web01"}, multiHost("db01", "10.0.0.7")}, nil) + prepareConfig(t, fmt.Sprintf("netbox:\n url: %q\n token: t\nresolver:\n strategies: [primary_ip, hostname]\n hostname_domain: example.com\n", srv.URL)) + for host, want := range map[string]string{"web01": "ssh web01.example.com", "db01": "ssh 10.0.0.7"} { + if out, err := runNetssh(t, "--dry-run", host); err != nil || out != want { + t.Errorf("%s: got %s (%v), want %s", host, out, err, want) + } + } +} diff --git a/cmd/netssh/instances.go b/cmd/netssh/instances.go index 206de3a..070abc3 100644 --- a/cmd/netssh/instances.go +++ b/cmd/netssh/instances.go @@ -10,6 +10,11 @@ import ( "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts" ) +// newCache opens the host cache with the config's static hosts. +func newCache(cfg *config.Config) *cache.Cache { + return cache.New(cfg.CachePath(), cfg.Cache.TTL).WithStatic(cfg.Hosts) +} + func newNetBoxClient(cfg *config.Config) *netbox.Client { if len(cfg.NetBoxes) > 0 { sources := make([]netbox.Source, 0, len(cfg.NetBoxes)) diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 4540fd2..b8b679d 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -45,6 +45,7 @@ var managedSubcommands = map[string]bool{ "sftp": true, "scp": true, "tunnel": true, + "hosts": true, } func main() { @@ -75,7 +76,7 @@ func loadConfigOrSetup() *config.Config { if err != nil { fatalf("config: %v", err) } - if !cfg.HasNetBox() { + if !cfg.HasHosts() { fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…") if err := setup.RunWizard(cfg); err != nil { fatalf("setup: %v", err) @@ -143,16 +144,35 @@ func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args [ return } - // Inject the configured default user if none was given on the command line. - if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) { - args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests) + host := resolveTarget(cfg, dest.host) + user, port := hostDefaults(cfg, host) + // Inject the default user if none was given on the command line. + if user != "" && dest.user == "" && !client.HasUserFlag(args) { + args = prependArgs(client.UserArgs(user), args, dest.dests) } - // Inject the configured default port if none was given on the command line. - if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) { - args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests) + // Inject the default port if none was given on the command line. + if port > 0 && !client.HasPortFlag(args) { + args = prependArgs(client.PortArgs(strconv.Itoa(port)), args, dest.dests) } - connectHost(cfg, client, flags, resolveTarget(cfg, dest.host), dest.dests, args) + connectHost(cfg, client, flags, host, dest.dests, args) +} + +// hostDefaults returns the login user and port for host: those of a static +// host if set, else ssh.default_user and ssh.default_port. +func hostDefaults(cfg *config.Config, host resolvedHost) (string, int) { + user, port := cfg.SSH.DefaultUser, cfg.SSH.DefaultPort + if host.Kind == config.StaticKind { + if static := cfg.StaticHost(host.Name); static != nil { + if static.User != "" { + user = static.User + } + if static.Port > 0 { + port = static.Port + } + } + } + return user, port } // prependArgs puts extra before args and shifts the destination indices. @@ -174,7 +194,7 @@ type resolvedHost struct { // 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 := newCache(cfg) _ = c.Load() maybeBackgroundRefresh(cfg, c) @@ -186,6 +206,21 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost { return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags} } + // Static hosts from the config win and need no NetBox. + if source == "" { + var normalize func(string) string + if cfg.Shortcuts.Enabled() { + normalize = normalizeTarget(cfg.Shortcuts, false) + } + entry, found, err := c.GetStatic(host, normalize) + if err != nil { + fatalf("%v", err) + } + if found { + return used(entry) + } + } + // Cache hit with a fresh TTL — connect directly without querying NetBox. if entry, fresh := c.Get(host); fresh && useCache { return used(entry) @@ -211,7 +246,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost { } if !cfg.HasNetBox() { - fatalf("netbox.url is not configured (~/.config/netssh.yaml)") + fatalf("host %q is not in hosts: and no NetBox is configured (netssh configure)", host) } nbClient := newNetBoxClient(cfg) @@ -316,7 +351,7 @@ func execClient(client internalssh.Client, result internalssh.Result, flags inte func runTUI() { cfg := loadConfigOrSetup() - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) _ = c.Load() maybeBackgroundRefresh(cfg, c) @@ -392,17 +427,18 @@ func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, } var args []string - // User override from TUI edit mode takes priority, then config default. + // User override from TUI edit mode takes priority, then static host and config defaults. + defaultUser, defaultPort := hostDefaults(cfg, resolvedHost{Name: host.Name, Kind: host.Kind}) user := host.User if user == "" { - user = cfg.SSH.DefaultUser + user = defaultUser } if user != "" { args = append(args, client.UserArgs(user)...) } port := host.Port - if port == "" && cfg.SSH.DefaultPort > 0 { - port = strconv.Itoa(cfg.SSH.DefaultPort) + if port == "" && defaultPort > 0 { + port = strconv.Itoa(defaultPort) } if port != "" { args = append(args, client.PortArgs(port)...) @@ -453,7 +489,7 @@ func rootCmd() *cobra.Command { }, } - root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd()) + root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd()) // cobra builds the "completion" command lazily; force init so we can extend it. root.InitDefaultCompletionCmd() @@ -575,7 +611,7 @@ func searchCmd() *cobra.Command { if err != nil { return err } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) if err := c.Load(); err != nil { return err } @@ -609,7 +645,7 @@ func cacheListCmd() *cobra.Command { if err != nil { return err } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) if err := c.Load(); err != nil { return err } @@ -635,7 +671,7 @@ func cacheClearCmd() *cobra.Command { if err != nil { return err } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) c.Clear() if err := c.Save(); err != nil { return err @@ -677,7 +713,7 @@ func cacheRefreshCmd() *cobra.Command { return fmt.Errorf("NetBox: %w", err) } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) _ = c.Load() chain, err := resolver.New(cfg.Resolver) @@ -762,7 +798,7 @@ Or use in a script: if err != nil { return err } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) if err := c.Load(); err != nil { return err } @@ -838,7 +874,7 @@ func shellInitCmd() *cobra.Command { if err != nil { return nil } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) _ = c.Load() // Regenerate alias files from the current cache every shell start @@ -897,7 +933,7 @@ After installation, reload your profile or open a new shell.`, // Generate the aliases file immediately so it is ready on the next shell start. cfg, cfgErr := config.Load() if cfgErr == nil && cfg.HasNetBox() { - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) if _ = c.Load(); true { if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { if err := hook.WriteAliasFiles(entries); err != nil { diff --git a/cmd/netssh/notes.go b/cmd/netssh/notes.go index 5c62579..d2651e3 100644 --- a/cmd/netssh/notes.go +++ b/cmd/netssh/notes.go @@ -38,7 +38,7 @@ func notesCmd() *cobra.Command { client = newNetBoxClient(cfg) } host := netbox.HostEntry{Name: args[0], Kind: kind} - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) if err := c.Load(); err != nil { return err } diff --git a/cmd/netssh/transfer.go b/cmd/netssh/transfer.go index 0653961..1a291ad 100644 --- a/cmd/netssh/transfer.go +++ b/cmd/netssh/transfer.go @@ -133,7 +133,7 @@ func completeHosts(toComplete string) []string { if err != nil { return nil } - c := cache.New(cfg.CachePath(), cfg.Cache.TTL) + c := newCache(cfg) _ = c.Load() hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts) if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { @@ -144,7 +144,7 @@ func completeHosts(toComplete string) []string { // 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) + c := newCache(cfg) if err := c.Load(); err != nil { return cache.Entry{}, false } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 0b1c6fd..fdeda5c 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -12,6 +12,7 @@ import ( "golang.org/x/sys/unix" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) @@ -22,6 +23,9 @@ type Entry struct { Tags []string `json:"tags,omitempty"` CachedAt time.Time `json:"cached_at"` LastUsed time.Time `json:"last_used,omitempty"` + // Static marks a host from the config's hosts: list. It never expires; + // it is stored only to remember LastUsed for the recently-used list. + Static bool `json:"static,omitempty"` } type changeKind uint8 @@ -40,6 +44,7 @@ type Cache struct { path string ttl time.Duration refreshStamp string // path to last_refresh timestamp file + static []config.StaticHost } type diskFormat struct { @@ -98,6 +103,7 @@ func (c *Cache) Load() error { data, err := os.ReadFile(c.path) if os.IsNotExist(err) { + c.applyStatic() return nil } if err != nil { @@ -115,6 +121,7 @@ func (c *Cache) Load() error { for _, e := range df.Entries { c.entries[e.Name] = e } + c.applyStatic() return nil } @@ -188,6 +195,15 @@ func (c *Cache) Save() error { if err := storage.WriteFile(c.path, data, 0o600); err != nil { return err } + // Static hosts come from the config, not the file: keep them in memory. + for name, e := range c.entries { + if e.Static { + if old, ok := merged[name]; ok && old.LastUsed.After(e.LastUsed) { + e.LastUsed = old.LastUsed + } + merged[name] = e + } + } c.entries = merged c.dirty = make(map[string]changeKind) c.cleared = false @@ -221,7 +237,10 @@ func (c *Cache) MarkUsed(name string) { if e, ok := c.entries[name]; ok { e.LastUsed = time.Now() c.entries[name] = e - if _, changed := c.dirty[name]; !changed { + if e.Static { + // A static host may not be on disk yet; usage alone would not add it. + c.dirty[name] = upsertChange + } else if _, changed := c.dirty[name]; !changed { c.dirty[name] = usageChange } } @@ -279,7 +298,7 @@ func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (e entry, found = e, true } } - return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil + return entry, found, found && (entry.Static || c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl), nil } // Get returns an entry and reports whether it is still within the TTL. @@ -291,20 +310,92 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) { if !ok { return Entry{}, false } + if e.Static { + return e, true + } if c.ttl == 0 { return e, false } return e, time.Since(e.CachedAt) < c.ttl } +// Clear removes all NetBox entries; static hosts come from the config and stay. func (c *Cache) Clear() { c.mu.Lock() - c.entries = make(map[string]Entry) + static := make(map[string]Entry) + for name, e := range c.entries { + if e.Static { + static[name] = e + } + } + c.entries = static c.dirty = make(map[string]changeKind) c.cleared = true c.mu.Unlock() } +// SetStatic adds the config's static hosts. They replace cached entries of the +// same name, keep their recorded LastUsed, and static entries that are no +// longer configured are removed on the next Save. +func (c *Cache) SetStatic(hosts []config.StaticHost) { + c.mu.Lock() + defer c.mu.Unlock() + c.static = hosts + c.applyStatic() +} + +// WithStatic sets the static hosts now and after every Load. +func (c *Cache) WithStatic(hosts []config.StaticHost) *Cache { + c.SetStatic(hosts) + return c +} + +func (c *Cache) applyStatic() { + hosts := c.static + configured := make(map[string]bool, len(hosts)) + for _, host := range hosts { + e := Entry{Name: host.Name, IP: host.Address, Kind: config.StaticKind, Tags: host.Tags, CachedAt: time.Now(), Static: true} + if old, ok := c.entries[host.Name]; ok && old.Static { + e.LastUsed = old.LastUsed + } + c.entries[host.Name] = e + configured[host.Name] = true + } + for name, e := range c.entries { + if e.Static && !configured[name] { + delete(c.entries, name) + c.dirty[name] = deleteChange + } + } +} + +// GetStatic finds a static host by name or by shortcut. Static hosts are +// checked before NetBox, so they also work when NetBox is unreachable. +func (c *Cache) GetStatic(name string, normalize func(string) string) (Entry, bool, error) { + c.mu.RLock() + defer c.mu.RUnlock() + var match Entry + found := false + for key, e := range c.entries { + if e.Static && strings.EqualFold(key, name) { + return e, true, nil + } + } + if normalize == nil { + return Entry{}, false, nil + } + short := normalize(name) + for _, e := range c.entries { + if e.Static && normalize(e.Name) == short { + if found { + return Entry{}, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", name) + } + match, found = e, true + } + } + return match, found, nil +} + func (c *Cache) All() []Entry { c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 638e08d..7e1d649 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -4,8 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "time" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" ) func TestNew(t *testing.T) { @@ -495,3 +498,62 @@ func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) { } } } + +func TestStaticHosts(t *testing.T) { + path := filepath.Join(t.TempDir(), "hosts.json") + hosts := []config.StaticHost{{Name: "router", Address: "192.168.1.1", Tags: []string{"home"}}, {Name: "nas", Address: "nas.lan"}} + c := New(path, 1).WithStatic(hosts) + c.Upsert(Entry{Name: "web01", IP: "10.0.0.1", Kind: "device"}) + if err := c.Load(); err != nil { // no file yet; static hosts stay + t.Fatal(err) + } + if e, fresh := c.Get("router"); !fresh || e.IP != "192.168.1.1" || e.Kind != config.StaticKind || !e.Static { + t.Fatalf("static get: %+v %v", e, fresh) + } + if e, found, err := c.GetStatic("ROUTER", nil); !found || err != nil || e.Name != "router" { + t.Fatalf("by name: %+v %v %v", e, found, err) + } + normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(s), ".lan") } + if e, found, _ := c.GetStatic("nas.lan", normalize); !found || e.Name != "nas" { + t.Fatalf("by shortcut: %+v", e) + } + if _, found, _ := c.GetStatic("web01", normalize); found { + t.Fatal("NetBox entry returned as static") + } + + // Usage of a static host persists; the host itself stays after Save and Clear. + c.MarkUsed("router") + if err := c.Save(); err != nil { + t.Fatal(err) + } + if _, fresh := c.Get("nas"); !fresh { + t.Fatal("static host lost after Save") + } + reloaded := New(path, 1).WithStatic(hosts) + if err := reloaded.Load(); err != nil { + t.Fatal(err) + } + if recent := reloaded.RecentlyUsed(5); len(recent) != 1 || recent[0].Name != "router" { + t.Fatalf("recent: %+v", recent) + } + reloaded.Clear() + if _, fresh := reloaded.Get("router"); !fresh { + t.Fatal("Clear removed a static host") + } + + // A host removed from the config disappears, also from the file. + removed := New(path, 1).WithStatic(hosts[1:]) + if err := removed.Load(); err != nil { + t.Fatal(err) + } + if e, _ := removed.Get("router"); e.Name != "" { + t.Fatalf("removed static host still present: %+v", e) + } + if err := removed.Save(); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if strings.Contains(string(data), "192.168.1.1") { + t.Fatalf("removed static host still on disk:\n%s", data) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 04d7ea5..8e6dbeb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ type Config struct { SSH SSHConfig `mapstructure:"ssh"` Shortcuts ShortcutsConfig `mapstructure:"shortcuts"` Completion CompletionConfig `mapstructure:"completion"` + Hosts []StaticHost `mapstructure:"hosts"` } type ShellConfig struct { @@ -37,6 +38,8 @@ type ResolverConfig struct { Strategies []string `mapstructure:"strategies"` ManagementSubnets []string `mapstructure:"management_subnets"` InterfaceName string `mapstructure:"interface_name"` + // HostnameDomain is appended by the hostname strategy to names without a dot. + HostnameDomain string `mapstructure:"hostname_domain"` } type CacheConfig struct { @@ -114,7 +117,7 @@ func Load() (*Config, error) { v.SetEnvPrefix("NETSSH") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() - for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} { + for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} { if err := v.BindEnv(key); err != nil { return nil, err } @@ -157,6 +160,9 @@ func Load() (*Config, error) { if err := cfg.Shortcuts.Validate(); err != nil { return nil, err } + if err := cfg.ValidateHosts(); 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/hosts.go b/internal/config/hosts.go new file mode 100644 index 0000000..4adaff3 --- /dev/null +++ b/internal/config/hosts.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "regexp" + "strings" +) + +// StaticKind is the kind of hosts defined under hosts: instead of NetBox. +const StaticKind = "static" + +// StaticHost is a host that is not in NetBox, defined in the config. +type StaticHost struct { + Name string `mapstructure:"name"` + Address string `mapstructure:"address"` // IP address or DNS name + User string `mapstructure:"user"` + Port int `mapstructure:"port"` + Tags []string `mapstructure:"tags"` + SSH SSHOptions `mapstructure:"ssh"` +} + +var staticHostName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// ValidateHosts keeps static host names usable as selectors and addresses +// safe to pass to ssh. +func (c *Config) ValidateHosts() error { + seen := make(map[string]bool) + for i, host := range c.Hosts { + scope := fmt.Sprintf("hosts[%d]", i) + if !staticHostName.MatchString(host.Name) { + return fmt.Errorf("%s: name %q must start with a letter or digit and contain only letters, digits, dots, underscores, or hyphens", scope, host.Name) + } + name := strings.ToLower(host.Name) + if seen[name] { + return fmt.Errorf("%s: duplicate host name %q", scope, host.Name) + } + seen[name] = true + if host.Address == "" || strings.HasPrefix(host.Address, "-") || strings.ContainsAny(host.Address, " \t\n\r/@") { + return fmt.Errorf("%s: address %q must be an IP address or hostname", scope, host.Address) + } + if host.Port < 0 || host.Port > 65535 { + return fmt.Errorf("%s: port must be between 1 and 65535", scope) + } + if strings.HasPrefix(host.User, "-") || strings.ContainsAny(host.User, " \t\n\r@") { + return fmt.Errorf("%s: invalid user %q", scope, host.User) + } + if err := host.SSH.Validate(scope + ".ssh"); err != nil { + return err + } + } + return nil +} + +// StaticHost returns the static host with the given name, or nil. +func (c *Config) StaticHost(name string) *StaticHost { + for i := range c.Hosts { + if strings.EqualFold(c.Hosts[i].Name, name) { + return &c.Hosts[i] + } + } + return nil +} diff --git a/internal/config/instances.go b/internal/config/instances.go index 19f8b0c..46691b3 100644 --- a/internal/config/instances.go +++ b/internal/config/instances.go @@ -40,6 +40,9 @@ func (c *Config) ValidateNetBoxes() error { func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 } +// HasHosts reports whether netssh has anything to connect to: NetBox or static hosts. +func (c *Config) HasHosts() bool { return c.HasNetBox() || len(c.Hosts) > 0 } + // CachePath isolates named sources from legacy caches and from changed endpoints. // Rotating a token or reordering the sources does not discard usage history. func (c *Config) CachePath() string { diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 2cbc49f..db86082 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -169,7 +169,11 @@ func (o SSHOptions) Validate(scope string) error { // SSHOptionsFor merges global, instance, and matching rule options for host. func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions { merged := c.SSH.SSHOptions.merge(SSHOptions{}) - if host.Instance == "" { + if host.Kind == StaticKind { + if static := c.StaticHost(host.Name); static != nil { + merged = merged.merge(static.SSH) + } + } else if host.Instance == "" { merged = merged.merge(c.NetBox.SSH) } for _, source := range c.NetBoxes { diff --git a/internal/config/ssh_test.go b/internal/config/ssh_test.go index 4e8cf25..79743da 100644 --- a/internal/config/ssh_test.go +++ b/internal/config/ssh_test.go @@ -135,3 +135,58 @@ func TestForwardsValidation(t *testing.T) { } } } + +func TestStaticHosts(t *testing.T) { + cfg, err := loadYAML(t, ` +resolver: + hostname_domain: example.com +ssh: + proxy_jump: jh + rules: + - match: {kind: static, tag: home} + host_key_checking: off +hosts: + - name: Router + address: 192.168.1.1 + user: admin + port: 2222 + tags: [home] + ssh: + proxy_jump: none +`) + if err != nil { + t.Fatal(err) + } + if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" { + t.Fatalf("got %+v", cfg) + } + host := cfg.StaticHost("router") + if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 { + t.Fatalf("host: %+v", host) + } + opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}}) + if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" { + t.Fatalf("static host options: %+v", opts) + } + // A NetBox host of the same name does not get the static host's options. + if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" { + t.Fatalf("netbox host: %+v", got) + } +} + +func TestStaticHostsValidation(t *testing.T) { + for _, hosts := range []string{ + "- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}", + "- {name: 'bad name', address: 10.0.0.1}", + "- {name: prod/a, address: 10.0.0.1}", + "- {name: a}", + "- {name: a, address: '-oProxyCommand=x'}", + "- {name: a, address: 10.0.0.1, port: 70000}", + "- {name: a, address: 10.0.0.1, user: 'a b'}", + "- {name: a, address: 10.0.0.1, ssh: {target: nope}}", + } { + if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") { + t.Errorf("%s: got %v", hosts, err) + } + } +} diff --git a/internal/resolver/chain.go b/internal/resolver/chain.go index bc693e9..77309d3 100644 --- a/internal/resolver/chain.go +++ b/internal/resolver/chain.go @@ -46,6 +46,8 @@ func newStrategy(name string, cfg config.ResolverConfig) (Strategy, error) { return nil, err } return s, nil + case "hostname": + return &HostnameStrategy{domain: cfg.HostnameDomain}, nil case "interface_name": if cfg.InterfaceName == "" { return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set") diff --git a/internal/resolver/hostname.go b/internal/resolver/hostname.go new file mode 100644 index 0000000..237332e --- /dev/null +++ b/internal/resolver/hostname.go @@ -0,0 +1,31 @@ +package resolver + +import ( + "context" + "regexp" + "strings" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" +) + +var validHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`) + +// HostnameStrategy connects by the NetBox name instead of an IP, so DNS or +// ~/.ssh/config resolves it. Names without a dot get the optional domain. +// Names that are no valid hostname fall through to the next strategy. +type HostnameStrategy struct { + domain string +} + +func (s *HostnameStrategy) Name() string { return "hostname" } + +func (s *HostnameStrategy) Resolve(_ context.Context, entry *netbox.HostEntry, _ *netbox.Client) (string, error) { + name := strings.ToLower(entry.Name) + if domain := strings.Trim(s.domain, "."); domain != "" && !strings.Contains(name, ".") { + name += "." + strings.ToLower(domain) + } + if !validHostname.MatchString(name) { + return "", ErrNoIP + } + return name, nil +} diff --git a/internal/resolver/hostname_test.go b/internal/resolver/hostname_test.go new file mode 100644 index 0000000..8ab583e --- /dev/null +++ b/internal/resolver/hostname_test.go @@ -0,0 +1,43 @@ +package resolver + +import ( + "context" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" +) + +func TestHostnameStrategy(t *testing.T) { + for _, tc := range []struct { + name, domain, want string + }{ + {"Web01.Example.com", "", "web01.example.com"}, + {"web01", "example.com", "web01.example.com"}, + {"web01", ".example.com.", "web01.example.com"}, + {"web01.lab", "example.com", "web01.lab"}, // already qualified + } { + got, err := (&HostnameStrategy{domain: tc.domain}).Resolve(context.Background(), &netbox.HostEntry{Name: tc.name}, nil) + if err != nil || got != tc.want { + t.Errorf("%q + %q: got %q, %v; want %q", tc.name, tc.domain, got, err, tc.want) + } + } + for _, name := range []string{"Core Switch 1", "-x", "a..b", "web_01"} { + if _, err := (&HostnameStrategy{}).Resolve(context.Background(), &netbox.HostEntry{Name: name}, nil); err != ErrNoIP { + t.Errorf("%q: got %v, want ErrNoIP", name, err) + } + } +} + +func TestHostnameAsFallback(t *testing.T) { + chain, err := New(config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"}) + if err != nil { + t.Fatal(err) + } + if ip, _ := chain.Resolve(context.Background(), &netbox.HostEntry{Name: "web01", PrimaryIP4: "192.0.2.1"}, nil); ip != "192.0.2.1" { + t.Fatalf("primary IP first: %q", ip) + } + if ip, _ := chain.Resolve(context.Background(), &netbox.HostEntry{Name: "web02"}, nil); ip != "web02.example.com" { + t.Fatalf("hostname fallback: %q", ip) + } +} diff --git a/internal/setup/hosts.go b/internal/setup/hosts.go new file mode 100644 index 0000000..815133e --- /dev/null +++ b/internal/setup/hosts.go @@ -0,0 +1,140 @@ +package setup + +import ( + "fmt" + "strconv" + "strings" + + "charm.land/huh/v2" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" +) + +// configureHosts edits a copy of the static hosts; the caller decides whether to keep it. +func configureHosts(hosts []config.StaticHost) ([]config.StaticHost, error) { + hosts = append([]config.StaticHost(nil), hosts...) + for { + action := "done" + var options []huh.Option[string] + for i, host := range hosts { + index := strconv.Itoa(i) + options = append(options, + huh.NewOption("Edit "+host.Name+" ("+host.Address+")", "edit:"+index), + huh.NewOption("SSH options for "+host.Name, "ssh:"+index), + huh.NewOption("Remove "+host.Name, "remove:"+index)) + } + options = append(options, huh.NewOption("Add host", "add"), huh.NewOption("Back", "done")) + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("Static hosts"). + Description("Hosts that are not in NetBox. They are found before NetBox and work offline."). + Options(options...). + Value(&action))).Run() + if err != nil { + return nil, err + } + if action == "done" { + return hosts, nil + } + if action == "add" { + host, err := editHost(config.StaticHost{}, hosts, -1) + if err != nil { + return nil, err + } + hosts = append(hosts, host) + continue + } + verb, index, _ := strings.Cut(action, ":") + i, _ := strconv.Atoi(index) + switch verb { + case "edit": + host, err := editHost(hosts[i], hosts, i) + if err != nil { + return nil, err + } + hosts[i] = host + case "ssh": + options, err := editSSHOptions("SSH options for "+hosts[i].Name, hosts[i].SSH) + if err != nil { + return nil, err + } + hosts[i].SSH = options + case "remove": + remove := false + if err := huh.NewConfirm().Title("Remove " + hosts[i].Name + "?").Value(&remove).Run(); err != nil { + return nil, err + } + if remove { + hosts = append(hosts[:i], hosts[i+1:]...) + } + } + } +} + +func editHost(host config.StaticHost, hosts []config.StaticHost, index int) (config.StaticHost, error) { + name, address, user := host.Name, host.Address, host.User + port := "" + if host.Port > 0 { + port = strconv.Itoa(host.Port) + } + tags := strings.Join(host.Tags, ", ") + candidate := func() config.StaticHost { + out := host + out.Name = strings.TrimSpace(name) + out.Address = strings.TrimSpace(address) + out.User = strings.TrimSpace(user) + out.Port, _ = strconv.Atoi(strings.TrimSpace(port)) + out.Tags = splitList(tags) + return out + } + // huh validates the typed value before storing it, so each validator + // checks the host with its own field taken from the argument. + validate := func(set func(*config.StaticHost, string)) func(string) error { + return func(value string) error { + h := candidate() + set(&h, strings.TrimSpace(value)) + return validateHostIn(h, hosts, index) + } + } + + err := huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Name").Description("What you type: netssh NAME. Letters, digits, dots, hyphens.").Value(&name). + Validate(validate(func(h *config.StaticHost, v string) { h.Name = v })), + huh.NewInput().Title("Address").Description("IP address or DNS name to connect to.").Value(&address). + Validate(validate(func(h *config.StaticHost, v string) { + h.Address = v + if v == "" { + h.Address = "-" // required: reported as invalid address + } + })), + huh.NewInput().Title("User").Description("Leave empty for ssh.default_user.").Value(&user). + Validate(validate(func(h *config.StaticHost, v string) { h.User = v })), + huh.NewInput().Title("Port").Description("Leave empty for ssh.default_port or 22.").Value(&port).Validate(func(s string) error { + if s = strings.TrimSpace(s); s == "" { + return nil + } + if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 65535 { + return fmt.Errorf("must be a port number between 1 and 65535") + } + return nil + }), + huh.NewInput().Title("Tags").Description("Comma-separated, for ssh.rules match: {tag: …}.").Value(&tags), + ).Title("Static host")).Run() + if err != nil { + return host, err + } + return candidate(), nil +} + +// validateHostIn checks host as entry index of hosts (-1 for a new host). +func validateHostIn(host config.StaticHost, hosts []config.StaticHost, index int) error { + others := make([]config.StaticHost, 0, len(hosts)+1) + for i, other := range hosts { + if i != index { + others = append(others, other) + } + } + if host.Address == "" { + host.Address = "placeholder" // reported once the address field is filled + } + return (&config.Config{Hosts: append(others, host)}).ValidateHosts() +} diff --git a/internal/setup/instances.go b/internal/setup/instances.go index 2540506..bc46962 100644 --- a/internal/setup/instances.go +++ b/internal/setup/instances.go @@ -20,6 +20,18 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) { } for { if len(sources) == 0 { + choice := "add" + err := huh.NewForm(huh.NewGroup(huh.NewSelect[string](). + Title("NetBox"). + Description("netssh looks up hosts in NetBox. Without NetBox, only static hosts (configured next) are available."). + Options(huh.NewOption("Add a NetBox instance", "add"), huh.NewOption("Continue without NetBox (static hosts only)", "none")). + Value(&choice))).Run() + if err != nil { + return nil, err + } + if choice == "none" { + return nil, nil + } source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "") if err != nil { return nil, err diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 5293376..7a8f254 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -36,7 +36,7 @@ func RunWizard(cfg *config.Config) error { if err != nil { return err } - c := cache.New(loaded.CachePath(), loaded.Cache.TTL) + c := cache.New(loaded.CachePath(), loaded.Cache.TTL).WithStatic(loaded.Hosts) if err := c.Load(); err != nil { return fmt.Errorf("updating aliases: %w", err) } @@ -90,6 +90,7 @@ func runMenu(cfg *config.Config) error { 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(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"), huh.NewOption("Resolver & cache", "resolver"), huh.NewOption("Shortcuts & command alias", "shortcuts"), huh.NewOption("Completion", "completion"), @@ -109,6 +110,11 @@ func runMenu(cfg *config.Config) error { if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil { cfg.SSH.Rules = rules } + case "hosts": + var hosts []config.StaticHost + if hosts, err = configureHosts(cfg.Hosts); err == nil { + cfg.Hosts = hosts + } case "resolver": err = editResolverCache(cfg) case "shortcuts": @@ -163,12 +169,13 @@ func editResolverCache(cfg *config.Config) error { strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") interfaceName := cfg.Resolver.InterfaceName + hostnameDomain := cfg.Resolver.HostnameDomain 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"). + Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name, hostname"). Placeholder("primary_ip, management_subnet"). Value(&strategiesRaw). Validate(validateStrategies), @@ -181,6 +188,10 @@ func editResolverCache(cfg *config.Config) error { Description("Only used when interface_name strategy is active."). Placeholder("eth0"). Value(&interfaceName), + huh.NewInput(). + Title("Hostname domain"). + Description("Only used by the hostname strategy: appended to NetBox names without a dot,\ne.g. example.com turns web01 into web01.example.com."). + Value(&hostnameDomain), huh.NewInput(). Title("Cache TTL (seconds)"). Value(&cacheTTL). @@ -201,6 +212,7 @@ func editResolverCache(cfg *config.Config) error { cfg.Resolver.Strategies = parseStrategies(strategiesRaw) cfg.Resolver.ManagementSubnets = splitList(subnets) cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName) + cfg.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".") cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL) cfg.Cache.Path = strings.TrimSpace(cachePath) return nil @@ -321,6 +333,7 @@ var knownStrategies = map[string]bool{ "primary_ip": true, "management_subnet": true, "interface_name": true, + "hostname": true, } func validateStrategies(s string) error { @@ -329,7 +342,7 @@ func validateStrategies(s string) error { } for _, name := range parseStrategies(s) { if !knownStrategies[name] { - return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name) + return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name, hostname", name) } } return nil @@ -337,6 +350,9 @@ func validateStrategies(s string) error { func parseStrategies(s string) []string { return splitList(s) } +// Save validates cfg and writes it to the config file, replacing the old file. +func Save(cfg config.Config) error { return save(cfg) } + func save(cfg config.Config) error { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { return err @@ -350,6 +366,9 @@ func save(cfg config.Config) error { if err := cfg.Shortcuts.Validate(); err != nil { return err } + if err := cfg.ValidateHosts(); 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) @@ -365,7 +384,7 @@ func save(cfg config.Config) error { writeSSHOptions(&b, " ", source.SSH) } } - } else { + } else if cfg.NetBox.URL != "" || len(cfg.Hosts) == 0 { b.WriteString("netbox:\n") fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) if !sshOptionsEmpty(cfg.NetBox.SSH) { @@ -388,6 +407,9 @@ func save(cfg config.Config) error { if cfg.Resolver.InterfaceName != "" { fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName) } + if cfg.Resolver.HostnameDomain != "" { + fmt.Fprintf(&b, " hostname_domain: %q\n", cfg.Resolver.HostnameDomain) + } b.WriteString("\ncache:\n") fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL) @@ -416,6 +438,29 @@ func save(cfg config.Config) error { } } + if len(cfg.Hosts) > 0 { + b.WriteString("\nhosts:\n") + for _, host := range cfg.Hosts { + fmt.Fprintf(&b, " - name: %q\n address: %q\n", host.Name, host.Address) + if host.User != "" { + fmt.Fprintf(&b, " user: %q\n", host.User) + } + if host.Port > 0 { + fmt.Fprintf(&b, " port: %d\n", host.Port) + } + if len(host.Tags) > 0 { + b.WriteString(" tags:\n") + for _, tag := range host.Tags { + fmt.Fprintf(&b, " - %q\n", tag) + } + } + if !sshOptionsEmpty(host.SSH) { + b.WriteString(" ssh:\n") + writeSSHOptions(&b, " ", host.SSH) + } + } + } + if cfg.Completion.MaxResults > 0 { fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) } @@ -443,7 +488,7 @@ func save(cfg config.Config) error { fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias) } - if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil { + if err := storage.WriteFile(path, []byte(strings.TrimLeft(b.String(), "\n")), 0o600); err != nil { return fmt.Errorf("writing config: %w", err) } diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index d968c8c..da1ecde 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -295,6 +295,12 @@ func TestParseStrategies_PreservesOrder(t *testing.T) { } } +func TestValidateStrategiesHostname(t *testing.T) { + if err := validateStrategies("primary_ip, hostname"); err != nil { + t.Fatal(err) + } +} + func TestValidateStrategies_Valid(t *testing.T) { cases := []string{ "primary_ip", @@ -568,3 +574,46 @@ func TestSaveForwardsRoundtrip(t *testing.T) { t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules) } } + +func TestSaveStaticHostsRoundtrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + hosts := []config.StaticHost{ + {Name: "router", Address: "192.168.1.1", User: "admin", Port: 2222, Tags: []string{"home"}, SSH: config.SSHOptions{ProxyJump: "none"}}, + {Name: "nas", Address: "nas.example.org"}, + } + cfg := config.Config{ + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"}, + Hosts: hosts, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(config.Path()) + if strings.Contains(string(data), "netbox:") { + t.Fatalf("static-only config got an empty netbox section:\n%s", data) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(loaded.Hosts, hosts) || loaded.Resolver.HostnameDomain != "example.com" || loaded.HasNetBox() { + t.Fatalf("got %+v / %+v", loaded.Hosts, loaded.Resolver) + } +} + +func TestValidateHostIn(t *testing.T) { + hosts := []config.StaticHost{{Name: "router", Address: "10.0.0.1"}, {Name: "nas", Address: "10.0.0.2"}} + if err := validateHostIn(config.StaticHost{Name: "Router", Address: "10.0.0.9"}, hosts, -1); err == nil { + t.Fatal("duplicate name accepted") + } + if err := validateHostIn(config.StaticHost{Name: "router", Address: "10.0.0.9"}, hosts, 0); err != nil { + t.Fatalf("editing a host in place rejected: %v", err) + } + if err := validateHostIn(config.StaticHost{Name: "new"}, hosts, -1); err != nil { + t.Fatalf("empty address while typing rejected: %v", err) + } + if err := validateHostIn(config.StaticHost{Name: "new", Address: "a b"}, hosts, -1); err == nil { + t.Fatal("invalid address accepted") + } +} -- 2.54.0