Compare commits

...
Author SHA1 Message Date
Sebastian UnterschützandClaude Opus 5.5 dbb99bdded test: add a local Docker test environment for all features
testenv/ starts two real NetBox 4.7 instances (v2 and v1 token), SSH
targets on public, mgmt, and prod networks, a jump host, an IPv6-only
host, a static host, and a client with ssh, sftp, scp, tmux, zellij,
bash, zsh, and fish. A seed script fills NetBox through the REST API.

141 end-to-end checks cover cache and refresh filters, connecting,
all resolver strategies, multiple instances, shortcuts and rewrites,
SSH options and rules, static hosts, sftp/scp incl. IPv6 and jump
hosts, tunnels, local notes and NetBox comments, completion and shell
hooks, the TUI (driven through tmux), the configurator, and uninstall.

Local only: testenv/up.sh, test.sh, shell.sh, down.sh. Not part of
CI/CD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:30:24 +01:00
Sebastian UnterschützandClaude Opus 5.5 55e7a5106b fix: interface_name strategy, instance hosts in sftp/scp, alias rewrites
Found by the new Docker test environment against a real NetBox 4.7:

- The interface_name strategy filtered IP addresses with interface_name
  and vminterface_name, which NetBox ignores, so it returned the first
  address of the host instead of the named interface's. Use the real
  filters interface and vminterface; a missing interface (HTTP 400)
  falls through to the next strategy.
- sftp and scp took instance-qualified hosts (prod/web01:/path) for
  local paths, because a "/" comes before the ":". Such arguments are
  remote when the prefix is a configured NetBox instance.
- Shell aliases of instance-qualified hosts ran the shortcut rules on
  "instance/host", so anchored rewrite rules never matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 12:30:24 +01:00
