Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb99bdded | ||
|
|
55e7a5106b | ||
|
|
3c9e54b254 | ||
|
|
a950a82552 | ||
|
|
5821dfbebd | ||
|
|
2be9f19cc2 | ||
|
|
c71cc0ae3b | ||
|
|
2cadea30c7 | ||
|
|
25c03fff9d | ||
|
|
8e4417a6ea | ||
|
|
2fafa0b119 | ||
|
|
b009863c37 | ||
|
|
a1c9210f3c | ||
|
|
15fb57c0ea | ||
|
|
c55aae520f | ||
|
|
88af34a64b | ||
|
|
314905c538 | ||
|
|
ad3f14186d | ||
|
|
4f08a1c92a | ||
|
|
45a2abce90 | ||
|
|
f7fc1b2a37 | ||
|
|
c45ad9f0d1 | ||
|
|
97fc8f5458 | ||
|
|
d24870e4c0 | ||
|
|
053db984be | ||
|
|
6419fbb00d | ||
|
|
b7fa71fe45 | ||
|
|
878701aba7 | ||
|
|
d51c040d9f | ||
|
|
bbc04723b4 | ||
|
|
22c9a3c32b | ||
|
|
b3f40ac3ad | ||
|
|
a80049a7c3 | ||
|
|
238e3a7798 | ||
|
|
0ce7753a9b | ||
|
|
76e27393dd | ||
|
|
a26e96cd32 | ||
|
|
ac713ac97b | ||
|
|
091f5cc518 | ||
|
|
6243514def |
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
testenv/.keys
|
||||||
|
testenv/.state
|
||||||
@@ -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/
|
||||||
@@ -1,5 +1,31 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
|
||||||
|
- Fix `netssh configure` dropping global SSH options, `ssh.rules`, and `completion.max_results` from the config file when saving.
|
||||||
|
- The configurator validates SSH options and rules before writing the config.
|
||||||
|
- Add `shortcuts.rewrites`: regex rules that shorten hostnames, e.g. `^([a-z]+)[0-9]+-` → `$1` turns `fsn1-bx1.hoster.de` into `fsnbx1`. Shortcuts work for connecting, shell aliases, and Tab completion (offered when the full name does not match), and can be edited in `netssh configure`.
|
||||||
|
- Add `netssh alias --test HOSTNAME` to show the shortcut of a name and the cached hosts it matches.
|
||||||
|
- Add `netssh sftp` and `netssh scp`: host lookup, shortcuts, instances, default user and port, SSH options and rules, jump host, `--dry-run`, and completion work as for ssh. IPv6 addresses are bracketed for host:path.
|
||||||
|
- Add named port forwards (`ssh.forwards` globally, per instance, and per rule), `netssh tunnel HOST NAME|SPEC...` (ssh -N with ExitOnForwardFailure), and `netssh --forward NAME`. Forwards are editable in `netssh configure` and completed in the shell.
|
||||||
|
- TUI: `Ctrl+O` opens SFTP, `Ctrl+T` starts a tunnel with configured or ad hoc forwards.
|
||||||
|
- Add the `hostname` resolver strategy: connect by the NetBox name (plus optional `resolver.hostname_domain`) instead of an IP, always or as a fallback after `primary_ip`.
|
||||||
|
- Add static hosts (`hosts:`) for machines that are not in NetBox, with user, port, tags, and SSH options, managed with `netssh hosts list|add|remove` and in `netssh configure`. They are found before NetBox, work offline and without any NetBox, and support shortcuts, rules (`kind: static`), completion, aliases, sftp, scp, tunnels, and the TUI.
|
||||||
|
- Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination.
|
||||||
|
|
||||||
|
## v0.0.22
|
||||||
|
|
||||||
|
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
|
||||||
|
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
|
||||||
|
|
||||||
## v0.0.21
|
## v0.0.21
|
||||||
|
|
||||||
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
|
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
|
||||||
|
|||||||
@@ -13,10 +13,26 @@ netssh -p 2222 admin@app-server-03 uptime
|
|||||||
|
|
||||||
`netssh` looks up the host in NetBox, resolves the right IP using a configurable strategy chain, and replaces the process with the native `ssh` binary — so all your existing SSH configs, keys, and agent forwarding work without any changes.
|
`netssh` looks up the host in NetBox, resolves the right IP using a configurable strategy chain, and replaces the process with the native `ssh` binary — so all your existing SSH configs, keys, and agent forwarding work without any changes.
|
||||||
|
|
||||||
|
The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the interactive search, shell completion, and generated shell aliases.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Features](#features)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Configuration](#configuration) — [wizard](#interactive-wizard), [manual config](#manual-config), [reference](#configuration-reference), [environment variables](#environment-variables), [files](#files-and-directories), [multiple instances](#multiple-netbox-instances)
|
||||||
|
- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management)
|
||||||
|
- [Recipes](#recipes)
|
||||||
|
- [IP Resolution Strategies](#ip-resolution-strategies)
|
||||||
|
- [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Uninstall](#uninstall), [Development](#development), [How it works](#how-it-works)
|
||||||
|
|
||||||
## 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`
|
||||||
@@ -29,10 +45,12 @@ netssh -p 2222 admin@app-server-03 uptime
|
|||||||
- **Shell completion** — install without sudo via `netssh completion install`
|
- **Shell completion** — install without sudo via `netssh completion install`
|
||||||
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
- **Default SSH user** — set a fallback username once in config instead of typing it every time
|
||||||
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
|
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
|
||||||
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip and hyphen folding
|
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`)
|
||||||
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
|
||||||
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
|
||||||
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
|
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
|
||||||
|
- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh
|
||||||
|
- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI
|
||||||
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
|
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
|
||||||
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
|
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
|
||||||
|
|
||||||
@@ -95,6 +113,22 @@ cd ssh-netbox-wrapper
|
|||||||
go build -o netssh ./cmd/netssh
|
go build -o netssh ./cmd/netssh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. **Install** with the one-liner above and open a new terminal.
|
||||||
|
2. **Configure** — run `netssh` (or `netssh configure`). The wizard asks for your NetBox URL and an API token; a read-only token is enough unless you want to edit NetBox comments. Everything else has working defaults and can be changed later.
|
||||||
|
3. **Fill the cache** — `netssh cache refresh` fetches all devices and virtual machines once. Connecting works without it, but completion, shortcuts, aliases, and the recently-used list need cached hosts.
|
||||||
|
4. **Connect** — `netssh web01`, or just `netssh` for the interactive search.
|
||||||
|
5. **Optional** — enable completion and aliases with `netssh hook install` (the installer usually did this already), and try `netssh --dry-run web01` to see the exact ssh command.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh configure # NetBox URL + token
|
||||||
|
netssh cache refresh # fetch all hosts once
|
||||||
|
netssh web01 # ssh to web01 via its NetBox IP
|
||||||
|
netssh sftp web01 # sftp to the same host
|
||||||
|
netssh tunnel web01 8080:localhost:80 # local port 8080 → web01:80
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Interactive wizard
|
### Interactive wizard
|
||||||
@@ -106,8 +140,20 @@ Re-run it at any time to change settings without editing the file manually:
|
|||||||
netssh configure
|
netssh configure
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
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.
|
||||||
then saves to `~/.config/netssh.yaml`.
|
A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**:
|
||||||
|
|
||||||
|
| Section | Settings |
|
||||||
|
|---|---|
|
||||||
|
| SSH defaults | `ssh.default_user`, `ssh.default_port` |
|
||||||
|
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) |
|
||||||
|
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
|
||||||
|
| Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options |
|
||||||
|
| Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file |
|
||||||
|
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
|
||||||
|
| Completion | `completion.max_results` |
|
||||||
|
|
||||||
|
The result is validated and saved to `~/.config/netssh.yaml`; Ctrl+C cancels without writing.
|
||||||
|
|
||||||
### Manual config
|
### Manual config
|
||||||
|
|
||||||
@@ -150,7 +196,74 @@ shortcuts:
|
|||||||
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
||||||
```
|
```
|
||||||
|
|
||||||
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.; see [Environment variables](#environment-variables)).
|
||||||
|
|
||||||
|
netssh reads `netssh.yaml` from the user config directory and, if none exists there, from the current working directory. Unknown keys are ignored; invalid values (an unknown strategy, a bad regex, an invalid SSH option) stop netssh with an error that names the key.
|
||||||
|
|
||||||
|
### Configuration reference
|
||||||
|
|
||||||
|
Every key, its default, and the section that explains it. Keys marked *layered* can also be set per NetBox instance (`netboxes[].ssh.…`) and per rule (`ssh.rules[].…`).
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `netbox.url` | string | — | NetBox base URL (single instance). Use either `netbox` or `netboxes`. |
|
||||||
|
| `netbox.token` | string | — | API token; `nbt_…` is v2, anything else v1. |
|
||||||
|
| `netbox.token_version` | 1 \| 2 | auto | Detected from the token prefix. |
|
||||||
|
| `netboxes[]` | list | — | Named instances with `name`, `url`, `token`, `token_version`, `ssh` — see [Multiple NetBox instances](#multiple-netbox-instances). |
|
||||||
|
| `resolver.strategies` | list | `[management_subnet, primary_ip]` | Tried in order, first IP wins — see [IP Resolution Strategies](#ip-resolution-strategies). The wizard suggests `primary_ip`. |
|
||||||
|
| `resolver.management_subnets` | list of CIDR | `[]` | Used by `management_subnet`. |
|
||||||
|
| `resolver.interface_name` | string | — | Used by `interface_name`, e.g. `mgmt0`. |
|
||||||
|
| `resolver.hostname_domain` | string | — | Used by `hostname`: appended to NetBox names without a dot. |
|
||||||
|
| `cache.ttl` | seconds | `3600` | How long a cached IP is used without asking NetBox; `0` always asks NetBox when connecting. |
|
||||||
|
| `cache.path` | path | user cache dir `/netssh/hosts.json` | Cache file; named instances use a subdirectory next to it. |
|
||||||
|
| `ssh.default_user` | string | — | Login user when none is given (`-l` for ssh, `-o User=` for sftp/scp). |
|
||||||
|
| `ssh.default_port` | int | — | Port when none is given (`-p` for ssh, `-P` for sftp/scp). |
|
||||||
|
| `ssh.proxy_jump` | string | — | *Layered.* Jump host (`-J`); `none` disables an inherited one. |
|
||||||
|
| `ssh.host_key_checking` | `strict` \| `accept-new` \| `off` | ssh's own | *Layered.* See [SSH options](#ssh-options). |
|
||||||
|
| `ssh.target` | `ip` \| `name` \| `hostname` | `ip` | *Layered.* What is passed to ssh as destination. |
|
||||||
|
| `ssh.address_family` | `any` \| `inet` \| `inet6` | — | *Layered.* `-o AddressFamily=`. |
|
||||||
|
| `ssh.extra_args` | list | `[]` | *Layered.* Extra ssh arguments; accumulate across layers; ssh only. |
|
||||||
|
| `ssh.options` | map | `{}` | *Layered.* `-o Key=Value`; per key, the more specific layer wins. |
|
||||||
|
| `ssh.forwards` | map | `{}` | *Layered.* Named port forwards (`local`, `remote`, `dynamic`) — see [Port forwarding](#port-forwarding). |
|
||||||
|
| `ssh.rules[]` | list | `[]` | `match` (`tag`, `instance`, `kind`, `name`) plus any layered key. |
|
||||||
|
| `shortcuts.domains` | list | `[]` | Suffixes stripped for shortcuts and tried when expanding a short name. |
|
||||||
|
| `shortcuts.strip_hyphens` | bool | `false` | Ignore hyphens when matching 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. |
|
||||||
|
| `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). |
|
||||||
|
| `hosts[]` | list | `[]` | Static hosts with `name`, `address`, `user`, `port`, `tags`, `ssh` — see [Static hosts](#static-hosts). |
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`) can only be set in the file.
|
||||||
|
|
||||||
|
| Variable | Key |
|
||||||
|
|---|---|
|
||||||
|
| `NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, `NETSSH_NETBOX_TOKEN_VERSION` | `netbox.*` (single instance only; ignored when the file has only `netboxes:`) |
|
||||||
|
| `NETSSH_RESOLVER_STRATEGIES`, `NETSSH_RESOLVER_MANAGEMENT_SUBNETS`, `NETSSH_RESOLVER_INTERFACE_NAME`, `NETSSH_RESOLVER_HOSTNAME_DOMAIN` | `resolver.*` |
|
||||||
|
| `NETSSH_CACHE_TTL`, `NETSSH_CACHE_PATH` | `cache.*` |
|
||||||
|
| `NETSSH_SSH_DEFAULT_USER`, `NETSSH_SSH_DEFAULT_PORT` | `ssh.default_*` |
|
||||||
|
| `NETSSH_SSH_PROXY_JUMP`, `NETSSH_SSH_HOST_KEY_CHECKING`, `NETSSH_SSH_TARGET`, `NETSSH_SSH_ADDRESS_FAMILY` | global `ssh.*` options |
|
||||||
|
| `NETSSH_SHORTCUTS_DOMAINS`, `NETSSH_SHORTCUTS_STRIP_HYPHENS` | `shortcuts.*` |
|
||||||
|
| `NETSSH_COMPLETION_MAX_RESULTS` | `completion.max_results` |
|
||||||
|
| `NETSSH_SHELL_ALIAS` | `shell.alias` |
|
||||||
|
|
||||||
|
netssh also respects `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `ZDOTDIR`, and `SHELL` (for `hook install`, `completion install`, and `alias` without `--shell`).
|
||||||
|
|
||||||
|
### Files and directories
|
||||||
|
|
||||||
|
Paths are shown for Linux, where `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` replace `~/.config` and `~/.cache`. On macOS, config and notes live under `~/Library/Application Support`, the host cache and `aliases.sh` under `~/Library/Caches/netssh/` (unless `XDG_CACHE_HOME` is set for `aliases.sh`); fish and completion files use the paths in the table.
|
||||||
|
|
||||||
|
| Path | Content |
|
||||||
|
|---|---|
|
||||||
|
| `~/.config/netssh.yaml` | Configuration, including API tokens; written with mode `0600`. |
|
||||||
|
| `~/.config/netssh/notes/` | Local server notes (`0600`), keyed by NetBox URL, kind, and hostname. |
|
||||||
|
| `~/.cache/netssh/hosts.json` | Host cache (single instance). |
|
||||||
|
| `~/.cache/netssh/instances-<hash>/hosts.json` | Host cache for a set of named instances; the hash changes when names or URLs change. |
|
||||||
|
| `~/.cache/netssh/last_refresh` | Time of the last full refresh, for the 24-hour background refresh. |
|
||||||
|
| `~/.cache/netssh/aliases.sh` | Generated aliases for bash and zsh. |
|
||||||
|
| `~/.config/fish/conf.d/netssh.fish` | Generated aliases for fish. |
|
||||||
|
| `~/.local/share/bash-completion/completions/netssh`, `~/.zfunc/_netssh`, `~/.config/fish/completions/netssh.fish` | Installed completions. |
|
||||||
|
|
||||||
### Multiple NetBox instances
|
### Multiple NetBox instances
|
||||||
|
|
||||||
@@ -236,6 +349,28 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered.
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
### Command overview
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `netssh` | Interactive search (TUI). |
|
||||||
|
| `netssh [--dry-run] [--jump HOST\|--no-jump] [--forward NAME]... [ssh options] HOST [command]` | Connect with ssh. |
|
||||||
|
| `netssh sftp [netssh flags] [sftp options] HOST[:PATH]` | SFTP session — see [SFTP and SCP](#sftp-and-scp). |
|
||||||
|
| `netssh scp [netssh flags] [scp options] SOURCE... TARGET` | Copy files — see [SFTP and SCP](#sftp-and-scp). |
|
||||||
|
| `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). |
|
||||||
|
| `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). |
|
||||||
|
| `netssh configure` | Setup wizard. |
|
||||||
|
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. |
|
||||||
|
| `netssh search QUERY` | Print matching cached hostnames, one per line. |
|
||||||
|
| `netssh alias [--shell bash\|zsh\|fish] [--test HOSTNAME]` | Print shell aliases, or test how a name is shortened. |
|
||||||
|
| `netssh notes show\|set\|append HOST [TEXT] [--netbox] [--kind device\|vm]` | Local notes or NetBox comments. |
|
||||||
|
| `netssh hook install\|uninstall [--shell S]` | Shell integration (aliases, completion, background refresh). |
|
||||||
|
| `netssh completion install [--shell S]` / `netssh completion bash\|zsh\|fish` | Install or print completion scripts. |
|
||||||
|
| `netssh uninstall [--dry-run] [--keep-config] [--yes]` | Remove netssh. |
|
||||||
|
| `netssh version` / `netssh --version` | Print the version. |
|
||||||
|
|
||||||
|
The netssh flags `--dry-run`, `--jump`, `--no-jump`, and `--forward` must come before the host. Everything after the host is passed to ssh as the remote command (for `tunnel`: forward names).
|
||||||
|
|
||||||
### SSH wrapper mode
|
### SSH wrapper mode
|
||||||
|
|
||||||
Pass any SSH flags and a NetBox hostname:
|
Pass any SSH flags and a NetBox hostname:
|
||||||
@@ -316,6 +451,102 @@ With `target: hostname`, ssh resolves the NetBox name itself (DNS or
|
|||||||
Switching to `name` stores new known_hosts entries under the device name, so
|
Switching to `name` stores new known_hosts entries under the device name, so
|
||||||
ssh asks once to confirm each host key.
|
ssh asks once to confirm each host key.
|
||||||
|
|
||||||
|
**Rules.** A rule applies when every field set in its `match` fits the host:
|
||||||
|
|
||||||
|
| Match field | Compared with | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `tag` | NetBox tags of the host | Tag name or slug, case-insensitive (`DMZ Hosts` matches `dmz-hosts`). |
|
||||||
|
| `instance` | Name of the NetBox instance | Only useful with `netboxes:`. |
|
||||||
|
| `kind` | `device`, `vm`, or `static` | Devices come from DCIM, VMs from Virtualization, `static` from `hosts:`. |
|
||||||
|
| `name` | Hostname without instance prefix | Shell glob, case-insensitive (`*.dmz.example.com`, `fw-??`). |
|
||||||
|
|
||||||
|
Tags and kind are taken from the cache or the NetBox answer, so rules work offline for cached hosts. A rule needs at least one match field. All matching rules apply in file order; a later rule overrides an earlier one.
|
||||||
|
|
||||||
|
**How layers combine.** netssh merges global → instance (or the static host's own `ssh`) → matching rules:
|
||||||
|
|
||||||
|
- single values (`proxy_jump`, `host_key_checking`, `target`, `address_family`) — the most specific non-empty value wins;
|
||||||
|
- `options` and `forwards` — merged per key or name, the more specific layer wins;
|
||||||
|
- `extra_args` — collected from all layers.
|
||||||
|
|
||||||
|
Options you type yourself always win: netssh adds its `-o` options after yours, and ssh uses the first value it sees; `-J`, `-4`/`-6`, or your own `StrictHostKeyChecking` suppress the matching setting. Check the result with `--dry-run`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ netssh --dry-run fw01.dmz.example.com
|
||||||
|
ssh -A -l admin -J dmz-jump.example.com -o StrictHostKeyChecking=accept-new -o serveraliveinterval=60 192.0.2.10
|
||||||
|
```
|
||||||
|
|
||||||
|
### SFTP and SCP
|
||||||
|
|
||||||
|
`netssh sftp` and `netssh scp` look up the host exactly like a connection does: cache, shortcuts and rewrite rules, `instance/host`, default user and port, SSH options and rules, `--jump`/`--no-jump`, and `--dry-run`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh sftp fsnbx1 # interactive sftp
|
||||||
|
netssh sftp prod/web01:/var/log # start in a directory
|
||||||
|
netssh scp ./app.tar fsnbx1:/tmp/ # upload
|
||||||
|
netssh scp -r web01:/etc/nginx ./nginx # download
|
||||||
|
netssh scp --dry-run app.tar web01:/tmp/ # print the scp command
|
||||||
|
```
|
||||||
|
|
||||||
|
sftp and scp options are passed through. Mind their differences from ssh: the port is `-P`, and `-l` is a bandwidth limit, so the default user is passed as `-o User=`. `extra_args` and `forwards` contain ssh flags and apply to ssh only. All remote operands of one `scp` call must name the same host; copy between two NetBox hosts in two steps. Tab completion offers `host:` for scp and local files otherwise.
|
||||||
|
|
||||||
|
### Port forwarding
|
||||||
|
|
||||||
|
Plain `-L`, `-R`, and `-D` flags are passed to ssh as usual (`netssh -L 8080:localhost:80 web01`). For tunnels you use often, name them under `ssh.forwards`, globally, per NetBox instance (`netboxes[].ssh.forwards`), or per rule. A more specific layer replaces a forward of the same name.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ssh:
|
||||||
|
forwards:
|
||||||
|
web: {local: "8080:localhost:80"} # -L
|
||||||
|
socks: {dynamic: "1080"} # -D, SOCKS proxy
|
||||||
|
back: {remote: "9000:localhost:9000"} # -R
|
||||||
|
rules:
|
||||||
|
- match: {tag: postgres}
|
||||||
|
forwards:
|
||||||
|
db: {local: "5432:localhost:5432"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh tunnel fsnbx1 web socks # only forwards: ssh -N, stops with Ctrl+C
|
||||||
|
netssh tunnel prod/db01 db 6379:localhost:6379 # names and ad hoc specs mixed
|
||||||
|
netssh --forward web fsnbx1 # shell plus forward (repeatable)
|
||||||
|
```
|
||||||
|
|
||||||
|
A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 <Tab>`) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards.
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
Set `ssh.default_user` in the config to avoid typing a username every time:
|
Set `ssh.default_user` in the config to avoid typing a username every time:
|
||||||
@@ -355,13 +586,26 @@ shortcuts:
|
|||||||
- .example.com
|
- .example.com
|
||||||
- .example.de
|
- .example.de
|
||||||
strip_hyphens: false # optional
|
strip_hyphens: false # optional
|
||||||
|
rewrites: # optional, applied in order
|
||||||
|
- pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1
|
||||||
|
replace: '$1'
|
||||||
```
|
```
|
||||||
|
|
||||||
Resolution order:
|
Resolution order:
|
||||||
|
|
||||||
1. **Exact match** — the input is looked up as-is in the cache.
|
1. **Exact match** — the input is looked up as-is in the cache.
|
||||||
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
|
||||||
3. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
3. **Shortcut match** — the input and every cached hostname are shortened the same way and compared: lowercase, strip the domain suffix, apply each rewrite rule in order, then remove hyphens if `strip_hyphens` is set (`fsn1web01` → `fsn1-web01.example.com`, `fsnbx1` → `fsn1-bx1.hoster.de`). This only works when the host is already cached; run `netssh cache refresh` first.
|
||||||
|
|
||||||
|
**Rewrite rules** use Go regular expressions ([syntax](https://pkg.go.dev/regexp/syntax)); `replace` may refer to groups as `$1` or `${1}`. Every match in the name is replaced. Because the typed shortcut is rewritten too, write rules that leave an already shortened name unchanged: `^([a-z]+)[0-9]+-` matches `fsn1-bx1` but not `fsnbx1`. Shortcuts also become shell aliases and are offered by Tab completion when the full name does not match what you typed (`fsnb<Tab>` → `fsnbx1`).
|
||||||
|
|
||||||
|
Check what a hostname is shortened to, and whether the shortcut is unique in the cache:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ netssh alias --test fsn1-bx1.hoster.de
|
||||||
|
fsn1-bx1.hoster.de → fsnbx1
|
||||||
|
matches fsn1-bx1.hoster.de
|
||||||
|
```
|
||||||
|
|
||||||
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
|
Domains may be configured with or without a leading dot. If several cached hosts normalize to the same shortcut, the connection is rejected; use the full hostname. Live host lookups check every result page and reject duplicate exact names.
|
||||||
|
|
||||||
@@ -369,6 +613,7 @@ Domains may be configured with or without a leading dot. If several cached hosts
|
|||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
|
||||||
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
|
||||||
|
| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) |
|
||||||
|
|
||||||
### Shell aliases
|
### Shell aliases
|
||||||
|
|
||||||
@@ -419,6 +664,8 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
|
|||||||
| `Tab` | autocomplete top result into the search field |
|
| `Tab` | autocomplete top result into the search field |
|
||||||
| `↑` / `↓` | navigate results |
|
| `↑` / `↓` | navigate results |
|
||||||
| `Enter` | connect to selected host |
|
| `Enter` | connect to selected host |
|
||||||
|
| `Ctrl+O` | open an SFTP session to the selected host |
|
||||||
|
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) |
|
||||||
| `Ctrl+E` | open inline editor to override user/port before connecting |
|
| `Ctrl+E` | open inline editor to override user/port before connecting |
|
||||||
| `Ctrl+N` | edit local notes for the selected server |
|
| `Ctrl+N` | edit local notes for the selected server |
|
||||||
| `Ctrl+B` | edit NetBox comments for the selected server |
|
| `Ctrl+B` | edit NetBox comments for the selected server |
|
||||||
@@ -512,6 +759,88 @@ The hook is a no-op when the cache is fresh — it adds no measurable delay to y
|
|||||||
netssh search app- # prints matching hostnames, one per line
|
netssh search app- # prints matching hostnames, one per line
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Recipes
|
||||||
|
|
||||||
|
**Everything through a bastion, except the bastion itself**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ssh:
|
||||||
|
proxy_jump: admin@bastion.example.com
|
||||||
|
rules:
|
||||||
|
- match: {name: "bastion*"}
|
||||||
|
proxy_jump: none
|
||||||
|
```
|
||||||
|
|
||||||
|
**A different jump host per site (NetBox instance)**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
netboxes:
|
||||||
|
- name: fsn
|
||||||
|
url: https://netbox.fsn.example.com
|
||||||
|
token: nbt_…
|
||||||
|
ssh: {proxy_jump: jump.fsn.example.com}
|
||||||
|
- name: hel
|
||||||
|
url: https://netbox.hel.example.com
|
||||||
|
token: nbt_…
|
||||||
|
ssh: {proxy_jump: jump.hel.example.com}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lab machines that are reinstalled every day**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ssh:
|
||||||
|
rules:
|
||||||
|
- match: {instance: lab}
|
||||||
|
host_key_checking: off # prints a notice on every connection
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database and web tunnels by tag**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ssh:
|
||||||
|
forwards:
|
||||||
|
socks: {dynamic: "1080"} # browser via SOCKS on localhost:1080
|
||||||
|
rules:
|
||||||
|
- match: {tag: postgres}
|
||||||
|
forwards: {db: {local: "5432:localhost:5432"}}
|
||||||
|
- match: {tag: grafana}
|
||||||
|
forwards: {web: {local: "3000:localhost:3000"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh tunnel db01 db # psql -h localhost then reaches db01
|
||||||
|
netssh tunnel grafana01 web socks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Short names for a hosting naming scheme** (`fsn1-bx1.hoster.de` → `fsnbx1`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
shortcuts:
|
||||||
|
domains: [.hoster.de]
|
||||||
|
rewrites:
|
||||||
|
- pattern: '^([a-z]+)[0-9]+-'
|
||||||
|
replace: '$1'
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh cache refresh && netssh alias --test fsn1-bx1.hoster.de
|
||||||
|
netssh fsnbx1 # also works as shell alias: fsnbx1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy a file and restart a service**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh scp ./app.tar prod/app01:/tmp/ && netssh prod/app01 'sudo systemctl restart app'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use netssh in scripts**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for host in $(netssh search web-); do
|
||||||
|
netssh "$host" uptime
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
## IP Resolution Strategies
|
## IP Resolution Strategies
|
||||||
|
|
||||||
Strategies are tried in the configured order; the first to succeed wins.
|
Strategies are tried in the configured order; the first to succeed wins.
|
||||||
@@ -523,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
|
||||||
|
|
||||||
@@ -552,6 +897,10 @@ rm -f ~/.zcompdump* && exec zsh
|
|||||||
```
|
```
|
||||||
|
|
||||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
Completions are served from the local cache — no network request on every `<Tab>`.
|
||||||
|
With several NetBox instances, a hostname that exists in only one instance is
|
||||||
|
completed without the instance prefix (`web01`); names that exist in several
|
||||||
|
instances are offered as `prod/web01`, `staging/web01`. In bash, type the
|
||||||
|
instance prefix for those; zsh and fish offer them for the hostname alone.
|
||||||
The zsh script that `netssh completion zsh` prints is built for large caches:
|
The zsh script that `netssh completion zsh` prints is built for large caches:
|
||||||
5000 hosts complete in well under a second. To limit the number of hostnames
|
5000 hosts complete in well under a second. To limit the number of hostnames
|
||||||
offered, set `completion.max_results`.
|
offered, set `completion.max_results`.
|
||||||
@@ -655,6 +1004,28 @@ The shell hook and the connect-time trigger (which fires on each `netssh` invoca
|
|||||||
|
|
||||||
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
|
Both are non-blocking — the refresh runs as a background process and never delays your prompt or SSH connection. They share `~/.cache/netssh/last_refresh`, so only one background refresh runs per 24-hour window regardless of how many shells or connections are opened.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**See what netssh would run.** `--dry-run` works for ssh, `sftp`, `scp`, and `tunnel` and prints the final command with the resolved IP and all added options.
|
||||||
|
|
||||||
|
| Message / symptom | Cause and fix |
|
||||||
|
|---|---|
|
||||||
|
| `host not found` | The name is not in NetBox (devices and VMs are searched). Check the spelling, or configure `shortcuts.domains` if you type short names. |
|
||||||
|
| `host "x" is not in hosts: and no NetBox is configured` | Only static hosts are configured. Add the host with `netssh hosts add`, or a NetBox with `netssh configure`. |
|
||||||
|
| `no strategy resolved an IP` | No resolver strategy found an address. Add `primary_ip` or `hostname` as a fallback at the end of `resolver.strategies`. |
|
||||||
|
| `ambiguous shortcut "x"; use a full hostname` | Several cached hosts shorten to the same name. Use the full name, or check with `netssh alias --test`. |
|
||||||
|
| `found 2 exact matches` | The name exists in several NetBox instances, or as both device and VM. Prefix the instance (`prod/web01`); for notes and the TUI also `--kind`. |
|
||||||
|
| Shortcut works for one host but not another | Shortcuts (hyphen folding, rewrites) only match cached hosts. Run `netssh cache refresh`. |
|
||||||
|
| Connection fails although a NetBox instance is down | Without an instance prefix, every instance must answer to rule out duplicates. Use `instance/host`; a fresh cache entry then needs no NetBox at all. |
|
||||||
|
| Wrong or outdated IP | Lower `cache.ttl`, run `netssh cache refresh`, and check the [strategy order](#ip-resolution-strategies). |
|
||||||
|
| `REMOTE HOST IDENTIFICATION HAS CHANGED` | The host was reinstalled or the IP reused. Remove the old key (`ssh-keygen -R <ip>`), consider `target: name`, or for lab hosts `host_key_checking: off`. |
|
||||||
|
| `unknown forward "x"` | The name is not configured for this host (forwards can be per instance or rule). The message lists the available ones. |
|
||||||
|
| `bind [127.0.0.1]:8080: Address already in use` | The local port of a forward is taken; `netssh tunnel` then exits instead of hanging. Choose another port. |
|
||||||
|
| Saving notes with `--netbox` fails with a permission error | The API token is read-only. Local notes work without write permission. |
|
||||||
|
| `<Tab>` completes files instead of hosts | Run `netssh completion install` and open a new terminal; for zsh also `rm -f ~/.zcompdump*`. |
|
||||||
|
| Aliases missing in a new shell | `netssh hook install`, then a new terminal. The cache must not be empty. |
|
||||||
|
| A host is named `sftp`, `scp`, `tunnel`, `hosts`, or like another subcommand | Use `netssh -- sftp`. |
|
||||||
|
|
||||||
## Uninstall
|
## Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -684,7 +1055,31 @@ go test ./... # includes offline installer/update tests
|
|||||||
go build ./... # build all packages
|
go build ./... # build all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, setup wizard, shortcut normalization, and shell hook install/uninstall.
|
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.
|
||||||
|
|
||||||
|
Project layout:
|
||||||
|
|
||||||
|
| Directory | Content |
|
||||||
|
|---|---|
|
||||||
|
| `cmd/netssh` | Commands, host resolution, completion. |
|
||||||
|
| `internal/config` | Config loading, validation, SSH option layering. |
|
||||||
|
| `internal/netbox` | NetBox API client, multiple instances. |
|
||||||
|
| `internal/resolver` | IP resolution strategies. |
|
||||||
|
| `internal/cache` | Host cache. |
|
||||||
|
| `internal/ssh` | Argument parsing and option injection for ssh, sftp, scp. |
|
||||||
|
| `internal/shortcuts` | Shortcut normalization. |
|
||||||
|
| `internal/tui` | Interactive search. |
|
||||||
|
| `internal/setup` | Setup wizard. |
|
||||||
|
| `internal/hook` | Shell hooks, aliases, completion files. |
|
||||||
|
| `internal/notes` | Local notes and NetBox comments. |
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@@ -692,9 +1087,10 @@ 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`, `search`, `cache`, `completion`, `version`, `uninstall`, …). If not, it enters SSH wrapper mode.
|
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 `netbox.url` is empty, 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 parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
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 resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made.
|
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. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
|
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 adds the configured SSH options for the host (jump host, host key checking, `-o` options) before the destination and calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP (or the name, with `ssh.target: name`).
|
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`.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
"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))
|
||||||
@@ -30,3 +39,86 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostCompletions returns the cached hosts matching toComplete in a form that
|
||||||
|
// starts with the typed text, because bash and fish only accept such
|
||||||
|
// candidates. Without an instance prefix, a hostname that is unique across
|
||||||
|
// all instances is offered bare ("web01"), so it completes like with a single
|
||||||
|
// NetBox; connecting to it still checks every instance for ambiguity.
|
||||||
|
// Names present in several instances stay qualified ("prod/web01").
|
||||||
|
// Hosts whose full name does not match are offered by their configured
|
||||||
|
// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com).
|
||||||
|
func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string {
|
||||||
|
prefix := strings.ToLower(toComplete)
|
||||||
|
qualifiedInput := strings.Contains(prefix, "/")
|
||||||
|
instances := make(map[string]int)
|
||||||
|
for _, e := range entries {
|
||||||
|
_, name := netbox.SplitTarget(e.Name)
|
||||||
|
instances[strings.ToLower(name)]++
|
||||||
|
}
|
||||||
|
var unmatched []cache.Entry
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
source, name := netbox.SplitTarget(e.Name)
|
||||||
|
target := strings.ToLower(e.Name)
|
||||||
|
switch {
|
||||||
|
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
|
||||||
|
if instances[strings.ToLower(name)] == 1 {
|
||||||
|
names = append(names, name)
|
||||||
|
} else {
|
||||||
|
names = append(names, e.Name)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(target, prefix):
|
||||||
|
names = append(names, e.Name)
|
||||||
|
default:
|
||||||
|
unmatched = append(unmatched, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc.Enabled() {
|
||||||
|
names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcutCompletions offers the shortcut of each unmatched host when it starts
|
||||||
|
// with prefix and selects exactly one host, qualified by instance if needed.
|
||||||
|
func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string {
|
||||||
|
bare := make(map[string]int)
|
||||||
|
qualified := make(map[string]int)
|
||||||
|
short := func(e cache.Entry) (string, string) {
|
||||||
|
source, name := netbox.SplitTarget(e.Name)
|
||||||
|
s := shortcuts.Normalize(name, sc)
|
||||||
|
if source == "" {
|
||||||
|
return s, s
|
||||||
|
}
|
||||||
|
return s, strings.ToLower(source) + "/" + s
|
||||||
|
}
|
||||||
|
for _, e := range all {
|
||||||
|
b, q := short(e)
|
||||||
|
bare[b]++
|
||||||
|
qualified[q]++
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var out []string
|
||||||
|
for _, e := range unmatched {
|
||||||
|
b, q := short(e)
|
||||||
|
candidate := ""
|
||||||
|
switch {
|
||||||
|
case strings.Contains(prefix, "/"):
|
||||||
|
if qualified[q] == 1 && strings.HasPrefix(q, prefix) {
|
||||||
|
candidate = q
|
||||||
|
}
|
||||||
|
case !strings.HasPrefix(b, prefix):
|
||||||
|
case bare[b] == 1:
|
||||||
|
candidate = b
|
||||||
|
case q != b && qualified[q] == 1:
|
||||||
|
candidate = q // like full names: zsh accepts it, bash/fish ignore it
|
||||||
|
}
|
||||||
|
if candidate != "" && !seen[candidate] {
|
||||||
|
seen[candidate] = true
|
||||||
|
out = append(out, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -206,3 +207,113 @@ func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostCompletionsMatchTypedText(t *testing.T) {
|
||||||
|
entries := []cache.Entry{
|
||||||
|
{Name: "prod/fsn1-bx1.example.com"},
|
||||||
|
{Name: "prod/fsn1-bx2.example.com"},
|
||||||
|
{Name: "stg/fsn1-bx2.example.com"},
|
||||||
|
{Name: "stg/hel1-tb1.example.com"},
|
||||||
|
{Name: "prod/HEL1-TB2"},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
// Unique names are bare; names in both instances stay qualified.
|
||||||
|
{"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}},
|
||||||
|
{"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}},
|
||||||
|
{"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}},
|
||||||
|
{"stg/fsn", []string{"stg/fsn1-bx2.example.com"}},
|
||||||
|
// An instance name typed without the slash completes qualified targets.
|
||||||
|
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
|
||||||
|
} {
|
||||||
|
if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Single-instance caches are unchanged.
|
||||||
|
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
|
||||||
|
t.Errorf("single instance: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fsnRewrite = config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostCompletionsOfferShortcuts(t *testing.T) {
|
||||||
|
entries := []cache.Entry{
|
||||||
|
{Name: "prod/fsn1-bx1.hoster.de"},
|
||||||
|
{Name: "prod/fsn1-db1.hoster.de"},
|
||||||
|
{Name: "stg/fsn1-db1.hoster.de"},
|
||||||
|
{Name: "prod/web01.hoster.de"},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"fsnb", []string{"fsnbx1"}},
|
||||||
|
// Shortcut shared by two instances stays qualified.
|
||||||
|
{"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}},
|
||||||
|
{"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}},
|
||||||
|
{"stg/fsnd", []string{"stg/fsndb1"}},
|
||||||
|
// A matching full name is not repeated as shortcut.
|
||||||
|
{"web", []string{"web01.hoster.de"}},
|
||||||
|
} {
|
||||||
|
if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two hosts of the same instance with one shortcut cannot be selected by it.
|
||||||
|
same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}}
|
||||||
|
if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 {
|
||||||
|
t.Errorf("ambiguous shortcut offered: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortcutTestOutput(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite)
|
||||||
|
want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n"
|
||||||
|
if out.String() != want {
|
||||||
|
t.Fatalf("got:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable,
|
||||||
|
// so only the cached host can answer.
|
||||||
|
func TestWrapperConnectsByRewriteShortcut(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "")
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||||
|
data := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
|
||||||
|
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil || !strings.Contains(string(out), "10.0.0.9\n") {
|
||||||
|
t.Fatalf("shortcut not resolved from cache: %s, %v", out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+272
-98
@@ -42,6 +42,10 @@ var managedSubcommands = map[string]bool{
|
|||||||
"help": true,
|
"help": true,
|
||||||
"version": true,
|
"version": true,
|
||||||
"uninstall": true,
|
"uninstall": true,
|
||||||
|
"sftp": true,
|
||||||
|
"scp": true,
|
||||||
|
"tunnel": true,
|
||||||
|
"hosts": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -72,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)
|
||||||
@@ -91,48 +95,151 @@ func runSSHWrapper(args []string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
}
|
}
|
||||||
cfg := loadConfigOrSetup()
|
runClient(internalssh.SSH, flags, args)
|
||||||
|
}
|
||||||
|
|
||||||
parsed := internalssh.Parse(args)
|
// destination is the NetBox host named in the arguments of a client.
|
||||||
|
type destination struct {
|
||||||
|
host string
|
||||||
|
user string
|
||||||
|
dests []int // argument indices naming the host; several only for scp
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDestination(client internalssh.Client, args []string) (*destination, error) {
|
||||||
|
if client.Name == internalssh.SCP.Name {
|
||||||
|
operands, err := client.Operands(args)
|
||||||
|
if err != nil || len(operands) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dest := &destination{host: operands[0].Host}
|
||||||
|
for _, operand := range operands {
|
||||||
|
dest.dests = append(dest.dests, operand.Idx)
|
||||||
|
if operand.User != "" {
|
||||||
|
dest.user = operand.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
parsed := client.Parse(args)
|
||||||
if parsed == nil {
|
if parsed == nil {
|
||||||
// No destination found — pass arguments straight through to ssh.
|
return nil, nil
|
||||||
execSSH(internalssh.Result{Args: args}, flags)
|
}
|
||||||
|
return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runClient resolves the NetBox host in args and execs ssh, sftp, or scp.
|
||||||
|
func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) {
|
||||||
|
cfg := loadConfigOrSetup()
|
||||||
|
client = client.WithInstances(instanceNames(cfg))
|
||||||
|
|
||||||
|
dest, err := findDestination(client, args)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
if dest == nil {
|
||||||
|
if len(flags.Forwards) > 0 {
|
||||||
|
fatalf("--forward needs a destination host")
|
||||||
|
}
|
||||||
|
// No destination found — pass arguments straight through.
|
||||||
|
execClient(client, internalssh.Result{Args: args}, flags)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connect := func(name, kind, ip string, tags []string) {
|
|
||||||
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
|
host := resolveTarget(cfg, dest.host)
|
||||||
|
user, port := hostDefaults(cfg, host)
|
||||||
|
// Inject the default user if none was given on the command line.
|
||||||
|
if user != "" && dest.user == "" && !client.HasUserFlag(args) {
|
||||||
|
args = prependArgs(client.UserArgs(user), args, dest.dests)
|
||||||
|
}
|
||||||
|
// Inject the default port if none was given on the command line.
|
||||||
|
if port > 0 && !client.HasPortFlag(args) {
|
||||||
|
args = prependArgs(client.PortArgs(strconv.Itoa(port)), args, dest.dests)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject the configured default user if none was given on the command line.
|
connectHost(cfg, client, flags, host, dest.dests, args)
|
||||||
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
}
|
||||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
|
||||||
parsed.DestIdx += 2
|
|
||||||
}
|
|
||||||
// Inject the configured default port if none was given on the command line.
|
|
||||||
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
|
|
||||||
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
|
|
||||||
parsed.DestIdx += 2
|
|
||||||
}
|
|
||||||
|
|
||||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
// 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.
|
||||||
|
func prependArgs(extra, args []string, dests []int) []string {
|
||||||
|
for i := range dests {
|
||||||
|
dests[i] += len(extra)
|
||||||
|
}
|
||||||
|
return append(append([]string(nil), extra...), args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedHost is a NetBox host with the address to connect to.
|
||||||
|
type resolvedHost struct {
|
||||||
|
Name string // cache/selector name, may carry an instance prefix
|
||||||
|
Kind string
|
||||||
|
IP string
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTarget finds host in the cache (also by shortcut) or NetBox and
|
||||||
|
// resolves its address. It exits on failure.
|
||||||
|
func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||||
|
c := newCache(cfg)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
source, _ := netbox.SplitTarget(parsed.Host)
|
source, _ := netbox.SplitTarget(host)
|
||||||
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||||
|
used := func(entry cache.Entry) resolvedHost {
|
||||||
|
c.MarkUsed(entry.Name)
|
||||||
|
_ = c.Save()
|
||||||
|
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(parsed.Host); fresh && useCache {
|
if entry, fresh := c.Get(host); fresh && useCache {
|
||||||
c.MarkUsed(parsed.Host)
|
return used(entry)
|
||||||
_ = c.Save()
|
|
||||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||||
lookupHost := parsed.Host
|
lookupHost := host
|
||||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
shortcutsEnabled := cfg.Shortcuts.Enabled()
|
||||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||||
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||||
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||||
@@ -141,10 +248,7 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
if fresh {
|
if fresh {
|
||||||
c.MarkUsed(entry.Name)
|
return used(entry)
|
||||||
_ = c.Save()
|
|
||||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||||
lookupHost = entry.Name
|
lookupHost = entry.Name
|
||||||
@@ -152,7 +256,7 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -161,7 +265,7 @@ func runSSHWrapper(args []string) {
|
|||||||
|
|
||||||
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
||||||
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
||||||
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
|
entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false))
|
||||||
if cacheErr != nil {
|
if cacheErr != nil {
|
||||||
fatalf("%v", cacheErr)
|
fatalf("%v", cacheErr)
|
||||||
}
|
}
|
||||||
@@ -174,10 +278,7 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||||
c.MarkUsed(target.Target())
|
return used(cached)
|
||||||
_ = c.Save()
|
|
||||||
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
@@ -187,14 +288,12 @@ func runSSHWrapper(args []string) {
|
|||||||
|
|
||||||
ip, err := chain.Resolve(ctx, &target, nbClient)
|
ip, err := chain.Resolve(ctx, &target, nbClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
fatalf("IP resolution for %q: %v", host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
|
||||||
c.MarkUsed(target.Target())
|
c.Upsert(entry)
|
||||||
_ = c.Save()
|
return used(entry)
|
||||||
|
|
||||||
connect(target.Target(), target.Kind, ip, target.Tags)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||||
@@ -217,24 +316,43 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain
|
|||||||
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectHost applies the SSH options configured for the host and execs ssh.
|
// connectHost applies the SSH options configured for the host and execs the client.
|
||||||
// target is the cache/selector name, which may carry an instance prefix.
|
func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) {
|
||||||
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
|
opts := hostOptions(cfg, host)
|
||||||
instance, name := netbox.SplitTarget(target)
|
if len(flags.Forwards) > 0 {
|
||||||
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
|
forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards)
|
||||||
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
flags.ForwardArgs = forwardArgs
|
||||||
|
}
|
||||||
|
if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) {
|
||||||
|
available := "none configured (ssh.forwards)"
|
||||||
|
if names := forwardNames(opts); len(names) > 0 {
|
||||||
|
available = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available)
|
||||||
|
}
|
||||||
|
_, name := netbox.SplitTarget(host.Name)
|
||||||
|
execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.IP}, opts, flags), flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
// execSSH replaces the process with ssh, or prints the command for --dry-run.
|
// hostOptions merges the global, instance, and rule SSH options for host.
|
||||||
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
|
func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions {
|
||||||
|
instance, name := netbox.SplitTarget(host.Name)
|
||||||
|
return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
// execClient replaces the process with the client, or prints the command for --dry-run.
|
||||||
|
func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||||
if flags.DryRun {
|
if flags.DryRun {
|
||||||
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
|
fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if result.Notice != "" {
|
if result.Notice != "" {
|
||||||
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
||||||
}
|
}
|
||||||
if err := internalssh.Exec(result.Args); err != nil {
|
if err := client.Exec(result.Args); err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,7 +361,7 @@ func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
|
|||||||
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)
|
||||||
|
|
||||||
@@ -254,6 +372,9 @@ func runTUI() {
|
|||||||
|
|
||||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||||
|
m.SetForwards(func(name, kind string, tags []string) []string {
|
||||||
|
return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags}))
|
||||||
|
})
|
||||||
p := tea.NewProgram(m)
|
p := tea.NewProgram(m)
|
||||||
final, err := p.Run()
|
final, err := p.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -297,31 +418,55 @@ func runTUI() {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
var sshArgs []string
|
|
||||||
|
|
||||||
// User override from TUI edit mode takes priority, then config default.
|
|
||||||
user := host.User
|
|
||||||
if user == "" {
|
|
||||||
user = cfg.SSH.DefaultUser
|
|
||||||
}
|
|
||||||
if user != "" {
|
|
||||||
sshArgs = append(sshArgs, "-l", user)
|
|
||||||
}
|
|
||||||
|
|
||||||
port := host.Port
|
|
||||||
if port == "" && cfg.SSH.DefaultPort > 0 {
|
|
||||||
port = strconv.Itoa(cfg.SSH.DefaultPort)
|
|
||||||
}
|
|
||||||
if port != "" {
|
|
||||||
sshArgs = append(sshArgs, "-p", port)
|
|
||||||
}
|
|
||||||
|
|
||||||
sshArgs = append(sshArgs, host.IP)
|
|
||||||
|
|
||||||
c.MarkUsed(host.Name)
|
c.MarkUsed(host.Name)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
|
|
||||||
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
|
client, flags, args := tuiCommand(cfg, host)
|
||||||
|
connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuiCommand builds the client and arguments for the action chosen in the TUI.
|
||||||
|
func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) {
|
||||||
|
client := internalssh.SSH
|
||||||
|
var flags internalssh.WrapperFlags
|
||||||
|
switch host.Action {
|
||||||
|
case tui.ActionSFTP:
|
||||||
|
client = internalssh.SFTP
|
||||||
|
case tui.ActionTunnel:
|
||||||
|
flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
var args []string
|
||||||
|
// User override from TUI edit mode takes priority, then static host and config defaults.
|
||||||
|
defaultUser, defaultPort := hostDefaults(cfg, resolvedHost{Name: host.Name, Kind: host.Kind})
|
||||||
|
user := host.User
|
||||||
|
if user == "" {
|
||||||
|
user = defaultUser
|
||||||
|
}
|
||||||
|
if user != "" {
|
||||||
|
args = append(args, client.UserArgs(user)...)
|
||||||
|
}
|
||||||
|
port := host.Port
|
||||||
|
if port == "" && defaultPort > 0 {
|
||||||
|
port = strconv.Itoa(defaultPort)
|
||||||
|
}
|
||||||
|
if port != "" {
|
||||||
|
args = append(args, client.PortArgs(port)...)
|
||||||
|
}
|
||||||
|
// Apply replaces the destination; a bare name cannot be mistaken for
|
||||||
|
// host:path by sftp, unlike an IPv6 address.
|
||||||
|
_, name := netbox.SplitTarget(host.Name)
|
||||||
|
return client, flags, append(args, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardNames lists the forwards configured for a host, sorted.
|
||||||
|
func forwardNames(opts config.SSHOptions) []string {
|
||||||
|
names := make([]string, 0, len(opts.Forwards))
|
||||||
|
for name := range opts.Forwards {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- cobra root + subcommands ---
|
// --- cobra root + subcommands ---
|
||||||
@@ -332,21 +477,17 @@ func rootCmd() *cobra.Command {
|
|||||||
Short: "SSH wrapper with NetBox hostname resolution",
|
Short: "SSH wrapper with NetBox hostname resolution",
|
||||||
DisableFlagParsing: true,
|
DisableFlagParsing: true,
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
cfg, err := config.Load()
|
if len(args) > 0 && args[len(args)-1] == "--forward" {
|
||||||
if err != nil {
|
cfg, err := config.Load()
|
||||||
return nil, cobra.ShellCompDirectiveError
|
if err != nil {
|
||||||
}
|
return nil, cobra.ShellCompDirectiveError
|
||||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
}
|
||||||
_ = c.Load()
|
return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
|
||||||
hosts := c.Search(toComplete)
|
|
||||||
sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name })
|
|
||||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
|
||||||
hosts = hosts[:limit]
|
|
||||||
}
|
}
|
||||||
|
hosts := completeHosts(toComplete)
|
||||||
completions := make([]cobra.Completion, 0, len(hosts))
|
completions := make([]cobra.Completion, 0, len(hosts))
|
||||||
for _, e := range hosts {
|
for _, host := range hosts {
|
||||||
completions = append(completions, cobra.Completion(e.Name))
|
completions = append(completions, cobra.Completion(host))
|
||||||
}
|
}
|
||||||
// Subcommands at the end, after all hostnames.
|
// Subcommands at the end, after all hostnames.
|
||||||
for _, sub := range cmd.Commands() {
|
for _, sub := range cmd.Commands() {
|
||||||
@@ -358,7 +499,7 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
|
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), 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()
|
||||||
@@ -480,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
|
||||||
}
|
}
|
||||||
@@ -514,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
|
||||||
}
|
}
|
||||||
@@ -540,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
|
||||||
@@ -582,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)
|
||||||
@@ -646,13 +787,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func aliasCmd() *cobra.Command {
|
func aliasCmd() *cobra.Command {
|
||||||
var shell string
|
var shell, test string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "alias",
|
Use: "alias",
|
||||||
Short: "Print shell aliases for all cached hosts",
|
Short: "Print shell aliases for all cached hosts",
|
||||||
Long: `Print shell alias definitions for all cached hosts.
|
Long: `Print shell alias definitions for all cached hosts.
|
||||||
The alias name is the shortened form derived from the configured shortcuts
|
The alias name is the shortened form derived from the configured shortcuts
|
||||||
(domain suffixes stripped, hyphens optionally stripped).
|
(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped).
|
||||||
|
|
||||||
|
Check a rule with: netssh alias --test fsn1-bx1.example.com
|
||||||
|
|
||||||
Source the output in your shell profile:
|
Source the output in your shell profile:
|
||||||
bash/zsh: eval "$(netssh alias)"
|
bash/zsh: eval "$(netssh alias)"
|
||||||
@@ -665,10 +808,14 @@ 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
|
||||||
}
|
}
|
||||||
|
if test != "" {
|
||||||
|
printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if shell == "" {
|
if shell == "" {
|
||||||
shell = filepath.Base(os.Getenv("SHELL"))
|
shell = filepath.Base(os.Getenv("SHELL"))
|
||||||
}
|
}
|
||||||
@@ -695,9 +842,36 @@ Or use in a script:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
||||||
|
cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printShortcutTest helps writing shortcut rules: it shows what a name is
|
||||||
|
// shortened to and which cached hosts share that shortcut.
|
||||||
|
func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) {
|
||||||
|
normalize := normalizeTarget(sc, strings.Contains(name, "/"))
|
||||||
|
short := normalize(name)
|
||||||
|
fmt.Fprintf(w, "%s → %s\n", name, short)
|
||||||
|
var matches []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if normalize(e.Name) == short {
|
||||||
|
matches = append(matches, e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(matches)
|
||||||
|
switch len(matches) {
|
||||||
|
case 0:
|
||||||
|
fmt.Fprintln(w, "no cached host has this shortcut")
|
||||||
|
case 1:
|
||||||
|
fmt.Fprintf(w, "matches %s\n", matches[0])
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
fmt.Fprintf(w, " %s\n", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// shellInitCmd is called at shell startup to update alias files and trigger a
|
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||||
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||||
func shellInitCmd() *cobra.Command {
|
func shellInitCmd() *cobra.Command {
|
||||||
@@ -710,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
|
||||||
@@ -769,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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sftpCmd() *cobra.Command {
|
||||||
|
return transferCmd(internalssh.SFTP, "sftp [--dry-run] [--jump HOST|--no-jump] [sftp options] HOST[:PATH]",
|
||||||
|
"Open an SFTP session to a NetBox host",
|
||||||
|
`Resolve HOST like netssh does for ssh (cache, shortcuts, instance/host,
|
||||||
|
SSH options and rules, jump host) and run sftp. sftp options are passed
|
||||||
|
through; note that sftp uses -P for the port.
|
||||||
|
|
||||||
|
netssh sftp fsnbx1
|
||||||
|
netssh sftp -P 2222 prod/web01:/var/log`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scpCmd() *cobra.Command {
|
||||||
|
return transferCmd(internalssh.SCP, "scp [--dry-run] [--jump HOST|--no-jump] [scp options] SOURCE... TARGET",
|
||||||
|
"Copy files to or from a NetBox host",
|
||||||
|
`Resolve the host in HOST:PATH operands like netssh does for ssh and run scp.
|
||||||
|
All remote operands must name the same host.
|
||||||
|
|
||||||
|
netssh scp ./app.tar fsnbx1:/tmp/
|
||||||
|
netssh scp -r prod/web01:/etc/nginx ./nginx`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferCmd(client internalssh.Client, use, short, long string) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: use,
|
||||||
|
Short: short,
|
||||||
|
Long: long,
|
||||||
|
DisableFlagParsing: true,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||||
|
_ = cmd.Help()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flags, args, err := client.ExtractWrapperFlags(args)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
if len(flags.Forwards) > 0 {
|
||||||
|
fatalf("--forward is only available for ssh and netssh tunnel")
|
||||||
|
}
|
||||||
|
runClient(client, flags, args)
|
||||||
|
},
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
if client.Name == internalssh.SFTP.Name && client.Parse(args) != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(toComplete, "-") {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
if client.Name == internalssh.SCP.Name {
|
||||||
|
if strings.Contains(toComplete, ":") {
|
||||||
|
return nil, cobra.ShellCompDirectiveNoFileComp // remote paths are not completed
|
||||||
|
}
|
||||||
|
// HOST: for remote operands; without a matching host, local files.
|
||||||
|
hosts := completeHosts(toComplete)
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return nil, cobra.ShellCompDirectiveDefault
|
||||||
|
}
|
||||||
|
for i := range hosts {
|
||||||
|
hosts[i] += ":"
|
||||||
|
}
|
||||||
|
return hosts, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tunnelCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "tunnel [--dry-run] [--jump HOST|--no-jump] [ssh options] HOST FORWARD...",
|
||||||
|
Short: "Open port forwards to a NetBox host without a shell",
|
||||||
|
Long: `Run ssh -N with the named forwards configured under ssh.forwards (globally,
|
||||||
|
per NetBox instance, or per rule). A FORWARD that is no configured name but a
|
||||||
|
port spec such as 8080:localhost:80 is used as a local forward (-L).
|
||||||
|
|
||||||
|
netssh tunnel fsnbx1 web socks
|
||||||
|
netssh tunnel prod/db01 5432:localhost:5432`,
|
||||||
|
DisableFlagParsing: true,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
|
||||||
|
_ = cmd.Help()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flags, args, err := internalssh.SSH.ExtractWrapperFlags(args)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
parsed := internalssh.SSH.Parse(args)
|
||||||
|
if parsed == nil {
|
||||||
|
fatalf("usage: netssh tunnel HOST FORWARD...")
|
||||||
|
}
|
||||||
|
// Everything after the host names forwards instead of a remote command.
|
||||||
|
flags.Forwards = append(flags.Forwards, args[parsed.DestIdx+1:]...)
|
||||||
|
flags.Tunnel = true
|
||||||
|
runClient(internalssh.SSH, flags, args[:parsed.DestIdx+1])
|
||||||
|
},
|
||||||
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||||
|
parsed := internalssh.SSH.Parse(args)
|
||||||
|
if parsed == nil {
|
||||||
|
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cobra.ShellCompDirectiveError
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
if entry, ok := cachedHost(cfg, parsed.Host); ok {
|
||||||
|
names = forwardNames(hostOptions(cfg, resolvedHost{Name: entry.Name, Kind: entry.Kind, Tags: entry.Tags}))
|
||||||
|
} else {
|
||||||
|
names = allForwardNames(cfg)
|
||||||
|
}
|
||||||
|
return filterPrefix(names, toComplete, args[parsed.DestIdx+1:]), cobra.ShellCompDirectiveNoFileComp
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeHosts returns the cached hosts for a completion request.
|
||||||
|
func completeHosts(toComplete string) []string {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c := newCache(cfg)
|
||||||
|
_ = c.Load()
|
||||||
|
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||||
|
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||||
|
hosts = hosts[:limit]
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedHost finds a host for completion by name or shortcut, without NetBox.
|
||||||
|
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) {
|
||||||
|
c := newCache(cfg)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
return cache.Entry{}, false
|
||||||
|
}
|
||||||
|
if entry, _ := c.Get(host); entry.Name != "" {
|
||||||
|
return entry, true
|
||||||
|
}
|
||||||
|
if !cfg.Shortcuts.Enabled() {
|
||||||
|
return cache.Entry{}, false
|
||||||
|
}
|
||||||
|
entry, found, _, err := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, strings.Contains(host, "/")))
|
||||||
|
return entry, found && err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allForwardNames lists every configured forward name, for when the host is unknown.
|
||||||
|
func allForwardNames(cfg *config.Config) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
add := func(o config.SSHOptions) {
|
||||||
|
for name := range o.Forwards {
|
||||||
|
seen[strings.ToLower(name)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(cfg.SSH.SSHOptions)
|
||||||
|
add(cfg.NetBox.SSH)
|
||||||
|
for _, source := range cfg.NetBoxes {
|
||||||
|
add(source.SSH)
|
||||||
|
}
|
||||||
|
for _, rule := range cfg.SSH.Rules {
|
||||||
|
add(rule.SSHOptions)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(seen))
|
||||||
|
for name := range seen {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterPrefix keeps the names starting with prefix that were not given yet.
|
||||||
|
func filterPrefix(names []string, prefix string, given []string) []string {
|
||||||
|
used := map[string]bool{}
|
||||||
|
for _, name := range given {
|
||||||
|
used[strings.ToLower(name)] = true
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, name := range names {
|
||||||
|
if strings.HasPrefix(name, strings.ToLower(prefix)) && !used[name] {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNetsshHelper runs main() with the arguments in NETSSH_TEST_ARGS.
|
||||||
|
func TestNetsshHelper(t *testing.T) {
|
||||||
|
if args := os.Getenv("NETSSH_TEST_ARGS"); args != "" {
|
||||||
|
os.Args = append([]string{"netssh"}, strings.Split(args, "\x1f")...)
|
||||||
|
main()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareTransferTest writes a config whose only host is cached; NetBox is
|
||||||
|
// unreachable, so every result must come from cache, shortcuts, and options.
|
||||||
|
func prepareTransferTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "")
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||||
|
data := `netbox:
|
||||||
|
url: http://127.0.0.1:1
|
||||||
|
token: t
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
default_port: 2222
|
||||||
|
extra_args: ["-A"]
|
||||||
|
forwards:
|
||||||
|
web: {local: "8080:localhost:80"}
|
||||||
|
rules:
|
||||||
|
- match: {tag: dmz}
|
||||||
|
proxy_jump: jh.example.com
|
||||||
|
forwards:
|
||||||
|
socks: {dynamic: "1080"}
|
||||||
|
shortcuts:
|
||||||
|
domains: [.hoster.de]
|
||||||
|
rewrites:
|
||||||
|
- pattern: '^([a-z]+)[0-9]+-'
|
||||||
|
replace: '$1'
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), 3600)
|
||||||
|
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device", Tags: []string{"dmz"}})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNetssh(t *testing.T, args ...string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestNetsshHelper$")
|
||||||
|
cmd.Env = append(os.Environ(), "NETSSH_TEST_ARGS="+strings.Join(args, "\x1f"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferAndTunnelCommands(t *testing.T) {
|
||||||
|
prepareTransferTest(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// sftp: -P and -o User, rule jump host, path kept, no ssh-only extra_args.
|
||||||
|
{[]string{"sftp", "--dry-run", "fsnbx1:/var/log"}, "sftp -P 2222 -o User=ops -J jh.example.com 10.0.0.9:/var/log"},
|
||||||
|
{[]string{"sftp", "--dry-run", "-P", "22", "root@fsnbx1"}, "sftp -P 22 -J jh.example.com root@10.0.0.9"},
|
||||||
|
{[]string{"scp", "--dry-run", "-r", "a.txt", "fsn1-bx1.hoster.de:/tmp/"}, "scp -P 2222 -o User=ops -r -J jh.example.com a.txt 10.0.0.9:/tmp/"},
|
||||||
|
{[]string{"scp", "--dry-run", "--no-jump", "fsnbx1:/etc/hosts", "."}, "scp -P 2222 -o User=ops -o ProxyJump=none 10.0.0.9:/etc/hosts ."},
|
||||||
|
// Forwards come from the global options and the matching rule.
|
||||||
|
{[]string{"tunnel", "--dry-run", "fsnbx1", "web", "socks", "5432:db:5432"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 -D 1080 -L 5432:db:5432 -o ExitOnForwardFailure=yes -N 10.0.0.9"},
|
||||||
|
{[]string{"--dry-run", "--forward", "web", "fsnbx1"}, "ssh -A -p 2222 -l ops -J jh.example.com -L 8080:localhost:80 10.0.0.9"},
|
||||||
|
} {
|
||||||
|
out, err := runNetssh(t, tc.args...)
|
||||||
|
if err != nil || out != tc.want {
|
||||||
|
t.Errorf("%v:\n got %s (%v)\n want %s", tc.args, out, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"tunnel", "--dry-run", "fsnbx1"}, "forwards for fsn1-bx1.hoster.de: socks, web"},
|
||||||
|
{[]string{"tunnel", "--dry-run", "fsnbx1", "db"}, `unknown forward "db"; available: socks, web`},
|
||||||
|
{[]string{"sftp", "--forward", "web", "fsnbx1"}, "only available for ssh"},
|
||||||
|
{[]string{"scp", "fsnbx1:a", "other:b"}, "not supported"},
|
||||||
|
} {
|
||||||
|
out, err := runNetssh(t, tc.args...)
|
||||||
|
if err == nil || !strings.Contains(out, tc.want) {
|
||||||
|
t.Errorf("%v: got %s (%v), want error containing %q", tc.args, out, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransferCompletion(t *testing.T) {
|
||||||
|
prepareTransferTest(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"__complete", "sftp", "fsnb"}, "fsnbx1\n"},
|
||||||
|
{[]string{"__complete", "scp", "a.txt", "fsnb"}, "fsnbx1:\n"},
|
||||||
|
{[]string{"__complete", "tunnel", "fsnbx1", ""}, "socks\nweb\n"},
|
||||||
|
{[]string{"__complete", "tunnel", "fsnbx1", "web", ""}, "socks\n"},
|
||||||
|
{[]string{"__complete", "--forward", ""}, "socks\nweb\n"},
|
||||||
|
} {
|
||||||
|
out, err := runNetssh(t, tc.args...)
|
||||||
|
if err != nil || !strings.HasPrefix(out+"\n", tc.want) {
|
||||||
|
t.Errorf("%v: got %q (%v), want prefix %q", tc.args, out, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
||||||
|
|
||||||
go 1.27.3
|
go 1.27.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1
|
charm.land/bubbles/v2 v2.2.1
|
||||||
charm.land/bubbletea/v2 v2.1.0
|
charm.land/bubbletea/v2 v2.1.1
|
||||||
charm.land/huh/v2 v2.0.3
|
charm.land/huh/v2 v2.0.3
|
||||||
charm.land/lipgloss/v2 v2.0.6
|
charm.land/lipgloss/v2 v2.1.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/sys v0.49.0
|
golang.org/x/sys v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -43,11 +43,11 @@ require (
|
|||||||
github.com/sahilm/fuzzy v0.1.3 // indirect
|
github.com/sahilm/fuzzy v0.1.3 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.1.0 // indirect
|
||||||
github.com/stretchr/testify v1.12.1 // indirect
|
github.com/stretchr/testify v1.12.1 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xo/terminfo v1.2.0 // indirect
|
github.com/xo/terminfo v1.3.1 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.6 // indirect
|
||||||
golang.org/x/sync v0.24.0 // indirect
|
golang.org/x/sync v0.25.0 // indirect
|
||||||
golang.org/x/text v0.43.0 // indirect
|
golang.org/x/text v0.44.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
charm.land/bubbletea/v2 v2.1.1 h1:S1S9l37pCZGrj1JexBcr9x4Nr3MVeM42InqCYM/tDpU=
|
||||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
charm.land/bubbletea/v2 v2.1.1/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
charm.land/lipgloss/v2 v2.1.0 h1:RLCNLhSQST+NPOmvUSbRWZ3UFOXsf3ZXFmJ5E/2tHEU=
|
||||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
charm.land/lipgloss/v2 v2.1.0/go.mod h1:wwfOgrNqUA9yjL8nmG6BIO0qQnmkEgJ8ISptbme83gI=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
@@ -87,23 +87,23 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq
|
|||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.1.0 h1:6W6MbVUrnMb8PEhjkqwsVLY7ryuzRfFHF4mQd7L94ag=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.1.0/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
github.com/xo/terminfo v1.3.1 h1:4ZwPCuu4PEFw0PkNp3Hv/x3gqvgLCzYhurszsj101cM=
|
||||||
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
github.com/xo/terminfo v1.3.1/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
golang.org/x/sys v0.50.0 h1:dkKZEFTEKaLr1sjhs0WiKqoi8Jj5nHbS3Njmx1UoMJs=
|
||||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.50.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
Vendored
+94
-3
@@ -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()
|
||||||
|
|||||||
Vendored
+62
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -57,8 +60,34 @@ type CompletionConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ShortcutsConfig struct {
|
type ShortcutsConfig struct {
|
||||||
Domains []string `mapstructure:"domains"`
|
Domains []string `mapstructure:"domains"`
|
||||||
StripHyphens bool `mapstructure:"strip_hyphens"`
|
StripHyphens bool `mapstructure:"strip_hyphens"`
|
||||||
|
Rewrites []ShortcutRewrite `mapstructure:"rewrites"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortcutRewrite replaces every match of Pattern (Go regexp syntax) with
|
||||||
|
// Replace, which may reference groups as $1. Applied after domain stripping.
|
||||||
|
type ShortcutRewrite struct {
|
||||||
|
Pattern string `mapstructure:"pattern"`
|
||||||
|
Replace string `mapstructure:"replace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether any shortcut normalization is configured.
|
||||||
|
func (s ShortcutsConfig) Enabled() bool {
|
||||||
|
return len(s.Domains) > 0 || s.StripHyphens || len(s.Rewrites) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rejects rewrite rules that cannot be compiled.
|
||||||
|
func (s ShortcutsConfig) Validate() error {
|
||||||
|
for i, rewrite := range s.Rewrites {
|
||||||
|
if rewrite.Pattern == "" {
|
||||||
|
return fmt.Errorf("shortcuts.rewrites[%d]: pattern is required", i)
|
||||||
|
}
|
||||||
|
if _, err := regexp.Compile(rewrite.Pattern); err != nil {
|
||||||
|
return fmt.Errorf("shortcuts.rewrites[%d]: invalid pattern %q: %v", i, rewrite.Pattern, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns the canonical config file path.
|
// Path returns the canonical config file path.
|
||||||
@@ -88,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
|
||||||
}
|
}
|
||||||
@@ -128,6 +157,12 @@ func Load() (*Config, error) {
|
|||||||
if err := cfg.ValidateSSH(); err != nil {
|
if err := cfg.ValidateSSH(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||||
|
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)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,3 +178,21 @@ func TestCommandAliasEnvironmentAndValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_ShortcutRewrites(t *testing.T) {
|
||||||
|
dir := writeConfig(t, "shortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n")
|
||||||
|
cfg := loadFromDir(t, dir)
|
||||||
|
want := []ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}
|
||||||
|
if len(cfg.Shortcuts.Rewrites) != 1 || cfg.Shortcuts.Rewrites[0] != want[0] || !cfg.Shortcuts.Enabled() {
|
||||||
|
t.Fatalf("rewrites: %+v", cfg.Shortcuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_InvalidShortcutRewrite(t *testing.T) {
|
||||||
|
for _, rule := range []string{"pattern: '[a-'", "replace: x"} {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "shortcuts:\n rewrites:\n - "+rule+"\n"))
|
||||||
|
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "shortcuts.rewrites[0]") {
|
||||||
|
t.Errorf("%s: got %v", rule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+88
-12
@@ -22,6 +22,48 @@ type SSHOptions struct {
|
|||||||
ExtraArgs []string `mapstructure:"extra_args"`
|
ExtraArgs []string `mapstructure:"extra_args"`
|
||||||
// Options are passed as -o Key=Value.
|
// Options are passed as -o Key=Value.
|
||||||
Options map[string]string `mapstructure:"options"`
|
Options map[string]string `mapstructure:"options"`
|
||||||
|
// Forwards are named port forwards, selected with --forward or netssh tunnel.
|
||||||
|
Forwards map[string]Forward `mapstructure:"forwards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward is one port forward; exactly one of the fields is set.
|
||||||
|
type Forward struct {
|
||||||
|
Local string `mapstructure:"local"` // -L [bind:]port:host:hostport
|
||||||
|
Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport
|
||||||
|
Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80.
|
||||||
|
func (f Forward) Flag() []string {
|
||||||
|
switch {
|
||||||
|
case f.Local != "":
|
||||||
|
return []string{"-L", f.Local}
|
||||||
|
case f.Remote != "":
|
||||||
|
return []string{"-R", f.Remote}
|
||||||
|
case f.Dynamic != "":
|
||||||
|
return []string{"-D", f.Dynamic}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80".
|
||||||
|
func (f Forward) String() string { return strings.Join(f.Flag(), " ") }
|
||||||
|
|
||||||
|
func (f Forward) validate() error {
|
||||||
|
set := 0
|
||||||
|
for _, spec := range []string{f.Local, f.Remote, f.Dynamic} {
|
||||||
|
if spec == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set++
|
||||||
|
if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") {
|
||||||
|
return fmt.Errorf("invalid forward %q", spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if set != 1 {
|
||||||
|
return fmt.Errorf("set exactly one of local, remote, dynamic")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHRule applies SSH options to hosts that match every non-empty field.
|
// SSHRule applies SSH options to hosts that match every non-empty field.
|
||||||
@@ -50,32 +92,46 @@ var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
|
|||||||
|
|
||||||
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
|
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
|
||||||
func (c *Config) ValidateSSH() error {
|
func (c *Config) ValidateSSH() error {
|
||||||
if err := c.SSH.SSHOptions.validate("ssh"); err != nil {
|
if err := c.SSH.SSHOptions.Validate("ssh"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i, rule := range c.SSH.Rules {
|
for i, rule := range c.SSH.Rules {
|
||||||
if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
if err := rule.Validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rule.Match == (SSHMatch{}) {
|
|
||||||
return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i)
|
|
||||||
}
|
|
||||||
if _, err := path.Match(rule.Match.Name, ""); err != nil {
|
|
||||||
return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil {
|
if err := c.NetBox.SSH.Validate("netbox.ssh"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, source := range c.NetBoxes {
|
for _, source := range c.NetBoxes {
|
||||||
if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil {
|
if err := source.SSH.Validate("netboxes." + source.Name + ".ssh"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o SSHOptions) validate(scope string) error {
|
// Validate checks the match and options of a single rule.
|
||||||
|
func (r SSHRule) Validate(scope string) error {
|
||||||
|
if err := r.SSHOptions.Validate(scope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.Match.Validate(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate requires at least one selector and a well-formed name pattern.
|
||||||
|
func (m SSHMatch) Validate(scope string) error {
|
||||||
|
if m == (SSHMatch{}) {
|
||||||
|
return fmt.Errorf("%s: match must set at least one of tag, instance, kind, name", scope)
|
||||||
|
}
|
||||||
|
if _, err := path.Match(m.Name, ""); err != nil {
|
||||||
|
return fmt.Errorf("%s: invalid name pattern %q", scope, m.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rejects values that ssh would not accept or would misinterpret.
|
||||||
|
func (o SSHOptions) Validate(scope string) error {
|
||||||
switch o.HostKeyChecking {
|
switch o.HostKeyChecking {
|
||||||
case "", "strict", "accept-new", "off":
|
case "", "strict", "accept-new", "off":
|
||||||
default:
|
default:
|
||||||
@@ -99,13 +155,25 @@ func (o SSHOptions) validate(scope string) error {
|
|||||||
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for name, forward := range o.Forwards {
|
||||||
|
if !instanceName.MatchString(name) {
|
||||||
|
return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name)
|
||||||
|
}
|
||||||
|
if err := forward.validate(); err != nil {
|
||||||
|
return fmt.Errorf("%s.forwards.%s: %v", scope, name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -133,6 +201,14 @@ func (o SSHOptions) merge(override SSHOptions) SSHOptions {
|
|||||||
for key, value := range override.Options {
|
for key, value := range override.Options {
|
||||||
out.Options[strings.ToLower(key)] = value
|
out.Options[strings.ToLower(key)] = value
|
||||||
}
|
}
|
||||||
|
// Forwards override per name, so a rule can redefine a global "web" forward.
|
||||||
|
out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards))
|
||||||
|
for name, forward := range o.Forwards {
|
||||||
|
out.Forwards[strings.ToLower(name)] = forward
|
||||||
|
}
|
||||||
|
for name, forward := range override.Forwards {
|
||||||
|
out.Forwards[strings.ToLower(name)] = forward
|
||||||
|
}
|
||||||
if override.ProxyJump != "" {
|
if override.ProxyJump != "" {
|
||||||
out.ProxyJump = override.ProxyJump
|
out.ProxyJump = override.ProxyJump
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,3 +88,105 @@ func TestSSHOptionsValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForwardsLayering(t *testing.T) {
|
||||||
|
cfg, err := loadYAML(t, `
|
||||||
|
netboxes:
|
||||||
|
- name: prod
|
||||||
|
url: https://prod.example
|
||||||
|
token: t
|
||||||
|
ssh:
|
||||||
|
forwards:
|
||||||
|
db: {local: "5432:localhost:5432"}
|
||||||
|
ssh:
|
||||||
|
forwards:
|
||||||
|
web: {local: "8080:localhost:80"}
|
||||||
|
socks: {dynamic: "1080"}
|
||||||
|
rules:
|
||||||
|
- match: {tag: dmz}
|
||||||
|
forwards:
|
||||||
|
web: {local: "8443:localhost:443"}
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
|
||||||
|
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
|
||||||
|
t.Fatalf("other instance: %+v", got)
|
||||||
|
}
|
||||||
|
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
|
||||||
|
t.Fatal(f.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardsValidation(t *testing.T) {
|
||||||
|
for _, forwards := range []string{
|
||||||
|
`web: {local: "8080:a:80", dynamic: "1080"}`,
|
||||||
|
`web: {local: "-oProxyCommand=x"}`,
|
||||||
|
`web: {local: "8080 a:80"}`,
|
||||||
|
`"bad name": {local: "8080:a:80"}`,
|
||||||
|
} {
|
||||||
|
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
|
||||||
|
t.Errorf("%s: got %v", forwards, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,3 +242,14 @@ func TestAliasesIncludeInstanceName(t *testing.T) {
|
|||||||
t.Fatalf("alias lost source: %+v", entries)
|
t.Fatalf("alias lost source: %+v", entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAliasesUseRewriteRules(t *testing.T) {
|
||||||
|
cfg := config.Config{Shortcuts: config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
|
||||||
|
}}
|
||||||
|
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "fsn1-bx1.hoster.de"}}, cfg)
|
||||||
|
if len(entries) != 1 || entries[0].Definition("bash") != "alias fsnbx1='netssh fsn1-bx1.hoster.de'" {
|
||||||
|
t.Fatalf("entries: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,7 +200,7 @@ _netssh
|
|||||||
t.Fatalf("%v\n%s", err, out)
|
t.Fatalf("%v\n%s", err, out)
|
||||||
}
|
}
|
||||||
// Directive 36 = NoFileComp|KeepOrder: unsorted host group, no file fallback.
|
// Directive 36 = NoFileComp|KeepOrder: unsorted host group, no file fallback.
|
||||||
want := "describe cache:Manage the cache\ncompadd -V hosts -X %Bhosts%b -- web01 web:02\n"
|
want := "describe cache:Manage the cache\ncompadd -V hosts -X %Bhosts%b -M l:|=* -- web01 web:02\n"
|
||||||
if string(out) != want {
|
if string(out) != want {
|
||||||
t.Fatalf("got:\n%s\nwant:\n%s", out, want)
|
t.Fatalf("got:\n%s\nwant:\n%s", out, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,12 +58,13 @@ _netssh() {
|
|||||||
_describe -t commands 'netssh command' items $nospace && ret=0
|
_describe -t commands 'netssh command' items $nospace && ret=0
|
||||||
fi
|
fi
|
||||||
if (( $#plain )); then
|
if (( $#plain )); then
|
||||||
|
# netssh already filtered the hosts, also by the name after an instance
|
||||||
|
# prefix: "fsn1" offers "prod/fsn1-…". 'l:|=*' lets zsh accept text
|
||||||
|
# before the typed word instead of discarding these matches.
|
||||||
|
local -a group=(-J hosts)
|
||||||
# Directive 32: keep the order supplied by netssh.
|
# Directive 32: keep the order supplied by netssh.
|
||||||
if (( directive & 32 )); then
|
(( directive & 32 )) && group=(-V hosts)
|
||||||
compadd -V hosts -X '%Bhosts%b' $nospace -- $plain && ret=0
|
compadd $group -X '%Bhosts%b' -M 'l:|=*' $nospace -- $plain && ret=0
|
||||||
else
|
|
||||||
compadd -J hosts -X '%Bhosts%b' $nospace -- $plain && ret=0
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
# Directive 4: no file completion as a fallback.
|
# Directive 4: no file completion as a fallback.
|
||||||
if (( ! $#lines && ! (directive & 4) )); then
|
if (( ! $#lines && ! (directive & 4) )); then
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -30,6 +42,7 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
|||||||
var options []huh.Option[string]
|
var options []huh.Option[string]
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
|
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
|
||||||
|
options = append(options, huh.NewOption("SSH options for "+source.Name, "ssh:"+source.Name))
|
||||||
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
|
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
|
||||||
}
|
}
|
||||||
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
|
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
|
||||||
@@ -53,13 +66,20 @@ func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
|||||||
if source.Name != name {
|
if source.Name != name {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if verb == "edit" {
|
switch verb {
|
||||||
|
case "edit":
|
||||||
edited, err := editInstance(source, sources, name)
|
edited, err := editInstance(source, sources, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sources[i] = edited
|
sources[i] = edited
|
||||||
} else {
|
case "ssh":
|
||||||
|
options, err := editSSHOptions("SSH options for "+name, source.SSH)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sources[i].SSH = options
|
||||||
|
default:
|
||||||
remove := false
|
remove := false
|
||||||
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
|
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/huh/v2"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides.
|
||||||
|
func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") }
|
||||||
|
|
||||||
|
// editSSHOptions edits one SSH option layer: global, per instance, or per rule.
|
||||||
|
func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) {
|
||||||
|
proxyJump := o.ProxyJump
|
||||||
|
hostKeyChecking := o.HostKeyChecking
|
||||||
|
target := o.Target
|
||||||
|
addressFamily := o.AddressFamily
|
||||||
|
extraArgs := strings.Join(o.ExtraArgs, "\n")
|
||||||
|
options := formatOptions(o.Options)
|
||||||
|
forwards := formatForwards(o.Forwards)
|
||||||
|
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Jump host").
|
||||||
|
Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere.").
|
||||||
|
Value(&proxyJump).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh")
|
||||||
|
}),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Host key checking").
|
||||||
|
Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")).
|
||||||
|
Value(&hostKeyChecking),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Connection target").
|
||||||
|
Description("What is passed to ssh as destination.").
|
||||||
|
Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")).
|
||||||
|
Value(&target),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Address family").
|
||||||
|
Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")).
|
||||||
|
Value(&addressFamily),
|
||||||
|
huh.NewText().
|
||||||
|
Title("Extra arguments").
|
||||||
|
Description("One argument per line, inserted before the destination, e.g. -A").
|
||||||
|
Lines(3).
|
||||||
|
Value(&extraArgs),
|
||||||
|
huh.NewText().
|
||||||
|
Title("SSH options").
|
||||||
|
Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60").
|
||||||
|
Lines(4).
|
||||||
|
Value(&options).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
_, err := parseOptions(s)
|
||||||
|
return err
|
||||||
|
}),
|
||||||
|
huh.NewText().
|
||||||
|
Title("Port forwards").
|
||||||
|
Description("One per line: NAME L|R|D SPEC, e.g. web L 8080:localhost:80 or socks D 1080.\n"+
|
||||||
|
"Use with netssh tunnel HOST NAME or netssh --forward NAME HOST.").
|
||||||
|
Lines(3).
|
||||||
|
Value(&forwards).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
_, err := parseForwards(s)
|
||||||
|
return err
|
||||||
|
}),
|
||||||
|
).Title(title)).Run()
|
||||||
|
if err != nil {
|
||||||
|
return o, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, _ := parseOptions(options)
|
||||||
|
parsedForwards, _ := parseForwards(forwards)
|
||||||
|
return config.SSHOptions{
|
||||||
|
Forwards: parsedForwards,
|
||||||
|
ProxyJump: strings.TrimSpace(proxyJump),
|
||||||
|
HostKeyChecking: hostKeyChecking,
|
||||||
|
Target: target,
|
||||||
|
AddressFamily: addressFamily,
|
||||||
|
ExtraArgs: parseLines(extraArgs),
|
||||||
|
Options: parsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLines returns the non-empty, trimmed lines of s.
|
||||||
|
func parseLines(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
if line = strings.TrimSpace(line); line != "" {
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOptions reads Key=Value lines into the map stored under ssh.options.
|
||||||
|
func parseOptions(s string) (map[string]string, error) {
|
||||||
|
var out map[string]string
|
||||||
|
for _, line := range parseLines(s) {
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if !ok || key == "" {
|
||||||
|
return nil, fmt.Errorf("%q: expected Key=Value", line)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = make(map[string]string)
|
||||||
|
}
|
||||||
|
out[key] = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardKinds maps the letters used in the form to the ssh flags.
|
||||||
|
var forwardKinds = map[string]func(spec string) config.Forward{
|
||||||
|
"L": func(spec string) config.Forward { return config.Forward{Local: spec} },
|
||||||
|
"R": func(spec string) config.Forward { return config.Forward{Remote: spec} },
|
||||||
|
"D": func(spec string) config.Forward { return config.Forward{Dynamic: spec} },
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseForwards reads "NAME L|R|D SPEC" lines into ssh.forwards.
|
||||||
|
func parseForwards(s string) (map[string]config.Forward, error) {
|
||||||
|
var out map[string]config.Forward
|
||||||
|
for _, line := range parseLines(s) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) != 3 || forwardKinds[strings.ToUpper(fields[1])] == nil {
|
||||||
|
return nil, fmt.Errorf("%q: expected NAME L|R|D SPEC", line)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = make(map[string]config.Forward)
|
||||||
|
}
|
||||||
|
out[strings.ToLower(fields[0])] = forwardKinds[strings.ToUpper(fields[1])](fields[2])
|
||||||
|
}
|
||||||
|
if err := (config.SSHOptions{Forwards: out}).Validate("ssh"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatForwards(forwards map[string]config.Forward) string {
|
||||||
|
names := make([]string, 0, len(forwards))
|
||||||
|
for name := range forwards {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
lines := make([]string, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
flag := forwards[name].Flag()
|
||||||
|
if len(flag) == 2 {
|
||||||
|
lines = append(lines, name+" "+strings.TrimPrefix(flag[0], "-")+" "+flag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOptions(options map[string]string) string {
|
||||||
|
keys := make([]string, 0, len(options))
|
||||||
|
for key := range options {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
lines := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
lines = append(lines, key+"="+options[key])
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureRules edits a copy of the rules; the caller decides whether to keep it.
|
||||||
|
func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) {
|
||||||
|
rules = append([]config.SSHRule(nil), rules...)
|
||||||
|
for {
|
||||||
|
action := "done"
|
||||||
|
var options []huh.Option[string]
|
||||||
|
for i, rule := range rules {
|
||||||
|
index := strconv.Itoa(i)
|
||||||
|
options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index))
|
||||||
|
options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index))
|
||||||
|
}
|
||||||
|
options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done"))
|
||||||
|
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
|
||||||
|
Title("SSH rules").
|
||||||
|
Description("Rules apply SSH options to hosts matching every set field. Later rules win.").
|
||||||
|
Options(options...).
|
||||||
|
Value(&action))).Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if action == "done" {
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
if action == "add" {
|
||||||
|
rule, err := editRule(config.SSHRule{}, sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
verb, index, _ := strings.Cut(action, ":")
|
||||||
|
i, _ := strconv.Atoi(index)
|
||||||
|
if verb == "edit" {
|
||||||
|
rule, err := editRule(rules[i], sources)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules[i] = rule
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remove := false
|
||||||
|
if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
rules = append(rules[:i], rules[i+1:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) {
|
||||||
|
match := rule.Match
|
||||||
|
instances := []huh.Option[string]{huh.NewOption("(any)", "")}
|
||||||
|
known := false
|
||||||
|
for _, source := range sources {
|
||||||
|
instances = append(instances, huh.NewOption(source.Name, source.Name))
|
||||||
|
known = known || strings.EqualFold(source.Name, match.Instance)
|
||||||
|
}
|
||||||
|
if match.Instance != "" && !known {
|
||||||
|
instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance))
|
||||||
|
}
|
||||||
|
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Tag").
|
||||||
|
Description("NetBox tag name or slug, e.g. dmz").
|
||||||
|
Value(&match.Tag),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Instance").
|
||||||
|
Options(instances...).
|
||||||
|
Value(&match.Instance),
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Kind").
|
||||||
|
Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")).
|
||||||
|
Value(&match.Kind),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Name pattern").
|
||||||
|
Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com").
|
||||||
|
Value(&match.Name).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
candidate := match
|
||||||
|
candidate.Tag = strings.TrimSpace(candidate.Tag)
|
||||||
|
candidate.Name = strings.TrimSpace(s)
|
||||||
|
return candidate.Validate("rule")
|
||||||
|
}),
|
||||||
|
).Title("Rule match")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return rule, err
|
||||||
|
}
|
||||||
|
match.Tag = strings.TrimSpace(match.Tag)
|
||||||
|
match.Name = strings.TrimSpace(match.Name)
|
||||||
|
|
||||||
|
options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions)
|
||||||
|
if err != nil {
|
||||||
|
return rule, err
|
||||||
|
}
|
||||||
|
return config.SSHRule{Match: match, SSHOptions: options}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeMatch(m config.SSHMatch) string {
|
||||||
|
var parts []string
|
||||||
|
for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} {
|
||||||
|
if field[1] != "" {
|
||||||
|
parts = append(parts, field[0]+"="+field[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "[" + strings.Join(parts, " ") + "]"
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLines(t *testing.T) {
|
||||||
|
got := parseLines(" -A \n\n\t-C\n \n")
|
||||||
|
if want := []string{"-A", "-C"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := parseLines(" \n "); got != nil {
|
||||||
|
t.Fatalf("blank input: got %q, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsRoundtrip(t *testing.T) {
|
||||||
|
text := "ServerAliveInterval = 60\n\nForwardAgent=yes\nLocalCommand=echo a=b\n"
|
||||||
|
got, err := parseOptions(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{"ServerAliveInterval": "60", "ForwardAgent": "yes", "LocalCommand": "echo a=b"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
again, err := parseOptions(formatOptions(got))
|
||||||
|
if err != nil || !reflect.DeepEqual(again, want) {
|
||||||
|
t.Fatalf("roundtrip: got %v (%v)", again, err)
|
||||||
|
}
|
||||||
|
if formatOptions(got) != "ForwardAgent=yes\nLocalCommand=echo a=b\nServerAliveInterval=60" {
|
||||||
|
t.Fatalf("formatOptions not sorted: %q", formatOptions(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsEmpty(t *testing.T) {
|
||||||
|
got, err := parseOptions("\n \n")
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("got %v, %v; want nil, nil", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOptionsInvalid(t *testing.T) {
|
||||||
|
for _, text := range []string{"ServerAliveInterval", "=60", "Bad-Key=1", "1Key=1"} {
|
||||||
|
if _, err := parseOptions(text); err == nil {
|
||||||
|
t.Errorf("%q: expected error", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeMatch(t *testing.T) {
|
||||||
|
got := describeMatch(config.SSHMatch{Tag: "dmz", Name: "*.dmz"})
|
||||||
|
if got != "[tag=dmz name=*.dmz]" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleValidation(t *testing.T) {
|
||||||
|
if err := (config.SSHMatch{}).Validate("rule"); err == nil || !strings.Contains(err.Error(), "at least one") {
|
||||||
|
t.Fatalf("empty match: got %v", err)
|
||||||
|
}
|
||||||
|
if err := (config.SSHMatch{Name: "[web"}).Validate("rule"); err == nil {
|
||||||
|
t.Fatal("invalid glob accepted")
|
||||||
|
}
|
||||||
|
if err := (config.SSHMatch{Kind: "vm"}).Validate("rule"); err != nil {
|
||||||
|
t.Fatalf("valid match rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseForwardsRoundtrip(t *testing.T) {
|
||||||
|
text := "Web L 8080:localhost:80\n\nsocks d 1080\nback R 9000:localhost:9000\n"
|
||||||
|
got, err := parseForwards(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
if formatForwards(got) != "back R 9000:localhost:9000\nsocks D 1080\nweb L 8080:localhost:80" {
|
||||||
|
t.Fatalf("format: %q", formatForwards(got))
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"web 8080:localhost:80", "web X 8080", "web L -oFoo", "bad/name L 1:a:2"} {
|
||||||
|
if _, err := parseForwards(bad); err == nil {
|
||||||
|
t.Errorf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+345
-165
@@ -17,170 +17,26 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
// RunWizard runs the interactive configurator, pre-filled with any existing cfg values.
|
||||||
// It saves the result to the config file on success.
|
// It saves the result to the config file on success.
|
||||||
func RunWizard(cfg *config.Config) error {
|
func RunWizard(cfg *config.Config) error {
|
||||||
sources, err := configureInstances(cfg)
|
sources, err := configureInstances(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, huh.ErrUserAborted) {
|
return cancelled(err)
|
||||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
defaultUser := cfg.SSH.DefaultUser
|
work := prepare(*cfg, sources)
|
||||||
defaultPort := ""
|
if err := runMenu(&work); err != nil {
|
||||||
if cfg.SSH.DefaultPort > 0 {
|
return cancelled(err)
|
||||||
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
|
||||||
}
|
|
||||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
|
||||||
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
|
||||||
interfaceName := cfg.Resolver.InterfaceName
|
|
||||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
|
||||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
|
||||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
|
||||||
commandAlias := cfg.Shell.Alias
|
|
||||||
|
|
||||||
if strategiesRaw == "" {
|
|
||||||
strategiesRaw = "primary_ip"
|
|
||||||
}
|
|
||||||
if cacheTTL == "0" {
|
|
||||||
cacheTTL = "3600"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form := huh.NewForm(
|
if err := save(work); err != nil {
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Default SSH user").
|
|
||||||
Description("Leave empty to use your system user ($USER).").
|
|
||||||
Value(&defaultUser),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Default SSH port").
|
|
||||||
Description("Leave empty to use the standard port (22).").
|
|
||||||
Placeholder("22").
|
|
||||||
Value(&defaultPort).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
|
||||||
if err != nil || n < 1 || n > 65535 {
|
|
||||||
return errors.New("must be a port number between 1 and 65535")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
).Title("SSH defaults"),
|
|
||||||
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Resolver strategies").
|
|
||||||
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
|
||||||
Placeholder("primary_ip, management_subnet").
|
|
||||||
Value(&strategiesRaw).
|
|
||||||
Validate(validateStrategies),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Management subnets").
|
|
||||||
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
|
|
||||||
Value(&subnets),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Interface name").
|
|
||||||
Description("Only used when interface_name strategy is active.").
|
|
||||||
Placeholder("eth0").
|
|
||||||
Value(&interfaceName),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Cache TTL (seconds)").
|
|
||||||
Value(&cacheTTL).
|
|
||||||
Validate(func(s string) error {
|
|
||||||
if _, err := strconv.Atoi(s); err != nil {
|
|
||||||
return errors.New("must be a number")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
).Title("Resolver & cache"),
|
|
||||||
|
|
||||||
huh.NewGroup(
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Domain suffixes").
|
|
||||||
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
|
||||||
Placeholder(".example.com").
|
|
||||||
Value(&shortcutDomains),
|
|
||||||
huh.NewConfirm().
|
|
||||||
Title("Strip hyphens").
|
|
||||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
|
||||||
Value(&stripHyphens),
|
|
||||||
huh.NewInput().
|
|
||||||
Title("Command alias").
|
|
||||||
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
|
||||||
Placeholder("j").
|
|
||||||
Value(&commandAlias).
|
|
||||||
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
|
||||||
).Title("Shortcuts"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := form.Run(); err != nil {
|
|
||||||
if errors.Is(err, huh.ErrUserAborted) {
|
|
||||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
strategies := parseStrategies(strategiesRaw)
|
|
||||||
|
|
||||||
ttl, _ := strconv.Atoi(cacheTTL)
|
|
||||||
|
|
||||||
port := 0
|
|
||||||
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
|
|
||||||
port = p
|
|
||||||
}
|
|
||||||
|
|
||||||
var subnetList []string
|
|
||||||
for _, s := range strings.Split(subnets, ",") {
|
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
|
||||||
subnetList = append(subnetList, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainList []string
|
|
||||||
for _, s := range strings.Split(shortcutDomains, ",") {
|
|
||||||
if s = strings.TrimSpace(s); s != "" {
|
|
||||||
if !strings.HasPrefix(s, ".") {
|
|
||||||
s = "." + s
|
|
||||||
}
|
|
||||||
domainList = append(domainList, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := config.Config{
|
|
||||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
|
||||||
NetBoxes: sources,
|
|
||||||
SSH: config.SSHConfig{
|
|
||||||
DefaultUser: strings.TrimSpace(defaultUser),
|
|
||||||
DefaultPort: port,
|
|
||||||
},
|
|
||||||
Resolver: config.ResolverConfig{
|
|
||||||
Strategies: strategies,
|
|
||||||
ManagementSubnets: subnetList,
|
|
||||||
InterfaceName: strings.TrimSpace(interfaceName),
|
|
||||||
},
|
|
||||||
Cache: config.CacheConfig{
|
|
||||||
TTL: ttl,
|
|
||||||
Path: cfg.Cache.Path,
|
|
||||||
},
|
|
||||||
Shortcuts: config.ShortcutsConfig{
|
|
||||||
Domains: domainList,
|
|
||||||
StripHyphens: stripHyphens,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := save(out); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
loaded, err := config.Load()
|
loaded, err := config.Load()
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -202,10 +58,282 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cancelled(err error) error {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare starts from the loaded config so settings without a menu entry survive
|
||||||
|
// a rewrite, migrates a legacy single instance, and fills in usable defaults.
|
||||||
|
func prepare(cfg config.Config, sources []config.NetBoxConfig) config.Config {
|
||||||
|
cfg.NetBox = config.NetBoxConfig{}
|
||||||
|
cfg.NetBoxes = sources
|
||||||
|
if len(cfg.Resolver.Strategies) == 0 {
|
||||||
|
cfg.Resolver.Strategies = []string{"primary_ip"}
|
||||||
|
}
|
||||||
|
if cfg.Cache.TTL == 0 {
|
||||||
|
cfg.Cache.TTL = 3600
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMenu(cfg *config.Config) error {
|
||||||
|
for {
|
||||||
|
action := "save"
|
||||||
|
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
|
||||||
|
Title("netssh configuration").
|
||||||
|
Description("Choose a section to edit, then save.").
|
||||||
|
Options(
|
||||||
|
huh.NewOption("SSH defaults — user, port", "defaults"),
|
||||||
|
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
|
||||||
|
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
|
||||||
|
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
|
||||||
|
huh.NewOption("Resolver & cache", "resolver"),
|
||||||
|
huh.NewOption("Shortcuts & command alias", "shortcuts"),
|
||||||
|
huh.NewOption("Completion", "completion"),
|
||||||
|
huh.NewOption("Save and exit", "save"),
|
||||||
|
).
|
||||||
|
Value(&action))).Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "defaults":
|
||||||
|
err = editSSHDefaults(cfg)
|
||||||
|
case "ssh":
|
||||||
|
cfg.SSH.SSHOptions, err = editSSHOptions("SSH options (all hosts)", cfg.SSH.SSHOptions)
|
||||||
|
case "rules":
|
||||||
|
var rules []config.SSHRule
|
||||||
|
if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil {
|
||||||
|
cfg.SSH.Rules = rules
|
||||||
|
}
|
||||||
|
case "hosts":
|
||||||
|
var hosts []config.StaticHost
|
||||||
|
if hosts, err = configureHosts(cfg.Hosts); err == nil {
|
||||||
|
cfg.Hosts = hosts
|
||||||
|
}
|
||||||
|
case "resolver":
|
||||||
|
err = editResolverCache(cfg)
|
||||||
|
case "shortcuts":
|
||||||
|
err = editShortcuts(cfg)
|
||||||
|
case "completion":
|
||||||
|
err = editCompletion(cfg)
|
||||||
|
case "save":
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func editSSHDefaults(cfg *config.Config) error {
|
||||||
|
defaultUser := cfg.SSH.DefaultUser
|
||||||
|
defaultPort := ""
|
||||||
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
|
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||||
|
}
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Default SSH user").
|
||||||
|
Description("Leave empty to use your system user ($USER).").
|
||||||
|
Value(&defaultUser),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Default SSH port").
|
||||||
|
Description("Leave empty to use the standard port (22).").
|
||||||
|
Placeholder("22").
|
||||||
|
Value(&defaultPort).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil || n < 1 || n > 65535 {
|
||||||
|
return errors.New("must be a port number between 1 and 65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
).Title("SSH defaults")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.SSH.DefaultUser = strings.TrimSpace(defaultUser)
|
||||||
|
cfg.SSH.DefaultPort, _ = strconv.Atoi(strings.TrimSpace(defaultPort))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func editResolverCache(cfg *config.Config) error {
|
||||||
|
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
||||||
|
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||||
|
interfaceName := cfg.Resolver.InterfaceName
|
||||||
|
hostnameDomain := cfg.Resolver.HostnameDomain
|
||||||
|
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||||
|
cachePath := cfg.Cache.Path
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Resolver strategies").
|
||||||
|
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name, hostname").
|
||||||
|
Placeholder("primary_ip, management_subnet").
|
||||||
|
Value(&strategiesRaw).
|
||||||
|
Validate(validateStrategies),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Management subnets").
|
||||||
|
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
|
||||||
|
Value(&subnets),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Interface name").
|
||||||
|
Description("Only used when interface_name strategy is active.").
|
||||||
|
Placeholder("eth0").
|
||||||
|
Value(&interfaceName),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Hostname domain").
|
||||||
|
Description("Only used by the hostname strategy: appended to NetBox names without a dot,\ne.g. example.com turns web01 into web01.example.com.").
|
||||||
|
Value(&hostnameDomain),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Cache TTL (seconds)").
|
||||||
|
Value(&cacheTTL).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if _, err := strconv.Atoi(s); err != nil {
|
||||||
|
return errors.New("must be a number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Cache file").
|
||||||
|
Description("Where resolved hosts are stored. Leave empty for the default location.").
|
||||||
|
Value(&cachePath),
|
||||||
|
).Title("Resolver & cache")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Resolver.Strategies = parseStrategies(strategiesRaw)
|
||||||
|
cfg.Resolver.ManagementSubnets = splitList(subnets)
|
||||||
|
cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName)
|
||||||
|
cfg.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".")
|
||||||
|
cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL)
|
||||||
|
cfg.Cache.Path = strings.TrimSpace(cachePath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func editShortcuts(cfg *config.Config) error {
|
||||||
|
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||||
|
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||||
|
rewrites := formatRewrites(cfg.Shortcuts.Rewrites)
|
||||||
|
commandAlias := cfg.Shell.Alias
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Domain suffixes").
|
||||||
|
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
||||||
|
Placeholder(".example.com").
|
||||||
|
Value(&shortcutDomains),
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title("Strip hyphens").
|
||||||
|
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||||
|
Value(&stripHyphens),
|
||||||
|
huh.NewText().
|
||||||
|
Title("Rewrite rules").
|
||||||
|
Description("One rule per line: REGEX => REPLACEMENT, applied after the domain suffix is removed.\n"+
|
||||||
|
"Example: ^([a-z]+)[0-9]+- => $1 turns fsn1-bx1.example.com into fsnbx1.\n"+
|
||||||
|
"Check rules with: netssh alias --test HOSTNAME").
|
||||||
|
Lines(3).
|
||||||
|
Value(&rewrites).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
_, err := parseRewrites(s)
|
||||||
|
return err
|
||||||
|
}),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Command alias").
|
||||||
|
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
||||||
|
Placeholder("j").
|
||||||
|
Value(&commandAlias).
|
||||||
|
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
||||||
|
).Title("Shortcuts")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Shortcuts.Domains = nil
|
||||||
|
for _, s := range splitList(shortcutDomains) {
|
||||||
|
if !strings.HasPrefix(s, ".") {
|
||||||
|
s = "." + s
|
||||||
|
}
|
||||||
|
cfg.Shortcuts.Domains = append(cfg.Shortcuts.Domains, s)
|
||||||
|
}
|
||||||
|
cfg.Shortcuts.StripHyphens = stripHyphens
|
||||||
|
cfg.Shortcuts.Rewrites, _ = parseRewrites(rewrites)
|
||||||
|
cfg.Shell.Alias = strings.TrimSpace(commandAlias)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func editCompletion(cfg *config.Config) error {
|
||||||
|
maxResults := ""
|
||||||
|
if cfg.Completion.MaxResults > 0 {
|
||||||
|
maxResults = strconv.Itoa(cfg.Completion.MaxResults)
|
||||||
|
}
|
||||||
|
err := huh.NewForm(huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Maximum completion results").
|
||||||
|
Description("Limits the hostnames offered per Tab completion. Leave empty for unlimited.").
|
||||||
|
Value(&maxResults).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(s)); err != nil || n < 0 {
|
||||||
|
return errors.New("must be a non-negative number")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
).Title("Completion")).Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Completion.MaxResults, _ = strconv.Atoi(strings.TrimSpace(maxResults))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRewrites reads "REGEX => REPLACEMENT" lines; the replacement may be empty.
|
||||||
|
func parseRewrites(s string) ([]config.ShortcutRewrite, error) {
|
||||||
|
var out []config.ShortcutRewrite
|
||||||
|
for _, line := range parseLines(s) {
|
||||||
|
pattern, replace, ok := strings.Cut(line, "=>")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%q: expected REGEX => REPLACEMENT", line)
|
||||||
|
}
|
||||||
|
out = append(out, config.ShortcutRewrite{Pattern: strings.TrimSpace(pattern), Replace: strings.TrimSpace(replace)})
|
||||||
|
}
|
||||||
|
if err := (config.ShortcutsConfig{Rewrites: out}).Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatRewrites(rewrites []config.ShortcutRewrite) string {
|
||||||
|
lines := make([]string, 0, len(rewrites))
|
||||||
|
for _, r := range rewrites {
|
||||||
|
lines = append(lines, r.Pattern+" => "+r.Replace)
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitList returns the non-empty, trimmed entries of a comma-separated list.
|
||||||
|
func splitList(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
if part = strings.TrimSpace(part); part != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
var knownStrategies = map[string]bool{
|
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 {
|
||||||
@@ -214,21 +342,16 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseStrategies(s string) []string {
|
func parseStrategies(s string) []string { return splitList(s) }
|
||||||
var out []string
|
|
||||||
for _, part := range strings.Split(s, ",") {
|
// Save validates cfg and writes it to the config file, replacing the old file.
|
||||||
if name := strings.TrimSpace(part); name != "" {
|
func Save(cfg config.Config) error { return save(cfg) }
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -237,6 +360,15 @@ func save(cfg config.Config) error {
|
|||||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := cfg.ValidateSSH(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||||
|
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)
|
||||||
@@ -252,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) {
|
||||||
@@ -275,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)
|
||||||
@@ -303,11 +438,34 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
if cfg.Shortcuts.Enabled() {
|
||||||
b.WriteString("\nshortcuts:\n")
|
b.WriteString("\nshortcuts:\n")
|
||||||
if len(cfg.Shortcuts.Domains) > 0 {
|
if len(cfg.Shortcuts.Domains) > 0 {
|
||||||
b.WriteString(" domains:\n")
|
b.WriteString(" domains:\n")
|
||||||
@@ -318,13 +476,19 @@ func save(cfg config.Config) error {
|
|||||||
if cfg.Shortcuts.StripHyphens {
|
if cfg.Shortcuts.StripHyphens {
|
||||||
b.WriteString(" strip_hyphens: true\n")
|
b.WriteString(" strip_hyphens: true\n")
|
||||||
}
|
}
|
||||||
|
if len(cfg.Shortcuts.Rewrites) > 0 {
|
||||||
|
b.WriteString(" rewrites:\n")
|
||||||
|
for _, r := range cfg.Shortcuts.Rewrites {
|
||||||
|
fmt.Fprintf(&b, " - pattern: %q\n replace: %q\n", r.Pattern, r.Replace)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Shell.Alias != "" {
|
if cfg.Shell.Alias != "" {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +497,7 @@ func save(cfg config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sshOptionsEmpty(o config.SSHOptions) bool {
|
func sshOptionsEmpty(o config.SSHOptions) bool {
|
||||||
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
|
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 && len(o.Forwards) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
||||||
@@ -360,4 +524,20 @@ func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
|
|||||||
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(o.Forwards) > 0 {
|
||||||
|
names := make([]string, 0, len(o.Forwards))
|
||||||
|
for name := range o.Forwards {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
fmt.Fprintf(b, "%sforwards:\n", indent)
|
||||||
|
for _, name := range names {
|
||||||
|
f := o.Forwards[name]
|
||||||
|
for _, field := range [][2]string{{"local", f.Local}, {"remote", f.Remote}, {"dynamic", f.Dynamic}} {
|
||||||
|
if field[1] != "" {
|
||||||
|
fmt.Fprintf(b, "%s %s:\n%s %s: %q\n", indent, name, indent, field[0], field[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -455,3 +461,159 @@ func TestSaveKeepsSSHOptions(t *testing.T) {
|
|||||||
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
|
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-running the configurator must keep settings that have no form field of
|
||||||
|
// their own or were not touched in the menu.
|
||||||
|
func TestPrepareKeepsUnmanagedSettings(t *testing.T) {
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBox: config.NetBoxConfig{URL: "https://legacy.example", Token: "t"},
|
||||||
|
SSH: config.SSHConfig{
|
||||||
|
DefaultUser: "root",
|
||||||
|
SSHOptions: config.SSHOptions{ProxyJump: "jh.example.com", Options: map[string]string{"forwardagent": "yes"}},
|
||||||
|
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
|
||||||
|
},
|
||||||
|
Completion: config.CompletionConfig{MaxResults: 50},
|
||||||
|
Cache: config.CacheConfig{Path: "/var/cache/netssh/hosts.json"},
|
||||||
|
}
|
||||||
|
sources := []config.NetBoxConfig{{Name: "default", URL: "https://legacy.example", Token: "t"}}
|
||||||
|
got := prepare(cfg, sources)
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got.SSH, cfg.SSH) || got.Completion != cfg.Completion || got.Cache.Path != cfg.Cache.Path {
|
||||||
|
t.Fatalf("settings lost: %+v", got)
|
||||||
|
}
|
||||||
|
if got.NetBox.URL != "" || !reflect.DeepEqual(got.NetBoxes, sources) {
|
||||||
|
t.Fatalf("legacy instance not migrated: %+v / %+v", got.NetBox, got.NetBoxes)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.Resolver.Strategies, []string{"primary_ip"}) || got.Cache.TTL != 3600 {
|
||||||
|
t.Fatalf("defaults missing: %+v %+v", got.Resolver, got.Cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveRejectsInvalidSSHOptions(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
|
||||||
|
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||||
|
SSH: config.SSHConfig{Rules: []config.SSHRule{{SSHOptions: config.SSHOptions{ProxyJump: "none"}}}},
|
||||||
|
}
|
||||||
|
if err := save(cfg); err == nil {
|
||||||
|
t.Fatal("rule without match was saved")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(config.Path()); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("config file written despite error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRewrites(t *testing.T) {
|
||||||
|
text := `^([a-z]+)[0-9]+- => $1
|
||||||
|
|
||||||
|
\.lab$ =>
|
||||||
|
`
|
||||||
|
got, err := parseRewrites(text)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab$`, Replace: ""}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
if again, _ := parseRewrites(formatRewrites(got)); !reflect.DeepEqual(again, want) {
|
||||||
|
t.Fatalf("roundtrip: %+v", again)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"no arrow", "[a- => x", " => x"} {
|
||||||
|
if _, err := parseRewrites(bad); err == nil {
|
||||||
|
t.Errorf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
rewrites := []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}, {Pattern: `\.lab\.example\.com$`, Replace: `"q"`}}
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t"}},
|
||||||
|
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||||
|
Shortcuts: config.ShortcutsConfig{Rewrites: rewrites},
|
||||||
|
}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(loaded.Shortcuts.Rewrites, rewrites) {
|
||||||
|
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveForwardsRoundtrip(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}}
|
||||||
|
cfg := config.Config{
|
||||||
|
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}},
|
||||||
|
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||||
|
SSH: config.SSHConfig{
|
||||||
|
SSHOptions: config.SSHOptions{Forwards: global},
|
||||||
|
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := save(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(loaded.SSH.Forwards, global) ||
|
||||||
|
!reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) ||
|
||||||
|
!reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) {
|
||||||
|
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
package shortcuts
|
package shortcuts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
|
// compiled caches rewrite patterns: Normalize runs for every cache entry on each lookup.
|
||||||
// The result is lowercased for case-insensitive comparison.
|
var compiled sync.Map // pattern → *regexp.Regexp
|
||||||
|
|
||||||
|
func rewritePattern(pattern string) *regexp.Regexp {
|
||||||
|
if re, ok := compiled.Load(pattern); ok {
|
||||||
|
return re.(*regexp.Regexp)
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil // rejected by config.Load already
|
||||||
|
}
|
||||||
|
compiled.Store(pattern, re)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize strips configured domain suffixes, applies rewrite rules in order, and
|
||||||
|
// optionally strips hyphens. The result is lowercased for case-insensitive comparison.
|
||||||
func Normalize(name string, cfg config.ShortcutsConfig) string {
|
func Normalize(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := strings.ToLower(name)
|
s := strings.ToLower(name)
|
||||||
for _, domain := range cfg.Domains {
|
for _, domain := range cfg.Domains {
|
||||||
@@ -20,6 +37,11 @@ func Normalize(name string, cfg config.ShortcutsConfig) string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, rewrite := range cfg.Rewrites {
|
||||||
|
if re := rewritePattern(rewrite.Pattern); re != nil {
|
||||||
|
s = re.ReplaceAllString(s, rewrite.Replace)
|
||||||
|
}
|
||||||
|
}
|
||||||
if cfg.StripHyphens {
|
if cfg.StripHyphens {
|
||||||
s = strings.ReplaceAll(s, "-", "")
|
s = strings.ReplaceAll(s, "-", "")
|
||||||
}
|
}
|
||||||
@@ -34,9 +56,14 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AliasName returns a shell-safe alias name for the given host.
|
// AliasName returns a shell-safe alias name for the given host.
|
||||||
// It normalizes the name (strips configured domains and optionally hyphens) then
|
// It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then
|
||||||
// replaces any remaining dots with underscores so the result is a valid identifier.
|
// 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)
|
||||||
}
|
}
|
||||||
@@ -78,3 +78,50 @@ func TestAliasName_NoConfig(t *testing.T) {
|
|||||||
t.Errorf("AliasName = %q, want %q", got, want)
|
t.Errorf("AliasName = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRewrites(t *testing.T) {
|
||||||
|
cfg := config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
|
||||||
|
}
|
||||||
|
for input, want := range map[string]string{
|
||||||
|
"fsn1-bx1.hoster.de": "fsnbx1",
|
||||||
|
"FSN1-BX1.hoster.de": "fsnbx1",
|
||||||
|
"fsn1-bx1": "fsnbx1",
|
||||||
|
"fsnbx1": "fsnbx1", // the shortcut itself is left alone
|
||||||
|
"web01.hoster.de": "web01",
|
||||||
|
} {
|
||||||
|
if got := shortcuts.Normalize(input, cfg); got != want {
|
||||||
|
t.Errorf("Normalize(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := shortcuts.AliasName("fsn1-bx1.hoster.de", cfg); got != "fsnbx1" {
|
||||||
|
t.Errorf("AliasName = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRewritesOrder(t *testing.T) {
|
||||||
|
// Rules run in order after domain stripping, before hyphen stripping.
|
||||||
|
cfg := config.ShortcutsConfig{
|
||||||
|
Domains: []string{".hoster.de"},
|
||||||
|
StripHyphens: true,
|
||||||
|
Rewrites: []config.ShortcutRewrite{
|
||||||
|
{Pattern: `\.hoster\.de$`, Replace: "-never"},
|
||||||
|
{Pattern: `^fsn`, Replace: "f"},
|
||||||
|
{Pattern: `^f1`, Replace: "falkenstein"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if got := shortcuts.Normalize("fsn1-bx-1.hoster.de", cfg); got != "falkensteinbx1" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-33
@@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{
|
|||||||
|
|
||||||
// ParsedArgs holds the result of parsing SSH arguments.
|
// ParsedArgs holds the result of parsing SSH arguments.
|
||||||
type ParsedArgs struct {
|
type ParsedArgs struct {
|
||||||
Host string // hostname without the user@ prefix
|
Host string // hostname without the user@ prefix or :path
|
||||||
User string // empty if not specified
|
User string // empty if not specified
|
||||||
|
Path string // sftp only: the remote path after host:
|
||||||
DestIdx int // index in Args where [user@]host sits
|
DestIdx int // index in Args where [user@]host sits
|
||||||
Args []string
|
Args []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse scans SSH arguments and extracts the destination ([user@]host).
|
// Parse scans SSH arguments and extracts the destination ([user@]host).
|
||||||
// Returns nil if no destination is found.
|
// Returns nil if no destination is found.
|
||||||
func Parse(args []string) *ParsedArgs {
|
func Parse(args []string) *ParsedArgs { return SSH.Parse(args) }
|
||||||
|
|
||||||
|
// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp.
|
||||||
|
func (c Client) Parse(args []string) *ParsedArgs {
|
||||||
i := 0
|
i := 0
|
||||||
for i < len(args) {
|
for i < len(args) {
|
||||||
arg := args[i]
|
arg := args[i]
|
||||||
@@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs {
|
|||||||
|
|
||||||
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
||||||
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
|
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
|
||||||
for j := 1; j < len(arg); j++ {
|
if c.takesSeparateValue(arg) {
|
||||||
if flagsWithArg[arg[j]] {
|
i++
|
||||||
if j == len(arg)-1 {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// first non-flag argument is the destination
|
// first non-flag argument is the destination
|
||||||
host, user := splitUserHost(arg)
|
return c.parsed(args, i)
|
||||||
return &ParsedArgs{
|
|
||||||
Host: host,
|
|
||||||
User: user,
|
|
||||||
DestIdx: i,
|
|
||||||
Args: args,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle arguments after "--"
|
// handle arguments after "--"
|
||||||
if i < len(args) {
|
if i < len(args) {
|
||||||
host, user := splitUserHost(args[i])
|
return c.parsed(args, i)
|
||||||
return &ParsedArgs{
|
|
||||||
Host: host,
|
|
||||||
User: user,
|
|
||||||
DestIdx: i,
|
|
||||||
Args: args,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Client) parsed(args []string, i int) *ParsedArgs {
|
||||||
|
dest, path := args[i], ""
|
||||||
|
if c.paths {
|
||||||
|
if d, p, remote := c.splitRemote(dest); remote {
|
||||||
|
dest, path = d, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host, user := splitUserHost(dest)
|
||||||
|
return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args}
|
||||||
|
}
|
||||||
|
|
||||||
// ReplaceHost returns a copy of args with the destination replaced by newHost,
|
// ReplaceHost returns a copy of args with the destination replaced by newHost,
|
||||||
// preserving any user@ prefix.
|
// preserving any user@ prefix.
|
||||||
func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||||
|
return SSH.replaceHost(args, []int{destIdx}, newHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read
|
||||||
|
// host:path, so an IPv6 address must be bracketed.
|
||||||
|
func (c Client) replaceHost(args []string, dests []int, newHost string) []string {
|
||||||
result := make([]string, len(args))
|
result := make([]string, len(args))
|
||||||
copy(result, args)
|
copy(result, args)
|
||||||
|
if c.paths && strings.Contains(newHost, ":") {
|
||||||
original := args[destIdx]
|
newHost = "[" + newHost + "]"
|
||||||
if at := strings.Index(original, "@"); at != -1 {
|
}
|
||||||
result[destIdx] = original[:at+1] + newHost
|
for _, destIdx := range dests {
|
||||||
} else {
|
original := args[destIdx]
|
||||||
result[destIdx] = newHost
|
prefix, suffix := "", ""
|
||||||
|
if at := strings.Index(original, "@"); at != -1 {
|
||||||
|
prefix, original = original[:at+1], original[at+1:]
|
||||||
|
}
|
||||||
|
if c.paths {
|
||||||
|
if _, path, remote := c.splitRemote(original); remote {
|
||||||
|
suffix = ":" + path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[destIdx] = prefix + newHost + suffix
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPortFlag reports whether a port was explicitly supplied.
|
// HasPortFlag reports whether a port was explicitly supplied.
|
||||||
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
|
func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
|
||||||
|
|
||||||
// HasUserFlag reports whether a login user was explicitly supplied.
|
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||||
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
|
func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) }
|
||||||
|
|
||||||
|
// HasPortFlag reports whether a port was explicitly supplied.
|
||||||
|
func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") }
|
||||||
|
|
||||||
|
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||||
|
func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") }
|
||||||
|
|
||||||
func hasOption(args []string, wanted byte, keyword string) bool {
|
func hasOption(args []string, wanted byte, keyword string) bool {
|
||||||
|
return SSH.hasOption(args, wanted, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) hasOption(args []string, wanted byte, keyword string) bool {
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
arg := args[i]
|
arg := args[i]
|
||||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||||
@@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
|
|||||||
}
|
}
|
||||||
for j := 1; j < len(arg); j++ {
|
for j := 1; j < len(arg); j++ {
|
||||||
flag := arg[j]
|
flag := arg[j]
|
||||||
if !flagsWithArg[flag] {
|
if !c.withArg[flag] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value := arg[j+1:]
|
value := arg[j+1:]
|
||||||
@@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
|
|||||||
}
|
}
|
||||||
value = args[i]
|
value = args[i]
|
||||||
}
|
}
|
||||||
if flag == wanted {
|
if wanted != 0 && flag == wanted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if flag == 'o' {
|
if flag == 'o' {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client describes one OpenSSH program netssh can run. The programs share
|
||||||
|
// ssh's connection options but differ in flags and destination syntax.
|
||||||
|
type Client struct {
|
||||||
|
Name string // binary name: ssh, sftp, or scp
|
||||||
|
withArg map[byte]bool // short flags that consume a value
|
||||||
|
port byte // flag for the port: -p (ssh) or -P (sftp, scp)
|
||||||
|
user byte // flag for the login user; 0 when only user@ or -o User work
|
||||||
|
paths bool // destinations are host[:path]
|
||||||
|
// 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 (
|
||||||
|
// SSH is the default client; its flag table is flagsWithArg.
|
||||||
|
SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'}
|
||||||
|
// SFTP follows sftp(1): -l is a bandwidth limit, -P the port.
|
||||||
|
SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true}
|
||||||
|
// SCP follows scp(1); unlike sftp, -B and -R take no value.
|
||||||
|
SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
func flagSet(flags string) map[byte]bool {
|
||||||
|
set := make(map[byte]bool, len(flags))
|
||||||
|
for i := 0; i < len(flags); i++ {
|
||||||
|
set[flags[i]] = true
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortArgs returns the arguments that set port for this client.
|
||||||
|
func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} }
|
||||||
|
|
||||||
|
// UserArgs returns the arguments that set the login user for this client.
|
||||||
|
func (c Client) UserArgs(user string) []string {
|
||||||
|
if c.user == 0 {
|
||||||
|
return []string{"-o", "User=" + user}
|
||||||
|
}
|
||||||
|
return []string{"-" + string(c.user), user}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operand is a remote scp argument such as user@host:/path.
|
||||||
|
type Operand struct {
|
||||||
|
Idx int
|
||||||
|
User string
|
||||||
|
Host string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operands returns the remote operands of scp arguments. All remote operands
|
||||||
|
// must name the same host, because one set of options applies per call.
|
||||||
|
func (c Client) Operands(args []string) ([]Operand, error) {
|
||||||
|
start := c.optionsEnd(args)
|
||||||
|
var out []Operand
|
||||||
|
for i := start; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
|
||||||
|
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
|
||||||
|
}
|
||||||
|
dest, path, remote := c.splitRemote(arg)
|
||||||
|
if !remote {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
host, user := splitUserHost(dest)
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(out) > 0 && !strings.EqualFold(out[0].Host, host) {
|
||||||
|
return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host)
|
||||||
|
}
|
||||||
|
out = append(out, Operand{Idx: i, User: user, Host: host, Path: path})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionsEnd returns the index of the first operand; scp, like ssh, stops
|
||||||
|
// option parsing at the first non-option or after "--".
|
||||||
|
func (c Client) optionsEnd(args []string) int {
|
||||||
|
end := c.insertionPoint(args)
|
||||||
|
if end < len(args) && args[end] == "--" {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertionPoint is where added options still count as options: before the
|
||||||
|
// first operand or before "--".
|
||||||
|
func (c Client) insertionPoint(args []string) int {
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if arg == "--" {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(arg, "-") || len(arg) < 2 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if c.takesSeparateValue(arg) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRemote 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.
|
||||||
|
func splitHostPath(arg string) (dest, path string, remote bool) {
|
||||||
|
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
inBracket := false
|
||||||
|
for i := 0; i < len(arg); i++ {
|
||||||
|
switch arg[i] {
|
||||||
|
case '[':
|
||||||
|
inBracket = true
|
||||||
|
case ']':
|
||||||
|
inBracket = false
|
||||||
|
case '/':
|
||||||
|
if !inBracket {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
case ':':
|
||||||
|
if !inBracket {
|
||||||
|
return arg[:i], arg[i+1:], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given.
|
||||||
|
func (c Client) HasForwardFlag(args []string) bool {
|
||||||
|
return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward")
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSFTPParse(t *testing.T) {
|
||||||
|
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
|
||||||
|
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
|
||||||
|
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
|
||||||
|
t.Fatalf("got %+v", parsed)
|
||||||
|
}
|
||||||
|
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
|
||||||
|
t.Fatalf("instance-qualified host: %+v", p)
|
||||||
|
}
|
||||||
|
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
|
||||||
|
t.Fatal("sftp port flag is -P")
|
||||||
|
}
|
||||||
|
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
|
||||||
|
t.Fatal("sftp has no -l user flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCPOperands(t *testing.T) {
|
||||||
|
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
|
||||||
|
if !reflect.DeepEqual(ops, want) {
|
||||||
|
t.Fatalf("got %+v", ops)
|
||||||
|
}
|
||||||
|
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
|
||||||
|
t.Fatalf("local copy has remote operands: %+v", ops)
|
||||||
|
}
|
||||||
|
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
|
||||||
|
t.Fatal("two remote hosts accepted")
|
||||||
|
}
|
||||||
|
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
|
||||||
|
t.Fatal("URI accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSFTPApply(t *testing.T) {
|
||||||
|
args := []string{"-P", "2222", "ops@web01:/var/log"}
|
||||||
|
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
|
||||||
|
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
|
||||||
|
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
|
||||||
|
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
|
||||||
|
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
|
||||||
|
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
// sftp needs brackets even without a path; ssh never does.
|
||||||
|
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
|
||||||
|
t.Fatalf("sftp: %v", got)
|
||||||
|
}
|
||||||
|
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
|
||||||
|
t.Fatalf("ssh: %v", got.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSFTPTargetName(t *testing.T) {
|
||||||
|
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
|
||||||
|
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientUserAndPortArgs(t *testing.T) {
|
||||||
|
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientExtractWrapperFlags(t *testing.T) {
|
||||||
|
// For scp, -X takes a value; for ssh it does not.
|
||||||
|
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
|
||||||
|
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
|
||||||
|
t.Fatalf("got %+v %v %v", flags, rest, err)
|
||||||
|
}
|
||||||
|
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
|
||||||
|
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
|
||||||
|
t.Fatalf("got %+v %v %v", flags, rest, err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
|
||||||
|
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||||
|
t.Errorf("%v accepted", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardArgs(t *testing.T) {
|
||||||
|
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
|
||||||
|
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
|
||||||
|
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
|
||||||
|
if err != nil || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
|
||||||
|
t.Fatalf("unknown name: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
|
||||||
|
t.Fatalf("no forwards: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyForwardsAndTunnel(t *testing.T) {
|
||||||
|
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
|
||||||
|
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
|
||||||
|
t.Fatalf("forward: %v", got)
|
||||||
|
}
|
||||||
|
flags.Tunnel = true
|
||||||
|
result := apply([]string{"web"}, config.SSHOptions{}, flags)
|
||||||
|
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
|
||||||
|
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
|
||||||
|
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
|
||||||
|
}
|
||||||
|
// The user's own ExitOnForwardFailure wins.
|
||||||
|
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
|
||||||
|
t.Fatalf("overrode user option: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasForwardFlag(t *testing.T) {
|
||||||
|
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
|
||||||
|
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
|
||||||
|
t.Errorf("%q: got %v", args, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
|
||||||
|
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
|
||||||
|
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,14 @@ import (
|
|||||||
|
|
||||||
// Exec replaces the current process with the native ssh client via syscall.Exec.
|
// Exec replaces the current process with the native ssh client via syscall.Exec.
|
||||||
// All existing SSH configs, keys, and agent forwarding remain intact.
|
// All existing SSH configs, keys, and agent forwarding remain intact.
|
||||||
func Exec(args []string) error {
|
func Exec(args []string) error { return SSH.Exec(args) }
|
||||||
sshPath, err := exec.LookPath("ssh")
|
|
||||||
|
// Exec replaces the current process with the client's binary.
|
||||||
|
func (c Client) Exec(args []string) error {
|
||||||
|
path, err := exec.LookPath(c.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ssh not found in PATH: %w", err)
|
return fmt.Errorf("%s not found in PATH: %w", c.Name, err)
|
||||||
}
|
}
|
||||||
argv := append([]string{"ssh"}, args...)
|
argv := append([]string{c.Name}, args...)
|
||||||
return syscall.Exec(sshPath, argv, os.Environ())
|
return syscall.Exec(path, argv, os.Environ())
|
||||||
}
|
}
|
||||||
+99
-16
@@ -12,14 +12,24 @@ import (
|
|||||||
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
|
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
|
||||||
// so these never collide with flags meant for ssh.
|
// so these never collide with flags meant for ssh.
|
||||||
type WrapperFlags struct {
|
type WrapperFlags struct {
|
||||||
DryRun bool
|
DryRun bool
|
||||||
Jump string // --jump HOST overrides the configured jump host
|
Jump string // --jump HOST overrides the configured jump host
|
||||||
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
||||||
|
Forwards []string // --forward NAME|SPEC, repeatable (ssh only)
|
||||||
|
// ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them.
|
||||||
|
ForwardArgs []string
|
||||||
|
// Tunnel runs ssh without a remote command (-N), for netssh tunnel.
|
||||||
|
Tunnel bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractWrapperFlags removes netssh's long options that appear before the
|
// ExtractWrapperFlags removes netssh's long options that appear before the
|
||||||
// destination. Anything after the destination belongs to the remote command.
|
// destination. Anything after the destination belongs to the remote command.
|
||||||
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||||
|
return SSH.ExtractWrapperFlags(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractWrapperFlags removes netssh's long options before the destination.
|
||||||
|
func (c Client) ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||||
var flags WrapperFlags
|
var flags WrapperFlags
|
||||||
out := make([]string, 0, len(args))
|
out := make([]string, 0, len(args))
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
@@ -37,6 +47,14 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
|||||||
flags.Jump = args[i]
|
flags.Jump = args[i]
|
||||||
case strings.HasPrefix(arg, "--jump="):
|
case strings.HasPrefix(arg, "--jump="):
|
||||||
flags.Jump = strings.TrimPrefix(arg, "--jump=")
|
flags.Jump = strings.TrimPrefix(arg, "--jump=")
|
||||||
|
case arg == "--forward":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return flags, nil, fmt.Errorf("--forward requires a name or spec")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
flags.Forwards = append(flags.Forwards, args[i])
|
||||||
|
case strings.HasPrefix(arg, "--forward="):
|
||||||
|
flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward="))
|
||||||
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
|
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
|
||||||
// Destination or end of options: keep the rest unchanged.
|
// Destination or end of options: keep the rest unchanged.
|
||||||
return flags, append(out, args[i:]...), validateJump(flags)
|
return flags, append(out, args[i:]...), validateJump(flags)
|
||||||
@@ -45,7 +63,7 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
|||||||
default:
|
default:
|
||||||
out = append(out, arg)
|
out = append(out, arg)
|
||||||
// Keep the value of an ssh option such as -l USER together with it.
|
// Keep the value of an ssh option such as -l USER together with it.
|
||||||
if takesSeparateValue(arg) && i+1 < len(args) {
|
if c.takesSeparateValue(arg) && i+1 < len(args) {
|
||||||
i++
|
i++
|
||||||
out = append(out, args[i])
|
out = append(out, args[i])
|
||||||
}
|
}
|
||||||
@@ -64,14 +82,48 @@ func validateJump(flags WrapperFlags) error {
|
|||||||
if strings.HasPrefix(flags.Jump, "-") {
|
if strings.HasPrefix(flags.Jump, "-") {
|
||||||
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
|
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
|
||||||
}
|
}
|
||||||
|
for _, forward := range flags.Forwards {
|
||||||
|
if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") {
|
||||||
|
return fmt.Errorf("--forward: invalid value %q", forward)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`)
|
||||||
|
|
||||||
|
// ForwardArgs resolves forward names against the configured forwards. A value
|
||||||
|
// that is no configured name but looks like a port or [bind:]port:host:port
|
||||||
|
// is used as a local forward (-L).
|
||||||
|
func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, name := range names {
|
||||||
|
if forward, ok := available[strings.ToLower(name)]; ok {
|
||||||
|
out = append(out, forward.Flag()...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(name, ":") && adHocForward.MatchString(name) {
|
||||||
|
out = append(out, "-L", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
known := make([]string, 0, len(available))
|
||||||
|
for key := range available {
|
||||||
|
known = append(known, key)
|
||||||
|
}
|
||||||
|
sort.Strings(known)
|
||||||
|
if len(known) == 0 {
|
||||||
|
return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", "))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// takesSeparateValue reports whether a short option cluster ends with a flag
|
// takesSeparateValue reports whether a short option cluster ends with a flag
|
||||||
// whose value is the next argument (e.g. "-l" or "-vp").
|
// whose value is the next argument (e.g. "-l" or "-vp").
|
||||||
func takesSeparateValue(arg string) bool {
|
func (c Client) takesSeparateValue(arg string) bool {
|
||||||
for j := 1; j < len(arg); j++ {
|
for j := 1; j < len(arg); j++ {
|
||||||
if flagsWithArg[arg[j]] {
|
if c.withArg[arg[j]] {
|
||||||
return j == len(arg)-1
|
return j == len(arg)-1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,9 +148,17 @@ var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])
|
|||||||
// options directly before it. Explicit options given by the user come first,
|
// options directly before it. Explicit options given by the user come first,
|
||||||
// and ssh uses the first -o value it obtains, so the user's choice wins.
|
// and ssh uses the first -o value it obtains, so the user's choice wins.
|
||||||
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||||
|
return SSH.Apply(args, []int{destIdx}, host, opts, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply replaces the host in every destination (several only for scp) and
|
||||||
|
// inserts the configured options before the first one. extra_args and
|
||||||
|
// forwards are ssh flags and apply to ssh only.
|
||||||
|
func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||||
|
destIdx := dests[0]
|
||||||
var inserted []string
|
var inserted []string
|
||||||
add := func(keyword, value string) {
|
add := func(keyword, value string) {
|
||||||
if !hasOption(args[:destIdx], 0, keyword) {
|
if !c.hasOption(args[:destIdx], 0, keyword) {
|
||||||
inserted = append(inserted, "-o", keyword+"="+value)
|
inserted = append(inserted, "-o", keyword+"="+value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,7 +174,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
|
userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand")
|
||||||
switch {
|
switch {
|
||||||
case userJump:
|
case userJump:
|
||||||
case flags.NoJump:
|
case flags.NoJump:
|
||||||
@@ -140,7 +200,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
|
if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') {
|
||||||
add("AddressFamily", opts.AddressFamily)
|
add("AddressFamily", opts.AddressFamily)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,19 +213,42 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
|
|||||||
add(key, opts.Options[key])
|
add(key, opts.Options[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var extra []string
|
||||||
|
if c.Name == SSH.Name {
|
||||||
|
extra = opts.ExtraArgs
|
||||||
|
inserted = append(inserted, flags.ForwardArgs...)
|
||||||
|
if flags.Tunnel {
|
||||||
|
add("ExitOnForwardFailure", "yes")
|
||||||
|
inserted = append(inserted, "-N")
|
||||||
|
forwards := strings.Join(flags.ForwardArgs, " ")
|
||||||
|
if forwards == "" {
|
||||||
|
forwards = "the given ports"
|
||||||
|
}
|
||||||
|
tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name)
|
||||||
|
if notice == "" {
|
||||||
|
notice = tunnel
|
||||||
|
} else {
|
||||||
|
notice += "\nnetssh: " + tunnel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extra arguments go first: short flags such as -4 or -A take the last
|
// Extra arguments go first: short flags such as -4 or -A take the last
|
||||||
// value given, so the user's own flags later on the line still win.
|
// value given, so the user's own flags later on the line still win.
|
||||||
replaced := ReplaceHost(args, destIdx, dest)
|
// Options go before the first operand: scp stops reading options there,
|
||||||
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
|
// and after "--" they would be taken as the destination.
|
||||||
out = append(out, opts.ExtraArgs...)
|
at := c.insertionPoint(args)
|
||||||
out = append(out, replaced[:destIdx]...)
|
replaced := c.replaceHost(args, dests, dest)
|
||||||
|
out := make([]string, 0, len(extra)+len(replaced)+len(inserted))
|
||||||
|
out = append(out, extra...)
|
||||||
|
out = append(out, replaced[:at]...)
|
||||||
out = append(out, inserted...)
|
out = append(out, inserted...)
|
||||||
out = append(out, replaced[destIdx:]...)
|
out = append(out, replaced[at:]...)
|
||||||
return Result{Args: out, Notice: notice}
|
return Result{Args: out, Notice: notice}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
||||||
func hasBoolFlag(args []string, wanted byte) bool {
|
func (c Client) hasBoolFlag(args []string, wanted byte) bool {
|
||||||
for i := 0; i < len(args); i++ {
|
for i := 0; i < len(args); i++ {
|
||||||
arg := args[i]
|
arg := args[i]
|
||||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||||
@@ -175,7 +258,7 @@ func hasBoolFlag(args []string, wanted byte) bool {
|
|||||||
if arg[j] == wanted {
|
if arg[j] == wanted {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if flagsWithArg[arg[j]] {
|
if c.withArg[arg[j]] {
|
||||||
if j == len(arg)-1 {
|
if j == len(arg)-1 {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestExtractWrapperFlags(t *testing.T) {
|
|||||||
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
||||||
} {
|
} {
|
||||||
flags, rest, err := ExtractWrapperFlags(tc.args)
|
flags, rest, err := ExtractWrapperFlags(tc.args)
|
||||||
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
|
if err != nil || !reflect.DeepEqual(flags, tc.flags) || !reflect.DeepEqual(rest, tc.rest) {
|
||||||
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-17
@@ -22,13 +22,22 @@ import (
|
|||||||
|
|
||||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||||
type SelectedHost struct {
|
type SelectedHost struct {
|
||||||
Kind string
|
Kind string
|
||||||
Name string
|
Name string
|
||||||
IP string
|
IP string
|
||||||
User string // empty = use config default
|
User string // empty = use config default
|
||||||
Port string // empty = use default port
|
Port string // empty = use default port
|
||||||
|
Action string // ActionSSH, ActionSFTP, or ActionTunnel
|
||||||
|
Forwards []string // forward names or specs for ActionTunnel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Actions the user can start on a host.
|
||||||
|
const (
|
||||||
|
ActionSSH = ""
|
||||||
|
ActionSFTP = "sftp"
|
||||||
|
ActionTunnel = "tunnel"
|
||||||
|
)
|
||||||
|
|
||||||
// --- bubbletea messages ---
|
// --- bubbletea messages ---
|
||||||
|
|
||||||
type debounceMsg struct {
|
type debounceMsg struct {
|
||||||
@@ -145,6 +154,9 @@ type Model struct {
|
|||||||
input textinput.Model
|
input textinput.Model
|
||||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||||
editInput textinput.Model // user@host:port inline editor
|
editInput textinput.Model // user@host:port inline editor
|
||||||
|
forwardInput textinput.Model // forward names/specs, ctrl+t
|
||||||
|
forwardHost *hostItem
|
||||||
|
forwardsFor func(name, kind string, tags []string) []string
|
||||||
list list.Model
|
list list.Model
|
||||||
client *netbox.Client
|
client *netbox.Client
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
@@ -160,6 +172,7 @@ type Model struct {
|
|||||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||||
filterOpen bool // Ctrl+F toggles
|
filterOpen bool // Ctrl+F toggles
|
||||||
editMode bool // Ctrl+E on the selected item
|
editMode bool // Ctrl+E on the selected item
|
||||||
|
forwardMode bool // Ctrl+T on the selected item
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||||
@@ -174,6 +187,10 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
|||||||
ei := textinput.New()
|
ei := textinput.New()
|
||||||
ei.Prompt = ""
|
ei.Prompt = ""
|
||||||
|
|
||||||
|
fwd := textinput.New()
|
||||||
|
fwd.Prompt = ""
|
||||||
|
fwd.Placeholder = "web, 8080:localhost:80"
|
||||||
|
|
||||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.SetShowTitle(false)
|
l.SetShowTitle(false)
|
||||||
@@ -189,16 +206,17 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
|||||||
ni.CharLimit = 0
|
ni.CharLimit = 0
|
||||||
ni.SetHeight(10)
|
ni.SetHeight(10)
|
||||||
return &Model{
|
return &Model{
|
||||||
noteInput: ni,
|
noteInput: ni,
|
||||||
defaultPort: port,
|
defaultPort: port,
|
||||||
input: ti,
|
input: ti,
|
||||||
filterInput: fi,
|
filterInput: fi,
|
||||||
editInput: ei,
|
editInput: ei,
|
||||||
list: l,
|
forwardInput: fwd,
|
||||||
client: client,
|
list: l,
|
||||||
cache: c,
|
client: client,
|
||||||
defaultUser: defaultUser,
|
cache: c,
|
||||||
recentMode: true,
|
defaultUser: defaultUser,
|
||||||
|
recentMode: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +299,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
// --- Forward mode ---
|
||||||
|
if m.forwardMode {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.forwardMode = false
|
||||||
|
m.forwardInput.Blur()
|
||||||
|
m.input.Focus()
|
||||||
|
m.err = nil
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
item := *m.forwardHost
|
||||||
|
forwards, err := parseForwards(m.forwardInput.Value(), m.availableForwards(item))
|
||||||
|
if err != nil {
|
||||||
|
m.err = err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionTunnel, Forwards: forwards}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.forwardInput, cmd = m.forwardInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
// --- Edit mode ---
|
// --- Edit mode ---
|
||||||
if m.editMode {
|
if m.editMode {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
@@ -355,6 +397,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "ctrl+o":
|
||||||
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
|
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionSFTP}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ctrl+t":
|
||||||
|
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||||
|
m.forwardMode = true
|
||||||
|
m.forwardHost = &item
|
||||||
|
m.err = nil
|
||||||
|
m.input.Blur()
|
||||||
|
m.forwardInput.SetValue("")
|
||||||
|
m.forwardInput.Focus()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
case "ctrl+n", "ctrl+b":
|
case "ctrl+n", "ctrl+b":
|
||||||
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||||
m.noteMode = true
|
m.noteMode = true
|
||||||
@@ -509,13 +568,23 @@ func (m *Model) viewContent() string {
|
|||||||
sb.WriteString(m.list.View())
|
sb.WriteString(m.list.View())
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.editMode {
|
if m.forwardMode {
|
||||||
|
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
|
sb.WriteString("\n" + dim.Render("Forward via "+m.forwardHost.name+": "))
|
||||||
|
sb.WriteString(m.forwardInput.View() + "\n")
|
||||||
|
available := "none configured — enter specs such as 8080:localhost:80"
|
||||||
|
if names := m.availableForwards(*m.forwardHost); len(names) > 0 {
|
||||||
|
available = strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
sb.WriteString(dim.Render(" forwards: "+available) + "\n")
|
||||||
|
sb.WriteString(dim.Render(" enter start tunnel esc cancel") + "\n")
|
||||||
|
} else if m.editMode {
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
||||||
sb.WriteString(m.editInput.View() + "\n")
|
sb.WriteString(m.editInput.View() + "\n")
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||||
} else {
|
} else {
|
||||||
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,3 +723,34 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||||
|
|
||||||
|
// SetForwards provides the forward names configured for a host (ctrl+t).
|
||||||
|
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
|
||||||
|
m.forwardsFor = forwardsFor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) availableForwards(item hostItem) []string {
|
||||||
|
if m.forwardsFor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.forwardsFor(item.name, item.kind, item.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseForwards accepts configured names and specs containing a colon,
|
||||||
|
// separated by commas or spaces.
|
||||||
|
func parseForwards(input string, available []string) ([]string, error) {
|
||||||
|
known := make(map[string]bool, len(available))
|
||||||
|
for _, name := range available {
|
||||||
|
known[strings.ToLower(name)] = true
|
||||||
|
}
|
||||||
|
fields := strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return nil, fmt.Errorf("enter a forward name or a spec such as 8080:localhost:80")
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
if !known[strings.ToLower(field)] && !strings.Contains(field, ":") {
|
||||||
|
return nil, fmt.Errorf("unknown forward %q", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
@@ -70,3 +72,53 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
|||||||
t.Fatalf("edited host changed: %+v", m.selected)
|
t.Fatalf("edited host changed: %+v", m.selected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCtrlOSelectsSFTP(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1"}})
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||||
|
if m.selected == nil || m.selected.Action != ActionSFTP || m.selected.Name != "web01" {
|
||||||
|
t.Fatalf("got %+v", m.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCtrlTStartsTunnelWithForwards(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.SetForwards(func(name, kind string, tags []string) []string {
|
||||||
|
if name != "web01" || len(tags) != 1 {
|
||||||
|
t.Errorf("forwards asked for %s %v", name, tags)
|
||||||
|
}
|
||||||
|
return []string{"web"}
|
||||||
|
})
|
||||||
|
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1", Tags: []string{"dmz"}}})
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
|
||||||
|
if !m.forwardMode || m.selected != nil {
|
||||||
|
t.Fatal("forward input not opened")
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.viewContent(), "forwards: web") {
|
||||||
|
t.Fatalf("configured forwards not shown:\n%s", m.viewContent())
|
||||||
|
}
|
||||||
|
|
||||||
|
m.forwardInput.SetValue("nope")
|
||||||
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||||
|
if m.selected != nil || m.err == nil {
|
||||||
|
t.Fatal("unknown forward accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.forwardInput.SetValue("WEB, 9000:localhost:9000")
|
||||||
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||||
|
want := []string{"WEB", "9000:localhost:9000"}
|
||||||
|
if m.selected == nil || m.selected.Action != ActionTunnel || !reflect.DeepEqual(m.selected.Forwards, want) {
|
||||||
|
t.Fatalf("got %+v", m.selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardInputEscCancels(t *testing.T) {
|
||||||
|
m := New(nil, nil, "")
|
||||||
|
m.updateListItems([]netbox.HostEntry{{Name: "web01"}})
|
||||||
|
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
|
||||||
|
m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
||||||
|
if m.forwardMode || m.selected != nil {
|
||||||
|
t.Fatal("esc did not cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,19 @@
|
|||||||
],
|
],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Skip major updates of indirect Go modules: a new major version is a separate module path that no dependency imports, so the update only adds an unused requirement",
|
||||||
|
"matchManagers": [
|
||||||
|
"gomod"
|
||||||
|
],
|
||||||
|
"matchDepTypes": [
|
||||||
|
"indirect"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"major"
|
||||||
|
],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Update the Go version declared in go.mod",
|
"description": "Update the Go version declared in go.mod",
|
||||||
"matchManagers": [
|
"matchManagers": [
|
||||||
|
|||||||
@@ -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.
|
||||||
Executable
+20
@@ -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 "$@"
|
||||||
@@ -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"]
|
||||||
@@ -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: {}
|
||||||
Executable
+6
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
Executable
+20
@@ -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'
|
||||||
Executable
+21
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Executable
+12
@@ -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:-}"
|
||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
+26
@@ -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
@@ -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"
|
||||||
Reference in new issue
Block a user