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 <noreply@anthropic.com>
This commit is contained in:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-13 23:46:13 +01:00
1 parent 5821dfbebd
commit a950a82552
22 files changed
+1024 -49

No files matched your search

+68 -13
View File
@@ -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 `<Tab>`; `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 <Tab>`) 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. |
| `<Tab>` 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`.