Files
Sebastian UnterschützandClaude Opus 5.5 396d489187 feat(multi): tmux keys to toggle broadcast and exclude single panes
In windows opened by netssh multi, prefix S toggles broadcast for all
panes and prefix e excludes the current pane (a pane-level
synchronize-panes off, shown as [SOLO] in the border) or includes it
again. netssh sync --pane does the same from a shell.

S and e are unbound in a default tmux (E would be "spread evenly").
netssh writes the bindings to a small tmux config and sources it, binds
a key only if the user's tmux.conf does not use it (netssh's own earlier
binding is refreshed), and the bindings act only in windows marked
@netssh. multi.sync_key and multi.solo_key change the keys or turn them
off (none); also in the configurator. zellij syncs whole tabs only, so
excluding a pane is tmux-only.

testenv: the render test presses the keys through the xterm client
(send-keys -K) and checks [SOLO], that broadcast skips the excluded
pane, that S toggles, and that other windows are unaffected. The client
gets GNU grep: BusyBox grep -oF counted only one match per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:32:32 +01:00

1168 lines
62 KiB
Markdown

# netssh
> **Vibe-coded project** — this codebase was written entirely by an AI assistant (Claude) without human code review. Use in production at your own risk.
A transparent SSH wrapper that resolves hostnames via [NetBox](https://netbox.dev/) before connecting.
Instead of looking up an IP manually, you just type the hostname as it appears in NetBox:
```sh
netssh my-router-01
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.
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), [multiple servers](#multiple-servers-in-one-window), [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
- **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, named interface, or the hostname itself
- **Static hosts** — add hosts that are not in NetBox (`netssh hosts add router 192.168.1.1`); they work everywhere, also without any NetBox
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
- **User/port override** — press `Ctrl+E` in the TUI to override the SSH user or port before connecting
- **Server notes** — multiline local notes or optional NetBox comments, available in the TUI and CLI
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
- **Full pagination** — `cache refresh` fetches all hosts from NetBox (not just the first 50)
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
- **Shell completion** — install without sudo via `netssh completion install`
- **Default SSH user** — set a fallback username once in config instead of typing it every time
- **Default SSH port** — set a fallback port in config; injected as `-p` when not specified on the command line
- **Host shortcuts** — type `web01` instead of `web01.example.com`; configurable domain strip, hyphen folding, and regex rewrite rules (`fsn1-bx1.hoster.de` → `fsnbx1`)
- **Shell aliases** — `netssh alias` generates shell aliases for all cached hosts
- **Automatic 24h cache refresh** — the cache is refreshed in the background every 24 hours
- **Jump hosts and SSH options** — `proxy_jump`, host key checking, connection target, and free `-o` options per netssh, per NetBox instance, or per tag/name rule — without touching `~/.ssh/config`
- **SFTP & SCP** — `netssh sftp host` and `netssh scp file host:/tmp/` with the same host lookup, shortcuts, and SSH options as ssh
- **Several servers in one window** — `netssh multi` opens hosts, a tag, a NetBox cluster, or a saved group side by side in tmux or zellij, with optional typing into all of them
- **Port forwarding** — named forwards in the config (`netssh tunnel host web`, `netssh --forward web host`), ad hoc specs, and `Ctrl+T` in the TUI
- **Dry run** — `netssh --dry-run host` prints the final `ssh` command instead of connecting
- **Clean uninstall** — `netssh uninstall` removes the binary, profile entries of all versions, completions, cache, and config
## Installation
### One-liner (Linux & macOS)
```sh
curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | bash
```
The script detects your OS and architecture, downloads the matching binary from the [latest release](https://git.zb-server.de/Sebi/ssh-netbox-wrapper/releases/latest), verifies the SHA-256 checksum, and installs to `/usr/local/bin/netssh` (using `sudo` only if necessary).
To install to a custom directory:
```sh
curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | INSTALL_DIR="$HOME/.local/bin" bash
```
### Updating an existing installation
Run the same one-liner again to update to the latest release. The installer uses
an existing `netssh` found in `PATH`, or `/usr/local/bin/netssh` for a new installation.
`INSTALL_DIR` explicitly overrides the destination. The replacement is atomic,
so an installed binary can be updated while it is running.
The installer checks all required programs before downloading or modifying files:
`curl`, `uname`, `sed`, `awk`, `mktemp`, `rm`, `dirname`, `readlink`, `mkdir`, `cat`,
`install`, `mv`, `cp`, `chmod`, `cmp`, and either `sha256sum` (Linux) or `shasum`
(macOS). Bash runs the installer; `sudo` is checked beforehand only if the target
directory requires elevated permissions. Missing programs are listed together;
install them with your package manager and retry. **Python is not required. SHA-256 verification is mandatory:** missing,
duplicate or mismatched checksums abort the update before replacing anything.
It also consolidates netssh integration in existing Bash, Zsh and Fish profiles
and creates the current shell's rc file when needed. Old standalone netssh hooks,
inline host aliases and `eval "$(netssh alias)"` entries are replaced by a single
managed block per rc file. Legacy entries in login profiles are removed as well.
Other shell settings, NetBox configuration, cache and local notes are preserved.
Every changed existing profile gets a `.netssh-backup.XXXXXX` backup; symlinks
and permissions are preserved. `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and `ZDOTDIR`
are respected. Alias files and installed shell completions are regenerated.
Open a **new terminal** after updating to load the new aliases and hooks.
### Release downloads and change history
Each new release includes `install.sh`, binaries for Linux and macOS (amd64 and
arm64), and `checksums.txt` covering all of these files. The release notes include
an installation command pointing directly to that release's `install.sh` asset
and a list of changes since the previous version tag.
See [CHANGELOG.md](CHANGELOG.md) for the change history. The installer attached to
a release installs or updates to the latest published binary release.
### Build from source
```sh
git clone ssh://git@git.zb-server.de:30022/Sebi/ssh-netbox-wrapper.git
cd ssh-netbox-wrapper
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
### Interactive wizard
On first run (when no config exists), `netssh` automatically starts an interactive setup wizard.
Re-run it at any time to change settings without editing the file manually:
```sh
netssh configure
```
The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. Without any instance you can also continue with static hosts only.
A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**:
| Section | Settings |
|---|---|
| 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 |
| Multi-session | multiplexer (auto, tmux, zellij), panes per window, tmux keys for broadcast and excluding a pane, groups (`NAME: sel1, sel2` per line) |
| 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
`~/.config/netssh.yaml`:
```yaml
netbox:
url: https://netbox.example.com
token: nbt_your-api-token-here # v2 token (nbt_ prefix) recommended
token_version: 2 # auto-detected from token; 1 = legacy, 2 = nbt_
resolver:
# Strategies are tried in order; the first to return an IP wins.
strategies:
- management_subnet
- primary_ip
# Used by the management_subnet strategy.
management_subnets:
- 10.0.0.0/8
- 172.16.0.0/12
# Used by the interface_name strategy.
interface_name: mgmt0
cache:
ttl: 3600 # seconds; 0 = always query NetBox on connect (cache still used for completion)
# path: ~/.cache/netssh/hosts.json # default
ssh:
default_user: admin # used when no user is specified on the command line
default_port: 2222 # optional; injected as -p if not specified on the command line
# See "SSH options" below for proxy_jump, host_key_checking, target,
# address_family, extra_args, options, and rules.
completion:
max_results: 0 # hostnames offered per <Tab>; 0 = unlimited
shortcuts:
domains: # strip these suffixes to create short aliases
- .example.com
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.; 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). |
| `groups` | map of lists | `{}` | Named host lists for `netssh multi @NAME`; entries are hosts or selectors. |
| `multi.multiplexer` | `auto` \| `tmux` \| `zellij` | `auto` | Which multiplexer `netssh multi` uses. |
| `multi.panes_per_window` | int | `9` | More hosts open in further tmux windows or zellij tabs. |
| `multi.sync_key`, `multi.solo_key` | tmux key \| `none` | `S`, `e` | Keys after the tmux prefix: broadcast to all panes, exclude/include the current pane. |
### 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`, `groups`) 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_MULTI_MULTIPLEXER`, `NETSSH_MULTI_PANES_PER_WINDOW` | `multi.*` |
| `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
Use `netssh configure` to manage instances, or replace the `netbox:` section with a `netboxes:` list:
```yaml
netboxes:
- name: office
url: https://netbox.office.example
token: nbt_office-token
- name: lab
url: https://netbox.lab.example
token: nbt_lab-token
```
Names must be unique and contain only letters, digits, underscores, or hyphens. Names are normalized to lowercase. Each instance has its own token and optional `token_version`; SSH, resolver, and shortcut settings apply to all instances. Configure either `netbox:` or `netboxes:`. Existing single-instance files continue to work; the wizard migrates them to a named `default` instance when saved.
The legacy `NETSSH_NETBOX_URL` and `NETSSH_NETBOX_TOKEN` variables apply to single-instance configuration. They are ignored when the file contains only a named `netboxes:` list.
Each NetBox keeps its own inventory of devices and virtual machines; either inventory may be empty. The interactive search queries all instances in parallel, including when one already has cached matches. Results show the instance as part of the hostname, for example `office/web01`. If an instance is unavailable, available results and cached hosts remain visible with a warning.
```sh
netssh # search all instances interactively
netssh office/web01 # choose an instance explicitly
netssh admin@lab/web01 uptime
netssh web01 # accepted only if the live lookup is unambiguous
netssh cache refresh # refresh all instances
netssh cache refresh --instance lab # refresh just one instance
netssh notes set office/web01 "Office maintenance"
netssh notes show lab/web01 --netbox --kind device
```
For direct connections without an instance prefix, every instance must be reachable to verify uniqueness. A qualified target queries only its own instance and can use a fresh cached IP while other instances are offline. Qualified shortcuts such as `office/web01` also support configured domain suffixes and hyphen folding.
Cache entries and generated aliases retain the instance name (`office_web01` and `lab_web01`). Named-instance caches live in a configuration-specific subdirectory beneath the configured cache path, so changing an endpoint cannot reuse another server's cached IPs. Run `netssh cache refresh` after adding or changing instances. A failed full refresh leaves the cache and its refresh timestamp unchanged; a per-instance refresh preserves the other instances and does not mark the whole cache as refreshed.
Local notes remain keyed by the NetBox URL, host kind, and canonical hostname. Migrating a single instance or renaming it preserves its notes. Use `instance/hostname` and `--kind` for uncached local notes; these operations work offline. NetBox comments and interface/IP lookups always use the selected instance's credentials.
### Short command alias
In `netssh configure`, set **Command alias** to `j` to use `j` anywhere you would
use `netssh`:
```sh
j # interactive server search
j server01 # connect to a server
j notes show server01
```
The setting is saved as:
```yaml
shell:
alias: j
```
The wizard regenerates shell aliases and installs the current shell's hook when
an alias is set. Open a new terminal (or follow the displayed reload command).
Changing the setting replaces the old generated alias; leaving it empty disables
it for new shells. The command alias takes priority over a host alias with the
same name and works even when no servers are cached. Environment override:
`NETSSH_SHELL_ALIAS=j`.
The command alias also supports shell completion: `j ser<Tab>` suggests cached
server names, just like `netssh ser<Tab>`. The wizard, shell hook, installer, and
`netssh completion install` maintain the alias completion files automatically.
Changing or disabling the alias removes its old generated completion files.
Bash requires bash-completion to load user completion files; for Zsh, include
`~/.zfunc` in `fpath` before running `compinit` as described below. Fish loads
completion files automatically. Open a new terminal after changing the alias.
### API tokens
NetBox supports two token formats:
| Format | Example | Notes |
|--------|---------|-------|
| v2 (recommended) | `nbt_abc123…` | Create in NetBox → Admin → API Tokens |
| v1 (legacy) | `abc123def456…` | Older format; still works, but v2 is preferred |
`netssh` auto-detects the version from the token prefix and stores it as `token_version` in the config.
A hint is shown during `netssh configure` if a legacy v1 token is entered.
## 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 multi [--sync] [--kind K] [--tmux\|--zellij] [-l U] [--jump H\|--no-jump] HOST\|tag:T\|cluster:C\|@GROUP...` | Several hosts side by side — see [Multiple servers](#multiple-servers-in-one-window). |
| `netssh sync [--pane]` | Toggle typing into all panes of the current tmux window or zellij tab; `--pane` excludes/includes the current tmux pane. |
| `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
Pass any SSH flags and a NetBox hostname:
```sh
netssh my-router-01
netssh -p 2222 admin@app-server-03 uptime
netssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no db-primary
```
The process is replaced by `ssh` with the resolved IP — your `~/.ssh/config`, agent, and keys all work as normal.
### Dry run and version
```sh
netssh --dry-run web01 # print the final ssh command instead of connecting
netssh --version # or: netssh version
```
Long options (`--dry-run`, `--jump`, `--no-jump`) are netssh's own; OpenSSH has
no long options. They must come before the hostname — everything after the
hostname is the remote command.
### SSH options
All settings below go under `ssh:`. The same keys (except `rules`) can be set
per NetBox instance under `netboxes[].ssh` (or `netbox.ssh`), and per rule.
More specific settings win: global → instance → matching rules (in order) →
command-line flags. Options you pass on the command line always take precedence
over the config.
```yaml
ssh:
proxy_jump: "jumphost.example.com" # passed as -J; "none" disables an inherited jump host
host_key_checking: accept-new # strict | accept-new | off; unset = ssh's own setting
target: ip # ip (default) | name | hostname
address_family: inet # any | inet (-4) | inet6 (-6)
extra_args: ["-A"] # inserted before your own arguments
options: # passed as -o Key=Value
ServerAliveInterval: 60
ServerAliveCountMax: 5
rules: # every set match field must match
- match: {tag: dmz} # tag name or slug
proxy_jump: "dmz-jump.example.com"
- match: {instance: lab, name: "*.lab.example.com", kind: vm}
host_key_checking: off
netboxes:
- name: prod
url: "https://netbox.example.com"
token: "nbt_…"
ssh:
proxy_jump: "jumphost.example.com"
- name: staging
url: "https://netbox-staging.example.com"
token: "nbt_…"
ssh:
host_key_checking: off
```
**Jump hosts.** Override the configured jump host for a single call with
`--jump other.example.com` (or ssh's own `-J`), or disable all jump hosts —
including `ProxyJump` from `~/.ssh/config` — with `--no-jump`.
**Host key checking.** `strict` sets `StrictHostKeyChecking=yes`, `accept-new`
accepts keys of new hosts but rejects changed ones. `off` sets
`StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`, and `LogLevel=ERROR`
for netssh connections only and prints a short notice. Use it only for hosts
that are reinstalled often, such as staging; it disables protection against
man-in-the-middle attacks.
**Connection target.** By default netssh calls `ssh <ip>`. With `target: name`,
it calls `ssh -o HostName=<ip> -o HostKeyAlias=<name> <name>`: the resolved IP
is still used, but `Host *.example.com` rules in `~/.ssh/config` apply, and
known_hosts entries belong to the device instead of an IP that may be reused.
With `target: hostname`, ssh resolves the NetBox name itself (DNS or
`~/.ssh/config`). Names that are not valid hostnames fall back to the IP.
Switching to `name` stores new known_hosts entries under the device name, so
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`, `hosts`, `multi`, and `sync` 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.
### Multiple servers in one window
`netssh multi` opens one pane per host in tmux or zellij — for example every node of a Proxmox or Kubernetes cluster — and can send your typing to all of them at once.
```sh
netssh multi web01 web02 db01 # hosts, shortcuts, instance/host, static hosts
netssh multi tag:k8s-worker # all hosts with a NetBox tag (and static hosts with it)
netssh multi cluster:pve-fsn1 # the devices (nodes) of a NetBox virtualization cluster
netssh multi --kind vm cluster:pve-fsn1 # the cluster's VMs; --kind all for both
netssh multi @k8s # a saved group
netssh multi --sync @k8s # start with typing sent to every pane
netssh multi --dry-run cluster:pve-fsn1 # show the hosts and tmux/zellij commands
```
Each pane runs `netssh HOST`, so lookup, shortcuts, user and port, jump hosts, and rules work as for a single connection. `-l USER`, `--jump HOST`, and `--no-jump` apply to every pane. A pane whose connection fails stays open with the error until you press Enter.
**Groups** collect hosts and selectors under a name (or use `netssh configure` → *Multi-session*):
```yaml
groups:
k8s: [k8s-master1, "tag:k8s-worker"]
pve: ["cluster:pve-fsn1"]
all-web: ["@k8s", "tag:web", legacy-box]
multi:
multiplexer: auto # auto | tmux | zellij
panes_per_window: 9 # more hosts open in further windows/tabs
sync_key: S # tmux: prefix S = broadcast on/off (none = no key)
solo_key: e # tmux: prefix e = exclude/include this pane
```
**tmux and zellij.** With `auto`, netssh uses the multiplexer you are in; otherwise tmux, then zellij, whichever is installed (`--tmux` / `--zellij` choose explicitly).
| | tmux | zellij |
|---|---|---|
| Inside a session | new window in the current session | new tab in the current session |
| Outside | new session `netssh-NAME`, attached | new session from a generated layout |
| Layout | tiled; pane borders show the host | grid; pane titles show the host |
| Broadcast to all panes | `--sync`; toggle with `Ctrl+b S` or `netssh sync` | `--sync` inside zellij; toggle with `Ctrl+t` then `s` or `netssh sync` |
| Exclude one pane | `Ctrl+b e` (again: include) or `netssh sync --pane` | not possible — zellij syncs whole tabs |
| Shown in the border | `[SYNC]` receives broadcast, `[SOLO]` excluded | `(SYNC)` in the tab name |
**Keys in tmux.** `S` and `e` are free in a default tmux (`E` would be "spread panes evenly"). netssh binds them after your prefix when it opens panes, but only if your own `tmux.conf` does not already use them — then it keeps your binding and says so. They act only in windows opened by `netssh multi`; elsewhere they just show a message. Change or turn them off with `multi.sync_key` / `multi.solo_key` (`none`), e.g. `sync_key: C-s` for `prefix Ctrl+s`.
**In the TUI**, mark hosts with `Space` (or all listed hosts with `Ctrl+A`) and press `Enter` to open them together.
`tag:` and `cluster:` ask NetBox (all instances); if NetBox is unreachable, netssh uses the cached hosts and prints a warning. More than 24 hosts need `--yes`. The 24-hour background cache refresh runs once, not in every pane.
### Default username
Set `ssh.default_user` in the config to avoid typing a username every time:
```sh
netssh my-router # → ssh -l admin 10.0.0.1
```
The default is only applied when no user is specified on the command line. An explicit user always takes precedence:
```sh
netssh root@my-router # user@ prefix wins → ssh root@10.0.0.1
netssh -l ops my-router # -l flag wins → ssh -l ops 10.0.0.1
```
### Default SSH port
Set `ssh.default_port` in the config to use a non-standard port without specifying it every time:
```sh
netssh my-router # → ssh -p 2222 -l admin 10.0.0.1
```
The default is only applied when `-p` is not already present in the SSH arguments. An explicit `-p` always takes precedence:
```sh
netssh -p 22 my-router # -p flag wins → ssh -p 22 10.0.0.1
```
### Host shortcuts
Configure short aliases so you can type `web01` instead of `web01.example.com`:
```yaml
shortcuts:
domains:
- .example.com
- .example.de
strip_hyphens: false # optional
rewrites: # optional, applied in order
- pattern: '^([a-z]+)[0-9]+-' # fsn1-bx1 → fsnbx1
replace: '$1'
```
Resolution order:
1. **Exact match** — the input is looked up as-is in the cache.
2. **Domain expansion** — if the input contains no dot, each configured domain is appended and tried (`web01` → `web01.example.com`). If no cache hit, NetBox is queried with the expanded name.
3. **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.
| Input | Config | Resolved as |
|-------|--------|-------------|
| `web01` | `domains: [.example.com]` | `web01.example.com` |
| `fsn1web01` | `domains: [.example.com]`, `strip_hyphens: true` | `fsn1-web01.example.com` (cache only) |
| `fsnbx1` | `domains: [.hoster.de]`, rewrite `^([a-z]+)[0-9]+-` → `$1` | `fsn1-bx1.hoster.de` (cache only) |
### Shell aliases
Generate shell aliases for all cached hosts based on the shortcut settings:
```sh
netssh alias # bash/zsh syntax
netssh alias --shell fish # fish syntax
```
Example output:
```bash
# netssh aliases (3 hosts) — source with: eval "$(netssh alias)"
alias db01='netssh db01.example.com'
alias fsn1web01='netssh fsn1-web01.example.com'
alias web01='netssh web01.example.com'
```
Add to your shell startup file:
```sh
# bash / zsh — in ~/.bashrc or ~/.zshrc
eval "$(netssh alias)"
# fish — in ~/.config/fish/config.fish
netssh alias --shell fish | source
# scripts
source <(netssh alias)
```
Without a `shortcuts` config, the alias name is derived from the full hostname with dots replaced by underscores (e.g. `web01_example_com`). If two hosts would produce the same alias name, the first entry wins and a warning is printed to stderr.
### Interactive TUI
Run without arguments to open the interactive search:
```sh
netssh
```
The TUI opens with your 10 most-recently-connected hosts. Start typing to search all cached hosts or query NetBox live.
| Key | Action |
|-----|--------|
| type | filter hosts (300 ms debounce → NetBox query) |
| `Tab` | autocomplete top result into the search field |
| `↑` / `↓` | navigate results |
| `Enter` | connect to selected host |
| `Space` | mark/unmark the host; with marks, `Enter` opens them side by side (`netssh multi`) |
| `Ctrl+A` | mark all listed hosts (again: unmark all) |
| `Ctrl+O` | open an SFTP session to the selected host |
| `Ctrl+T` | start a tunnel: enter forward names or specs (`web, 8080:localhost:80`) |
| `Ctrl+E` | open inline editor to override user/port before connecting |
| `Ctrl+N` | edit local notes for the selected server |
| `Ctrl+B` | edit NetBox comments for the selected server |
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
**Tag and kind filter** — press `Ctrl+F` to open a second input line:
```
Filter: tag:prod kind:vm
```
Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter.
**User/port override** — press `Ctrl+E` on any highlighted host:
```
Connect as: admin@my-router:22
```
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. The configured default port is pre-filled. An explicit port 22 overrides a different configured default.
### Server notes
Select a server in the TUI and press `Ctrl+N` for local notes, or `Ctrl+B` for
NetBox comments. Both open a multiline editor: `Ctrl+S` saves, `Esc` cancels.
Existing text is loaded before editing. A failed save keeps your draft in the editor.
Local notes and NetBox comments are independent; there is no automatic synchronization.
The CLI also supports reading, replacing, and appending notes:
```sh
netssh notes set web01 "Maintenance on Friday"
netssh notes append web01 "Reboot completed"
netssh notes show web01
printf 'First line\nSecond line\n' | netssh notes set web01
netssh notes append web01 --netbox --kind vm "Maintenance completed"
netssh notes show web01 --netbox --kind vm
```
`set` replaces the selected note; `append` preserves existing text and adds a new line.
Omitting the text reads it from standard input. An empty string clears the note.
For an uncached local host, specify `--kind device` or `--kind vm`; local operations
work offline. Use the full server name as displayed in the TUI.
Local notes live under `~/.config/netssh/notes/` (`$XDG_CONFIG_HOME` is respected),
with file permissions `0600` and atomic file replacement. They are keyed by NetBox
URL, host kind, and hostname, so cache refreshes and `cache clear` preserve them.
Changing the hostname or NetBox URL creates a separate note identity.
`--netbox` explicitly selects the device/VM's shared `comments` field and requires
a token with write permissions. Existing comments are loaded before modification;
ambiguous names are rejected (specify `--kind` where appropriate).
The client uses the API's ETag/If-Match conflict protection when the server provides
it; older NetBox versions without ETags use the server's last-write-wins behavior.
See the [NetBox REST API documentation](https://netbox.readthedocs.io/en/stable/integrations/rest-api/).
### Cache management
```sh
netssh cache list # show all cached entries
netssh cache refresh # re-fetch ALL hosts from NetBox (paginated)
netssh cache refresh --tag prod # only hosts with the "prod" tag
netssh cache refresh --kind vm # only virtual machines
netssh cache refresh --tag prod --kind vm # combine filters
netssh cache clear # wipe the cache
```
The cache is refreshed automatically in the background every 24 hours. The trigger fires on the next SSH connect or TUI start after the interval has elapsed — there is no delay to the connection itself. The timestamp of the last refresh is stored in `~/.cache/netssh/last_refresh`.
To also trigger the check at shell startup (before you run any SSH command), install the shell hook:
```sh
netssh hook install # auto-detects $SHELL
netssh hook install --shell bash
netssh hook install --shell zsh
netssh hook install --shell fish
```
This adds a marked block (`# >>> netssh >>>` … `# <<< netssh <<<`) to your shell profile. To remove it:
```sh
netssh hook uninstall
```
The hook is a no-op when the cache is fresh — it adds no measurable delay to your shell startup.
### Search (for scripting)
```sh
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
```
**Maintenance on all nodes of a cluster at once**
```sh
netssh multi --sync cluster:pve-fsn1 # every node in one window, typing goes to all
# … run apt upgrade on all nodes; Ctrl+b e on a node that needs something
# different excludes just that one, Ctrl+b S turns broadcast off for all
```
**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
Strategies are tried in the configured order; the first to succeed wins.
Cache refresh uses the same strategies as a direct connection. To allow the primary IP as a fallback, include `primary_ip` in the strategy list. Hosts that cannot be resolved, or have ambiguous names in the refresh results, are removed from the cache and reported as warnings.
| Name | Description |
|------|-------------|
| `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. |
| `management_subnet` | Fetches all IPs for the host and returns the first one matching a configured CIDR. |
| `interface_name` | Fetches IPs attached to a specific named interface (e.g. `mgmt0`). |
| `hostname` | Uses the NetBox name instead of an IP, with `resolver.hostname_domain` appended to names without a dot. DNS or `~/.ssh/config` resolves it when ssh connects. No extra API call. |
**`hostname` strategy.** Its position in the chain decides when it applies:
```yaml
resolver:
strategies: [primary_ip, hostname] # IP if NetBox has one, otherwise the hostname
hostname_domain: example.com # web01 → web01.example.com
```
```yaml
resolver:
strategies: [hostname] # always connect by name, never by IP
```
Names that are not valid DNS hostnames (spaces, underscores) are skipped, so the next strategy is tried. The cache then stores the name instead of an IP, and `--dry-run` shows `ssh web01.example.com`. Compared with `ssh.target: hostname`, the strategy works as a fallback for hosts without an IP in NetBox and can be combined with `target: name`.
## Shell Completion
Install completion for the current user (no sudo required):
```sh
netssh completion install # auto-detects $SHELL
netssh completion install --shell bash
netssh completion install --shell zsh
netssh completion install --shell fish
```
| Shell | Install path |
|-------|-------------|
| bash | `~/.local/share/bash-completion/completions/netssh` |
| zsh | `~/.zfunc/_netssh` |
| fish | `~/.config/fish/completions/netssh.fish` |
For zsh, the netssh block in `~/.zshrc` (written by the installer and by
`netssh hook install --shell zsh`) adds `~/.zfunc` to `fpath` and registers the
completion with `compdef`, also when `compinit` already ran earlier — for
example via oh-my-zsh. Without the block, add `fpath=(~/.zfunc $fpath)` before
`compinit`. If `<Tab>` still completes files, clear the completion cache:
```zsh
rm -f ~/.zcompdump* && exec zsh
```
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:
5000 hosts complete in well under a second. To limit the number of hostnames
offered, set `completion.max_results`.
## Shell Hook
`netssh hook install` sets up two things in your shell profile:
1. **`netssh shell-init`** — runs on every new shell session. It regenerates the alias file from the current cache so your aliases are always up to date, and starts a background cache refresh if the last full refresh was more than 24 hours ago.
2. **A `source` line** (bash/zsh only) — loads the generated alias file so shortcuts like `web01` work immediately in every new shell. Fish uses `conf.d/` auto-sourcing instead, so no explicit source line is needed.
The alias file is also generated immediately during `hook install`, so aliases are available as soon as you reload your profile — no `cache refresh` needed first (though the cache must not be empty).
### Install
```sh
netssh hook install # auto-detects $SHELL
netssh hook install --shell bash
netssh hook install --shell zsh
netssh hook install --shell fish
```
Example output:
```
Hook installed → /home/user/.bashrc
Reload with: source /home/user/.bashrc
42 aliases written to /home/user/.cache/netssh/aliases.sh
```
What gets written to the profile:
**bash** (`~/.bashrc`):
```bash
# >>> netssh >>>
netssh shell-init
[ ! -f '/home/user/.cache/netssh/aliases.sh' ] || . '/home/user/.cache/netssh/aliases.sh'
# <<< netssh <<<
```
**zsh** (`~/.zshrc`) additionally registers the completion:
```zsh
fpath=('/home/user/.zfunc' $fpath)
if (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi
```
**fish** (`~/.config/fish/config.fish`):
```fish
# >>> netssh >>>
netssh shell-init
# <<< netssh <<<
```
Fish alias file (`~/.config/fish/conf.d/netssh.fish`) is written automatically and sourced by fish on every shell start without any profile changes.
| Shell | Profile file | Alias file |
|-------|-------------|------------|
| bash | `~/.bashrc` | `~/.cache/netssh/aliases.sh` |
| zsh | `~/.zshrc` | `~/.cache/netssh/aliases.sh` |
| fish | `~/.config/fish/config.fish` | `~/.config/fish/conf.d/netssh.fish` |
The install is idempotent — running it again changes nothing if the block is already present. Unmarked lines written by older versions are replaced by the block.
Reload your profile after installation to activate immediately:
```sh
source ~/.bashrc # bash
source ~/.zshrc # zsh
source ~/.config/fish/config.fish # fish
```
### Alias file updates
The alias file is regenerated automatically — no manual action needed:
| Event | Aliases updated |
|-------|----------------|
| New shell session (`shell-init`) | Yes — from the current local cache |
| `netssh cache refresh` | Yes — after the full refresh completes |
| Background 24h auto-refresh | Yes — after the background refresh completes |
Aliases for newly added hosts appear in the next shell session after a `cache refresh` has run.
### Uninstall
```sh
netssh hook uninstall # auto-detects $SHELL
netssh hook uninstall --shell zsh
```
Removes the netssh block and unmarked entries of older versions from the profile. The alias files (`aliases.sh`, `netssh.fish`) are left on disk. To remove netssh completely, use `netssh uninstall`.
### Relation to the connect-time auto-refresh
The shell hook and the connect-time trigger (which fires on each `netssh` invocation) are independent but complementary:
| Trigger | When it fires |
|---------|--------------|
| SSH connect / TUI start | On the next `netssh` call after 24 h have elapsed |
| Shell hook (`shell-init`) | On the first new shell session after 24 h have elapsed |
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`, `multi`, `sync`, or like another subcommand | Use `netssh -- sftp`. |
| `netssh multi needs tmux or zellij` | Install one of them (`apt install tmux`, `brew install tmux`). |
| zellij shows empty boxes in its tab and status bar | Those are Powerline arrows; use a Nerd Font in your terminal, or add `simplified_ui true` to `~/.config/zellij/config.kdl`. |
| `cluster:x matches no hosts` | Clusters select devices by default; VMs need `--kind vm` or `--kind all`. The name is matched case-insensitively. |
| `netssh multi` panes use another config | Inside an existing tmux/zellij session, panes get your `HOME` and `XDG_*` paths, but not `NETSSH_*` variables (they would be visible to other users in the process list). Put such settings in the config file. |
## Uninstall
```sh
netssh uninstall --dry-run # show what would be removed
netssh uninstall # asks for confirmation
netssh uninstall --keep-config # keep config and notes for a reinstall
```
`netssh uninstall` removes:
- netssh blocks and unmarked entries of older versions from `~/.bashrc`, `~/.bash_profile`, `~/.bash_login`, `~/.profile`, `~/.zshrc`, `~/.zprofile`, `~/.zshenv`, and `config.fish`
- completion files for bash, zsh (also the oh-my-zsh cache), fish, and the command alias
- the cache directory `~/.cache/netssh/` including per-instance caches and aliases
- `~/.config/netssh.yaml` (a private backup `netssh.yaml.backup-<timestamp>` is kept, because it contains API tokens) and local notes
- the netssh binary; if it is in a protected directory, netssh prints the `sudo rm` command to run
Afterwards open a new terminal; for zsh also run `rm -f ~/.zcompdump*`.
## Development
Renovate monitors the release workflow's actions, the Go version in `go.mod`, and
all direct and indirect Go module dependencies. Go dependency updates also run
`go mod tidy` to keep `go.mod` and `go.sum` consistent.
```sh
go test ./... # includes offline installer/update tests
go build ./... # build all packages
```
End-to-end tests run against real services in a local Docker environment — two NetBox instances, SSH targets on separate networks, a jump host, an IPv6 host, tmux, zellij, and bash/zsh/fish. See [testenv/README.md](testenv/README.md). It is not part of CI/CD.
```sh
testenv/up.sh # build and start (first start ~6 min)
testenv/test.sh # 185 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR)
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/multiplex` | tmux and zellij layouts for `netssh multi`. |
| `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
This is a **vibe-coded** project: the entire codebase — architecture, implementation, tests, and docs — was generated by an AI assistant (Claude by Anthropic). No human has reviewed or audited the code. It works for the author's personal use case, but correctness and security are not guaranteed. Read the source before running it in sensitive environments.
## How it works
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `multi`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI.
2. On first run, or when neither a NetBox nor static hosts are configured, the interactive setup wizard starts automatically.
3. It removes its own long options (`--dry-run`, `--jump`, `--no-jump`, `--forward`) and parses the remaining arguments with the flag table of the program it will run (ssh, sftp, or scp differ, e.g. in `-l` and `-P`) to find the destination: `[user@]host` for ssh, `[user@]host[:path]` for sftp, and every `host:path` operand for scp.
4. It resolves the host: first the static hosts from `hosts:` (by name or shortcut), then an exact cache lookup, a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached.
5. It adds the user and port (a static host's own, else the defaults) in the program's syntax unless you gave your own.
6. It merges the SSH options for the host (global, instance, matching rules), resolves named forwards, and inserts the options before the first operand: jump host, host key checking, `-o` options, forwards, and for `tunnel` `-N` with `ExitOnForwardFailure`.
7. It replaces the hostname with the resolved IP (or the name, with `ssh.target: name`; IPv6 in brackets for sftp/scp) and calls `syscall.Exec` to become `ssh`, `sftp`, or `scp` — or prints the command with `--dry-run`.