Sebi 3c9e54b254 Merge pull request 'Hostname-Strategie und statische Hosts' (#35) from feat/hostname-static-hosts into main
Release / release (push) Successful in 4m31s
2026-11-13 22:49:14 +00:00
Sebastian UnterschützandClaude Opus 5.5 a950a82552 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>
2026-11-13 23:46:13 +01:00
Sebi 5821dfbebd Merge pull request 'docs: README ausführlicher' (#34) from docs/readme-details into main 2026-11-13 22:31:12 +00:00
63 changed files with 2383 additions and 60 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.git
testenv/.keys
testenv/.state
+4
View File
@@ -19,3 +19,7 @@ dist/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# local test environment (testenv/up.sh)
testenv/.keys/
testenv/.state/
+9
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## Unreleased
- Fix the `interface_name` resolver strategy: it sent filter names that NetBox ignores and therefore returned the host's first IP address instead of the named interface's. It now uses NetBox's `interface` and `vminterface` filters; a missing interface falls through to the next strategy.
- Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths.
- Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`).
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 141 end-to-end checks of all features (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
## v0.0.23 ## 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. - `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.
@@ -10,6 +17,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 `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. - 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. - 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. - Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination.
## v0.0.22 ## v0.0.22
+76 -13
View File
@@ -21,7 +21,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int
- [Installation](#installation) - [Installation](#installation)
- [Quick start](#quick-start) - [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) - [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) - [Recipes](#recipes)
- [IP Resolution Strategies](#ip-resolution-strategies) - [IP Resolution Strategies](#ip-resolution-strategies)
- [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook) - [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 ## Features
- **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options - **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) - **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 - **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` - **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 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**: A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**:
| Section | Settings | | 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 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 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 | | 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` | | Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
| Completion | `completion.max_results` | | 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.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.management_subnets` | list of CIDR | `[]` | Used by `management_subnet`. |
| `resolver.interface_name` | string | — | Used by `interface_name`, e.g. `mgmt0`. | | `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.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. | | `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). | | `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). | | `shortcuts.rewrites[]` | list | `[]` | `pattern` (Go regex) and `replace` — see [Host shortcuts](#host-shortcuts). |
| `completion.max_results` | int | `0` | Hostnames offered per `<Tab>`; `0` = unlimited. | | `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). | | `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 ### 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 | | Variable | Key |
|---|---| |---|---|
| `NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, `NETSSH_NETBOX_TOKEN_VERSION` | `netbox.*` (single instance only; ignored when the file has only `netboxes:`) | | `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_CACHE_TTL`, `NETSSH_CACHE_PATH` | `cache.*` |
| `NETSSH_SSH_DEFAULT_USER`, `NETSSH_SSH_DEFAULT_PORT` | `ssh.default_*` | | `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 | | `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 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 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 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 configure` | Setup wizard. |
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. | | `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. | | `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`). | | `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:`. | | `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-??`). | | `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. 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; - 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; - `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. 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 ### 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. | | `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. | | `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`). | | `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 ## 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 | | 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 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`. | | `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`. | | `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`. | | 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. | | 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*`. | | `<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. | | 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 ## Uninstall
@@ -1000,6 +1055,14 @@ go test ./... # includes offline installer/update tests
go build ./... # build all packages go build ./... # build all packages
``` ```
End-to-end tests run against real services in a local Docker environment — two NetBox instances, SSH targets on separate networks, a jump host, an IPv6 host, tmux, zellij, and bash/zsh/fish. See [testenv/README.md](testenv/README.md). It is not part of CI/CD.
```sh
testenv/up.sh # build and start (first start ~6 min)
testenv/test.sh # 141 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator
testenv/down.sh
```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, the argument parsers for ssh, sftp, and scp, SSH options and rules, port forwards, config loading, the setup wizard, shortcut normalization and rewrites, completion, the TUI keys, and shell hook install/uninstall. Wrapper tests run the real command (`sftp`, `scp`, `tunnel` with `--dry-run`) in a subprocess against a pre-filled cache, so no NetBox or ssh server is needed. The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, the argument parsers for ssh, sftp, and scp, SSH options and rules, port forwards, config loading, the setup wizard, shortcut normalization and rewrites, completion, the TUI keys, and shell hook install/uninstall. Wrapper tests run the real command (`sftp`, `scp`, `tunnel` with `--dry-run`) in a subprocess against a pre-filled cache, so no NetBox or ssh server is needed.
Project layout: Project layout:
@@ -1025,9 +1088,9 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
## How it works ## 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. 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. 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. 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 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. 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`. 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`. 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`.
+142
View File
@@ -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
}
+130
View File
@@ -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)
}
}
}
+5
View File
@@ -10,6 +10,11 @@ import (
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts" "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 { func newNetBoxClient(cfg *config.Config) *netbox.Client {
if len(cfg.NetBoxes) > 0 { if len(cfg.NetBoxes) > 0 {
sources := make([]netbox.Source, 0, len(cfg.NetBoxes)) sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
+69 -23
View File
@@ -45,6 +45,7 @@ var managedSubcommands = map[string]bool{
"sftp": true, "sftp": true,
"scp": true, "scp": true,
"tunnel": true, "tunnel": true,
"hosts": true,
} }
func main() { func main() {
@@ -75,7 +76,7 @@ func loadConfigOrSetup() *config.Config {
if err != nil { if err != nil {
fatalf("config: %v", err) fatalf("config: %v", err)
} }
if !cfg.HasNetBox() { if !cfg.HasHosts() {
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…") fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
if err := setup.RunWizard(cfg); err != nil { if err := setup.RunWizard(cfg); err != nil {
fatalf("setup: %v", err) fatalf("setup: %v", err)
@@ -129,6 +130,7 @@ func findDestination(client internalssh.Client, args []string) (*destination, er
// runClient resolves the NetBox host in args and execs ssh, sftp, or scp. // runClient resolves the NetBox host in args and execs ssh, sftp, or scp.
func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) { func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) {
cfg := loadConfigOrSetup() cfg := loadConfigOrSetup()
client = client.WithInstances(instanceNames(cfg))
dest, err := findDestination(client, args) dest, err := findDestination(client, args)
if err != nil { if err != nil {
@@ -143,16 +145,44 @@ func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args [
return return
} }
// Inject the configured default user if none was given on the command line. host := resolveTarget(cfg, dest.host)
if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) { user, port := hostDefaults(cfg, host)
args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests) // 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. // Inject the default port if none was given on the command line.
if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) { if port > 0 && !client.HasPortFlag(args) {
args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests) 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
}
// instanceNames lists the named NetBox instances, which may prefix hostnames.
func instanceNames(cfg *config.Config) []string {
names := make([]string, 0, len(cfg.NetBoxes))
for _, source := range cfg.NetBoxes {
names = append(names, source.Name)
}
return names
} }
// prependArgs puts extra before args and shifts the destination indices. // prependArgs puts extra before args and shifts the destination indices.
@@ -174,7 +204,7 @@ type resolvedHost struct {
// resolveTarget finds host in the cache (also by shortcut) or NetBox and // resolveTarget finds host in the cache (also by shortcut) or NetBox and
// resolves its address. It exits on failure. // resolves its address. It exits on failure.
func resolveTarget(cfg *config.Config, host string) resolvedHost { func resolveTarget(cfg *config.Config, host string) resolvedHost {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c) maybeBackgroundRefresh(cfg, c)
@@ -186,6 +216,21 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags} 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. // Cache hit with a fresh TTL — connect directly without querying NetBox.
if entry, fresh := c.Get(host); fresh && useCache { if entry, fresh := c.Get(host); fresh && useCache {
return used(entry) return used(entry)
@@ -211,7 +256,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
} }
if !cfg.HasNetBox() { 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) nbClient := newNetBoxClient(cfg)
@@ -316,7 +361,7 @@ func execClient(client internalssh.Client, result internalssh.Result, flags inte
func runTUI() { func runTUI() {
cfg := loadConfigOrSetup() cfg := loadConfigOrSetup()
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
maybeBackgroundRefresh(cfg, c) maybeBackgroundRefresh(cfg, c)
@@ -392,17 +437,18 @@ func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client,
} }
var args []string 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 user := host.User
if user == "" { if user == "" {
user = cfg.SSH.DefaultUser user = defaultUser
} }
if user != "" { if user != "" {
args = append(args, client.UserArgs(user)...) args = append(args, client.UserArgs(user)...)
} }
port := host.Port port := host.Port
if port == "" && cfg.SSH.DefaultPort > 0 { if port == "" && defaultPort > 0 {
port = strconv.Itoa(cfg.SSH.DefaultPort) port = strconv.Itoa(defaultPort)
} }
if port != "" { if port != "" {
args = append(args, client.PortArgs(port)...) args = append(args, client.PortArgs(port)...)
@@ -453,7 +499,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. // cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd() root.InitDefaultCompletionCmd()
@@ -575,7 +621,7 @@ func searchCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return err return err
} }
@@ -609,7 +655,7 @@ func cacheListCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return err return err
} }
@@ -635,7 +681,7 @@ func cacheClearCmd() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
c.Clear() c.Clear()
if err := c.Save(); err != nil { if err := c.Save(); err != nil {
return err return err
@@ -677,7 +723,7 @@ func cacheRefreshCmd() *cobra.Command {
return fmt.Errorf("NetBox: %w", err) return fmt.Errorf("NetBox: %w", err)
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
chain, err := resolver.New(cfg.Resolver) chain, err := resolver.New(cfg.Resolver)
@@ -762,7 +808,7 @@ Or use in a script:
if err != nil { if err != nil {
return err return err
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if err := c.Load(); err != nil { if err := c.Load(); err != nil {
return err return err
} }
@@ -838,7 +884,7 @@ func shellInitCmd() *cobra.Command {
if err != nil { if err != nil {
return nil return nil
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
// Regenerate alias files from the current cache every shell start // Regenerate alias files from the current cache every shell start
@@ -897,7 +943,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. // Generate the aliases file immediately so it is ready on the next shell start.
cfg, cfgErr := config.Load() cfg, cfgErr := config.Load()
if cfgErr == nil && cfg.HasNetBox() { if cfgErr == nil && cfg.HasNetBox() {
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
if _ = c.Load(); true { if _ = c.Load(); true {
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
if err := hook.WriteAliasFiles(entries); err != nil { if err := hook.WriteAliasFiles(entries); err != nil {
+1 -1
View File
@@ -38,7 +38,7 @@ func notesCmd() *cobra.Command {
client = newNetBoxClient(cfg) client = newNetBoxClient(cfg)
} }
host := netbox.HostEntry{Name: args[0], Kind: kind} 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 { if err := c.Load(); err != nil {
return err return err
} }
+2 -2
View File
@@ -133,7 +133,7 @@ func completeHosts(toComplete string) []string {
if err != nil { if err != nil {
return nil return nil
} }
c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c := newCache(cfg)
_ = c.Load() _ = c.Load()
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts) hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { 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. // cachedHost finds a host for completion by name or shortcut, without NetBox.
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) { 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 { if err := c.Load(); err != nil {
return cache.Entry{}, false return cache.Entry{}, false
} }
+94 -3
View File
@@ -12,6 +12,7 @@ import (
"golang.org/x/sys/unix" "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" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
) )
@@ -22,6 +23,9 @@ type Entry struct {
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
CachedAt time.Time `json:"cached_at"` CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"` 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 type changeKind uint8
@@ -40,6 +44,7 @@ type Cache struct {
path string path string
ttl time.Duration ttl time.Duration
refreshStamp string // path to last_refresh timestamp file refreshStamp string // path to last_refresh timestamp file
static []config.StaticHost
} }
type diskFormat struct { type diskFormat struct {
@@ -98,6 +103,7 @@ func (c *Cache) Load() error {
data, err := os.ReadFile(c.path) data, err := os.ReadFile(c.path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
c.applyStatic()
return nil return nil
} }
if err != nil { if err != nil {
@@ -115,6 +121,7 @@ func (c *Cache) Load() error {
for _, e := range df.Entries { for _, e := range df.Entries {
c.entries[e.Name] = e c.entries[e.Name] = e
} }
c.applyStatic()
return nil return nil
} }
@@ -188,6 +195,15 @@ func (c *Cache) Save() error {
if err := storage.WriteFile(c.path, data, 0o600); err != nil { if err := storage.WriteFile(c.path, data, 0o600); err != nil {
return err 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.entries = merged
c.dirty = make(map[string]changeKind) c.dirty = make(map[string]changeKind)
c.cleared = false c.cleared = false
@@ -221,7 +237,10 @@ func (c *Cache) MarkUsed(name string) {
if e, ok := c.entries[name]; ok { if e, ok := c.entries[name]; ok {
e.LastUsed = time.Now() e.LastUsed = time.Now()
c.entries[name] = e 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 c.dirty[name] = usageChange
} }
} }
@@ -279,7 +298,7 @@ func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (e
entry, found = e, true 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. // 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 { if !ok {
return Entry{}, false return Entry{}, false
} }
if e.Static {
return e, true
}
if c.ttl == 0 { if c.ttl == 0 {
return e, false return e, false
} }
return e, time.Since(e.CachedAt) < c.ttl 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() { func (c *Cache) Clear() {
c.mu.Lock() 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.dirty = make(map[string]changeKind)
c.cleared = true c.cleared = true
c.mu.Unlock() 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 { func (c *Cache) All() []Entry {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
+62
View File
@@ -4,8 +4,11 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
) )
func TestNew(t *testing.T) { 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)
}
}
+7 -1
View File
@@ -19,6 +19,7 @@ type Config struct {
SSH SSHConfig `mapstructure:"ssh"` SSH SSHConfig `mapstructure:"ssh"`
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"` Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
Completion CompletionConfig `mapstructure:"completion"` Completion CompletionConfig `mapstructure:"completion"`
Hosts []StaticHost `mapstructure:"hosts"`
} }
type ShellConfig struct { type ShellConfig struct {
@@ -37,6 +38,8 @@ type ResolverConfig struct {
Strategies []string `mapstructure:"strategies"` Strategies []string `mapstructure:"strategies"`
ManagementSubnets []string `mapstructure:"management_subnets"` ManagementSubnets []string `mapstructure:"management_subnets"`
InterfaceName string `mapstructure:"interface_name"` 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 { type CacheConfig struct {
@@ -114,7 +117,7 @@ func Load() (*Config, error) {
v.SetEnvPrefix("NETSSH") v.SetEnvPrefix("NETSSH")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv() 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 { if err := v.BindEnv(key); err != nil {
return nil, err return nil, err
} }
@@ -157,6 +160,9 @@ func Load() (*Config, error) {
if err := cfg.Shortcuts.Validate(); err != nil { if err := cfg.Shortcuts.Validate(); err != nil {
return nil, err return nil, err
} }
if err := cfg.ValidateHosts(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes { for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i] source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name) source.Name = strings.ToLower(source.Name)
+62
View File
@@ -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
}
+3
View File
@@ -40,6 +40,9 @@ func (c *Config) ValidateNetBoxes() error {
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 } 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. // CachePath isolates named sources from legacy caches and from changed endpoints.
// Rotating a token or reordering the sources does not discard usage history. // Rotating a token or reordering the sources does not discard usage history.
func (c *Config) CachePath() string { func (c *Config) CachePath() string {
+5 -1
View File
@@ -169,7 +169,11 @@ func (o SSHOptions) Validate(scope string) error {
// SSHOptionsFor merges global, instance, and matching rule options for host. // SSHOptionsFor merges global, instance, and matching rule options for host.
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions { func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
merged := c.SSH.SSHOptions.merge(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) merged = merged.merge(c.NetBox.SSH)
} }
for _, source := range c.NetBoxes { for _, source := range c.NetBoxes {
+55
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -235,7 +235,7 @@ func TestGetIPsWithFilter(t *testing.T) {
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "token", 0) c := NewClient(srv.URL, "token", 0)
ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0") ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface=mgmt0")
if err != nil { if err != nil {
t.Fatalf("GetIPsWithFilter: %v", err) t.Fatalf("GetIPsWithFilter: %v", err)
} }
+1 -1
View File
@@ -78,7 +78,7 @@ func TestMultiSourceSearchAndRouting(t *testing.T) {
if err != nil || len(ips) != 1 || ips[0] != tc.ip { if err != nil || len(ips) != 1 || ips[0] != tc.ip {
t.Fatalf("wrong IP source: %v, %v", ips, err) t.Fatalf("wrong IP source: %v, %v", ips, err)
} }
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0") ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface=mgmt0")
if err != nil || len(ips) != 1 || ips[0] != tc.ip { if err != nil || len(ips) != 1 || ips[0] != tc.ip {
t.Fatalf("wrong interface source: %v, %v", ips, err) t.Fatalf("wrong interface source: %v, %v", ips, err)
} }
+2
View File
@@ -46,6 +46,8 @@ func newStrategy(name string, cfg config.ResolverConfig) (Strategy, error) {
return nil, err return nil, err
} }
return s, nil return s, nil
case "hostname":
return &HostnameStrategy{domain: cfg.HostnameDomain}, nil
case "interface_name": case "interface_name":
if cfg.InterfaceName == "" { if cfg.InterfaceName == "" {
return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set") return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set")
+31
View File
@@ -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
}
+43
View File
@@ -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)
}
}
+6 -3
View File
@@ -16,13 +16,16 @@ type InterfaceNameStrategy struct {
func (s *InterfaceNameStrategy) Name() string { return "interface_name" } func (s *InterfaceNameStrategy) Name() string { return "interface_name" }
func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) { func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) {
// Build filter parameters for IP addresses attached to the named interface. // NetBox filters IP addresses by interface name with interface= (devices)
// and vminterface= (VMs); unknown parameters would be ignored silently and
// return every address of the host. A missing interface is an HTTP 400,
// which lets the chain try the next strategy.
var filterParam string var filterParam string
switch entry.Kind { switch entry.Kind {
case "device": case "device":
filterParam = fmt.Sprintf("device_id=%d&interface_name=%s", entry.ID, url.QueryEscape(s.name)) filterParam = fmt.Sprintf("device_id=%d&interface=%s", entry.ID, url.QueryEscape(s.name))
case "vm": case "vm":
filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface_name=%s", entry.ID, url.QueryEscape(s.name)) filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface=%s", entry.ID, url.QueryEscape(s.name))
default: default:
return "", fmt.Errorf("unknown kind %q", entry.Kind) return "", fmt.Errorf("unknown kind %q", entry.Kind)
} }
+56
View File
@@ -0,0 +1,56 @@
package resolver
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
)
// NetBox ignores unknown filters, so the strategy must use the real filter
// names; otherwise it silently returns the host's first address.
func TestInterfaceNameStrategyFilters(t *testing.T) {
for kind, want := range map[string]string{
"device": "device_id=7&interface=mgmt0",
"vm": "virtual_machine_id=7&vminterface=mgmt0",
} {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
q.Del("limit")
q.Del("offset")
got = q.Encode()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"count":1,"results":[{"address":"10.0.0.7/24"}]}`))
}))
ip, err := (&InterfaceNameStrategy{name: "mgmt0"}).Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: kind}, netbox.NewClient(srv.URL, "t", 1))
srv.Close()
if err != nil || ip != "10.0.0.7" {
t.Fatalf("%s: %q %v", kind, ip, err)
}
if parsed, _ := urlQuery(want); got != parsed {
t.Errorf("%s: query %q, want %q", kind, got, parsed)
}
}
}
func TestInterfaceNameStrategyMissingInterfaceFallsThrough(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"vminterface":["Select a valid choice."]}`, http.StatusBadRequest)
}))
defer srv.Close()
client := netbox.NewClient(srv.URL, "t", 1)
chain := &Chain{strategies: []Strategy{&InterfaceNameStrategy{name: "mgmt0"}, &PrimaryIPStrategy{}}}
ip, err := chain.Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: "vm", PrimaryIP4: "10.0.0.9"}, client)
if err != nil || ip != "10.0.0.9" {
t.Fatalf("got %q %v", ip, err)
}
}
func urlQuery(s string) (string, error) {
q, err := url.ParseQuery(s)
return q.Encode(), err
}
+140
View File
@@ -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()
}
+12
View File
@@ -20,6 +20,18 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
} }
for { for {
if len(sources) == 0 { 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, "") source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
if err != nil { if err != nil {
return nil, err return nil, err
+50 -5
View File
@@ -36,7 +36,7 @@ func RunWizard(cfg *config.Config) error {
if err != nil { if err != nil {
return err 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 { if err := c.Load(); err != nil {
return fmt.Errorf("updating aliases: %w", err) 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 defaults — user, port", "defaults"),
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"), 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("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("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"), huh.NewOption("Shortcuts & command alias", "shortcuts"),
huh.NewOption("Completion", "completion"), 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 { if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil {
cfg.SSH.Rules = rules cfg.SSH.Rules = rules
} }
case "hosts":
var hosts []config.StaticHost
if hosts, err = configureHosts(cfg.Hosts); err == nil {
cfg.Hosts = hosts
}
case "resolver": case "resolver":
err = editResolverCache(cfg) err = editResolverCache(cfg)
case "shortcuts": case "shortcuts":
@@ -163,12 +169,13 @@ func editResolverCache(cfg *config.Config) error {
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ") strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ") subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
interfaceName := cfg.Resolver.InterfaceName interfaceName := cfg.Resolver.InterfaceName
hostnameDomain := cfg.Resolver.HostnameDomain
cacheTTL := strconv.Itoa(cfg.Cache.TTL) cacheTTL := strconv.Itoa(cfg.Cache.TTL)
cachePath := cfg.Cache.Path cachePath := cfg.Cache.Path
err := huh.NewForm(huh.NewGroup( err := huh.NewForm(huh.NewGroup(
huh.NewInput(). huh.NewInput().
Title("Resolver strategies"). 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"). Placeholder("primary_ip, management_subnet").
Value(&strategiesRaw). Value(&strategiesRaw).
Validate(validateStrategies), Validate(validateStrategies),
@@ -181,6 +188,10 @@ func editResolverCache(cfg *config.Config) error {
Description("Only used when interface_name strategy is active."). Description("Only used when interface_name strategy is active.").
Placeholder("eth0"). Placeholder("eth0").
Value(&interfaceName), 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(). huh.NewInput().
Title("Cache TTL (seconds)"). Title("Cache TTL (seconds)").
Value(&cacheTTL). Value(&cacheTTL).
@@ -201,6 +212,7 @@ func editResolverCache(cfg *config.Config) error {
cfg.Resolver.Strategies = parseStrategies(strategiesRaw) cfg.Resolver.Strategies = parseStrategies(strategiesRaw)
cfg.Resolver.ManagementSubnets = splitList(subnets) cfg.Resolver.ManagementSubnets = splitList(subnets)
cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName) cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName)
cfg.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".")
cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL) cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL)
cfg.Cache.Path = strings.TrimSpace(cachePath) cfg.Cache.Path = strings.TrimSpace(cachePath)
return nil return nil
@@ -321,6 +333,7 @@ var knownStrategies = map[string]bool{
"primary_ip": true, "primary_ip": true,
"management_subnet": true, "management_subnet": true,
"interface_name": true, "interface_name": true,
"hostname": true,
} }
func validateStrategies(s string) error { func validateStrategies(s string) error {
@@ -329,7 +342,7 @@ func validateStrategies(s string) error {
} }
for _, name := range parseStrategies(s) { for _, name := range parseStrategies(s) {
if !knownStrategies[name] { 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 return nil
@@ -337,6 +350,9 @@ func validateStrategies(s string) error {
func parseStrategies(s string) []string { return splitList(s) } 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 { func save(cfg config.Config) error {
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
return err return err
@@ -350,6 +366,9 @@ func save(cfg config.Config) error {
if err := cfg.Shortcuts.Validate(); err != nil { if err := cfg.Shortcuts.Validate(); err != nil {
return err return err
} }
if err := cfg.ValidateHosts(); err != nil {
return err
}
path := config.Path() path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err) return fmt.Errorf("creating config dir: %w", err)
@@ -365,7 +384,7 @@ func save(cfg config.Config) error {
writeSSHOptions(&b, " ", source.SSH) writeSSHOptions(&b, " ", source.SSH)
} }
} }
} else { } else if cfg.NetBox.URL != "" || len(cfg.Hosts) == 0 {
b.WriteString("netbox:\n") 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) 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) { if !sshOptionsEmpty(cfg.NetBox.SSH) {
@@ -388,6 +407,9 @@ func save(cfg config.Config) error {
if cfg.Resolver.InterfaceName != "" { if cfg.Resolver.InterfaceName != "" {
fmt.Fprintf(&b, " interface_name: %q\n", 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") b.WriteString("\ncache:\n")
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL) 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 { if cfg.Completion.MaxResults > 0 {
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults) 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) 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) return fmt.Errorf("writing config: %w", err)
} }
+49
View File
@@ -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) { func TestValidateStrategies_Valid(t *testing.T) {
cases := []string{ cases := []string{
"primary_ip", "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) 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")
}
}
+6 -1
View File
@@ -59,6 +59,11 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
// It normalizes the name (strips configured domains, applies rewrites, optionally strips 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. // replaces any remaining dots with underscores so the result is a valid identifier.
func AliasName(name string, cfg config.ShortcutsConfig) string { func AliasName(name string, cfg config.ShortcutsConfig) string {
s := Normalize(name, cfg) // Shorten only the hostname: anchored rewrite rules must not see "instance/".
prefix := ""
if source, host, ok := strings.Cut(name, "/"); ok {
prefix, name = strings.ToLower(source)+"_", host
}
s := prefix + Normalize(name, cfg)
return strings.NewReplacer(".", "_", "/", "_").Replace(s) return strings.NewReplacer(".", "_", "/", "_").Replace(s)
} }
+10
View File
@@ -115,3 +115,13 @@ func TestNormalizeRewritesOrder(t *testing.T) {
t.Fatalf("got %q", got) t.Fatalf("got %q", got)
} }
} }
func TestAliasNameRewritesInstanceQualifiedHosts(t *testing.T) {
cfg := config.ShortcutsConfig{
Domains: []string{".hoster.test"},
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
}
if got := shortcuts.AliasName("fsn/fsn1-pve1.hoster.test", cfg); got != "fsn_fsnpve1" {
t.Fatalf("got %q", got)
}
}
+2 -2
View File
@@ -59,7 +59,7 @@ func (c Client) Parse(args []string) *ParsedArgs {
func (c Client) parsed(args []string, i int) *ParsedArgs { func (c Client) parsed(args []string, i int) *ParsedArgs {
dest, path := args[i], "" dest, path := args[i], ""
if c.paths { if c.paths {
if d, p, remote := splitRemote(dest); remote { if d, p, remote := c.splitRemote(dest); remote {
dest, path = d, p dest, path = d, p
} }
} }
@@ -88,7 +88,7 @@ func (c Client) replaceHost(args []string, dests []int, newHost string) []string
prefix, original = original[:at+1], original[at+1:] prefix, original = original[:at+1], original[at+1:]
} }
if c.paths { if c.paths {
if _, path, remote := splitRemote(original); remote { if _, path, remote := c.splitRemote(original); remote {
suffix = ":" + path suffix = ":" + path
} }
} }
+28 -3
View File
@@ -13,6 +13,18 @@ type Client struct {
port byte // flag for the port: -p (ssh) or -P (sftp, scp) 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 user byte // flag for the login user; 0 when only user@ or -o User work
paths bool // destinations are host[:path] paths bool // destinations are host[:path]
// instances are the configured NetBox instance names; "inst/host:path"
// is remote only for these, so local paths like "dir/file:x" stay local.
instances map[string]bool
}
// WithInstances returns c aware of the NetBox instance prefixes in host:path.
func (c Client) WithInstances(names []string) Client {
c.instances = make(map[string]bool, len(names))
for _, name := range names {
c.instances[strings.ToLower(name)] = true
}
return c
} }
var ( var (
@@ -61,7 +73,7 @@ func (c Client) Operands(args []string) ([]Operand, error) {
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") { if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg) return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
} }
dest, path, remote := splitRemote(arg) dest, path, remote := c.splitRemote(arg)
if !remote { if !remote {
continue continue
} }
@@ -105,9 +117,22 @@ func (c Client) insertionPoint(args []string) int {
return len(args) return len(args)
} }
// splitRemote mirrors scp's colon(): an argument is remote when a ':' comes // splitRemote is splitHostPath with netssh's [user@]instance/host:path form.
func (c Client) splitRemote(arg string) (dest, path string, remote bool) {
if head, rest, ok := strings.Cut(arg, "/"); ok {
instance := head[strings.LastIndex(head, "@")+1:]
if c.instances[strings.ToLower(instance)] {
if d, p, r := splitHostPath(rest); r {
return head + "/" + d, p, true
}
}
}
return splitHostPath(arg)
}
// splitHostPath mirrors scp's colon(): an argument is remote when a ':' comes
// before any '/'. A leading "[" starts a bracketed IPv6 address. // before any '/'. A leading "[" starts a bracketed IPv6 address.
func splitRemote(arg string) (dest, path string, remote bool) { func splitHostPath(arg string) (dest, path string, remote bool) {
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") { if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
return "", "", false return "", "", false
} }
+24
View File
@@ -154,3 +154,27 @@ func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
t.Fatalf("got %v, want %v", got, want) t.Fatalf("got %v, want %v", got, want)
} }
} }
// Instance-qualified hosts contain "/" before the ":" of host:path; only
// configured instances make such an argument remote.
func TestInstanceQualifiedHostPath(t *testing.T) {
scp := SCP.WithInstances([]string{"prod"})
ops, err := scp.Operands([]string{"a.txt", "ops@prod/web01:/tmp/", "dir/file:x"})
if err != nil {
t.Fatal(err)
}
if want := []Operand{{Idx: 1, User: "ops", Host: "prod/web01", Path: "/tmp/"}}; !reflect.DeepEqual(ops, want) {
t.Fatalf("got %+v", ops)
}
got := scp.Apply([]string{"a.txt", "ops@prod/web01:/tmp/"}, []int{1}, Host{Name: "web01", IP: "192.0.2.1"}, config.SSHOptions{}, WrapperFlags{}).Args
if want := []string{"a.txt", "ops@192.0.2.1:/tmp/"}; !reflect.DeepEqual(got, want) {
t.Fatalf("apply: %v", got)
}
sftp := SFTP.WithInstances([]string{"prod"})
if p := sftp.Parse([]string{"prod/web01:/var/log"}); p == nil || p.Host != "prod/web01" || p.Path != "/var/log" {
t.Fatalf("sftp: %+v", p)
}
if ops, _ := SCP.Operands([]string{"prod/web01:/tmp/", "."}); len(ops) != 0 {
t.Fatalf("unknown instance must stay local: %+v", ops)
}
}
+87
View File
@@ -0,0 +1,87 @@
# netssh test environment
A local Docker environment for testing every netssh feature against real
services: two NetBox instances, SSH targets on three networks, a jump host,
and a client with ssh, sftp, scp, tmux, zellij, bash, zsh, and fish. It is for
local use only and is not part of CI/CD.
```sh
testenv/up.sh # build, start, seed NetBox (first start ~6 min, then ~1 min)
testenv/test.sh # run all tests; testenv/test.sh tunnel runs only matching files
testenv/shell.sh # interactive shell in the client, netssh configured
testenv/down.sh # stop and delete everything (volumes, generated state)
```
`test.sh` rebuilds the client image first, so the tests always run the current
source. `NO_BUILD=1 testenv/test.sh` skips that. Exit code 1 if any check fails.
Requirements: Docker with Compose v2, about 3 GB RAM, ports 18000/18001 on
127.0.0.1 free.
## Topology
```
public 10.231.0.0/24 + fd00:30::/64 mgmt 10.231.1.0/24 prod 10.231.2.0/24
client .10 ────────────┬─ bastion .30 ───────────────────────────────┬─ (only via bastion)
netbox-fsn .20 │ fsn1-pve1..3 .11-.13 (mgmt0) │ fsn1-pve1..3 .11-.13 (eth0)
netbox-lab .21 │ lab-db1 .41, lab-web1 .42 │ web1 .21, web2 .22
legacy-box .50 client .10 ───────┘
v6-host fd00:30::60
```
| NetBox | Host | Kind | Primary IP | Other | Tags |
|---|---|---|---|---|---|
| fsn | `fsn1-pve1..3.hoster.test` | device, cluster `pve-fsn1` | eth0 = prod (needs bastion) | mgmt0 = mgmt (direct) | `pve` |
| fsn | `bastion.hoster.test` | device | 10.231.0.30 | | `bastion` |
| fsn | `v6-host` | device | fd00:30::60 (IPv6 only) | | `ipv6` |
| fsn | `web1`, `web2` | VM, cluster `pve-fsn1` | prod (needs bastion) | | `web` |
| lab | `lab-db1` | device | 10.231.1.41 | | `db` |
| lab | `web1` | VM, cluster `lab-k8s` | 10.231.1.42 | | `web` |
| — | `legacy-box` (10.231.0.50) | static host | | | |
- **fsn** uses a v2 token (`nbt_…`), **lab** a legacy v1 token; both come from `.state/env`.
- `web1` exists in both instances (ambiguity, `instance/host`).
- Docker DNS resolves the `*.hoster.test` names on the mgmt network (`hostname` strategy).
- Every target runs sshd on ports 22 and 2222 with users `ops` and `admin`, and an HTTP server on its own 127.0.0.1:8081 for tunnel tests ("hello from HOST").
- NetBox UI: http://127.0.0.1:18000 (fsn), http://127.0.0.1:18001 (lab), login `admin` / `admin`.
## Files
| Path | Purpose |
|---|---|
| `compose.yaml` | All services and networks. |
| `client.Dockerfile`, `client-entrypoint.sh` | Client image; builds netssh from the repository. |
| `targets/` | SSH target image (sshd config, entrypoint). |
| `seed/seed.py` | Fills both NetBox instances via the REST API (idempotent), writes `.state/`. |
| `tests/run.sh`, `tests/lib.sh` | Test runner and helpers (`check`, `expect_out`, `expect_eq`, `expect_fail`, `wait_for`). |
| `tests/NN-*.sh` | Test files; each runs in a fresh `HOME`. |
| `tests/drive.py` | Drives `netssh configure` in huh's accessible mode. |
| `.keys/`, `.state/` | Generated test key and seed output (git-ignored). |
## Writing tests
Each `tests/NN-name.sh` is sourced by `run.sh` in a subshell with a new
`HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME`. Write the netssh config with
`config <<'EOF' … EOF` (both NetBox instances plus your YAML) or `config_only`
(no NetBox), then use the helpers:
```sh
config <<'EOF'
ssh: {default_user: ops}
EOF
expect_eq "connect" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_out "dry run" "-J ops@10.231.0.30" netssh --dry-run fsn/web1
expect_fail "ambiguous" "found 2 exact matches" netssh web1
```
ssh always reads `/home/tester/.ssh/config` (OpenSSH ignores `$HOME`): it uses
the test key, `BatchMode`, `StrictHostKeyChecking accept-new`, and
`LogLevel ERROR`. Tests that need their own known_hosts set
`ssh.options.UserKnownHostsFile`. The TUI test runs netssh inside tmux and
reads the screen with `tmux capture-pane`.
## Not covered
- The installer (`install.sh`) downloads release assets from Gitea; it is tested by the Go tests with local fixtures.
- macOS paths and behaviour.
- `netssh uninstall` runs only with `--dry-run`, so the client keeps its binary.
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# ssh reads ~/.ssh from the passwd entry, not $HOME, so the key and ssh config
# live in /home/tester for every test; tests isolate netssh via HOME/XDG_*.
set -eu
mkdir -p /home/tester/.ssh
if [ -f /keys/id_ed25519 ]; then
install -m 600 /keys/id_ed25519 /home/tester/.ssh/id_ed25519
fi
cat >/home/tester/.ssh/config <<'EOF'
Host *
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
BatchMode yes
ConnectTimeout 5
StrictHostKeyChecking accept-new
LogLevel ERROR
EOF
chmod 700 /home/tester/.ssh
chmod 600 /home/tester/.ssh/config
exec "$@"
+23
View File
@@ -0,0 +1,23 @@
# Client for the netssh test environment: netssh built from this repository
# plus everything it drives or integrates with (ssh, sftp, scp, tmux, zellij,
# bash, zsh, fish). Rebuild after code changes: testenv/up.sh or
# docker compose -f testenv/compose.yaml build client
FROM golang:1.27.4-alpine3.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh
FROM alpine:3.23
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
curl python3 jq ncurses coreutils \
&& adduser -D -s /bin/bash tester
COPY --from=build /out/netssh /usr/local/bin/netssh
COPY testenv/client-entrypoint.sh /entrypoint.sh
USER tester
WORKDIR /home/tester
ENV TERM=xterm-256color LANG=C.UTF-8
ENTRYPOINT ["/entrypoint.sh"]
CMD ["sleep", "infinity"]
+210
View File
@@ -0,0 +1,210 @@
# netssh test environment — local only, not used by CI/CD. See testenv/README.md.
#
# public 10.231.0.0/24 + fd00:30::/64 client, NetBox x2, bastion, static host, IPv6 host
# mgmt 10.231.1.0/24 client, Proxmox nodes (mgmt0), lab hosts
# prod 10.231.2.0/24 bastion, Proxmox nodes (eth0), VMs — not reachable from the client
name: netssh-test
x-netbox: &netbox
image: netboxcommunity/netbox:v4.7.4-5.1.1
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8080/login/ >/dev/null"]
interval: 10s
timeout: 5s
retries: 60
start_period: 60s
x-netbox-env: &netbox-env
DB_HOST: postgres
DB_USER: netbox
DB_PASSWORD: netbox
REDIS_HOST: redis
SECRET_KEY: netssh-test-environment-secret-key-0123456789-abcdefghijklmnopqrstuvwxyz
API_TOKEN_PEPPER_1: netssh-test-environment-pepper-0123456789-abcdefghijklmnop
SUPERUSER_NAME: admin
SUPERUSER_EMAIL: admin@example.com
SUPERUSER_PASSWORD: admin
SUPERUSER_API_KEY: netsshtest01
SUPERUSER_API_TOKEN: 0123456789abcdef0123456789abcdef01234567
SKIP_SUPERUSER: "false"
x-target: &target
build:
context: ..
dockerfile: testenv/targets/target.Dockerfile
image: netssh-test-target
volumes:
- ./.keys/authorized_keys:/keys/authorized_keys:ro
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: netbox
POSTGRES_PASSWORD: netbox
POSTGRES_DB: netbox
volumes:
- ./postgres-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- postgres:/var/lib/postgresql
networks: [backend]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netbox"]
interval: 5s
retries: 30
redis:
image: redis:8-alpine
networks: [backend]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 30
netbox-fsn:
<<: *netbox
hostname: netbox-fsn
environment:
<<: *netbox-env
DB_NAME: netbox_fsn
REDIS_DATABASE: "0"
REDIS_CACHE_DATABASE: "1"
ports: ["127.0.0.1:18000:8080"]
networks:
backend: {}
public: {ipv4_address: 10.231.0.20}
netbox-lab:
<<: *netbox
hostname: netbox-lab
environment:
<<: *netbox-env
DB_NAME: netbox_lab
REDIS_DATABASE: "2"
REDIS_CACHE_DATABASE: "3"
ports: ["127.0.0.1:18001:8080"]
networks:
backend: {}
public: {ipv4_address: 10.231.0.21}
# Jump host: the only way from the client into the prod network.
bastion:
<<: *target
hostname: bastion
networks:
public: {ipv4_address: 10.231.0.30, aliases: [bastion.hoster.test]}
prod: {ipv4_address: 10.231.2.30}
fsn1-pve1:
<<: *target
hostname: fsn1-pve1
networks:
mgmt: {ipv4_address: 10.231.1.11, aliases: [fsn1-pve1.hoster.test]}
prod: {ipv4_address: 10.231.2.11}
fsn1-pve2:
<<: *target
hostname: fsn1-pve2
networks:
mgmt: {ipv4_address: 10.231.1.12, aliases: [fsn1-pve2.hoster.test]}
prod: {ipv4_address: 10.231.2.12}
fsn1-pve3:
<<: *target
hostname: fsn1-pve3
networks:
mgmt: {ipv4_address: 10.231.1.13, aliases: [fsn1-pve3.hoster.test]}
prod: {ipv4_address: 10.231.2.13}
# VMs of cluster pve-fsn1: only in prod, so they need the bastion.
web1:
<<: *target
hostname: web1
networks:
prod: {ipv4_address: 10.231.2.21}
web2:
<<: *target
hostname: web2
networks:
prod: {ipv4_address: 10.231.2.22}
# NetBox instance "lab": a device and a VM named web1 like the fsn VM.
lab-db1:
<<: *target
hostname: lab-db1
networks:
mgmt: {ipv4_address: 10.231.1.41}
lab-web1:
<<: *target
hostname: lab-web1
networks:
mgmt: {ipv4_address: 10.231.1.42}
# Not in NetBox: tested as a static host.
legacy-box:
<<: *target
hostname: legacy-box
networks:
public: {ipv4_address: 10.231.0.50}
# In NetBox with an IPv6 primary address only.
v6-host:
<<: *target
hostname: v6-host
networks:
public: {ipv4_address: 10.231.0.60, ipv6_address: "fd00:30::60"}
client:
build:
context: ..
dockerfile: testenv/client.Dockerfile
image: netssh-test-client
hostname: client
init: true
volumes:
- ./.keys:/keys:ro
- ./.state:/state
- ./tests:/tests:ro
networks:
public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"}
mgmt: {ipv4_address: 10.231.1.10}
# Fills both NetBox instances and writes /state/netssh.yaml, then exits.
seed:
image: netssh-test-client
depends_on:
client: {condition: service_started}
netbox-fsn: {condition: service_healthy}
netbox-lab: {condition: service_healthy}
volumes:
- ./.state:/state
- ./seed:/seed:ro
networks:
public: {}
command: ["python3", "/seed/seed.py"]
restart: "no"
# Fixed subnets outside Docker's default address pools (172.17-31.x, 192.168.x),
# so automatically numbered networks of other projects cannot collide.
networks:
backend:
ipam:
config:
- subnet: 10.231.3.0/24
public:
enable_ipv6: true
ipam:
config:
- subnet: 10.231.0.0/24
- subnet: fd00:30::/64
mgmt:
ipam:
config:
- subnet: 10.231.1.0/24
prod:
ipam:
config:
- subnet: 10.231.2.0/24
volumes:
postgres: {}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Stop the netssh test environment and remove its volumes and state.
set -euo pipefail
cd "$(dirname "$0")"
docker compose -f compose.yaml down -v --remove-orphans
rm -rf .state
+3
View File
@@ -0,0 +1,3 @@
-- One Postgres serves both NetBox instances of the test environment.
CREATE DATABASE netbox_fsn OWNER netbox;
CREATE DATABASE netbox_lab OWNER netbox;
+171
View File
@@ -0,0 +1,171 @@
"""Fill both NetBox instances of the netssh test environment.
Idempotent: objects are looked up before they are created, so running the
seed again leaves NetBox unchanged. Writes /state/env (tokens) and
/state/netssh.yaml (base config for the tests).
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
FSN = "http://10.231.0.20:8080"
LAB = "http://10.231.0.21:8080"
# Superuser v2 token from compose.yaml (SUPERUSER_API_KEY / SUPERUSER_API_TOKEN).
V2_TOKEN = "nbt_netsshtest01.0123456789abcdef0123456789abcdef01234567"
STATE = "/state"
class NetBox:
def __init__(self, url):
self.url = url
def call(self, method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(self.url + "/api/" + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + V2_TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as err:
sys.exit(f"{method} {path}: HTTP {err.code}: {err.read().decode(errors='replace')}")
def find(self, path, **filters):
query = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in filters.items())
results = self.call("GET", f"{path}?{query}&limit=2")["results"]
return results[0] if results else None
def ensure(self, path, lookup, body):
"""Return the object matching lookup, creating it from body if missing."""
found = self.find(path, **lookup)
if found:
return found
return self.call("POST", path, body)
def wait(self):
for _ in range(120):
try:
self.call("GET", "status/")
return
except SystemExit:
pass
except (urllib.error.URLError, ConnectionError, TimeoutError):
pass
time.sleep(5)
sys.exit(f"NetBox {self.url} did not become ready")
def base(nb, site, cluster_type, cluster):
for tag in ("pve", "web", "bastion", "db", "ipv6"):
nb.ensure("extras/tags/", {"slug": tag}, {"name": tag, "slug": tag})
site = nb.ensure("dcim/sites/", {"slug": site}, {"name": site, "slug": site, "status": "active"})
mfr = nb.ensure("dcim/manufacturers/", {"slug": "generic"}, {"name": "Generic", "slug": "generic"})
role = nb.ensure("dcim/device-roles/", {"slug": "server"}, {"name": "Server", "slug": "server", "color": "2196f3"})
dtype = nb.ensure("dcim/device-types/", {"slug": "generic-server"},
{"manufacturer": mfr["id"], "model": "Generic Server", "slug": "generic-server"})
ctype = nb.ensure("virtualization/cluster-types/", {"slug": cluster_type},
{"name": cluster_type, "slug": cluster_type})
cluster = nb.ensure("virtualization/clusters/", {"name": cluster},
{"name": cluster, "type": ctype["id"], "status": "active"})
return {"site": site["id"], "role": role["id"], "type": dtype["id"], "cluster": cluster["id"]}
def device(nb, ids, name, ips, tags=(), cluster=False, comments=""):
"""ips: list of (interface, address, primary) with primary in (None, 4, 6)."""
body = {"name": name, "role": ids["role"], "device_type": ids["type"], "site": ids["site"],
"status": "active", "tags": [{"slug": t} for t in tags], "comments": comments}
if cluster:
body["cluster"] = ids["cluster"]
dev = nb.ensure("dcim/devices/", {"name": name}, body)
primary = {}
for ifname, address, family in ips:
iface = nb.ensure("dcim/interfaces/", {"device_id": dev["id"], "name": ifname},
{"device": dev["id"], "name": ifname, "type": "1000base-t"})
ip = nb.ensure("ipam/ip-addresses/", {"address": address},
{"address": address, "assigned_object_type": "dcim.interface", "assigned_object_id": iface["id"]})
if family:
primary[f"primary_ip{family}"] = ip["id"]
if primary:
nb.call("PATCH", f"dcim/devices/{dev['id']}/", primary)
def vm(nb, ids, name, address, tags=()):
machine = nb.ensure("virtualization/virtual-machines/", {"name": name},
{"name": name, "cluster": ids["cluster"], "status": "active",
"tags": [{"slug": t} for t in tags]})
iface = nb.ensure("virtualization/interfaces/", {"virtual_machine_id": machine["id"], "name": "eth0"},
{"virtual_machine": machine["id"], "name": "eth0"})
ip = nb.ensure("ipam/ip-addresses/", {"address": address},
{"address": address, "assigned_object_type": "virtualization.vminterface",
"assigned_object_id": iface["id"]})
nb.call("PATCH", f"virtualization/virtual-machines/{machine['id']}/", {"primary_ip4": ip["id"]})
def v1_token(nb):
"""A legacy v1 token for the lab instance, reused from /state/env if present."""
env = read_env()
if env.get("LAB_TOKEN"):
return env["LAB_TOKEN"]
user = nb.find("users/users/", username="admin")
token = nb.call("POST", "users/tokens/", {"user": user["id"], "version": 1, "description": "netssh v1 test"})
plaintext = token.get("token") or token.get("plaintext")
if not plaintext:
sys.exit(f"v1 token response without plaintext: {token}")
return plaintext
def read_env():
env = {}
try:
with open(os.path.join(STATE, "env")) as f:
for line in f:
key, _, value = line.strip().partition("=")
env[key] = value
except FileNotFoundError:
pass
return env
def main():
fsn, lab = NetBox(FSN), NetBox(LAB)
fsn.wait()
lab.wait()
ids = base(fsn, "fsn1", "proxmox", "pve-fsn1")
for n in (1, 2, 3):
device(fsn, ids, f"fsn1-pve{n}.hoster.test",
[("eth0", f"10.231.2.1{n}/24", 4), ("mgmt0", f"10.231.1.1{n}/24", None)],
tags=("pve",), cluster=True, comments=f"Proxmox node {n}")
device(fsn, ids, "bastion.hoster.test", [("eth0", "10.231.0.30/24", 4)], tags=("bastion",))
device(fsn, ids, "v6-host", [("eth0", "fd00:30::60/64", 6)], tags=("ipv6",))
vm(fsn, ids, "web1", "10.231.2.21/24", tags=("web",))
vm(fsn, ids, "web2", "10.231.2.22/24", tags=("web",))
ids = base(lab, "lab", "kubernetes", "lab-k8s")
device(lab, ids, "lab-db1", [("eth0", "10.231.1.41/24", 4)], tags=("db",))
vm(lab, ids, "web1", "10.231.1.42/24", tags=("web",))
lab_token = v1_token(lab)
os.makedirs(STATE, exist_ok=True)
with open(os.path.join(STATE, "env"), "w") as f:
f.write(f"FSN_URL={FSN}\nLAB_URL={LAB}\nFSN_TOKEN={V2_TOKEN}\nLAB_TOKEN={lab_token}\n")
with open(os.path.join(STATE, "netssh.yaml"), "w") as f:
f.write(f"""netboxes:
- name: fsn
url: "{FSN}"
token: "{V2_TOKEN}"
- name: lab
url: "{LAB}"
token: "{lab_token}"
""")
print("seed: done (fsn: 3 nodes, bastion, v6-host, 2 VMs; lab: lab-db1, web1)")
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Interactive shell in the client with a ready netssh config (fresh home).
set -euo pipefail
cd "$(dirname "$0")"
exec docker compose -f compose.yaml exec -it client bash -c '
. /tests/lib.sh
new_home
base_config >"$XDG_CONFIG_HOME/netssh.yaml" <<EOF
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
host_key_checking: accept-new
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
echo "netssh is configured (HOME=$HOME). Try: netssh cache refresh && netssh"
exec bash -i'
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Prepares host keys and the test users, starts a small HTTP server for the
# tunnel tests (reachable only on the target itself), then runs sshd.
set -eu
[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -q -t ed25519 -N '' -f /etc/ssh/ssh_host_ed25519_key
for user in ops admin; do
home="/home/$user"
mkdir -p "$home/.ssh"
cp /keys/authorized_keys "$home/.ssh/authorized_keys"
chown -R "$user:$user" "$home/.ssh"
chmod 700 "$home/.ssh"
chmod 600 "$home/.ssh/authorized_keys"
done
mkdir -p /srv/www
echo "hello from $(hostname)" > /srv/www/index.html
python3 -m http.server 8081 --bind 127.0.0.1 --directory /srv/www >/dev/null 2>&1 &
exec /usr/sbin/sshd -D -e
+18
View File
@@ -0,0 +1,18 @@
# Test targets accept the generated test key only. Port 2222 tests ssh.default_port.
Port 22
Port 2222
ListenAddress 0.0.0.0
ListenAddress ::
HostKey /etc/ssh/ssh_host_ed25519_key
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
AllowTcpForwarding yes
AllowAgentForwarding yes
GatewayPorts no
PermitTunnel no
X11Forwarding no
PrintMotd no
Subsystem sftp /usr/lib/ssh/sftp-server
+16
View File
@@ -0,0 +1,16 @@
# SSH target for the netssh test environment: every NetBox host, the bastion,
# and the static host run this image with a different hostname.
FROM alpine:3.23
# Alpine locks new accounts with "!"; sshd refuses key logins for locked
# accounts, so the password field is set to "*" (no password, not locked).
RUN apk add --no-cache openssh-server openssh-sftp-server python3 \
&& for user in ops admin; do \
adduser -D -s /bin/sh "$user" && sed -i "s/^$user:!/$user:*/" /etc/shadow; \
done
COPY testenv/targets/sshd_config /etc/ssh/sshd_config
COPY testenv/targets/entrypoint.sh /entrypoint.sh
EXPOSE 22 2222
ENTRYPOINT ["/entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Run the test scripts in the client container. Optional FILTER selects files
# by substring, e.g. testenv/test.sh tunnel. The client is rebuilt first so the
# tests always run the current source.
set -euo pipefail
cd "$(dirname "$0")"
compose=(docker compose -f compose.yaml)
if [ "${NO_BUILD:-}" != 1 ]; then
"${compose[@]}" build -q client
"${compose[@]}" up -d --wait client
fi
exec "${compose[@]}" exec -T client bash /tests/run.sh "${1:-}"
+19
View File
@@ -0,0 +1,19 @@
# Cache refresh across both NetBox instances, filters, list, search, clear.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
EOF
expect_out "refresh both instances" "9 entries written" netssh cache refresh
expect_out "list shows mgmt address" "fsn/fsn1-pve1.hoster.test 10.231.1.11" netssh cache list
expect_out "list shows lab VM" "lab/web1 10.231.1.42 vm" netssh cache list
expect_out "IPv6-only host" "fsn/v6-host fd00:30::60" netssh cache list
expect_out "refresh one instance" "2 entries written" netssh cache refresh --instance lab
expect_out "refresh by tag" "3 entries written" netssh cache refresh --tag pve
expect_out "refresh by kind" "3 entries written" netssh cache refresh --kind vm
expect_eq "search" "fsn/fsn1-pve1.hoster.test
fsn/fsn1-pve2.hoster.test
fsn/fsn1-pve3.hoster.test" bash -c 'netssh search fsn1 | sort'
check "clear" netssh cache clear
expect_eq "empty after clear" "" bash -c 'netssh search fsn'
+29
View File
@@ -0,0 +1,29 @@
# Connecting with the resolved address, default user and port, overrides.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
EOF
expect_eq "connect via NetBox" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "unique name without instance" "fsn1-pve2" netssh fsn1-pve2.hoster.test hostname
expect_eq "default user" "ops" netssh fsn/fsn1-pve1.hoster.test whoami
expect_eq "user@ wins" "admin" netssh admin@fsn/fsn1-pve1.hoster.test whoami
expect_eq "-l wins" "admin" netssh -l admin fsn/fsn1-pve1.hoster.test whoami
expect_eq "remote command with arguments" "a b" netssh fsn/fsn1-pve1.hoster.test echo a b
expect_eq "dry run" "ssh -l ops 10.231.1.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
default_port: 2222
EOF
expect_out "default port" " 2222" netssh fsn/fsn1-pve1.hoster.test 'echo $SSH_CONNECTION'
expect_out "-p wins over default port" " 22" netssh -p 22 fsn/fsn1-pve1.hoster.test 'echo $SSH_CONNECTION'
expect_eq "dry run with port" "ssh -p 2222 -l ops 10.231.1.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
expect_eq "version" "netssh" bash -c 'netssh --version | cut -d" " -f1'
+63
View File
@@ -0,0 +1,63 @@
# Every resolver strategy against real NetBox data. cache.ttl 0 makes every
# connection ask NetBox, so each config change takes effect immediately.
jump=' - match: {instance: fsn}
proxy_jump: ops@10.231.0.30
- match: {name: "bastion*"}
proxy_jump: none'
config <<EOF
cache: {ttl: 0}
resolver: {strategies: [primary_ip]}
ssh:
default_user: ops
rules:
$jump
EOF
expect_eq "primary_ip uses prod address via bastion" "ssh -l ops -J ops@10.231.0.30 10.231.2.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
expect_eq "primary_ip connects through the jump host" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "bastion rule disables the jump" "ssh -l ops 10.231.0.30" netssh --dry-run fsn/bastion.hoster.test
expect_eq "IPv6 primary address" "ssh -l ops -J ops@10.231.0.30 fd00:30::60" netssh --dry-run fsn/v6-host
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh: {default_user: ops}
EOF
expect_eq "management_subnet" "ssh -l ops 10.231.1.12" netssh --dry-run fsn/fsn1-pve2.hoster.test
expect_eq "management_subnet falls back to primary_ip" "ssh -l ops 10.231.2.21" netssh --dry-run fsn/web1
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [interface_name, primary_ip]
interface_name: mgmt0
ssh: {default_user: ops}
EOF
expect_eq "interface_name" "ssh -l ops 10.231.1.13" netssh --dry-run fsn/fsn1-pve3.hoster.test
expect_eq "interface_name connects" "fsn1-pve3" netssh fsn/fsn1-pve3.hoster.test hostname
expect_eq "interface_name falls back for VMs" "ssh -l ops 10.231.2.22" netssh --dry-run fsn/web2
# eth0 holds the prod address, which sorts after mgmt: catches an ignored filter.
sed -i 's/interface_name: mgmt0/interface_name: eth0/' "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "interface_name picks the named interface" "ssh -l ops 10.231.2.13" netssh --dry-run fsn/fsn1-pve3.hoster.test
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [hostname]
hostname_domain: example.org
ssh: {default_user: ops}
EOF
expect_eq "hostname strategy" "ssh -l ops fsn1-pve1.hoster.test" netssh --dry-run fsn/fsn1-pve1.hoster.test
expect_eq "hostname connects via DNS" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "hostname domain for names without a dot" "ssh -l ops lab-db1.example.org" netssh --dry-run lab/lab-db1
config <<'EOF'
cache: {ttl: 0}
resolver:
strategies: [interface_name]
interface_name: mgmt0
EOF
expect_fail "no strategy finds an address" "no strategy resolved an IP" netssh --dry-run fsn/web1
+20
View File
@@ -0,0 +1,20 @@
# Two NetBox instances: qualified names, ambiguity, an unreachable instance.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
expect_fail "same name in both instances is ambiguous" "found 2 exact matches" netssh --dry-run web1
expect_eq "fsn/web1 through the bastion" "web1" netssh fsn/web1 hostname
expect_eq "lab/web1 directly" "lab-web1" netssh lab/web1 hostname
expect_eq "lab uses its v1 token" "lab-db1" netssh lab/lab-db1 hostname
# lab unreachable: qualified fsn targets still work, unqualified ones need every instance.
sed -i 's#http://10.231.0.21:8080#http://10.231.0.21:9#' "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "qualified target with another instance down" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
expect_fail "unqualified target needs every instance" "10.231.0.21:9" netssh --dry-run fsn1-pve1.hoster.test
+31
View File
@@ -0,0 +1,31 @@
# Domains, hyphen folding, rewrite rules, alias --test, completion, aliases.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh: {default_user: ops}
shortcuts:
domains: [.hoster.test]
rewrites:
- pattern: '^([a-z]+)[0-9]+-'
replace: '$1'
EOF
check "refresh" netssh cache refresh
expect_eq "domain expansion via NetBox" "fsn1-pve1" netssh fsn/fsn1-pve1 hostname
expect_eq "rewrite shortcut, qualified" "fsn1-pve2" netssh fsn/fsnpve2 hostname
expect_eq "rewrite shortcut, unqualified" "fsn1-pve3" netssh fsnpve3 hostname
expect_out "alias --test" "fsn1-pve1.hoster.test → fsnpve1" netssh alias --test fsn1-pve1.hoster.test
expect_out "completion offers shortcuts" "fsnpve1" netssh __complete fsnp
expect_out "aliases use the shortcut" "alias fsn_fsnpve1='netssh " netssh alias --shell bash
expect_fail "unknown shortcut" "host not found" netssh --dry-run fsnpve9
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
shortcuts:
domains: [.hoster.test]
strip_hyphens: true
EOF
check "refresh" netssh cache refresh
expect_eq "hyphen folding" "ssh 10.231.1.12" netssh --dry-run fsn/fsn1pve2
+34
View File
@@ -0,0 +1,34 @@
# Jump hosts, rules, host key checking, connection target, options, extra args.
known="$HOME/known_hosts"
config <<EOF
cache: {ttl: 0}
resolver: {strategies: [primary_ip]}
ssh:
default_user: ops
options:
UserKnownHostsFile: $known
ServerAliveInterval: "30"
extra_args: ["-T"]
rules:
- match: {tag: web}
proxy_jump: ops@10.231.0.30
- match: {tag: pve}
proxy_jump: ops@10.231.0.30
host_key_checking: strict
- match: {name: "fsn1-pve2*"}
host_key_checking: accept-new
- match: {name: "fsn1-pve3*"}
host_key_checking: "off"
target: name
EOF
expect_eq "rule by tag adds jump host" "ssh -T -l ops -J ops@10.231.0.30 -o serveraliveinterval=30 -o userknownhostsfile=$known 10.231.2.21" netssh --dry-run fsn/web1
expect_eq "connect through rule jump" "web1" netssh fsn/web1 hostname
expect_out "--no-jump removes it" "-o ProxyJump=none" netssh --dry-run --no-jump fsn/web1
expect_out "--jump overrides it" "-J ops@10.231.0.30" netssh --dry-run --jump ops@10.231.0.30 fsn/v6-host
expect_fail "strict rejects an unknown host key" "Host key verification failed" netssh fsn/fsn1-pve1.hoster.test hostname
expect_eq "later rule accept-new wins" "fsn1-pve2" netssh fsn/fsn1-pve2.hoster.test hostname
expect_out "accept-new stored the key" "10.231.2.12" cat "$known"
expect_out "off prints a notice" "host key checking is disabled" netssh fsn/fsn1-pve3.hoster.test hostname
expect_out "target name keeps the address" "-o HostName=10.231.2.13 -o HostKeyAlias=fsn1-pve3.hoster.test" netssh --dry-run fsn/fsn1-pve3.hoster.test
expect_out "target name destination" " fsn1-pve3.hoster.test" netssh --dry-run fsn/fsn1-pve3.hoster.test
expect_out "user -o wins over config" "-o ServerAliveInterval=5" netssh --dry-run -o ServerAliveInterval=5 fsn/web1
+25
View File
@@ -0,0 +1,25 @@
# Static hosts without any NetBox: config, CLI, own user/port/options, rules.
config_only <<'EOF'
ssh:
default_user: ops
rules:
- match: {kind: static, tag: legacy}
options: {ServerAliveInterval: "15"}
hosts:
- name: legacy-box
address: 10.231.0.50
user: admin
tags: [legacy]
EOF
expect_eq "connect to a static host" "legacy-box" netssh legacy-box hostname
expect_eq "static user" "admin" netssh legacy-box whoami
expect_out "rule by kind and tag" "-o serveraliveinterval=15" netssh --dry-run legacy-box
expect_eq "-l still wins" "ops" netssh -l ops legacy-box whoami
check "hosts add" netssh hosts add box2 10.231.0.50 --port 2222 --tag lab
expect_eq "hosts list" "box2 10.231.0.50 2222 lab" bash -c 'netssh hosts list | grep "^box2" | tr -s " "'
expect_out "static port" " 2222" netssh box2 'echo $SSH_CONNECTION'
expect_out "completion" "box2" netssh __complete bo
expect_fail "duplicate" "already exists" netssh hosts add box2 10.0.0.1
check "hosts remove" netssh hosts remove box2
expect_fail "removed host is gone" "no NetBox is configured" netssh --dry-run box2
expect_eq "sftp to a static host" "Remote working directory: /home/admin" bash -c 'echo pwd | netssh sftp -b - legacy-box | grep Remote'
+24
View File
@@ -0,0 +1,24 @@
# sftp and scp: upload, download, jump host, IPv6, errors.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
echo "payload $$" >"$HOME/up.txt"
check "scp upload" netssh scp "$HOME/up.txt" fsn/fsn1-pve1.hoster.test:/tmp/up.txt
expect_eq "uploaded content" "payload $$" netssh fsn/fsn1-pve1.hoster.test cat /tmp/up.txt
check "scp download" netssh scp fsn/fsn1-pve1.hoster.test:/tmp/up.txt "$HOME/down.txt"
expect_eq "downloaded content" "payload $$" cat "$HOME/down.txt"
check "scp to a VM through the bastion" netssh scp "$HOME/up.txt" fsn/web1:/tmp/up.txt
expect_eq "VM content" "payload $$" netssh fsn/web1 cat /tmp/up.txt
check "scp to IPv6 host" netssh scp "$HOME/up.txt" fsn/v6-host:/tmp/v6.txt
expect_eq "IPv6 content" "payload $$" netssh fsn/v6-host cat /tmp/v6.txt
expect_out "sftp starts in the path" "Remote working directory: /tmp" bash -c 'echo pwd | netssh sftp -b - fsn/fsn1-pve2.hoster.test:/tmp'
expect_out "sftp to IPv6 host" "v6.txt" bash -c 'echo "ls /tmp" | netssh sftp -b - fsn/v6-host'
expect_out "sftp through the bastion" "up.txt" bash -c 'echo "ls /tmp" | netssh sftp -b - fsn/web1'
expect_fail "two remote hosts" "not supported" netssh scp fsn/web1:/a fsn/web2:/b
+40
View File
@@ -0,0 +1,40 @@
# Port forwards: named, ad hoc, --forward with a shell, port in use.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
forwards:
web: {local: "18081:127.0.0.1:8081"}
rules:
- match: {kind: vm, instance: fsn}
proxy_jump: ops@10.231.0.30
EOF
tunnel_test() { # NAME PORT WANT ARGS...
local name=$1 port=$2 want=$3 pid out
shift 3
"$@" >"$HOME/tunnel.log" 2>&1 &
pid=$!
if wait_for 15 curl -sf "http://127.0.0.1:$port/"; then
out=$(curl -s "http://127.0.0.1:$port/")
[[ $out == *"$want"* ]] && ok "$name" || bad "$name" "curl: $out"
else
bad "$name" "tunnel did not open: $(cat "$HOME/tunnel.log")"
fi
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
}
tunnel_test "named forward" 18081 "hello from fsn1-pve1" netssh tunnel fsn/fsn1-pve1.hoster.test web
expect_out "tunnel notice" "forwarding -L 18081:127.0.0.1:8081 via fsn1-pve1.hoster.test" cat "$HOME/tunnel.log"
tunnel_test "ad hoc spec through the bastion" 18082 "hello from web1" netssh tunnel fsn/web1 18082:127.0.0.1:8081
tunnel_test "--forward with a remote command" 18081 "hello from fsn1-pve2" netssh --forward web fsn/fsn1-pve2.hoster.test sleep 30
netssh tunnel fsn/fsn1-pve1.hoster.test web >/dev/null 2>&1 &
holder=$!
wait_for 15 curl -sf http://127.0.0.1:18081/
expect_fail "port in use fails instead of hanging" "Address in use" timeout 20 netssh tunnel fsn/fsn1-pve2.hoster.test web
kill "$holder"; wait "$holder" 2>/dev/null
expect_fail "unknown forward lists the configured ones" "available: web" netssh tunnel fsn/fsn1-pve1.hoster.test db
expect_fail "tunnel without forward" "forwards for fsn/fsn1-pve1.hoster.test: web" netssh tunnel fsn/fsn1-pve1.hoster.test
+22
View File
@@ -0,0 +1,22 @@
# Local notes and NetBox comments (v2 token on fsn, v1 token on lab).
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
EOF
check "refresh" netssh cache refresh
host=fsn/fsn1-pve1.hoster.test
check "set local note" netssh notes set $host "first line"
check "append local note" netssh notes append $host "second line"
expect_eq "show local note" "first line
second line" netssh notes show $host
expect_eq "note from stdin" "piped" bash -c "echo piped | netssh notes set $host && netssh notes show $host"
expect_eq "read NetBox comments (v2 token)" "Proxmox node 1" netssh notes show $host --netbox
check "append NetBox comments" netssh notes append $host --netbox "checked by netssh test"
expect_out "NetBox has the new line" "checked by netssh test" curl -s -H "Authorization: Bearer $FSN_TOKEN" "$FSN_URL/api/dcim/devices/?name=fsn1-pve1.hoster.test"
check "restore NetBox comments" netssh notes set $host --netbox "Proxmox node 1"
check "set NetBox comments (v1 token)" netssh notes set lab/lab-db1 --netbox "lab note"
expect_eq "read them back" "lab note" netssh notes show lab/lab-db1 --netbox
check "clear them" netssh notes set lab/lab-db1 --netbox ""
+32
View File
@@ -0,0 +1,32 @@
# Completion, aliases, hook install for bash, zsh, fish, and the command alias.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
forwards:
web: {local: "18081:127.0.0.1:8081"}
shortcuts:
domains: [.hoster.test]
shell:
alias: j
EOF
check "refresh" netssh cache refresh
expect_out "complete hosts" "fsn/fsn1-pve2.hoster.test" netssh __complete fsn/fsn1
expect_out "complete scp host:" "fsn/fsn1-pve1.hoster.test:" netssh __complete scp a.txt fsn/fsn1-pve1
expect_out "complete forwards after the host" "web" netssh __complete tunnel fsn/fsn1-pve1.hoster.test ""
expect_out "complete --forward" "web" netssh __complete --forward ""
expect_out "fish aliases" "alias fsn_fsn1-pve1 " netssh alias --shell fish
check "hook install bash" netssh hook install --shell bash
# Interactive bash without a terminal warns about job control on stderr.
expect_eq "bash loads host aliases" "fsn1-pve1" sh -c "bash -ic 'fsn_fsn1-pve1 hostname' 2>/dev/null </dev/null"
expect_eq "bash command alias" "fsn1-pve2" sh -c "bash -ic 'j fsn/fsn1-pve2.hoster.test hostname' 2>/dev/null </dev/null"
check "hook install zsh" netssh hook install --shell zsh
expect_eq "zsh loads host aliases" "fsn1-pve3" zsh -ic 'fsn_fsn1-pve3 hostname' </dev/null
mkdir -p "$XDG_CONFIG_HOME/fish"
check "hook install fish" netssh hook install --shell fish
expect_eq "fish loads host aliases" "fsn1-pve1" fish -c 'fsn_fsn1-pve1 hostname' </dev/null
check "completion install bash" netssh completion install --shell bash
expect_out "bash completes hosts" "fsn/fsn1-pve2.hoster.test" bash -ic '. ~/.local/share/bash-completion/completions/netssh; COMP_WORDS=(netssh fsn/fsn1-pve2); COMP_CWORD=1; COMP_LINE="netssh fsn/fsn1-pve2"; COMP_POINT=${#COMP_LINE}; __start_netssh netssh; printf "%s\n" "${COMPREPLY[@]}"' </dev/null
+64
View File
@@ -0,0 +1,64 @@
# The interactive TUI, driven through tmux: search, connect, sftp, tunnel, edit.
config <<'EOF'
resolver:
strategies: [management_subnet, primary_ip]
management_subnets: [10.231.1.0/24]
ssh:
default_user: ops
forwards:
web: {local: "18085:127.0.0.1:8081"}
EOF
check "refresh" netssh cache refresh
export TMUX_TMPDIR="$HOME/tmux"
mkdir -p "$TMUX_TMPDIR"
screen() { tmux capture-pane -p -t tui; }
shows() { screen | grep -qF -- "$1"; }
tui() { # start netssh in a fresh tmux session
tmux kill-server 2>/dev/null
tmux new-session -d -s tui -x 160 -y 40 -e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME" netssh
wait_for 10 shows "Search hostname"
}
step() { # NAME TEXT — wait until the pane shows TEXT
if wait_for 15 shows "$2"; then ok "$1"; else bad "$1" "$(screen)"; fi
}
tui && ok "TUI starts" || bad "TUI starts" "$(screen)"
tmux send-keys -t tui "fsn1-pve2"
step "search shows the host" "fsn/fsn1-pve2.hoster.test"
tmux send-keys -t tui Enter
sleep 1
tmux send-keys -t tui "hostname" Enter
step "enter connects" "fsn1-pve2"
tmux send-keys -t tui "exit" Enter
tui
tmux send-keys -t tui "fsn1-pve3"
wait_for 10 shows "fsn/fsn1-pve3.hoster.test"
tmux send-keys -t tui C-o
step "ctrl+o opens sftp" "sftp>"
tmux send-keys -t tui "pwd" Enter
step "sftp works" "Remote working directory: /home/ops"
tmux send-keys -t tui "bye" Enter
tui
tmux send-keys -t tui "fsn1-pve1"
wait_for 10 shows "fsn/fsn1-pve1.hoster.test"
tmux send-keys -t tui C-t
step "ctrl+t lists configured forwards" "forwards: web"
tmux send-keys -t tui "web" Enter
step "tunnel notice" "forwarding -L 18085:127.0.0.1:8081"
expect_out "tunnel from the TUI works" "hello from fsn1-pve1" curl -s --retry 10 --retry-connrefused --retry-delay 1 http://127.0.0.1:18085/
tmux send-keys -t tui C-c
tui
tmux send-keys -t tui "fsn1-pve1"
wait_for 10 shows "fsn/fsn1-pve1.hoster.test"
tmux send-keys -t tui C-e
step "ctrl+e opens the editor" "Connect as: ops@fsn/fsn1-pve1.hoster.test:22"
tmux send-keys -t tui C-u "admin@fsn/fsn1-pve1.hoster.test:22" Enter
sleep 1
tmux send-keys -t tui "whoami" Enter
step "edited user is used" "admin"
tmux send-keys -t tui "exit" Enter
tmux kill-server 2>/dev/null
+9
View File
@@ -0,0 +1,9 @@
# The configurator in huh's accessible mode: without NetBox, add a static host,
# save, then connect. Menu numbers follow the order in internal/setup.
config_only </dev/null
rm -f "$XDG_CONFIG_HOME/netssh.yaml"
# no NetBox, static hosts, add, name, address, user, port, tags, back, save
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 8)
[[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out"
expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml"
expect_eq "connect to the configured host" "admin" netssh legacy-box whoami
+12
View File
@@ -0,0 +1,12 @@
# Uninstall in dry-run mode lists everything netssh created.
config <<'EOF'
resolver: {strategies: [primary_ip]}
EOF
check "refresh" netssh cache refresh
check "hook install" netssh hook install --shell bash
expect_out "lists the profile" ".bashrc" netssh uninstall --dry-run
expect_out "lists the config" "netssh.yaml" netssh uninstall --dry-run
expect_out "lists the cache" ".cache/netssh" netssh uninstall --dry-run
out=$(netssh uninstall --dry-run --keep-config 2>&1)
[[ $out != *"netssh.yaml"* ]] && ok "--keep-config keeps the config" || bad "--keep-config keeps the config" "$out"
expect_out "nothing was removed" "netssh shell-init" cat "$HOME/.bashrc"
+32
View File
@@ -0,0 +1,32 @@
"""Drive `netssh configure` in huh's accessible mode (TERM=dumb): one answer
per prompt, sent after a short pause so each form reads its own line."""
import os
import pty
import select
import sys
import time
answers = sys.argv[1:]
pid, fd = pty.fork()
if pid == 0:
os.execvpe("netssh", ["netssh", "configure"], dict(os.environ, TERM="dumb"))
out = b""
def drain(seconds):
global out
end = time.time() + seconds
while time.time() < end:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
out += os.read(fd, 4096)
except OSError:
return
for answer in answers:
drain(0.5)
os.write(fd, (answer + "\n").encode())
drain(2)
sys.stdout.write(out.decode(errors="replace"))
+78
View File
@@ -0,0 +1,78 @@
# Helpers for the netssh test environment. Sourced by run.sh, the tests, and
# shell.sh inside the client container.
# shellcheck disable=SC1091
. /state/env
RESULTS=${RESULTS:-/tmp/netssh-results}
# new_home gives each test file its own netssh config, cache, notes, and rc files.
new_home() {
HOME=$(mktemp -d /tmp/home.XXXXXX)
export HOME XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache" SHELL=/bin/bash
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
unset TMUX ZELLIJ NETSSH_NETBOX_URL NETSSH_NETBOX_TOKEN
cd "$HOME" || exit 1
}
# base_config prints both NetBox instances followed by stdin.
base_config() { cat /state/netssh.yaml -; }
# config writes the netssh config: both NetBox instances plus stdin.
config() { base_config >"$XDG_CONFIG_HOME/netssh.yaml"; }
# config_only writes stdin as the whole config (no NetBox).
config_only() { cat >"$XDG_CONFIG_HOME/netssh.yaml"; }
ok() {
echo "ok $CURRENT: $1" >>"$RESULTS"
printf ' \033[32mok\033[0m %s\n' "$1"
}
bad() {
echo "FAIL $CURRENT: $1" >>"$RESULTS"
printf ' \033[31mFAIL\033[0m %s\n' "$1"
printf '%s\n' "$2" | sed 's/^/ /'
}
# check NAME CMD... — CMD must succeed.
check() {
local name=$1 out
shift
if out=$("$@" 2>&1); then ok "$name"; else bad "$name" "exit $?: $out"; fi
}
# expect_out NAME WANT CMD... — CMD must succeed and print WANT (substring).
expect_out() {
local name=$1 want=$2 out rc
shift 2
out=$("$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" = 0 ] && [[ $out == *"$want"* ]]; then ok "$name"; else bad "$name" "want: $want
got (exit $rc): $out"; fi
}
# expect_eq NAME WANT CMD... — CMD must succeed and print exactly WANT.
expect_eq() {
local name=$1 want=$2 out rc
shift 2
out=$("$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" = 0 ] && [ "$out" = "$want" ]; then ok "$name"; else bad "$name" "want: $want
got (exit $rc): $out"; fi
}
# expect_fail NAME WANT CMD... — CMD must fail and print WANT (substring).
expect_fail() {
local name=$1 want=$2 out rc
shift 2
out=$("$@" 2>&1) && rc=0 || rc=$?
if [ "$rc" != 0 ] && [[ $out == *"$want"* ]]; then ok "$name"; else bad "$name" "want failure with: $want
got (exit $rc): $out"; fi
}
# wait_for TIMEOUT CMD... — retry CMD every 0.2 s until it succeeds.
wait_for() {
local deadline=$((SECONDS + $1))
shift
until "$@" >/dev/null 2>&1; do
[ $SECONDS -ge $deadline ] && return 1
sleep 0.2
done
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Runs every tests/NN-*.sh (or those matching $1) in a fresh netssh home and
# prints a summary. Exit code 1 if any check failed.
set -u
export RESULTS=/tmp/netssh-results
: >"$RESULTS"
filter=${1:-}
for file in /tests/[0-9][0-9]-*.sh; do
name=$(basename "$file" .sh)
[[ -n $filter && $name != *"$filter"* ]] && continue
echo "== $name"
(
export CURRENT=$name
. /tests/lib.sh
new_home
. "$file"
)
done
passed=$(grep -c '^ok ' "$RESULTS" || true)
failed=$(grep -c '^FAIL ' "$RESULTS" || true)
echo
echo "passed: $passed, failed: $failed"
if [ "$failed" -gt 0 ]; then
grep '^FAIL ' "$RESULTS" | sed 's/^/ /'
exit 1
fi
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Start the netssh test environment: generate the test key, build the images
# (netssh from the current source), start everything, and seed NetBox.
set -euo pipefail
cd "$(dirname "$0")"
compose=(docker compose -f compose.yaml)
mkdir -p .keys .state
chmod a+rwx .state # written by the seed container's user
if [ ! -f .keys/id_ed25519 ]; then
ssh-keygen -q -t ed25519 -N '' -C netssh-test -f .keys/id_ed25519
cp .keys/id_ed25519.pub .keys/authorized_keys
fi
# Throwaway test key: readable by the container user; tests copy it with 0600.
chmod 644 .keys/id_ed25519 .keys/authorized_keys
"${compose[@]}" build
"${compose[@]}" up -d --wait --wait-timeout 600 \
postgres redis netbox-fsn netbox-lab bastion fsn1-pve1 fsn1-pve2 fsn1-pve3 \
web1 web2 lab-db1 lab-web1 legacy-box v6-host client
"${compose[@]}" run --rm seed
echo
echo "Test environment is up."
echo " Tests: testenv/test.sh [FILTER]"
echo " Shell: testenv/shell.sh"
echo " NetBox UI: http://127.0.0.1:18000 (fsn), http://127.0.0.1:18001 (lab) — admin / admin"