Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c9e54b254 | ||
|
|
a950a82552 | ||
|
|
5821dfbebd |
No files matched your search
@@ -10,6 +10,8 @@
|
||||
- Add `netssh sftp` and `netssh scp`: host lookup, shortcuts, instances, default user and port, SSH options and rules, jump host, `--dry-run`, and completion work as for ssh. IPv6 addresses are bracketed for host:path.
|
||||
- Add named port forwards (`ssh.forwards` globally, per instance, and per rule), `netssh tunnel HOST NAME|SPEC...` (ssh -N with ExitOnForwardFailure), and `netssh --forward NAME`. Forwards are editable in `netssh configure` and completed in the shell.
|
||||
- TUI: `Ctrl+O` opens SFTP, `Ctrl+T` starts a tunnel with configured or ad hoc forwards.
|
||||
- Add the `hostname` resolver strategy: connect by the NetBox name (plus optional `resolver.hostname_domain`) instead of an IP, always or as a fallback after `primary_ip`.
|
||||
- Add static hosts (`hosts:`) for machines that are not in NetBox, with user, port, tags, and SSH options, managed with `netssh hosts list|add|remove` and in `netssh configure`. They are found before NetBox, work offline and without any NetBox, and support shortcuts, rules (`kind: static`), completion, aliases, sftp, scp, tunnels, and the TUI.
|
||||
- Fix options added by netssh landing after `--` (`netssh --no-jump -- host`), where ssh read them as the destination.
|
||||
|
||||
## v0.0.22
|
||||
|
||||
@@ -21,7 +21,7 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int
|
||||
- [Installation](#installation)
|
||||
- [Quick start](#quick-start)
|
||||
- [Configuration](#configuration) — [wizard](#interactive-wizard), [manual config](#manual-config), [reference](#configuration-reference), [environment variables](#environment-variables), [files](#files-and-directories), [multiple instances](#multiple-netbox-instances)
|
||||
- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management)
|
||||
- [Usage](#usage) — [command overview](#command-overview), [SSH options](#ssh-options), [SFTP & SCP](#sftp-and-scp), [port forwarding](#port-forwarding), [static hosts](#static-hosts), [shortcuts](#host-shortcuts), [TUI](#interactive-tui), [notes](#server-notes), [cache](#cache-management)
|
||||
- [Recipes](#recipes)
|
||||
- [IP Resolution Strategies](#ip-resolution-strategies)
|
||||
- [Shell Completion](#shell-completion) and [Shell Hook](#shell-hook)
|
||||
@@ -31,7 +31,8 @@ The same lookup drives `netssh sftp`, `netssh scp`, and `netssh tunnel`, the int
|
||||
## Features
|
||||
|
||||
- **Transparent proxy** — replaces itself with `ssh` via `syscall.Exec`, preserving all SSH flags and options
|
||||
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
|
||||
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, named interface, or the hostname itself
|
||||
- **Static hosts** — add hosts that are not in NetBox (`netssh hosts add router 192.168.1.1`); they work everywhere, also without any NetBox
|
||||
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
|
||||
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
|
||||
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
|
||||
@@ -139,7 +140,7 @@ Re-run it at any time to change settings without editing the file manually:
|
||||
netssh configure
|
||||
```
|
||||
|
||||
The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance.
|
||||
The wizard first lets you add, edit, and remove named NetBox instances and set SSH options per instance. Without any instance you can also continue with static hosts only.
|
||||
A menu then gives access to every other setting; open only the sections you want to change and choose **Save and exit**:
|
||||
|
||||
| Section | Settings |
|
||||
@@ -147,7 +148,8 @@ A menu then gives access to every other setting; open only the sections you want
|
||||
| SSH defaults | `ssh.default_user`, `ssh.default_port` |
|
||||
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) |
|
||||
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
|
||||
| Resolver & cache | strategies, management subnets, interface name, cache TTL, cache file |
|
||||
| Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options |
|
||||
| Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file |
|
||||
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
|
||||
| Completion | `completion.max_results` |
|
||||
|
||||
@@ -211,6 +213,7 @@ Every key, its default, and the section that explains it. Keys marked *layered*
|
||||
| `resolver.strategies` | list | `[management_subnet, primary_ip]` | Tried in order, first IP wins — see [IP Resolution Strategies](#ip-resolution-strategies). The wizard suggests `primary_ip`. |
|
||||
| `resolver.management_subnets` | list of CIDR | `[]` | Used by `management_subnet`. |
|
||||
| `resolver.interface_name` | string | — | Used by `interface_name`, e.g. `mgmt0`. |
|
||||
| `resolver.hostname_domain` | string | — | Used by `hostname`: appended to NetBox names without a dot. |
|
||||
| `cache.ttl` | seconds | `3600` | How long a cached IP is used without asking NetBox; `0` always asks NetBox when connecting. |
|
||||
| `cache.path` | path | user cache dir `/netssh/hosts.json` | Cache file; named instances use a subdirectory next to it. |
|
||||
| `ssh.default_user` | string | — | Login user when none is given (`-l` for ssh, `-o User=` for sftp/scp). |
|
||||
@@ -228,15 +231,16 @@ Every key, its default, and the section that explains it. Keys marked *layered*
|
||||
| `shortcuts.rewrites[]` | list | `[]` | `pattern` (Go regex) and `replace` — see [Host shortcuts](#host-shortcuts). |
|
||||
| `completion.max_results` | int | `0` | Hostnames offered per `<Tab>`; `0` = unlimited. |
|
||||
| `shell.alias` | string | — | Short command name for netssh, e.g. `j` — see [Short command alias](#short-command-alias). |
|
||||
| `hosts[]` | list | `[]` | Static hosts with `name`, `address`, `user`, `port`, `tags`, `ssh` — see [Static hosts](#static-hosts). |
|
||||
|
||||
### Environment variables
|
||||
|
||||
These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`) can only be set in the file.
|
||||
These variables override the file. Lists are comma-separated (`NETSSH_SHORTCUTS_DOMAINS=.example.com,.example.de`). Settings without a variable (`extra_args`, `options`, `forwards`, `rules`, `rewrites`, `netboxes`, `hosts`) can only be set in the file.
|
||||
|
||||
| Variable | Key |
|
||||
|---|---|
|
||||
| `NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, `NETSSH_NETBOX_TOKEN_VERSION` | `netbox.*` (single instance only; ignored when the file has only `netboxes:`) |
|
||||
| `NETSSH_RESOLVER_STRATEGIES`, `NETSSH_RESOLVER_MANAGEMENT_SUBNETS`, `NETSSH_RESOLVER_INTERFACE_NAME` | `resolver.*` |
|
||||
| `NETSSH_RESOLVER_STRATEGIES`, `NETSSH_RESOLVER_MANAGEMENT_SUBNETS`, `NETSSH_RESOLVER_INTERFACE_NAME`, `NETSSH_RESOLVER_HOSTNAME_DOMAIN` | `resolver.*` |
|
||||
| `NETSSH_CACHE_TTL`, `NETSSH_CACHE_PATH` | `cache.*` |
|
||||
| `NETSSH_SSH_DEFAULT_USER`, `NETSSH_SSH_DEFAULT_PORT` | `ssh.default_*` |
|
||||
| `NETSSH_SSH_PROXY_JUMP`, `NETSSH_SSH_HOST_KEY_CHECKING`, `NETSSH_SSH_TARGET`, `NETSSH_SSH_ADDRESS_FAMILY` | global `ssh.*` options |
|
||||
@@ -354,6 +358,7 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered.
|
||||
| `netssh sftp [netssh flags] [sftp options] HOST[:PATH]` | SFTP session — see [SFTP and SCP](#sftp-and-scp). |
|
||||
| `netssh scp [netssh flags] [scp options] SOURCE... TARGET` | Copy files — see [SFTP and SCP](#sftp-and-scp). |
|
||||
| `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). |
|
||||
| `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). |
|
||||
| `netssh configure` | Setup wizard. |
|
||||
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. |
|
||||
| `netssh search QUERY` | Print matching cached hostnames, one per line. |
|
||||
@@ -452,12 +457,12 @@ ssh asks once to confirm each host key.
|
||||
|---|---|---|
|
||||
| `tag` | NetBox tags of the host | Tag name or slug, case-insensitive (`DMZ Hosts` matches `dmz-hosts`). |
|
||||
| `instance` | Name of the NetBox instance | Only useful with `netboxes:`. |
|
||||
| `kind` | `device` or `vm` | Devices come from DCIM, VMs from Virtualization. |
|
||||
| `kind` | `device`, `vm`, or `static` | Devices come from DCIM, VMs from Virtualization, `static` from `hosts:`. |
|
||||
| `name` | Hostname without instance prefix | Shell glob, case-insensitive (`*.dmz.example.com`, `fw-??`). |
|
||||
|
||||
Tags and kind are taken from the cache or the NetBox answer, so rules work offline for cached hosts. A rule needs at least one match field. All matching rules apply in file order; a later rule overrides an earlier one.
|
||||
|
||||
**How layers combine.** netssh merges global → instance → matching rules:
|
||||
**How layers combine.** netssh merges global → instance (or the static host's own `ssh`) → matching rules:
|
||||
|
||||
- single values (`proxy_jump`, `host_key_checking`, `target`, `address_family`) — the most specific non-empty value wins;
|
||||
- `options` and `forwards` — merged per key or name, the more specific layer wins;
|
||||
@@ -508,7 +513,39 @@ netssh --forward web fsnbx1 # shell plus forward (repeatable)
|
||||
|
||||
A value that is no configured name but a port spec (`[bind:]port:host:hostport`) becomes a local forward. `netssh tunnel` adds `-o ExitOnForwardFailure=yes`, so a port already in use fails instead of leaving a silent session. Without forward names it lists the forwards configured for the host. Forward names are completed after the host (`netssh tunnel fsnbx1 <Tab>`) and after `--forward`. In the TUI, `Ctrl+T` on a host opens an input with its configured forwards.
|
||||
|
||||
`sftp`, `scp`, and `tunnel` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
|
||||
`sftp`, `scp`, `tunnel`, and `hosts` are subcommands; to connect to a host with one of these names, use `netssh -- sftp`.
|
||||
|
||||
### Static hosts
|
||||
|
||||
Hosts that are not in NetBox — a home router, a cloud VM, a customer machine — can be added to the config. netssh checks them before NetBox, so they work offline and even without any NetBox configured.
|
||||
|
||||
```sh
|
||||
netssh hosts add router 192.168.1.1 --user admin --tag home
|
||||
netssh hosts add backup backup.example.org --port 2222 --tag offsite
|
||||
netssh hosts # list
|
||||
netssh hosts add router 192.168.1.254 --replace # change the address, keep its SSH options
|
||||
netssh hosts remove backup
|
||||
```
|
||||
|
||||
The same in the config (or in `netssh configure` → *Static hosts*):
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
- name: router
|
||||
address: 192.168.1.1 # IP address or DNS name
|
||||
user: admin # optional, overrides ssh.default_user
|
||||
port: 2222 # optional, overrides ssh.default_port
|
||||
tags: [home] # optional, for ssh.rules
|
||||
ssh: # optional, same keys as ssh: (proxy_jump, options, forwards, …)
|
||||
proxy_jump: none
|
||||
```
|
||||
|
||||
Static hosts behave like NetBox hosts everywhere: `netssh router`, `sftp`, `scp`, `tunnel`, shortcuts and rewrite rules, Tab completion, shell aliases, the TUI search and its recently-used list, and `ssh.rules` (match `kind: static` or their tags). Their SSH options are merged like an instance's: global → static host → rules. User and port on the command line still win.
|
||||
|
||||
- Names may contain letters, digits, dots, underscores, and hyphens, and are matched case-insensitively. They have no instance prefix.
|
||||
- A static host wins over a NetBox host of the same name. Give them distinct names to keep both reachable.
|
||||
- Static hosts never expire and are not touched by `cache refresh` or `cache clear`; removing one from the config removes it everywhere.
|
||||
- `--netbox` notes and NetBox comments are not available for static hosts; local notes work.
|
||||
|
||||
### Default username
|
||||
|
||||
@@ -815,6 +852,22 @@ Cache refresh uses the same strategies as a direct connection. To allow the prim
|
||||
| `primary_ip` | Returns the `primary_ip4` (or `primary_ip6`) set in NetBox. No extra API call. |
|
||||
| `management_subnet` | Fetches all IPs for the host and returns the first one matching a configured CIDR. |
|
||||
| `interface_name` | Fetches IPs attached to a specific named interface (e.g. `mgmt0`). |
|
||||
| `hostname` | Uses the NetBox name instead of an IP, with `resolver.hostname_domain` appended to names without a dot. DNS or `~/.ssh/config` resolves it when ssh connects. No extra API call. |
|
||||
|
||||
**`hostname` strategy.** Its position in the chain decides when it applies:
|
||||
|
||||
```yaml
|
||||
resolver:
|
||||
strategies: [primary_ip, hostname] # IP if NetBox has one, otherwise the hostname
|
||||
hostname_domain: example.com # web01 → web01.example.com
|
||||
```
|
||||
|
||||
```yaml
|
||||
resolver:
|
||||
strategies: [hostname] # always connect by name, never by IP
|
||||
```
|
||||
|
||||
Names that are not valid DNS hostnames (spaces, underscores) are skipped, so the next strategy is tried. The cache then stores the name instead of an IP, and `--dry-run` shows `ssh web01.example.com`. Compared with `ssh.target: hostname`, the strategy works as a fallback for hosts without an IP in NetBox and can be combined with `target: name`.
|
||||
|
||||
## Shell Completion
|
||||
|
||||
@@ -958,6 +1011,8 @@ Both are non-blocking — the refresh runs as a background process and never del
|
||||
| Message / symptom | Cause and fix |
|
||||
|---|---|
|
||||
| `host not found` | The name is not in NetBox (devices and VMs are searched). Check the spelling, or configure `shortcuts.domains` if you type short names. |
|
||||
| `host "x" is not in hosts: and no NetBox is configured` | Only static hosts are configured. Add the host with `netssh hosts add`, or a NetBox with `netssh configure`. |
|
||||
| `no strategy resolved an IP` | No resolver strategy found an address. Add `primary_ip` or `hostname` as a fallback at the end of `resolver.strategies`. |
|
||||
| `ambiguous shortcut "x"; use a full hostname` | Several cached hosts shorten to the same name. Use the full name, or check with `netssh alias --test`. |
|
||||
| `found 2 exact matches` | The name exists in several NetBox instances, or as both device and VM. Prefix the instance (`prod/web01`); for notes and the TUI also `--kind`. |
|
||||
| Shortcut works for one host but not another | Shortcuts (hyphen folding, rewrites) only match cached hosts. Run `netssh cache refresh`. |
|
||||
@@ -969,7 +1024,7 @@ Both are non-blocking — the refresh runs as a background process and never del
|
||||
| Saving notes with `--netbox` fails with a permission error | The API token is read-only. Local notes work without write permission. |
|
||||
| `<Tab>` completes files instead of hosts | Run `netssh completion install` and open a new terminal; for zsh also `rm -f ~/.zcompdump*`. |
|
||||
| Aliases missing in a new shell | `netssh hook install`, then a new terminal. The cache must not be empty. |
|
||||
| A host is named `sftp`, `scp`, `tunnel`, or like another subcommand | Use `netssh -- sftp`. |
|
||||
| A host is named `sftp`, `scp`, `tunnel`, `hosts`, or like another subcommand | Use `netssh -- sftp`. |
|
||||
|
||||
## Uninstall
|
||||
|
||||
@@ -1025,9 +1080,9 @@ This is a **vibe-coded** project: the entire codebase — architecture, implemen
|
||||
## How it works
|
||||
|
||||
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `cache`, `sftp`, `scp`, `tunnel`, `notes`, …). If not, it enters SSH wrapper mode; without arguments it starts the TUI.
|
||||
2. On first run or when no NetBox is configured, the interactive setup wizard starts automatically.
|
||||
2. On first run, or when neither a NetBox nor static hosts are configured, the interactive setup wizard starts automatically.
|
||||
3. It removes its own long options (`--dry-run`, `--jump`, `--no-jump`, `--forward`) and parses the remaining arguments with the flag table of the program it will run (ssh, sftp, or scp differ, e.g. in `-l` and `-P`) to find the destination: `[user@]host` for ssh, `[user@]host[:path]` for sftp, and every `host:path` operand for scp.
|
||||
4. It adds the default user and port in the program's syntax unless you gave your own.
|
||||
5. It resolves the host: an exact cache lookup, then a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached.
|
||||
4. It resolves the host: first the static hosts from `hosts:` (by name or shortcut), then an exact cache lookup, a shortcut match against the cache (domains, rewrites, hyphen folding), and finally NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/`, all instances in parallel) with the resolver chain. A fresh cache entry needs no network request; new results are cached.
|
||||
5. It adds the user and port (a static host's own, else the defaults) in the program's syntax unless you gave your own.
|
||||
6. It merges the SSH options for the host (global, instance, matching rules), resolves named forwards, and inserts the options before the first operand: jump host, host key checking, `-o` options, forwards, and for `tunnel` `-N` with `ExitOnForwardFailure`.
|
||||
7. It replaces the hostname with the resolved IP (or the name, with `ssh.target: name`; IPv6 in brackets for sftp/scp) and calls `syscall.Exec` to become `ssh`, `sftp`, or `scp` — or prints the command with `--dry-run`.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import (
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
)
|
||||
|
||||
// newCache opens the host cache with the config's static hosts.
|
||||
func newCache(cfg *config.Config) *cache.Cache {
|
||||
return cache.New(cfg.CachePath(), cfg.Cache.TTL).WithStatic(cfg.Hosts)
|
||||
}
|
||||
|
||||
func newNetBoxClient(cfg *config.Config) *netbox.Client {
|
||||
if len(cfg.NetBoxes) > 0 {
|
||||
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
|
||||
|
||||
+59
-23
@@ -45,6 +45,7 @@ var managedSubcommands = map[string]bool{
|
||||
"sftp": true,
|
||||
"scp": true,
|
||||
"tunnel": true,
|
||||
"hosts": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -75,7 +76,7 @@ func loadConfigOrSetup() *config.Config {
|
||||
if err != nil {
|
||||
fatalf("config: %v", err)
|
||||
}
|
||||
if !cfg.HasNetBox() {
|
||||
if !cfg.HasHosts() {
|
||||
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
||||
if err := setup.RunWizard(cfg); err != nil {
|
||||
fatalf("setup: %v", err)
|
||||
@@ -143,16 +144,35 @@ func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args [
|
||||
return
|
||||
}
|
||||
|
||||
// Inject the configured default user if none was given on the command line.
|
||||
if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) {
|
||||
args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests)
|
||||
host := resolveTarget(cfg, dest.host)
|
||||
user, port := hostDefaults(cfg, host)
|
||||
// Inject the default user if none was given on the command line.
|
||||
if user != "" && dest.user == "" && !client.HasUserFlag(args) {
|
||||
args = prependArgs(client.UserArgs(user), args, dest.dests)
|
||||
}
|
||||
// Inject the configured default port if none was given on the command line.
|
||||
if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) {
|
||||
args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests)
|
||||
// Inject the default port if none was given on the command line.
|
||||
if port > 0 && !client.HasPortFlag(args) {
|
||||
args = prependArgs(client.PortArgs(strconv.Itoa(port)), args, dest.dests)
|
||||
}
|
||||
|
||||
connectHost(cfg, client, flags, resolveTarget(cfg, dest.host), dest.dests, args)
|
||||
connectHost(cfg, client, flags, host, dest.dests, args)
|
||||
}
|
||||
|
||||
// hostDefaults returns the login user and port for host: those of a static
|
||||
// host if set, else ssh.default_user and ssh.default_port.
|
||||
func hostDefaults(cfg *config.Config, host resolvedHost) (string, int) {
|
||||
user, port := cfg.SSH.DefaultUser, cfg.SSH.DefaultPort
|
||||
if host.Kind == config.StaticKind {
|
||||
if static := cfg.StaticHost(host.Name); static != nil {
|
||||
if static.User != "" {
|
||||
user = static.User
|
||||
}
|
||||
if static.Port > 0 {
|
||||
port = static.Port
|
||||
}
|
||||
}
|
||||
}
|
||||
return user, port
|
||||
}
|
||||
|
||||
// prependArgs puts extra before args and shifts the destination indices.
|
||||
@@ -174,7 +194,7 @@ type resolvedHost struct {
|
||||
// resolveTarget finds host in the cache (also by shortcut) or NetBox and
|
||||
// resolves its address. It exits on failure.
|
||||
func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
@@ -186,6 +206,21 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags}
|
||||
}
|
||||
|
||||
// Static hosts from the config win and need no NetBox.
|
||||
if source == "" {
|
||||
var normalize func(string) string
|
||||
if cfg.Shortcuts.Enabled() {
|
||||
normalize = normalizeTarget(cfg.Shortcuts, false)
|
||||
}
|
||||
entry, found, err := c.GetStatic(host, normalize)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
if found {
|
||||
return used(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||
if entry, fresh := c.Get(host); fresh && useCache {
|
||||
return used(entry)
|
||||
@@ -211,7 +246,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
}
|
||||
|
||||
if !cfg.HasNetBox() {
|
||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||
fatalf("host %q is not in hosts: and no NetBox is configured (netssh configure)", host)
|
||||
}
|
||||
nbClient := newNetBoxClient(cfg)
|
||||
|
||||
@@ -316,7 +351,7 @@ func execClient(client internalssh.Client, result internalssh.Result, flags inte
|
||||
func runTUI() {
|
||||
cfg := loadConfigOrSetup()
|
||||
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
@@ -392,17 +427,18 @@ func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client,
|
||||
}
|
||||
|
||||
var args []string
|
||||
// User override from TUI edit mode takes priority, then config default.
|
||||
// User override from TUI edit mode takes priority, then static host and config defaults.
|
||||
defaultUser, defaultPort := hostDefaults(cfg, resolvedHost{Name: host.Name, Kind: host.Kind})
|
||||
user := host.User
|
||||
if user == "" {
|
||||
user = cfg.SSH.DefaultUser
|
||||
user = defaultUser
|
||||
}
|
||||
if user != "" {
|
||||
args = append(args, client.UserArgs(user)...)
|
||||
}
|
||||
port := host.Port
|
||||
if port == "" && cfg.SSH.DefaultPort > 0 {
|
||||
port = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||
if port == "" && defaultPort > 0 {
|
||||
port = strconv.Itoa(defaultPort)
|
||||
}
|
||||
if port != "" {
|
||||
args = append(args, client.PortArgs(port)...)
|
||||
@@ -453,7 +489,7 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
@@ -575,7 +611,7 @@ func searchCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -609,7 +645,7 @@ func cacheListCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -635,7 +671,7 @@ func cacheClearCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
c.Clear()
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
@@ -677,7 +713,7 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
return fmt.Errorf("NetBox: %w", err)
|
||||
}
|
||||
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
@@ -762,7 +798,7 @@ Or use in a script:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -838,7 +874,7 @@ func shellInitCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
|
||||
// Regenerate alias files from the current cache every shell start
|
||||
@@ -897,7 +933,7 @@ After installation, reload your profile or open a new shell.`,
|
||||
// Generate the aliases file immediately so it is ready on the next shell start.
|
||||
cfg, cfgErr := config.Load()
|
||||
if cfgErr == nil && cfg.HasNetBox() {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
if _ = c.Load(); true {
|
||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ func notesCmd() *cobra.Command {
|
||||
client = newNetBoxClient(cfg)
|
||||
}
|
||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func completeHosts(toComplete string) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
@@ -144,7 +144,7 @@ func completeHosts(toComplete string) []string {
|
||||
|
||||
// cachedHost finds a host for completion by name or shortcut, without NetBox.
|
||||
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c := newCache(cfg)
|
||||
if err := c.Load(); err != nil {
|
||||
return cache.Entry{}, false
|
||||
}
|
||||
|
||||
Vendored
+94
-3
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,9 @@ type Entry struct {
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
LastUsed time.Time `json:"last_used,omitempty"`
|
||||
// Static marks a host from the config's hosts: list. It never expires;
|
||||
// it is stored only to remember LastUsed for the recently-used list.
|
||||
Static bool `json:"static,omitempty"`
|
||||
}
|
||||
|
||||
type changeKind uint8
|
||||
@@ -40,6 +44,7 @@ type Cache struct {
|
||||
path string
|
||||
ttl time.Duration
|
||||
refreshStamp string // path to last_refresh timestamp file
|
||||
static []config.StaticHost
|
||||
}
|
||||
|
||||
type diskFormat struct {
|
||||
@@ -98,6 +103,7 @@ func (c *Cache) Load() error {
|
||||
|
||||
data, err := os.ReadFile(c.path)
|
||||
if os.IsNotExist(err) {
|
||||
c.applyStatic()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -115,6 +121,7 @@ func (c *Cache) Load() error {
|
||||
for _, e := range df.Entries {
|
||||
c.entries[e.Name] = e
|
||||
}
|
||||
c.applyStatic()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -188,6 +195,15 @@ func (c *Cache) Save() error {
|
||||
if err := storage.WriteFile(c.path, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Static hosts come from the config, not the file: keep them in memory.
|
||||
for name, e := range c.entries {
|
||||
if e.Static {
|
||||
if old, ok := merged[name]; ok && old.LastUsed.After(e.LastUsed) {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
merged[name] = e
|
||||
}
|
||||
}
|
||||
c.entries = merged
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = false
|
||||
@@ -221,7 +237,10 @@ func (c *Cache) MarkUsed(name string) {
|
||||
if e, ok := c.entries[name]; ok {
|
||||
e.LastUsed = time.Now()
|
||||
c.entries[name] = e
|
||||
if _, changed := c.dirty[name]; !changed {
|
||||
if e.Static {
|
||||
// A static host may not be on disk yet; usage alone would not add it.
|
||||
c.dirty[name] = upsertChange
|
||||
} else if _, changed := c.dirty[name]; !changed {
|
||||
c.dirty[name] = usageChange
|
||||
}
|
||||
}
|
||||
@@ -279,7 +298,7 @@ func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (e
|
||||
entry, found = e, true
|
||||
}
|
||||
}
|
||||
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil
|
||||
return entry, found, found && (entry.Static || c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl), nil
|
||||
}
|
||||
|
||||
// Get returns an entry and reports whether it is still within the TTL.
|
||||
@@ -291,20 +310,92 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||
if !ok {
|
||||
return Entry{}, false
|
||||
}
|
||||
if e.Static {
|
||||
return e, true
|
||||
}
|
||||
if c.ttl == 0 {
|
||||
return e, false
|
||||
}
|
||||
return e, time.Since(e.CachedAt) < c.ttl
|
||||
}
|
||||
|
||||
// Clear removes all NetBox entries; static hosts come from the config and stay.
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
c.entries = make(map[string]Entry)
|
||||
static := make(map[string]Entry)
|
||||
for name, e := range c.entries {
|
||||
if e.Static {
|
||||
static[name] = e
|
||||
}
|
||||
}
|
||||
c.entries = static
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetStatic adds the config's static hosts. They replace cached entries of the
|
||||
// same name, keep their recorded LastUsed, and static entries that are no
|
||||
// longer configured are removed on the next Save.
|
||||
func (c *Cache) SetStatic(hosts []config.StaticHost) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.static = hosts
|
||||
c.applyStatic()
|
||||
}
|
||||
|
||||
// WithStatic sets the static hosts now and after every Load.
|
||||
func (c *Cache) WithStatic(hosts []config.StaticHost) *Cache {
|
||||
c.SetStatic(hosts)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Cache) applyStatic() {
|
||||
hosts := c.static
|
||||
configured := make(map[string]bool, len(hosts))
|
||||
for _, host := range hosts {
|
||||
e := Entry{Name: host.Name, IP: host.Address, Kind: config.StaticKind, Tags: host.Tags, CachedAt: time.Now(), Static: true}
|
||||
if old, ok := c.entries[host.Name]; ok && old.Static {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
c.entries[host.Name] = e
|
||||
configured[host.Name] = true
|
||||
}
|
||||
for name, e := range c.entries {
|
||||
if e.Static && !configured[name] {
|
||||
delete(c.entries, name)
|
||||
c.dirty[name] = deleteChange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatic finds a static host by name or by shortcut. Static hosts are
|
||||
// checked before NetBox, so they also work when NetBox is unreachable.
|
||||
func (c *Cache) GetStatic(name string, normalize func(string) string) (Entry, bool, error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
var match Entry
|
||||
found := false
|
||||
for key, e := range c.entries {
|
||||
if e.Static && strings.EqualFold(key, name) {
|
||||
return e, true, nil
|
||||
}
|
||||
}
|
||||
if normalize == nil {
|
||||
return Entry{}, false, nil
|
||||
}
|
||||
short := normalize(name)
|
||||
for _, e := range c.entries {
|
||||
if e.Static && normalize(e.Name) == short {
|
||||
if found {
|
||||
return Entry{}, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", name)
|
||||
}
|
||||
match, found = e, true
|
||||
}
|
||||
}
|
||||
return match, found, nil
|
||||
}
|
||||
|
||||
func (c *Cache) All() []Entry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
Vendored
+62
@@ -4,8 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -495,3 +498,62 @@ func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHosts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
hosts := []config.StaticHost{{Name: "router", Address: "192.168.1.1", Tags: []string{"home"}}, {Name: "nas", Address: "nas.lan"}}
|
||||
c := New(path, 1).WithStatic(hosts)
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1", Kind: "device"})
|
||||
if err := c.Load(); err != nil { // no file yet; static hosts stay
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, fresh := c.Get("router"); !fresh || e.IP != "192.168.1.1" || e.Kind != config.StaticKind || !e.Static {
|
||||
t.Fatalf("static get: %+v %v", e, fresh)
|
||||
}
|
||||
if e, found, err := c.GetStatic("ROUTER", nil); !found || err != nil || e.Name != "router" {
|
||||
t.Fatalf("by name: %+v %v %v", e, found, err)
|
||||
}
|
||||
normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(s), ".lan") }
|
||||
if e, found, _ := c.GetStatic("nas.lan", normalize); !found || e.Name != "nas" {
|
||||
t.Fatalf("by shortcut: %+v", e)
|
||||
}
|
||||
if _, found, _ := c.GetStatic("web01", normalize); found {
|
||||
t.Fatal("NetBox entry returned as static")
|
||||
}
|
||||
|
||||
// Usage of a static host persists; the host itself stays after Save and Clear.
|
||||
c.MarkUsed("router")
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, fresh := c.Get("nas"); !fresh {
|
||||
t.Fatal("static host lost after Save")
|
||||
}
|
||||
reloaded := New(path, 1).WithStatic(hosts)
|
||||
if err := reloaded.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recent := reloaded.RecentlyUsed(5); len(recent) != 1 || recent[0].Name != "router" {
|
||||
t.Fatalf("recent: %+v", recent)
|
||||
}
|
||||
reloaded.Clear()
|
||||
if _, fresh := reloaded.Get("router"); !fresh {
|
||||
t.Fatal("Clear removed a static host")
|
||||
}
|
||||
|
||||
// A host removed from the config disappears, also from the file.
|
||||
removed := New(path, 1).WithStatic(hosts[1:])
|
||||
if err := removed.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := removed.Get("router"); e.Name != "" {
|
||||
t.Fatalf("removed static host still present: %+v", e)
|
||||
}
|
||||
if err := removed.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(data), "192.168.1.1") {
|
||||
t.Fatalf("removed static host still on disk:\n%s", data)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type Config struct {
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
Hosts []StaticHost `mapstructure:"hosts"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
@@ -37,6 +38,8 @@ type ResolverConfig struct {
|
||||
Strategies []string `mapstructure:"strategies"`
|
||||
ManagementSubnets []string `mapstructure:"management_subnets"`
|
||||
InterfaceName string `mapstructure:"interface_name"`
|
||||
// HostnameDomain is appended by the hostname strategy to names without a dot.
|
||||
HostnameDomain string `mapstructure:"hostname_domain"`
|
||||
}
|
||||
|
||||
type CacheConfig struct {
|
||||
@@ -114,7 +117,7 @@ func Load() (*Config, error) {
|
||||
v.SetEnvPrefix("NETSSH")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -157,6 +160,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateHosts(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
// HasHosts reports whether netssh has anything to connect to: NetBox or static hosts.
|
||||
func (c *Config) HasHosts() bool { return c.HasNetBox() || len(c.Hosts) > 0 }
|
||||
|
||||
// CachePath isolates named sources from legacy caches and from changed endpoints.
|
||||
// Rotating a token or reordering the sources does not discard usage history.
|
||||
func (c *Config) CachePath() string {
|
||||
|
||||
@@ -169,7 +169,11 @@ func (o SSHOptions) Validate(scope string) error {
|
||||
// SSHOptionsFor merges global, instance, and matching rule options for host.
|
||||
func (c *Config) SSHOptionsFor(host SSHHost) SSHOptions {
|
||||
merged := c.SSH.SSHOptions.merge(SSHOptions{})
|
||||
if host.Instance == "" {
|
||||
if host.Kind == StaticKind {
|
||||
if static := c.StaticHost(host.Name); static != nil {
|
||||
merged = merged.merge(static.SSH)
|
||||
}
|
||||
} else if host.Instance == "" {
|
||||
merged = merged.merge(c.NetBox.SSH)
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
|
||||
@@ -135,3 +135,58 @@ func TestForwardsValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHosts(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
resolver:
|
||||
hostname_domain: example.com
|
||||
ssh:
|
||||
proxy_jump: jh
|
||||
rules:
|
||||
- match: {kind: static, tag: home}
|
||||
host_key_checking: off
|
||||
hosts:
|
||||
- name: Router
|
||||
address: 192.168.1.1
|
||||
user: admin
|
||||
port: 2222
|
||||
tags: [home]
|
||||
ssh:
|
||||
proxy_jump: none
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.HasHosts() || cfg.HasNetBox() || cfg.Resolver.HostnameDomain != "example.com" {
|
||||
t.Fatalf("got %+v", cfg)
|
||||
}
|
||||
host := cfg.StaticHost("router")
|
||||
if host == nil || host.Address != "192.168.1.1" || host.User != "admin" || host.Port != 2222 {
|
||||
t.Fatalf("host: %+v", host)
|
||||
}
|
||||
opts := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: StaticKind, Tags: []string{"home"}})
|
||||
if opts.ProxyJump != "none" || opts.HostKeyChecking != "off" {
|
||||
t.Fatalf("static host options: %+v", opts)
|
||||
}
|
||||
// A NetBox host of the same name does not get the static host's options.
|
||||
if got := cfg.SSHOptionsFor(SSHHost{Name: "Router", Kind: "device"}); got.ProxyJump != "jh" {
|
||||
t.Fatalf("netbox host: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHostsValidation(t *testing.T) {
|
||||
for _, hosts := range []string{
|
||||
"- {name: a, address: 10.0.0.1}\n - {name: A, address: 10.0.0.2}",
|
||||
"- {name: 'bad name', address: 10.0.0.1}",
|
||||
"- {name: prod/a, address: 10.0.0.1}",
|
||||
"- {name: a}",
|
||||
"- {name: a, address: '-oProxyCommand=x'}",
|
||||
"- {name: a, address: 10.0.0.1, port: 70000}",
|
||||
"- {name: a, address: 10.0.0.1, user: 'a b'}",
|
||||
"- {name: a, address: 10.0.0.1, ssh: {target: nope}}",
|
||||
} {
|
||||
if _, err := loadYAML(t, "hosts:\n "+hosts+"\n"); err == nil || !strings.Contains(err.Error(), "hosts[") {
|
||||
t.Errorf("%s: got %v", hosts, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ func newStrategy(name string, cfg config.ResolverConfig) (Strategy, error) {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
case "hostname":
|
||||
return &HostnameStrategy{domain: cfg.HostnameDomain}, nil
|
||||
case "interface_name":
|
||||
if cfg.InterfaceName == "" {
|
||||
return nil, fmt.Errorf("interface_name strategy requires resolver.interface_name to be set")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
if len(sources) == 0 {
|
||||
choice := "add"
|
||||
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
|
||||
Title("NetBox").
|
||||
Description("netssh looks up hosts in NetBox. Without NetBox, only static hosts (configured next) are available.").
|
||||
Options(huh.NewOption("Add a NetBox instance", "add"), huh.NewOption("Continue without NetBox (static hosts only)", "none")).
|
||||
Value(&choice))).Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if choice == "none" {
|
||||
return nil, nil
|
||||
}
|
||||
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -36,7 +36,7 @@ func RunWizard(cfg *config.Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
|
||||
c := cache.New(loaded.CachePath(), loaded.Cache.TTL).WithStatic(loaded.Hosts)
|
||||
if err := c.Load(); err != nil {
|
||||
return fmt.Errorf("updating aliases: %w", err)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ func runMenu(cfg *config.Config) error {
|
||||
huh.NewOption("SSH defaults — user, port", "defaults"),
|
||||
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
|
||||
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
|
||||
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
|
||||
huh.NewOption("Resolver & cache", "resolver"),
|
||||
huh.NewOption("Shortcuts & command alias", "shortcuts"),
|
||||
huh.NewOption("Completion", "completion"),
|
||||
@@ -109,6 +110,11 @@ func runMenu(cfg *config.Config) error {
|
||||
if rules, err = configureRules(cfg.SSH.Rules, cfg.NetBoxes); err == nil {
|
||||
cfg.SSH.Rules = rules
|
||||
}
|
||||
case "hosts":
|
||||
var hosts []config.StaticHost
|
||||
if hosts, err = configureHosts(cfg.Hosts); err == nil {
|
||||
cfg.Hosts = hosts
|
||||
}
|
||||
case "resolver":
|
||||
err = editResolverCache(cfg)
|
||||
case "shortcuts":
|
||||
@@ -163,12 +169,13 @@ func editResolverCache(cfg *config.Config) error {
|
||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
||||
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||
interfaceName := cfg.Resolver.InterfaceName
|
||||
hostnameDomain := cfg.Resolver.HostnameDomain
|
||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||
cachePath := cfg.Cache.Path
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Resolver strategies").
|
||||
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
||||
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name, hostname").
|
||||
Placeholder("primary_ip, management_subnet").
|
||||
Value(&strategiesRaw).
|
||||
Validate(validateStrategies),
|
||||
@@ -181,6 +188,10 @@ func editResolverCache(cfg *config.Config) error {
|
||||
Description("Only used when interface_name strategy is active.").
|
||||
Placeholder("eth0").
|
||||
Value(&interfaceName),
|
||||
huh.NewInput().
|
||||
Title("Hostname domain").
|
||||
Description("Only used by the hostname strategy: appended to NetBox names without a dot,\ne.g. example.com turns web01 into web01.example.com.").
|
||||
Value(&hostnameDomain),
|
||||
huh.NewInput().
|
||||
Title("Cache TTL (seconds)").
|
||||
Value(&cacheTTL).
|
||||
@@ -201,6 +212,7 @@ func editResolverCache(cfg *config.Config) error {
|
||||
cfg.Resolver.Strategies = parseStrategies(strategiesRaw)
|
||||
cfg.Resolver.ManagementSubnets = splitList(subnets)
|
||||
cfg.Resolver.InterfaceName = strings.TrimSpace(interfaceName)
|
||||
cfg.Resolver.HostnameDomain = strings.Trim(strings.TrimSpace(hostnameDomain), ".")
|
||||
cfg.Cache.TTL, _ = strconv.Atoi(cacheTTL)
|
||||
cfg.Cache.Path = strings.TrimSpace(cachePath)
|
||||
return nil
|
||||
@@ -321,6 +333,7 @@ var knownStrategies = map[string]bool{
|
||||
"primary_ip": true,
|
||||
"management_subnet": true,
|
||||
"interface_name": true,
|
||||
"hostname": true,
|
||||
}
|
||||
|
||||
func validateStrategies(s string) error {
|
||||
@@ -329,7 +342,7 @@ func validateStrategies(s string) error {
|
||||
}
|
||||
for _, name := range parseStrategies(s) {
|
||||
if !knownStrategies[name] {
|
||||
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name)
|
||||
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name, hostname", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -337,6 +350,9 @@ func validateStrategies(s string) error {
|
||||
|
||||
func parseStrategies(s string) []string { return splitList(s) }
|
||||
|
||||
// Save validates cfg and writes it to the config file, replacing the old file.
|
||||
func Save(cfg config.Config) error { return save(cfg) }
|
||||
|
||||
func save(cfg config.Config) error {
|
||||
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||
return err
|
||||
@@ -350,6 +366,9 @@ func save(cfg config.Config) error {
|
||||
if err := cfg.Shortcuts.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ValidateHosts(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := config.Path()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("creating config dir: %w", err)
|
||||
@@ -365,7 +384,7 @@ func save(cfg config.Config) error {
|
||||
writeSSHOptions(&b, " ", source.SSH)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if cfg.NetBox.URL != "" || len(cfg.Hosts) == 0 {
|
||||
b.WriteString("netbox:\n")
|
||||
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
if !sshOptionsEmpty(cfg.NetBox.SSH) {
|
||||
@@ -388,6 +407,9 @@ func save(cfg config.Config) error {
|
||||
if cfg.Resolver.InterfaceName != "" {
|
||||
fmt.Fprintf(&b, " interface_name: %q\n", cfg.Resolver.InterfaceName)
|
||||
}
|
||||
if cfg.Resolver.HostnameDomain != "" {
|
||||
fmt.Fprintf(&b, " hostname_domain: %q\n", cfg.Resolver.HostnameDomain)
|
||||
}
|
||||
|
||||
b.WriteString("\ncache:\n")
|
||||
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
||||
@@ -416,6 +438,29 @@ func save(cfg config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Hosts) > 0 {
|
||||
b.WriteString("\nhosts:\n")
|
||||
for _, host := range cfg.Hosts {
|
||||
fmt.Fprintf(&b, " - name: %q\n address: %q\n", host.Name, host.Address)
|
||||
if host.User != "" {
|
||||
fmt.Fprintf(&b, " user: %q\n", host.User)
|
||||
}
|
||||
if host.Port > 0 {
|
||||
fmt.Fprintf(&b, " port: %d\n", host.Port)
|
||||
}
|
||||
if len(host.Tags) > 0 {
|
||||
b.WriteString(" tags:\n")
|
||||
for _, tag := range host.Tags {
|
||||
fmt.Fprintf(&b, " - %q\n", tag)
|
||||
}
|
||||
}
|
||||
if !sshOptionsEmpty(host.SSH) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", host.SSH)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
@@ -443,7 +488,7 @@ func save(cfg config.Config) error {
|
||||
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
|
||||
}
|
||||
|
||||
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||
if err := storage.WriteFile(path, []byte(strings.TrimLeft(b.String(), "\n")), 0o600); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,12 @@ func TestParseStrategies_PreservesOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategiesHostname(t *testing.T) {
|
||||
if err := validateStrategies("primary_ip, hostname"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategies_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"primary_ip",
|
||||
@@ -568,3 +574,46 @@ func TestSaveForwardsRoundtrip(t *testing.T) {
|
||||
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStaticHostsRoundtrip(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
hosts := []config.StaticHost{
|
||||
{Name: "router", Address: "192.168.1.1", User: "admin", Port: 2222, Tags: []string{"home"}, SSH: config.SSHOptions{ProxyJump: "none"}},
|
||||
{Name: "nas", Address: "nas.example.org"},
|
||||
}
|
||||
cfg := config.Config{
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip", "hostname"}, HostnameDomain: "example.com"},
|
||||
Hosts: hosts,
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(config.Path())
|
||||
if strings.Contains(string(data), "netbox:") {
|
||||
t.Fatalf("static-only config got an empty netbox section:\n%s", data)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.Hosts, hosts) || loaded.Resolver.HostnameDomain != "example.com" || loaded.HasNetBox() {
|
||||
t.Fatalf("got %+v / %+v", loaded.Hosts, loaded.Resolver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHostIn(t *testing.T) {
|
||||
hosts := []config.StaticHost{{Name: "router", Address: "10.0.0.1"}, {Name: "nas", Address: "10.0.0.2"}}
|
||||
if err := validateHostIn(config.StaticHost{Name: "Router", Address: "10.0.0.9"}, hosts, -1); err == nil {
|
||||
t.Fatal("duplicate name accepted")
|
||||
}
|
||||
if err := validateHostIn(config.StaticHost{Name: "router", Address: "10.0.0.9"}, hosts, 0); err != nil {
|
||||
t.Fatalf("editing a host in place rejected: %v", err)
|
||||
}
|
||||
if err := validateHostIn(config.StaticHost{Name: "new"}, hosts, -1); err != nil {
|
||||
t.Fatalf("empty address while typing rejected: %v", err)
|
||||
}
|
||||
if err := validateHostIn(config.StaticHost{Name: "new", Address: "a b"}, hosts, -1); err == nil {
|
||||
t.Fatal("invalid address accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user