Compare commits
38
Commits
No files matched your search
@@ -8,11 +8,16 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_PUBLIC_URL: https://git.zb-server.de
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -29,19 +34,28 @@ jobs:
|
||||
out="dist/netssh_${GOOS}_${GOARCH}"
|
||||
echo "→ $out"
|
||||
CGO_ENABLED=0 GOOS=$GOOS GOARCH=$GOARCH \
|
||||
go build -trimpath -ldflags="-s -w" \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${GITHUB_REF_NAME}" \
|
||||
-o "$out" ./cmd/netssh
|
||||
done
|
||||
|
||||
- name: Package installer
|
||||
run: install -m 755 install.sh dist/install.sh
|
||||
|
||||
- name: Generate checksums
|
||||
working-directory: dist
|
||||
run: sha256sum netssh_* > checksums.txt
|
||||
run: sha256sum netssh_* install.sh > checksums.txt
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: bash scripts/release-notes.sh "$RELEASE_TAG" "$GITEA_PUBLIC_URL/$GITEA_REPOSITORY" > dist/release-notes.md
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
API="$GITEA_PUBLIC_URL/api/v1/repos/$GITEA_REPOSITORY"
|
||||
AUTH="Authorization: token ${{ secrets.GITEA_TOKEN }}"
|
||||
|
||||
# Delete existing release for this tag if present
|
||||
@@ -51,17 +65,28 @@ jobs:
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/releases/$EXISTING"
|
||||
fi
|
||||
|
||||
# Serialize Markdown as JSON without hand-escaping quotes or newlines.
|
||||
python3 - <<'PY_RELEASE' > dist/release.json
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
tag = os.environ["TAG"]
|
||||
json.dump({
|
||||
"tag_name": tag,
|
||||
"name": tag,
|
||||
"body": Path("dist/release-notes.md").read_text(),
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}, sys.stdout)
|
||||
PY_RELEASE
|
||||
|
||||
RELEASE=$(curl -sf -X POST \
|
||||
-H "$AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"$TAG\",
|
||||
\"body\": \"## Installation\n\n\`\`\`sh\ncurl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | bash\n\`\`\`\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}")
|
||||
--data-binary @dist/release.json)
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
@@ -69,12 +94,12 @@ jobs:
|
||||
- name: Upload assets
|
||||
run: |
|
||||
RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
|
||||
for file in dist/netssh_* dist/checksums.txt; do
|
||||
for file in dist/netssh_* dist/install.sh dist/checksums.txt; do
|
||||
name=$(basename "$file")
|
||||
echo "↑ $name"
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||
"$GITEA_PUBLIC_URL/api/v1/repos/$GITEA_REPOSITORY/releases/${RELEASE_ID}/assets?name=${name}" \
|
||||
--data-binary "@$file"
|
||||
done
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Changelog
|
||||
|
||||
## v0.0.22
|
||||
|
||||
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
|
||||
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
|
||||
|
||||
## v0.0.21
|
||||
|
||||
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
|
||||
- Add `completion.max_results` to limit the number of hostnames offered per completion.
|
||||
- Register zsh completion in the managed `.zshrc` block (`fpath` and `compdef`), also when `compinit` already ran, e.g. with oh-my-zsh. The installer reports which completions it set up.
|
||||
- Add `netssh uninstall [--keep-config] [--dry-run] [--yes]` to remove the binary, profile entries of all versions, completions, cache, and config (with a private backup).
|
||||
- Write marked `# >>> netssh >>>` blocks from `netssh hook install` and the wizard, replacing unmarked entries of older versions instead of appending.
|
||||
- Add jump host support: `ssh.proxy_jump` globally, per NetBox instance, and per rule, plus `--jump HOST` and `--no-jump`.
|
||||
- Add `ssh.host_key_checking` (`strict`, `accept-new`, `off`) for netssh connections only.
|
||||
- Add `ssh.target: name|hostname` so `~/.ssh/config` host patterns apply and known_hosts entries belong to the device, and `ssh.address_family`.
|
||||
- Add `ssh.options` and `ssh.extra_args`, and `ssh.rules` that match by tag, instance, kind, or name pattern.
|
||||
- Add `netssh --version` / `netssh version` and `netssh --dry-run` to print the final ssh command.
|
||||
|
||||
## v0.0.20
|
||||
|
||||
- Configure multiple named NetBox instances and search them together in parallel.
|
||||
- Add, edit, and remove instances in the interactive setup wizard while retaining legacy single-instance configuration support.
|
||||
- Display instance-qualified hostnames and support `instance/hostname` for SSH, completion, aliases, and notes.
|
||||
- Keep credentials, IP resolution, comments, cache entries, and local notes tied to the correct instance.
|
||||
- Preserve partial interactive search results with an outage warning; reject incomplete full refreshes and ambiguous direct connections.
|
||||
- Add `cache refresh --instance NAME` and isolate caches when configured endpoints change.
|
||||
- Add regression tests covering duplicate names and IDs across instances, outages, SSH routing, notes, aliases, and configuration round trips.
|
||||
|
||||
## v0.0.19
|
||||
|
||||
- Update Ultraviolet to commit `a45b7c8`.
|
||||
|
||||
## v0.0.18
|
||||
|
||||
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
|
||||
- Update TUI rendering and keyboard events for Bubble Tea v2 while preserving alternate-screen behavior.
|
||||
- Route paste events to the focused editor, size input fields explicitly, and keep host lists within the terminal height.
|
||||
- Reject ambiguous cached shortcuts and skip duplicate hostnames during cache refresh.
|
||||
- Use paginated, unambiguous host lookups for SSH connections.
|
||||
- Accept shortcut domains with or without a leading dot during cold lookups.
|
||||
- Parse combined SSH options and preserve explicit users and ports supplied through `-o`.
|
||||
- Merge cache changes under a file lock to preserve updates from concurrent processes.
|
||||
- Honor configured resolver strategies during refresh and remove entries that no longer resolve safely.
|
||||
- Check the host kind before reusing a cached IP for a TUI selection.
|
||||
|
||||
## v0.0.17
|
||||
|
||||
- Update Go module dependencies to the latest versions within their existing module paths, including used transitive test dependencies.
|
||||
- Remove obsolete indirect requirements and refresh module checksums with `go mod tidy`.
|
||||
|
||||
## v0.0.16
|
||||
|
||||
- Enable Bash, Zsh, and Fish completion for the configured command alias, such as `j`.
|
||||
- Update or remove generated alias completion files when the command alias changes.
|
||||
|
||||
## v0.0.15
|
||||
|
||||
- Use `https://git.zb-server.de` for release download links and API requests instead of the runner's internal Gitea service URL.
|
||||
|
||||
## v0.0.14
|
||||
|
||||
- Enable Renovate updates for indirect Go modules as well as direct dependencies and CI actions.
|
||||
- Enable Go version updates and keep Go module metadata tidy after dependency updates.
|
||||
- Correct direct and indirect dependency declarations in `go.mod`.
|
||||
- Include `install.sh` as a downloadable release asset.
|
||||
- Include the installer in `checksums.txt` alongside the platform binaries.
|
||||
- Link the installation command in release notes directly to the release's installer asset.
|
||||
- Generate a change list from commits since the previous version tag for every release.
|
||||
|
||||
## v0.0.13
|
||||
|
||||
- Translate the remaining German setup message and test example into English.
|
||||
- Correct the connection editor comment to reference `Ctrl+E`.
|
||||
|
||||
## v0.0.12
|
||||
|
||||
- Support updating an existing installation by running the installer again.
|
||||
- Remove the installer's Python dependency.
|
||||
- Require valid SHA-256 checksums before replacing the binary.
|
||||
- Check required programs before downloading and list missing dependencies.
|
||||
- Check for `sudo` before downloading when the installation directory requires elevated permissions.
|
||||
- Consolidate legacy shell hooks and aliases in Bash, Zsh, and Fish profiles.
|
||||
- Back up changed profiles and preserve symlinks and file permissions.
|
||||
- Regenerate aliases and completions during installation and updates.
|
||||
- Add a configurable command alias, such as `j`, to the setup wizard.
|
||||
- Refresh or remove the generated command alias when its configuration changes.
|
||||
|
||||
## v0.0.11
|
||||
|
||||
- Add multiline local server notes and optional NetBox comments in the TUI.
|
||||
- Add CLI commands to show, replace, and append server notes.
|
||||
- Keep local notes separate from the host cache, with private permissions and atomic writes.
|
||||
- Use NetBox ETags for comment conflict protection when supported by the server.
|
||||
- Correct authentication for NetBox v2 tokens.
|
||||
- Fix environment variable overrides for configuration keys.
|
||||
- Preserve usage history when refreshing cached hosts.
|
||||
- Discard stale TUI search results and use `Ctrl+E` for connection editing.
|
||||
- Validate port overrides and preserve an explicit port 22.
|
||||
- Resolve stale and live TUI selections through the configured resolver chain.
|
||||
- Reject incomplete full cache refreshes and avoid marking filtered refreshes as full refreshes.
|
||||
- Stop interpreting remote command flags as SSH options when applying defaults.
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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:
|
||||
@@ -16,9 +18,23 @@ netssh -p 2222 admin@app-server-03 uptime
|
||||
- **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
|
||||
- **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
|
||||
- **Shell completion** — tab-complete hostnames from the cache in zsh, bash, and fish
|
||||
- **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 and hyphen folding
|
||||
- **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`
|
||||
- **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
|
||||
|
||||
@@ -33,9 +49,44 @@ The script detects your OS and architecture, downloads the matching binary from
|
||||
To install to a custom directory:
|
||||
|
||||
```sh
|
||||
INSTALL_DIR=~/.local/bin curl -fsSL https://git.zb-server.de/Sebi/ssh-netbox-wrapper/raw/branch/main/install.sh | bash
|
||||
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
|
||||
@@ -46,12 +97,27 @@ go build -o netssh ./cmd/netssh
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.config/netssh.yaml`:
|
||||
### 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 lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
||||
then saves to `~/.config/netssh.yaml`.
|
||||
|
||||
### Manual config
|
||||
|
||||
`~/.config/netssh.yaml`:
|
||||
|
||||
```yaml
|
||||
netbox:
|
||||
url: https://netbox.example.com
|
||||
token: your-api-token-here
|
||||
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.
|
||||
@@ -71,9 +137,102 @@ cache:
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.) or will be read from the config file.
|
||||
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -89,6 +248,74 @@ 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.
|
||||
|
||||
### Default username
|
||||
|
||||
Set `ssh.default_user` in the config to avoid typing a username every time:
|
||||
@@ -104,6 +331,78 @@ 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
|
||||
```
|
||||
|
||||
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. **Hyphen folding** (`strip_hyphens: true`) — hyphens are inserted back before looking up in the cache (`fsn1web01` → `fsn1-web01.example.com`). This only works when the host is already cached; run `netssh cache refresh` first.
|
||||
|
||||
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) |
|
||||
|
||||
### 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:
|
||||
@@ -112,22 +411,101 @@ Run without arguments to open the interactive search:
|
||||
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 |
|
||||
| `Esc` / `Ctrl+C` | quit |
|
||||
| `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
|
||||
netssh cache clear # wipe the cache
|
||||
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
|
||||
@@ -138,6 +516,8 @@ netssh search app- # prints matching hostnames, one per line
|
||||
|
||||
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. |
|
||||
@@ -146,45 +526,179 @@ Strategies are tried in the configured order; the first to succeed wins.
|
||||
|
||||
## Shell Completion
|
||||
|
||||
### zsh
|
||||
Install completion for the current user (no sudo required):
|
||||
|
||||
```sh
|
||||
netssh completion zsh > "${fpath[1]}/_netssh"
|
||||
netssh completion install # auto-detects $SHELL
|
||||
netssh completion install --shell bash
|
||||
netssh completion install --shell zsh
|
||||
netssh completion install --shell fish
|
||||
```
|
||||
|
||||
Or add to `.zshrc`:
|
||||
| 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
|
||||
source <(netssh completion zsh)
|
||||
```
|
||||
|
||||
### bash
|
||||
|
||||
```sh
|
||||
netssh completion bash > /etc/bash_completion.d/netssh
|
||||
```
|
||||
|
||||
### fish
|
||||
|
||||
```sh
|
||||
netssh completion fish > ~/.config/fish/completions/netssh.fish
|
||||
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.
|
||||
|
||||
## 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 ./... # run all tests
|
||||
go test ./... # includes offline installer/update tests
|
||||
go build ./... # build all packages
|
||||
```
|
||||
|
||||
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, and SSH argument parser.
|
||||
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, setup wizard, shortcut normalization, and shell hook install/uninstall.
|
||||
|
||||
## 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 (`search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
|
||||
2. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
||||
3. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
|
||||
4. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
|
||||
5. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
|
||||
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`, `version`, `uninstall`, …). If not, it enters SSH wrapper mode.
|
||||
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
|
||||
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
|
||||
4. It resolves the destination: first an exact cache lookup, then shortcut expansion (domain append, hyphen folding) against the cache, and finally a fresh NetBox query with the expanded name. If the cache entry is within the TTL, no network request is made.
|
||||
5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
|
||||
6. It adds the configured SSH options for the host (jump host, host key checking, `-o` options) before the destination and calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP (or the name, with `ssh.target: name`).
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
type connectionTestHost struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PrimaryIP4 *struct {
|
||||
Address string `json:"address"`
|
||||
} `json:"primary_ip4,omitempty"`
|
||||
}
|
||||
|
||||
func connectionTestServer(t *testing.T, devices, vms []connectionTestHost) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var hosts []connectionTestHost
|
||||
switch r.URL.Path {
|
||||
case "/api/dcim/devices/":
|
||||
hosts = devices
|
||||
case "/api/virtualization/virtual-machines/":
|
||||
hosts = vms
|
||||
case "/api/ipam/ip-addresses/":
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
query := strings.ToLower(r.URL.Query().Get("name__ic"))
|
||||
matched := make([]connectionTestHost, 0)
|
||||
for _, host := range hosts {
|
||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||
matched = append(matched, host)
|
||||
}
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
start, end := min(offset, len(matched)), min(offset+limit, len(matched))
|
||||
json.NewEncoder(w).Encode(map[string]any{"count": len(matched), "results": matched[start:end]})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestFindSSHHostBeyondFirstPage(t *testing.T) {
|
||||
var devices []connectionTestHost
|
||||
for i := range 50 {
|
||||
devices = append(devices, connectionTestHost{ID: i + 1, Name: fmt.Sprintf("web-extra-%02d", i)})
|
||||
}
|
||||
devices = append(devices, connectionTestHost{ID: 51, Name: "web"})
|
||||
srv := connectionTestServer(t, devices, nil)
|
||||
host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", nil)
|
||||
if err != nil || host.ID != 51 {
|
||||
t.Fatalf("host beyond first page not resolved: %+v, %v", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSSHHostDomainExpansion(t *testing.T) {
|
||||
for _, domain := range []string{"example.com", ".example.com", " .example.com "} {
|
||||
t.Run(domain, func(t *testing.T) {
|
||||
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web.example.com"}}, nil)
|
||||
host, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{domain})
|
||||
if err != nil || host.Name != "web.example.com" {
|
||||
t.Fatalf("domain expansion failed: %+v, %v", host, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSSHHostRejectsAmbiguity(t *testing.T) {
|
||||
srv := connectionTestServer(t, []connectionTestHost{{ID: 1, Name: "web"}, {ID: 2, Name: "web.example.com"}}, []connectionTestHost{{ID: 3, Name: "web"}})
|
||||
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"})
|
||||
if err == nil || errors.Is(err, netbox.ErrHostNotFound) || !strings.Contains(err.Error(), "2 exact matches") {
|
||||
t.Fatalf("ambiguous name accepted or expanded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSSHHostDoesNotHideAPIErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "web", []string{"example.com"})
|
||||
if err == nil || errors.Is(err, netbox.ErrHostNotFound) {
|
||||
t.Fatalf("API failure hidden as missing host: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSSHHostNotFound(t *testing.T) {
|
||||
srv := connectionTestServer(t, nil, nil)
|
||||
_, err := findSSHHost(context.Background(), netbox.NewClient(srv.URL, "token", 0), "missing", []string{"example.com"})
|
||||
if !errors.Is(err, netbox.ErrHostNotFound) {
|
||||
t.Fatalf("want missing-host error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshHonorsResolverAndRemovesUnsafeEntries(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, strategies string
|
||||
wantCached bool
|
||||
}{
|
||||
{"management only", "[management_subnet]", false},
|
||||
{"explicit primary fallback", "[management_subnet, primary_ip]", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct {
|
||||
Address string `json:"address"`
|
||||
}{Address: "192.0.2.1/24"}}
|
||||
srv := connectionTestServer(t, []connectionTestHost{host}, nil)
|
||||
path := prepareRefreshTest(t, srv.URL, tc.strategies)
|
||||
c := cache.New(path, 3600)
|
||||
c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"})
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs([]string{"cache", "refresh"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, fresh := c.Get("web")
|
||||
if tc.wantCached {
|
||||
if !fresh || got.IP != "192.0.2.1" {
|
||||
t.Fatalf("explicit fallback failed: %+v", got)
|
||||
}
|
||||
} else if got.Name != "" {
|
||||
t.Fatalf("unsafe primary IP retained: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshSkipsAmbiguousNames(t *testing.T) {
|
||||
host := connectionTestHost{ID: 1, Name: "web", PrimaryIP4: &struct {
|
||||
Address string `json:"address"`
|
||||
}{Address: "192.0.2.1/24"}}
|
||||
vm := host
|
||||
vm.ID = 2
|
||||
srv := connectionTestServer(t, []connectionTestHost{host}, []connectionTestHost{vm})
|
||||
path := prepareRefreshTest(t, srv.URL, "[primary_ip]")
|
||||
c := cache.New(path, 3600)
|
||||
c.Upsert(cache.Entry{Name: "web", IP: "192.0.2.99", Kind: "device"})
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs([]string{"cache", "refresh"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.All()) != 0 {
|
||||
t.Fatalf("ambiguous host cached: %+v", c.All())
|
||||
}
|
||||
}
|
||||
|
||||
func prepareRefreshTest(t *testing.T, baseURL, strategies string) string {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
t.Setenv("NETSSH_CACHE_PATH", path)
|
||||
t.Setenv("NETSSH_NETBOX_URL", baseURL)
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "token")
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||
data := fmt.Sprintf("resolver:\n strategies: %s\n management_subnets: [10.0.0.0/8]\n", strategies)
|
||||
if err := os.WriteFile(filepath.Join(configDir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
)
|
||||
|
||||
func newNetBoxClient(cfg *config.Config) *netbox.Client {
|
||||
if len(cfg.NetBoxes) > 0 {
|
||||
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
|
||||
for _, source := range cfg.NetBoxes {
|
||||
sources = append(sources, netbox.Source{Name: source.Name, URL: source.URL, Token: source.Token, TokenVersion: source.TokenVersion})
|
||||
}
|
||||
return netbox.NewMultiClient(sources)
|
||||
}
|
||||
if cfg.NetBox.URL == "" {
|
||||
return nil
|
||||
}
|
||||
return netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
}
|
||||
|
||||
func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) string {
|
||||
return func(target string) string {
|
||||
source, name := netbox.SplitTarget(target)
|
||||
name = shortcuts.Normalize(name, cfg)
|
||||
if qualified && source != "" {
|
||||
return source + "/" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// hostCompletions returns the cached hosts matching toComplete in a form that
|
||||
// starts with the typed text, because bash and fish only accept such
|
||||
// candidates. Without an instance prefix, a hostname that is unique across
|
||||
// all instances is offered bare ("web01"), so it completes like with a single
|
||||
// NetBox; connecting to it still checks every instance for ambiguity.
|
||||
// Names present in several instances stay qualified ("prod/web01").
|
||||
func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
||||
prefix := strings.ToLower(toComplete)
|
||||
qualifiedInput := strings.Contains(prefix, "/")
|
||||
instances := make(map[string]int)
|
||||
for _, e := range entries {
|
||||
_, name := netbox.SplitTarget(e.Name)
|
||||
instances[strings.ToLower(name)]++
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
source, name := netbox.SplitTarget(e.Name)
|
||||
target := strings.ToLower(e.Name)
|
||||
switch {
|
||||
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
|
||||
if instances[strings.ToLower(name)] == 1 {
|
||||
out = append(out, name)
|
||||
} else {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
case strings.HasPrefix(target, prefix):
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func prepareInstancesTest(t *testing.T, officeURL, labURL string) *config.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "")
|
||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||
data := fmt.Sprintf("netboxes:\n - name: office\n url: %q\n token: office-token\n - name: lab\n url: %q\n token: lab-token\nresolver:\n strategies: [primary_ip]\n", officeURL, labURL)
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func multiHost(name, ip string) connectionTestHost {
|
||||
return connectionTestHost{ID: 1, Name: name, PrimaryIP4: &struct {
|
||||
Address string `json:"address"`
|
||||
}{Address: ip + "/24"}}
|
||||
}
|
||||
|
||||
func TestRefreshStoresSameHostnameFromBothInstances(t *testing.T) {
|
||||
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs([]string{"cache", "refresh"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, ip := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2"} {
|
||||
got, fresh := c.Get(name)
|
||||
if !fresh || got.IP != ip {
|
||||
t.Fatalf("cache source collision: %+v, fresh=%v", got, fresh)
|
||||
}
|
||||
}
|
||||
if len(c.All()) != 2 || c.NeedsRefresh(time.Hour) {
|
||||
t.Fatal("full refresh is incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshFailureAndPerInstanceRefresh(t *testing.T) {
|
||||
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) }))
|
||||
defer failed.Close()
|
||||
cfg := prepareInstancesTest(t, office.URL, failed.URL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c.Upsert(cache.Entry{Name: "lab/old", Kind: "device", IP: "10.0.0.2"})
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs([]string{"cache", "refresh"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("failed instance marked as full refresh")
|
||||
}
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.All()) != 1 || !c.NeedsRefresh(time.Hour) {
|
||||
t.Fatal("partial refresh changed cache or full-refresh stamp")
|
||||
}
|
||||
cmd = rootCmd()
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--instance", "office"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.All()) != 2 || !c.NeedsRefresh(time.Hour) {
|
||||
t.Fatal("per-instance refresh lost other source or updated full stamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalNotesCLIKeepsInstancesSeparate(t *testing.T) {
|
||||
cfg := prepareInstancesTest(t, "https://office.example", "https://lab.example")
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
for _, name := range []string{"office/web", "lab/web"} {
|
||||
c.Upsert(cache.Entry{Name: name, Kind: "device", IP: "10.0.0.1"})
|
||||
}
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := func(args ...string) (string, error) {
|
||||
var out bytes.Buffer
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs(append([]string{"notes"}, args...))
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
for _, source := range []string{"office", "lab"} {
|
||||
if _, err := run("set", source+"/web", source+" note"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, source := range []string{"office", "lab"} {
|
||||
got, err := run("show", source+"/web")
|
||||
if err != nil || got != source+" note" {
|
||||
t.Fatalf("CLI notes crossed sources: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
if _, err := run("set", "web", "wrong note"); err == nil {
|
||||
t.Fatal("ambiguous note target accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiInstanceWrapperHelper(t *testing.T) {
|
||||
if target := os.Getenv("NETSSH_TEST_MULTI_TARGET"); target != "" {
|
||||
runSSHWrapper([]string{target})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapperRoutesQualifiedTargetsAndRejectsAmbiguity(t *testing.T) {
|
||||
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||
c := cache.New(cfg.CachePath(), 3600)
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for target, want := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2", "web": "ambiguous"} {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
|
||||
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET="+target, "PATH="+bin+":"+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if want == "ambiguous" {
|
||||
if err == nil || !strings.Contains(string(out), "2 exact matches") {
|
||||
t.Fatalf("ambiguous connection accepted: %s, %v", out, err)
|
||||
}
|
||||
} else if err != nil || !strings.Contains(string(out), want+"\n") {
|
||||
t.Fatalf("wrong SSH destination for %s: %s, %v", target, out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualifiedShortcutDoesNotMatchOtherInstance(t *testing.T) {
|
||||
cfg := config.ShortcutsConfig{Domains: []string{".example.com"}, StripHyphens: true}
|
||||
normalize := normalizeTarget(cfg, true)
|
||||
if normalize("office/web-01.example.com") != normalize("office/web01") {
|
||||
t.Fatal("qualified shortcut does not normalize")
|
||||
}
|
||||
if normalize("office/web01") == normalize("lab/web01") {
|
||||
t.Fatal("qualified shortcut lost instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
||||
office := connectionTestServer(t, []connectionTestHost{multiHost("router", "10.1.0.1")}, []connectionTestHost{multiHost("app", "10.1.0.2")})
|
||||
lab := connectionTestServer(t, []connectionTestHost{multiHost("switch", "10.2.0.1")}, []connectionTestHost{multiHost("test-vm", "10.2.0.2")})
|
||||
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||
cmd := rootCmd()
|
||||
cmd.SetArgs([]string{"cache", "refresh"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]struct{ kind, ip string }{
|
||||
"office/router": {"device", "10.1.0.1"}, "office/app": {"vm", "10.1.0.2"},
|
||||
"lab/switch": {"device", "10.2.0.1"}, "lab/test-vm": {"vm", "10.2.0.2"},
|
||||
}
|
||||
if len(c.All()) != len(want) {
|
||||
t.Fatalf("missing inventory entries: %+v", c.All())
|
||||
}
|
||||
for name, entry := range want {
|
||||
got, fresh := c.Get(name)
|
||||
if !fresh || got.Kind != entry.kind || got.IP != entry.ip {
|
||||
t.Fatalf("wrong cached host %s: %+v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCompletionsMatchTypedText(t *testing.T) {
|
||||
entries := []cache.Entry{
|
||||
{Name: "prod/fsn1-bx1.example.com"},
|
||||
{Name: "prod/fsn1-bx2.example.com"},
|
||||
{Name: "stg/fsn1-bx2.example.com"},
|
||||
{Name: "stg/hel1-tb1.example.com"},
|
||||
{Name: "prod/HEL1-TB2"},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
// Unique names are bare; names in both instances stay qualified.
|
||||
{"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}},
|
||||
{"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}},
|
||||
{"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}},
|
||||
{"stg/fsn", []string{"stg/fsn1-bx2.example.com"}},
|
||||
// An instance name typed without the slash completes qualified targets.
|
||||
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
|
||||
} {
|
||||
if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
// Single-instance caches are unchanged.
|
||||
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) {
|
||||
t.Errorf("single instance: %v", got)
|
||||
}
|
||||
}
|
||||
+549
-69
@@ -2,19 +2,26 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||
@@ -24,11 +31,17 @@ import (
|
||||
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
||||
var managedSubcommands = map[string]bool{
|
||||
"configure": true,
|
||||
"notes": true,
|
||||
"search": true,
|
||||
"cache": true,
|
||||
"alias": true,
|
||||
"hook": true,
|
||||
"shell-init": true,
|
||||
"completion": true,
|
||||
"__complete": true,
|
||||
"help": true,
|
||||
"version": true,
|
||||
"uninstall": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -38,6 +51,10 @@ func main() {
|
||||
runTUI()
|
||||
return
|
||||
}
|
||||
if args[0] == "--version" {
|
||||
fmt.Printf("netssh %s\n", buildVersion())
|
||||
return
|
||||
}
|
||||
|
||||
if managedSubcommands[args[0]] {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
@@ -55,7 +72,7 @@ func loadConfigOrSetup() *config.Config {
|
||||
if err != nil {
|
||||
fatalf("config: %v", err)
|
||||
}
|
||||
if cfg.NetBox.URL == "" {
|
||||
if !cfg.HasNetBox() {
|
||||
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
||||
if err := setup.RunWizard(cfg); err != nil {
|
||||
fatalf("setup: %v", err)
|
||||
@@ -70,54 +87,97 @@ func loadConfigOrSetup() *config.Config {
|
||||
|
||||
// runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary.
|
||||
func runSSHWrapper(args []string) {
|
||||
flags, args, err := internalssh.ExtractWrapperFlags(args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
cfg := loadConfigOrSetup()
|
||||
|
||||
parsed := internalssh.Parse(args)
|
||||
if parsed == nil {
|
||||
// No destination found — pass arguments straight through to ssh.
|
||||
if err := internalssh.Exec(args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
execSSH(internalssh.Result{Args: args}, flags)
|
||||
return
|
||||
}
|
||||
connect := func(name, kind, ip string, tags []string) {
|
||||
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
|
||||
}
|
||||
|
||||
// Inject the configured default user if none was given on the command line.
|
||||
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
||||
parsed.DestIdx += 2
|
||||
}
|
||||
// Inject the configured default port if none was given on the command line.
|
||||
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
|
||||
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
|
||||
parsed.DestIdx += 2
|
||||
}
|
||||
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
source, _ := netbox.SplitTarget(parsed.Host)
|
||||
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||
|
||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
||||
connect(entry.IP, parsed, args)
|
||||
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||
c.MarkUsed(parsed.Host)
|
||||
_ = c.Save()
|
||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||
return
|
||||
}
|
||||
|
||||
if cfg.NetBox.URL == "" {
|
||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||
lookupHost := parsed.Host
|
||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
if found {
|
||||
if fresh {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||
return
|
||||
}
|
||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||
lookupHost = entry.Name
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg.HasNetBox() {
|
||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||
}
|
||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
|
||||
nbClient := newNetBoxClient(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := nbClient.Search(ctx, parsed.Host)
|
||||
if err != nil {
|
||||
fatalf("NetBox search failed: %v", err)
|
||||
}
|
||||
|
||||
var target *netbox.HostEntry
|
||||
for i, e := range entries {
|
||||
if strings.EqualFold(e.Name, parsed.Host) {
|
||||
target = &entries[i]
|
||||
break
|
||||
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
||||
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
||||
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
|
||||
if cacheErr != nil {
|
||||
fatalf("%v", cacheErr)
|
||||
}
|
||||
if found {
|
||||
target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains)
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
fatalf("host %q not found in NetBox", parsed.Host)
|
||||
if err != nil {
|
||||
fatalf("NetBox: %v", err)
|
||||
}
|
||||
|
||||
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
|
||||
return
|
||||
}
|
||||
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
@@ -125,20 +185,56 @@ func runSSHWrapper(args []string) {
|
||||
fatalf("resolver: %v", err)
|
||||
}
|
||||
|
||||
ip, err := chain.Resolve(ctx, target, nbClient)
|
||||
ip, err := chain.Resolve(ctx, &target, nbClient)
|
||||
if err != nil {
|
||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
||||
}
|
||||
|
||||
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
|
||||
connect(ip, parsed, args)
|
||||
connect(target.Target(), target.Kind, ip, target.Tags)
|
||||
}
|
||||
|
||||
func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
||||
newArgs := internalssh.ReplaceHost(originalArgs, parsed.DestIdx, ip)
|
||||
if err := internalssh.Exec(newArgs); err != nil {
|
||||
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||
// targets. Only a genuine missing host permits trying configured suffixes.
|
||||
func findSSHHost(ctx context.Context, client *netbox.Client, name string, domains []string) (netbox.HostEntry, error) {
|
||||
target, err := client.FindHost(ctx, name, "")
|
||||
if !errors.Is(err, netbox.ErrHostNotFound) || strings.Contains(name, ".") {
|
||||
return target, err
|
||||
}
|
||||
for _, domain := range domains {
|
||||
domain = strings.TrimPrefix(strings.TrimSpace(domain), ".")
|
||||
if domain == "" {
|
||||
continue
|
||||
}
|
||||
target, err = client.FindHost(ctx, name+"."+domain, "")
|
||||
if !errors.Is(err, netbox.ErrHostNotFound) {
|
||||
return target, err
|
||||
}
|
||||
}
|
||||
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
||||
}
|
||||
|
||||
// connectHost applies the SSH options configured for the host and execs ssh.
|
||||
// target is the cache/selector name, which may carry an instance prefix.
|
||||
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
|
||||
instance, name := netbox.SplitTarget(target)
|
||||
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
|
||||
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
|
||||
}
|
||||
|
||||
// execSSH replaces the process with ssh, or prints the command for --dry-run.
|
||||
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||
if flags.DryRun {
|
||||
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
|
||||
return
|
||||
}
|
||||
if result.Notice != "" {
|
||||
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
||||
}
|
||||
if err := internalssh.Exec(result.Args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -147,16 +243,18 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
||||
func runTUI() {
|
||||
cfg := loadConfigOrSetup()
|
||||
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
var nbClient *netbox.Client
|
||||
if cfg.NetBox.URL != "" {
|
||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
|
||||
if cfg.HasNetBox() {
|
||||
nbClient = newNetBoxClient(cfg)
|
||||
}
|
||||
|
||||
m := tui.New(nbClient, c)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||
p := tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
fatalf("TUI: %v", err)
|
||||
@@ -171,17 +269,59 @@ func runTUI() {
|
||||
return
|
||||
}
|
||||
|
||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||
var tags []string
|
||||
if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind {
|
||||
host.IP = cached.IP
|
||||
tags = cached.Tags
|
||||
} else {
|
||||
if nbClient == nil {
|
||||
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
target, err := nbClient.FindHost(ctx, host.Name, host.Kind)
|
||||
if err != nil {
|
||||
fatalf("NetBox: %v", err)
|
||||
}
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
if err != nil {
|
||||
fatalf("resolver: %v", err)
|
||||
}
|
||||
host.IP, err = chain.Resolve(ctx, &target, nbClient)
|
||||
if err != nil {
|
||||
fatalf("IP resolution: %v", err)
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||
tags = target.Tags
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
|
||||
var sshArgs []string
|
||||
if cfg.SSH.DefaultUser != "" {
|
||||
sshArgs = append(sshArgs, "-l", cfg.SSH.DefaultUser)
|
||||
|
||||
// User override from TUI edit mode takes priority, then config default.
|
||||
user := host.User
|
||||
if user == "" {
|
||||
user = cfg.SSH.DefaultUser
|
||||
}
|
||||
if user != "" {
|
||||
sshArgs = append(sshArgs, "-l", user)
|
||||
}
|
||||
|
||||
port := host.Port
|
||||
if port == "" && cfg.SSH.DefaultPort > 0 {
|
||||
port = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||
}
|
||||
if port != "" {
|
||||
sshArgs = append(sshArgs, "-p", port)
|
||||
}
|
||||
|
||||
sshArgs = append(sshArgs, host.IP)
|
||||
|
||||
if err := internalssh.Exec(sshArgs); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
c.MarkUsed(host.Name)
|
||||
_ = c.Save()
|
||||
|
||||
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
|
||||
}
|
||||
|
||||
// --- cobra root + subcommands ---
|
||||
@@ -196,30 +336,133 @@ func rootCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
entries := c.Search(toComplete)
|
||||
names := make([]cobra.Completion, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = cobra.Completion(e.Name)
|
||||
|
||||
hosts := hostCompletions(c.All(), toComplete)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
}
|
||||
return names, cobra.ShellCompDirectiveNoFileComp
|
||||
completions := make([]cobra.Completion, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
completions = append(completions, cobra.Completion(host))
|
||||
}
|
||||
// Subcommands at the end, after all hostnames.
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) {
|
||||
completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short))
|
||||
}
|
||||
}
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
|
||||
},
|
||||
}
|
||||
|
||||
// cobra automatically adds a "completion" subcommand
|
||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
for _, cmd := range root.Commands() {
|
||||
if cmd.Name() == "completion" {
|
||||
cmd.AddCommand(completionInstallCmd(root))
|
||||
// Cobra's zsh script spawns a subshell per line and sends every host
|
||||
// through _describe, which hangs with thousands of cached hosts.
|
||||
for _, sub := range cmd.Commands() {
|
||||
if sub.Name() == "zsh" {
|
||||
sub.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
_, err := io.WriteString(cmd.OutOrStdout(), hook.ZshCompletionScript)
|
||||
return err
|
||||
}
|
||||
sub.Run = nil
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func completionInstallCmd(root *cobra.Command) *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install shell completion for the current user (no sudo required)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
|
||||
var (
|
||||
dir string
|
||||
file string
|
||||
gen func() ([]byte, error)
|
||||
note string
|
||||
)
|
||||
|
||||
switch shell {
|
||||
case "bash":
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions")
|
||||
file = filepath.Join(dir, "netssh")
|
||||
gen = func() ([]byte, error) {
|
||||
var buf strings.Builder
|
||||
err := root.GenBashCompletionV2(&buf, true)
|
||||
return []byte(buf.String()), err
|
||||
}
|
||||
note = "Reload your shell or run: source " + file
|
||||
case "zsh":
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
|
||||
file = filepath.Join(dir, "_netssh")
|
||||
gen = func() ([]byte, error) { return []byte(hook.ZshCompletionScript), nil }
|
||||
note = hook.ZshCompletionNote(dir)
|
||||
case "fish":
|
||||
dir = filepath.Join(hook.FishConfigDir(), "completions")
|
||||
file = filepath.Join(dir, "netssh.fish")
|
||||
gen = func() ([]byte, error) {
|
||||
var buf strings.Builder
|
||||
err := root.GenFishCompletion(&buf, true)
|
||||
return []byte(buf.String()), err
|
||||
}
|
||||
note = "Reload your shell or start a new fish session."
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell %q — use --shell bash|zsh|fish", shell)
|
||||
}
|
||||
|
||||
script, err := gen()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating completion: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating %s: %w", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(file, script, 0o644); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", file, err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Completion installed → %s\n%s\n", file, note)
|
||||
if cfg.Shell.Alias != "" {
|
||||
fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configureCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "configure",
|
||||
Short: "Interactively configure netssh",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, _ := config.Load()
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setup.RunWizard(cfg)
|
||||
},
|
||||
@@ -236,7 +479,7 @@ func searchCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,7 +513,7 @@ func cacheListCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -296,7 +539,7 @@ func cacheClearCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
c.Clear()
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
@@ -308,7 +551,8 @@ func cacheClearCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
func cacheRefreshCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var filterTag, filterKind, filterInstance string
|
||||
cmd := &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Re-fetch all known hosts from NetBox",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -316,45 +560,281 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.NetBox.URL == "" {
|
||||
if !cfg.HasNetBox() {
|
||||
return fmt.Errorf("netbox.url is not configured")
|
||||
}
|
||||
|
||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
nbClient := newNetBoxClient(cfg)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// An empty query returns up to 50 entries per type.
|
||||
entries, err := nbClient.Search(ctx, "")
|
||||
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
||||
query := ""
|
||||
if filterInstance != "" {
|
||||
if !nbClient.MultipleSources() {
|
||||
return fmt.Errorf("--instance requires named netboxes configuration")
|
||||
}
|
||||
query = filterInstance + "/"
|
||||
}
|
||||
entries, err := nbClient.SearchAll(ctx, query, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("NetBox: %w", err)
|
||||
}
|
||||
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
chain, _ := resolver.New(cfg.Resolver)
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolver: %w", err)
|
||||
}
|
||||
written := 0
|
||||
nameCounts := make(map[string]int)
|
||||
for _, e := range entries {
|
||||
nameCounts[strings.ToLower(e.Target())]++
|
||||
}
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
ip := e.PrimaryIP4
|
||||
if ip == "" && chain != nil {
|
||||
resolved, err := chain.Resolve(ctx, e, nbClient)
|
||||
if err == nil {
|
||||
ip = resolved
|
||||
if nameCounts[strings.ToLower(e.Target())] > 1 {
|
||||
c.Delete(e.Target())
|
||||
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
ip, err := chain.Resolve(ctx, e, nbClient)
|
||||
if err != nil {
|
||||
// Do not turn a failed configured strategy into a fresh
|
||||
// primary-IP cache hit that bypasses the resolver.
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
c.Delete(e.Target())
|
||||
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
||||
continue
|
||||
}
|
||||
if ip != "" {
|
||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||
written++
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
||||
if filterTag == "" && filterKind == "" && filterInstance == "" {
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||
}
|
||||
}
|
||||
if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 {
|
||||
if err := hook.WriteAliasFiles(aliasEntries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d entries written to cache.\n", written)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
||||
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
||||
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildAliasEntries computes the sorted, deduplicated alias list from the cache.
|
||||
func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
||||
return hook.BuildAliasEntries(c.All(), cfg)
|
||||
}
|
||||
|
||||
func aliasCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "alias",
|
||||
Short: "Print shell aliases for all cached hosts",
|
||||
Long: `Print shell alias definitions for all cached hosts.
|
||||
The alias name is the shortened form derived from the configured shortcuts
|
||||
(domain suffixes stripped, hyphens optionally stripped).
|
||||
|
||||
Source the output in your shell profile:
|
||||
bash/zsh: eval "$(netssh alias)"
|
||||
fish: netssh alias --shell fish | source
|
||||
|
||||
Or use in a script:
|
||||
source <(netssh alias)`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
|
||||
entries := buildAliasEntries(c, *cfg)
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first")
|
||||
return nil
|
||||
}
|
||||
|
||||
switch shell {
|
||||
case "fish":
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries))
|
||||
for _, e := range entries {
|
||||
fmt.Println(e.Definition("fish"))
|
||||
}
|
||||
default:
|
||||
fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries))
|
||||
for _, e := range entries {
|
||||
fmt.Println(e.Definition("bash"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||
func shellInitCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "shell-init",
|
||||
Short: "Regenerate alias files and trigger a background cache refresh if stale",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
// Regenerate alias files from the current cache every shell start
|
||||
// so aliases are always up-to-date even before a refresh completes.
|
||||
_ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg))
|
||||
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func hookCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "hook",
|
||||
Short: "Manage shell hooks for automatic cache refresh at shell startup",
|
||||
}
|
||||
cmd.AddCommand(hookInstallCmd(), hookUninstallCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hookInstallCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Add netssh shell-init to your shell profile",
|
||||
Long: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell
|
||||
profile that runs 'netssh shell-init' on every new shell session and, for zsh,
|
||||
registers the completion. Unmarked entries from older versions are replaced.
|
||||
shell-init checks whether the cache is older than 24 hours and, if so, starts
|
||||
a background refresh — no delay to your prompt.
|
||||
|
||||
After installation, reload your profile or open a new shell.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The managed block also sources the aliases (bash/zsh) and registers
|
||||
// zsh completion; fish uses conf.d auto-sourcing instead.
|
||||
installed, err := hook.Install(profile, shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !installed {
|
||||
fmt.Printf("Hook already installed in %s\n", profile)
|
||||
} else {
|
||||
fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile))
|
||||
}
|
||||
|
||||
// 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)
|
||||
if _ = c.Load(); true {
|
||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath())
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hookUninstallCmd() *cobra.Command {
|
||||
var shell string
|
||||
cmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove netssh shell-init from your shell profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !removed {
|
||||
fmt.Printf("No hook found in %s\n", profile)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Hook removed from %s\n", profile)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
||||
// has not been fully refreshed within the last 24 hours.
|
||||
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
||||
if !cfg.HasNetBox() {
|
||||
return
|
||||
}
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
return
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(self, "cache", "refresh")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
_ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func notesCmd() *cobra.Command {
|
||||
var remote bool
|
||||
var kind string
|
||||
cmd := &cobra.Command{Use: "notes", Short: "Read and write server notes (local by default)"}
|
||||
cmd.PersistentFlags().BoolVar(&remote, "netbox", false, "Use the NetBox comments field instead of local notes")
|
||||
cmd.PersistentFlags().StringVar(&kind, "kind", "", "Host kind: device or vm (required for uncached local hosts)")
|
||||
for _, action := range []string{"show", "set", "append"} {
|
||||
sub := &cobra.Command{Use: action + " <host> [text]", Short: action + " a server note", Args: cobra.RangeArgs(1, 2)}
|
||||
if action == "show" {
|
||||
sub.Args = cobra.ExactArgs(1)
|
||||
}
|
||||
sub.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if kind != "" && kind != "vm" && kind != "device" {
|
||||
return fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var client *netbox.Client
|
||||
if cfg.HasNetBox() {
|
||||
client = newNetBoxClient(cfg)
|
||||
}
|
||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
var cached []cache.Entry
|
||||
if !remote {
|
||||
cached = c.All()
|
||||
}
|
||||
query := host.Name
|
||||
source, rawName := netbox.SplitTarget(query)
|
||||
var matches []cache.Entry
|
||||
for _, entry := range cached {
|
||||
_, entryName := netbox.SplitTarget(entry.Name)
|
||||
if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
|
||||
matches = append(matches, entry)
|
||||
}
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return fmt.Errorf("ambiguous hostname %q; use instance/hostname", query)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
host.Name = matches[0].Name
|
||||
host.Kind = matches[0].Kind
|
||||
}
|
||||
|
||||
if !remote && host.Kind == "" {
|
||||
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
service := notes.New(cfg.NetBox.URL, client)
|
||||
note, err := service.Read(ctx, host, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == "show" {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), note.Text)
|
||||
return err
|
||||
}
|
||||
var text string
|
||||
if len(args) == 2 {
|
||||
text = args[1]
|
||||
} else {
|
||||
data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1024*1024+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > 1024*1024 {
|
||||
return fmt.Errorf("note exceeds 1 MiB")
|
||||
}
|
||||
text = string(data)
|
||||
}
|
||||
if action == "append" && note.Text != "" {
|
||||
note.Text = strings.TrimRight(note.Text, "\n") + "\n" + text
|
||||
} else {
|
||||
note.Text = text
|
||||
}
|
||||
return service.Write(ctx, note, remote)
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalNotesCLI(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||
run := func(args []string, input string) (string, error) {
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetIn(strings.NewReader(input))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
if _, err := run([]string{"notes", "set", "web01", "--kind", "vm"}, "First\nSecond"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := run([]string{"notes", "append", "web01", "Third", "--kind", "vm"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||
if err != nil || out != "First\nSecond\nThird" {
|
||||
t.Fatalf("show: %q, %v", out, err)
|
||||
}
|
||||
if _, err := run([]string{"notes", "set", "web01", "", "--kind", "vm"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||
if err != nil || out != "" {
|
||||
t.Fatalf("clear: %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "j")
|
||||
run := func() {
|
||||
t.Helper()
|
||||
root := rootCmd()
|
||||
root.SetArgs([]string{"shell-init"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
run()
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil || !strings.Contains(string(data), "alias j='netssh'") {
|
||||
t.Fatalf("alias missing: %s, %v", data, err)
|
||||
}
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "k")
|
||||
run()
|
||||
data, _ = os.ReadFile(hook.AliasesPath())
|
||||
if strings.Contains(string(data), "alias j=") || !strings.Contains(string(data), "alias k='netssh'") {
|
||||
t.Fatalf("alias change: %s", data)
|
||||
}
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||
run()
|
||||
data, _ = os.ReadFile(hook.AliasesPath())
|
||||
if strings.Contains(string(data), "alias k=") {
|
||||
t.Fatalf("stale alias: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/storage"
|
||||
)
|
||||
|
||||
// uninstallPlan lists everything netssh has written to this machine.
|
||||
type uninstallPlan struct {
|
||||
Profiles []string // shell profiles with a netssh block or legacy lines
|
||||
Paths []string // files and directories to delete
|
||||
Config string // config file to back up and delete; empty when kept
|
||||
Binaries []string
|
||||
}
|
||||
|
||||
func buildUninstallPlan(cfg *config.Config, keepConfig bool) uninstallPlan {
|
||||
var plan uninstallPlan
|
||||
seen := make(map[string]bool)
|
||||
addPath := func(path string) {
|
||||
if path == "" || seen[path] {
|
||||
return
|
||||
}
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
seen[path] = true
|
||||
plan.Paths = append(plan.Paths, path)
|
||||
}
|
||||
}
|
||||
|
||||
for _, profile := range hook.ProfileCandidates() {
|
||||
target, err := filepath.EvalSymlinks(profile)
|
||||
if err != nil || seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
if hook.HasEntries(profile) {
|
||||
plan.Profiles = append(plan.Profiles, profile)
|
||||
}
|
||||
}
|
||||
|
||||
aliases := []string{hook.CompletionAliasState()}
|
||||
if cfg != nil {
|
||||
aliases = append(aliases, cfg.Shell.Alias)
|
||||
}
|
||||
for _, path := range hook.CompletionFiles(aliases...) {
|
||||
if hook.IsGenerated(path) {
|
||||
addPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
// The cache directory holds hosts, aliases, refresh stamps, and instance caches.
|
||||
cacheDir := filepath.Dir(hook.AliasesPath())
|
||||
addPath(cacheDir)
|
||||
if cfg != nil {
|
||||
for _, path := range []string{cfg.Cache.Path, cfg.CachePath()} {
|
||||
if path == "" || strings.HasPrefix(path, cacheDir+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
addPath(path)
|
||||
addPath(path + ".lock")
|
||||
addPath(filepath.Join(filepath.Dir(path), "last_refresh"))
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") {
|
||||
addPath(filepath.Dir(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !keepConfig {
|
||||
if _, err := os.Stat(config.Path()); err == nil {
|
||||
plan.Config = config.Path()
|
||||
}
|
||||
// Local server notes live next to the config file.
|
||||
addPath(filepath.Join(filepath.Dir(config.Path()), "netssh"))
|
||||
}
|
||||
|
||||
plan.Binaries = installedBinaries()
|
||||
return plan
|
||||
}
|
||||
|
||||
// installedBinaries finds the running binary and any other netssh in PATH.
|
||||
func installedBinaries() []string {
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
add := func(path string) {
|
||||
real, err := filepath.EvalSymlinks(path)
|
||||
if err != nil || seen[real] {
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(real); err != nil || info.IsDir() || filepath.Base(real) != "netssh" {
|
||||
return
|
||||
}
|
||||
seen[real] = true
|
||||
out = append(out, real)
|
||||
}
|
||||
if self, err := os.Executable(); err == nil {
|
||||
add(self)
|
||||
}
|
||||
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
|
||||
if dir != "" {
|
||||
add(filepath.Join(dir, "netssh"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p uninstallPlan) empty() bool {
|
||||
return len(p.Profiles) == 0 && len(p.Paths) == 0 && p.Config == "" && len(p.Binaries) == 0
|
||||
}
|
||||
|
||||
func (p uninstallPlan) print(w io.Writer) {
|
||||
for _, profile := range p.Profiles {
|
||||
fmt.Fprintf(w, " edit %s (remove netssh entries)\n", profile)
|
||||
}
|
||||
for _, path := range p.Paths {
|
||||
fmt.Fprintf(w, " delete %s\n", path)
|
||||
}
|
||||
if p.Config != "" {
|
||||
fmt.Fprintf(w, " delete %s (a backup is kept)\n", p.Config)
|
||||
}
|
||||
for _, binary := range p.Binaries {
|
||||
fmt.Fprintf(w, " delete %s\n", binary)
|
||||
}
|
||||
}
|
||||
|
||||
// execute applies the plan. It continues after errors so that one protected
|
||||
// file does not leave the rest behind, and returns commands that need sudo.
|
||||
func (p uninstallPlan) execute(w io.Writer) (manual []string, err error) {
|
||||
var errs []error
|
||||
for _, profile := range p.Profiles {
|
||||
if _, err := hook.Uninstall(profile); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "cleaned %s\n", profile)
|
||||
}
|
||||
for _, path := range p.Paths {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "removed %s\n", path)
|
||||
}
|
||||
if p.Config != "" {
|
||||
backup, err := backupConfig(p.Config)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("config not removed: %w", err))
|
||||
} else if err := os.Remove(p.Config); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
fmt.Fprintf(w, "removed %s (backup: %s)\n", p.Config, backup)
|
||||
}
|
||||
}
|
||||
for _, binary := range p.Binaries {
|
||||
if err := os.Remove(binary); err != nil {
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
manual = append(manual, "sudo rm -f "+shellQuote(binary))
|
||||
continue
|
||||
}
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "removed %s\n", binary)
|
||||
}
|
||||
return manual, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// backupConfig keeps a private copy, because the config holds API tokens.
|
||||
func backupConfig(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
backup := path + ".backup-" + time.Now().Format("20060102-150405")
|
||||
if err := storage.WriteFile(backup, data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func uninstallCmd() *cobra.Command {
|
||||
var keepConfig, yes, dryRun bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "uninstall",
|
||||
Short: "Remove netssh, its shell integration, cache, and config",
|
||||
Long: `Removes everything netssh and install.sh have set up:
|
||||
|
||||
- netssh blocks and unmarked entries of older versions in bash, zsh, and fish profiles
|
||||
- shell completion files (bash, zsh, fish, oh-my-zsh cache, command alias)
|
||||
- the cache directory with hosts, aliases, and per-instance caches
|
||||
- the config file (a private backup is kept) and local server notes
|
||||
- the netssh binary
|
||||
|
||||
Use --keep-config to keep the config file and notes for a reinstall.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
// A broken config must not block the uninstall.
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
cfg = nil
|
||||
}
|
||||
plan := buildUninstallPlan(cfg, keepConfig)
|
||||
out := cmd.OutOrStdout()
|
||||
if plan.empty() {
|
||||
fmt.Fprintln(out, "Nothing to remove.")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(out, "netssh uninstall will:")
|
||||
plan.print(out)
|
||||
if dryRun {
|
||||
return nil
|
||||
}
|
||||
if !yes {
|
||||
fmt.Fprint(out, "Proceed? [y/N] ")
|
||||
answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
|
||||
if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" {
|
||||
return fmt.Errorf("aborted")
|
||||
}
|
||||
}
|
||||
manual, err := plan.execute(out)
|
||||
for _, command := range manual {
|
||||
fmt.Fprintf(out, "Permission denied. Run: %s\n", command)
|
||||
}
|
||||
if len(plan.Profiles) > 0 {
|
||||
fmt.Fprintln(out, "Open a new terminal. For zsh, also run: rm -f ~/.zcompdump*")
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&keepConfig, "keep-config", false, "Keep the config file and local notes")
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Do not ask for confirmation")
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only show what would be removed")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
func uninstallFixture(t *testing.T) (home string) {
|
||||
t.Helper()
|
||||
home = t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("ZDOTDIR", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
t.Setenv("ZSH", "")
|
||||
t.Setenv("ZSH_CACHE_DIR", "")
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||
t.Setenv("NETSSH_CACHE_PATH", "")
|
||||
t.Setenv("PATH", filepath.Join(home, "nobin"))
|
||||
files := map[string]string{
|
||||
".bashrc": "export KEEP=1\n# >>> netssh >>>\nnetssh shell-init\n# <<< netssh <<<\n",
|
||||
".zshrc": "plugins=(git)\nnetssh shell-init # netssh cache auto-refresh\nsource ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases\n",
|
||||
".profile": "export UNRELATED=1\n",
|
||||
".config/netssh.yaml": "netbox:\n url: \"https://netbox.example.com\"\n token: \"secret\"\nshell:\n alias: \"j\"\n",
|
||||
".config/netssh/notes/abc.txt": "note",
|
||||
".cache/netssh/hosts.json": "{}",
|
||||
".cache/netssh/instances-0011/hosts.json": "{}",
|
||||
".local/share/bash-completion/completions/netssh": "# bash completion for netssh",
|
||||
".zfunc/_netssh": hook.ZshCompletionScript,
|
||||
".zfunc/_j": "#compdef j\n# netssh command alias completion — generated automatically\n",
|
||||
".zfunc/_other": "#compdef other\n",
|
||||
}
|
||||
for name, content := range files {
|
||||
path := filepath.Join(home, name)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func runUninstall(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := rootCmd()
|
||||
var out strings.Builder
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs(append([]string{"uninstall"}, args...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("uninstall: %v\n%s", err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestUninstallRemovesEverythingAndBacksUpConfig(t *testing.T) {
|
||||
home := uninstallFixture(t)
|
||||
if out := runUninstall(t, "--dry-run"); !strings.Contains(out, ".zshrc") || strings.Contains(out, ".profile") {
|
||||
t.Fatalf("plan:\n%s", out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); err != nil {
|
||||
t.Fatal("dry run removed files")
|
||||
}
|
||||
runUninstall(t, "--yes")
|
||||
|
||||
for name, want := range map[string]string{".bashrc": "export KEEP=1\n", ".zshrc": "plugins=(git)\n", ".profile": "export UNRELATED=1\n"} {
|
||||
data, _ := os.ReadFile(filepath.Join(home, name))
|
||||
if string(data) != want {
|
||||
t.Errorf("%s: %q", name, data)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{".cache/netssh", ".config/netssh.yaml", ".config/netssh", ".zfunc/_netssh", ".zfunc/_j", ".local/share/bash-completion/completions/netssh"} {
|
||||
if _, err := os.Stat(filepath.Join(home, gone)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s not removed: %v", gone, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".zfunc", "_other")); err != nil {
|
||||
t.Error("unrelated completion removed")
|
||||
}
|
||||
backups, _ := filepath.Glob(filepath.Join(home, ".config", "netssh.yaml.backup-*"))
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("backups: %v", backups)
|
||||
}
|
||||
if info, _ := os.Stat(backups[0]); info.Mode().Perm() != 0o600 {
|
||||
t.Error("backup with token is not private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallKeepConfig(t *testing.T) {
|
||||
home := uninstallFixture(t)
|
||||
runUninstall(t, "--yes", "--keep-config")
|
||||
for _, kept := range []string{".config/netssh.yaml", ".config/netssh/notes/abc.txt"} {
|
||||
if _, err := os.Stat(filepath.Join(home, kept)); err != nil {
|
||||
t.Errorf("%s removed", kept)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".cache", "netssh")); !os.IsNotExist(err) {
|
||||
t.Error("cache kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallAbortsWithoutConfirmation(t *testing.T) {
|
||||
home := uninstallFixture(t)
|
||||
cmd := rootCmd()
|
||||
cmd.SetOut(&strings.Builder{})
|
||||
cmd.SetIn(strings.NewReader("n\n"))
|
||||
cmd.SetArgs([]string{"uninstall"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("no confirmation required")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".config", "netssh.yaml")); err != nil {
|
||||
t.Fatal("config removed without confirmation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// version is set at build time: -ldflags "-X main.version=v1.2.3".
|
||||
var version = ""
|
||||
|
||||
// buildVersion falls back to the module version for `go install` builds.
|
||||
func buildVersion() string {
|
||||
if version != "" {
|
||||
return version
|
||||
}
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
return info.Main.Version
|
||||
}
|
||||
return "dev"
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the netssh version",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "netssh %s\n", buildVersion())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,53 @@
|
||||
module git.zb-server.de/Sebi/ssh-netbox-wrapper
|
||||
|
||||
go 1.26.3
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/sys v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/huh v1.0.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/xpty v0.1.4 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.31 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.3 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
github.com/xo/terminfo v1.2.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sync v0.25.0 // indirect
|
||||
golang.org/x/text v0.44.0 // indirect
|
||||
)
|
||||
@@ -1,71 +1,85 @@
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f h1:D2JpB65eehsnkEwEG401dMBSNYtTDgvKXZZSl7ZCxWI=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
|
||||
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
|
||||
github.com/charmbracelet/x/exp/golden v0.1.0 h1:qgUxGaHX4I8Fg077vD+54+qFmDWrgO19lTCQLblHWko=
|
||||
github.com/charmbracelet/x/exp/golden v0.1.0/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.4 h1:4jaW7u+8AHQMxesiVc+zUMsspu7GyDwtJO+gy/tFtW4=
|
||||
github.com/charmbracelet/x/xpty v0.1.4/go.mod h1:7t8P7BpPiolHJ1pLzz7/4ujDbD+sUxI9yA3CBOLOIcU=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
|
||||
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
|
||||
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -77,16 +91,19 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sync v0.25.0 h1:dJnifr0nMV8/SBnBvdo4eSGGnrc3O4FFx6IqvtLcmRs=
|
||||
golang.org/x/sync v0.25.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.44.0 h1:YItdp/mQNGnsmrgpcdA5yQ3bnRxCfo/Tj/iByGW9GGo=
|
||||
golang.org/x/text v0.44.0/go.mod h1:S4FR1waQ9LbjQTls97SyRjDaUUVkaM+cXwwuG/G+MwY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
+242
-75
@@ -1,94 +1,261 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -eo pipefail
|
||||
# Bash 3 (macOS) treats empty arrays as unset under nounset.
|
||||
: "${HOME:?HOME must be set}"
|
||||
|
||||
REPO="Sebi/ssh-netbox-wrapper"
|
||||
BASE_URL="https://git.zb-server.de"
|
||||
BINARY="netssh"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}"
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed"; }
|
||||
# Check the complete toolchain before any network request or filesystem change.
|
||||
MISSING=()
|
||||
for program in curl uname sed awk mktemp rm dirname readlink mkdir cat install mv cp chmod cmp; do
|
||||
if ! command -v "$program" >/dev/null 2>&1; then MISSING+=("$program"); fi
|
||||
done
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
SHA256=(sha256sum)
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
SHA256=(shasum -a 256)
|
||||
else
|
||||
MISSING+=("sha256sum or shasum (SHA-256 verification)")
|
||||
fi
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
printf 'error: required programs are missing:\n' >&2
|
||||
printf ' - %s\n' "${MISSING[@]}" >&2
|
||||
die "install these dependencies using your package manager, then run the installer again"
|
||||
fi
|
||||
|
||||
# --- helpers -----------------------------------------------------------------
|
||||
|
||||
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||||
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
die() { red "error: $*" >&2; exit 1; }
|
||||
|
||||
need() {
|
||||
command -v "$1" &>/dev/null || die "'$1' is required but not installed"
|
||||
}
|
||||
|
||||
# --- detect OS / arch --------------------------------------------------------
|
||||
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux) echo linux ;;
|
||||
Darwin) echo darwin ;;
|
||||
*) die "unsupported OS: $(uname -s)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) echo amd64 ;;
|
||||
aarch64|arm64) echo arm64 ;;
|
||||
*) die "unsupported architecture: $(uname -m)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- main --------------------------------------------------------------------
|
||||
|
||||
need curl
|
||||
|
||||
OS=$(detect_os)
|
||||
ARCH=$(detect_arch)
|
||||
|
||||
bold "netssh installer"
|
||||
info "platform : $OS/$ARCH"
|
||||
# Re-running the one-liner updates the existing installation in its current path.
|
||||
if [[ -z "${INSTALL_DIR:-}" ]]; then
|
||||
EXISTING=$(command -v "$BINARY" || true)
|
||||
if [[ "$EXISTING" == /* && -f "$EXISTING" ]]; then
|
||||
INSTALL_DIR=$(dirname "$EXISTING")
|
||||
else
|
||||
INSTALL_DIR=/usr/local/bin
|
||||
fi
|
||||
fi
|
||||
[[ "$INSTALL_DIR" == /* ]] || INSTALL_DIR="$PWD/$INSTALL_DIR"
|
||||
# Check sudo only when the destination needs elevated permissions, also before
|
||||
# downloading. For a new directory, inspect its nearest existing parent.
|
||||
PRIVILEGE=()
|
||||
INSTALL_PARENT=$INSTALL_DIR
|
||||
while [[ ! -e "$INSTALL_PARENT" ]]; do INSTALL_PARENT=$(dirname "$INSTALL_PARENT"); done
|
||||
[[ -d "$INSTALL_PARENT" ]] || die "installation path is not a directory: $INSTALL_PARENT"
|
||||
if [[ ! -w "$INSTALL_PARENT" || ! -x "$INSTALL_PARENT" ]]; then
|
||||
need sudo
|
||||
PRIVILEGE=(sudo)
|
||||
fi
|
||||
case "$(uname -s)" in
|
||||
Linux) PLATFORM_OS=linux ;;
|
||||
Darwin) PLATFORM_OS=darwin ;;
|
||||
*) die "unsupported OS: $(uname -s)" ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) PLATFORM_ARCH=amd64 ;;
|
||||
aarch64|arm64) PLATFORM_ARCH=arm64 ;;
|
||||
*) die "unsupported architecture: $(uname -m)" ;;
|
||||
esac
|
||||
printf 'netssh install / update\n'
|
||||
info "platform : $PLATFORM_OS/$PLATFORM_ARCH"
|
||||
info "target : $INSTALL_DIR/$BINARY"
|
||||
|
||||
# Fetch the latest release tag from the Gitea API.
|
||||
API_URL="$BASE_URL/api/v1/repos/$REPO/releases/latest"
|
||||
TAG=$(curl -sf "$API_URL" | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])") \
|
||||
|| die "could not fetch latest release from $API_URL"
|
||||
|
||||
ASSET="${BINARY}_${OS}_${ARCH}"
|
||||
DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG/$ASSET"
|
||||
CHECKSUM_URL="$BASE_URL/$REPO/releases/download/$TAG/checksums.txt"
|
||||
|
||||
RELEASE=$(curl -fsSL "$API_URL") || die "could not fetch latest release from $API_URL"
|
||||
TAG=$(printf '%s\n' "$RELEASE" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.+-][a-zA-Z0-9.+-]+)?$ ]] || die "invalid or missing release tag"
|
||||
ASSET="${BINARY}_${PLATFORM_OS}_${PLATFORM_ARCH}"
|
||||
DOWNLOAD_URL="$BASE_URL/$REPO/releases/download/$TAG"
|
||||
info "version : $TAG"
|
||||
echo
|
||||
|
||||
# Download binary and checksums into a temp directory.
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
STAGED=""
|
||||
cleanup() {
|
||||
rm -rf "$TMP"
|
||||
if [[ -n "$STAGED" ]]; then "${PRIVILEGE[@]}" rm -f "$STAGED"; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
info "downloading $ASSET..."
|
||||
curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL"
|
||||
curl -fL --progress-bar -o "$TMP/$ASSET" "$DOWNLOAD_URL/$ASSET"
|
||||
curl -fsSL -o "$TMP/checksums.txt" "$DOWNLOAD_URL/checksums.txt" \
|
||||
|| die "could not fetch checksums; installation aborted"
|
||||
EXPECTED=$(awk -v asset="$ASSET" '$2 == asset || $2 == "*" asset {print tolower($1)}' "$TMP/checksums.txt")
|
||||
[[ "$EXPECTED" =~ ^[a-f0-9]{64}$ ]] || die "missing, duplicate, or invalid checksum; installation aborted"
|
||||
ACTUAL=$("${SHA256[@]}" "$TMP/$ASSET")
|
||||
ACTUAL=${ACTUAL%% *}
|
||||
[[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch; installation aborted"
|
||||
info "checksum OK"
|
||||
|
||||
info "verifying checksum..."
|
||||
curl -sf -o "$TMP/checksums.txt" "$CHECKSUM_URL" \
|
||||
|| { info "warning: could not fetch checksums, skipping verification"; }
|
||||
# Only standalone commands and our managed blocks are removed. Unrelated content
|
||||
# and commands which merely mention netssh are retained.
|
||||
cat > "$TMP/clean-profile.awk" <<'AWK'
|
||||
function legacy(s) {
|
||||
if (s ~ /^([^ \t]*\/)?netssh[ \t]+shell-init[ \t]*(#.*)?$/) return 1
|
||||
if (s ~ /^(source|\.)[ \t]+["']?[^;|&]*netssh\/aliases\.sh["']?([ \t]+2>\/dev\/null)?[ \t]*(#.*)?$/) return 1
|
||||
if (s ~ /^eval[ \t]+["']?\$\(([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+(bash|zsh))?\)["']?[ \t]*(#.*)?$/) return 1
|
||||
if (s ~ /^([^ \t]*\/)?netssh[ \t]+alias([ \t]+--shell[ \t]+fish)?[ \t]*\|[ \t]*source[ \t]*(#.*)?$/) return 1
|
||||
if (s ~ /^source[ \t]+<\(([^ \t]*\/)?netssh[ \t]+alias\)[ \t]*(#.*)?$/) return 1
|
||||
if (s ~ /^alias[ \t]+[A-Za-z0-9_.-]+(=|[ \t]+)["']netssh([ \t]+[^"']+)?["'][ \t]*(#.*)?$/) return 1
|
||||
return 0
|
||||
}
|
||||
{
|
||||
s = $0
|
||||
sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s)
|
||||
if (s == "# >>> netssh >>>") { if (inside) bad = 1; inside = 1; next }
|
||||
if (s == "# <<< netssh <<<") { if (!inside) bad = 1; inside = 0; next }
|
||||
if (!inside && !legacy(s)) print $0
|
||||
}
|
||||
END { if (inside || bad) exit 1 }
|
||||
AWK
|
||||
|
||||
if [[ -f "$TMP/checksums.txt" ]]; then
|
||||
# checksums.txt was built with `sha256sum` in the dist/ dir, so entries look
|
||||
# like "abc123 netssh_linux_amd64". We need to cd into the temp dir first.
|
||||
(cd "$TMP" && grep "$ASSET" checksums.txt | sha256sum --check --status) \
|
||||
|| die "checksum mismatch — aborting installation"
|
||||
info "checksum OK"
|
||||
fi
|
||||
# POSIX-style single quoting also works for these paths in fish, except embedded
|
||||
# quotes, which fish escapes with a backslash instead of concatenated quotes.
|
||||
quote_sh() { printf "'%s'" "${1//\'/\'\\\'\'}"; }
|
||||
quote_fish() {
|
||||
local value=${1//\\/\\\\}
|
||||
value=${value//\'/\\\'}
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
resolve_profile() {
|
||||
local path=$1 target steps=0
|
||||
while [[ -L "$path" ]]; do
|
||||
steps=$((steps + 1))
|
||||
[[ "$steps" -le 40 ]] || die "symlink loop in $1"
|
||||
target=$(readlink "$path")
|
||||
if [[ "$target" == /* ]]; then path=$target; else path="$(dirname "$path")/$target"; fi
|
||||
done
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
chmod +x "$TMP/$ASSET"
|
||||
|
||||
# Install — try without sudo first, fall back if needed.
|
||||
if [[ -w "$INSTALL_DIR" ]]; then
|
||||
mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY"
|
||||
PROFILE_PATHS=()
|
||||
PROFILE_TARGETS=()
|
||||
PROFILE_DATA=()
|
||||
SHELLS=()
|
||||
CURRENT_SHELL=${SHELL:-}
|
||||
CURRENT_SHELL=${CURRENT_SHELL##*/}
|
||||
CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
if [[ "$PLATFORM_OS" == darwin ]]; then
|
||||
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/Library/Caches}
|
||||
else
|
||||
info "sudo required to write to $INSTALL_DIR"
|
||||
sudo mv "$TMP/$ASSET" "$INSTALL_DIR/$BINARY"
|
||||
CACHE_DIR=${XDG_CACHE_HOME:-$HOME/.cache}
|
||||
fi
|
||||
ZSH_DIR=${ZDOTDIR:-$HOME}
|
||||
plan_profile() {
|
||||
local path=$1 kind=$2 target data previous count
|
||||
if [[ ! -e "$path" && ! -L "$path" ]] && { [[ -z "$kind" ]] || [[ "$kind" != "$CURRENT_SHELL" ]]; }; then return; fi
|
||||
target=$(resolve_profile "$path")
|
||||
# A dotfile manager may point two profile names at the same file.
|
||||
for previous in "${PROFILE_TARGETS[@]}"; do [[ "$previous" != "$target" ]] || return 0; done
|
||||
data="$TMP/profile-${#PROFILE_PATHS[@]}"
|
||||
if [[ -e "$target" ]]; then
|
||||
awk -f "$TMP/clean-profile.awk" "$target" > "$data" \
|
||||
|| die "unclosed or nested netssh block in $path; profiles were not changed"
|
||||
else
|
||||
: > "$data"
|
||||
fi
|
||||
if [[ -n "$kind" ]]; then
|
||||
count=0
|
||||
for previous in "${SHELLS[@]}"; do [[ "$previous" != "$kind" ]] || count=1; done
|
||||
if [[ "$count" == 0 ]]; then SHELLS+=("$kind"); fi
|
||||
{
|
||||
printf '# >>> netssh >>>\n'
|
||||
if [[ "$kind" == fish ]]; then
|
||||
printf 'set -gx PATH %s $PATH\n' "$(quote_fish "$INSTALL_DIR")"
|
||||
printf '%s shell-init\n' "$(quote_fish "$INSTALL_DIR/$BINARY")"
|
||||
else
|
||||
printf 'export PATH=%s:"$PATH"\n' "$(quote_sh "$INSTALL_DIR")"
|
||||
printf '%s shell-init\n' "$(quote_sh "$INSTALL_DIR/$BINARY")"
|
||||
printf '[ ! -f %s ] || . %s\n' "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")" "$(quote_sh "$CACHE_DIR/netssh/aliases.sh")"
|
||||
fi
|
||||
if [[ "$kind" == zsh ]]; then
|
||||
# Register completion also when compinit already ran (e.g. oh-my-zsh).
|
||||
printf 'fpath=(%s $fpath)\n' "$(quote_sh "$HOME/.zfunc")"
|
||||
printf 'if (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n'
|
||||
fi
|
||||
printf '# <<< netssh <<<\n'
|
||||
} >> "$data"
|
||||
fi
|
||||
PROFILE_PATHS+=("$path"); PROFILE_TARGETS+=("$target"); PROFILE_DATA+=("$data")
|
||||
}
|
||||
# Preflight every profile before installing the binary or modifying any profile.
|
||||
plan_profile "$HOME/.bashrc" bash
|
||||
plan_profile "$HOME/.bash_profile" ""
|
||||
plan_profile "$HOME/.bash_login" ""
|
||||
plan_profile "$HOME/.profile" ""
|
||||
plan_profile "$ZSH_DIR/.zshrc" zsh
|
||||
plan_profile "$ZSH_DIR/.zprofile" ""
|
||||
plan_profile "$ZSH_DIR/.zshenv" ""
|
||||
if [[ "$ZSH_DIR" != "$HOME" ]]; then
|
||||
plan_profile "$HOME/.zshrc" ""
|
||||
plan_profile "$HOME/.zprofile" ""
|
||||
plan_profile "$HOME/.zshenv" ""
|
||||
fi
|
||||
plan_profile "$CONFIG_DIR/fish/config.fish" fish
|
||||
|
||||
echo
|
||||
green "✓ netssh $TAG installed to $INSTALL_DIR/$BINARY"
|
||||
info "run 'netssh --help' to get started"
|
||||
if [[ ${#PRIVILEGE[@]} -gt 0 ]]; then
|
||||
info "sudo required to write to $INSTALL_DIR"
|
||||
fi
|
||||
"${PRIVILEGE[@]}" mkdir -p "$INSTALL_DIR"
|
||||
# Stage on the destination filesystem to replace a running binary atomically.
|
||||
STAGED=$("${PRIVILEGE[@]}" mktemp "$INSTALL_DIR/.netssh.XXXXXX")
|
||||
"${PRIVILEGE[@]}" install -m 755 "$TMP/$ASSET" "$STAGED"
|
||||
"${PRIVILEGE[@]}" mv -f "$STAGED" "$INSTALL_DIR/$BINARY"
|
||||
STAGED=""
|
||||
|
||||
# Regenerate aliases, including the configured command alias, as the calling user.
|
||||
# An empty cache clears obsolete generated aliases. Never run the wizard here.
|
||||
write_aliases() {
|
||||
local kind=$1 path=$2 staged
|
||||
"$INSTALL_DIR/$BINARY" alias --shell "$kind" > "$TMP/aliases-$kind" \
|
||||
|| die "could not regenerate $kind aliases; profiles were not changed"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
staged=$(mktemp "$(dirname "$path")/.netssh.XXXXXX")
|
||||
cat "$TMP/aliases-$kind" > "$staged"
|
||||
chmod 600 "$staged"
|
||||
mv -f "$staged" "$path"
|
||||
}
|
||||
write_aliases bash "$CACHE_DIR/netssh/aliases.sh"
|
||||
for kind in "${SHELLS[@]}"; do
|
||||
if [[ "$kind" == fish ]]; then write_aliases fish "$CONFIG_DIR/fish/conf.d/netssh.fish"; fi
|
||||
done
|
||||
|
||||
for ((index=0; index<${#PROFILE_PATHS[@]}; index++)); do
|
||||
target=${PROFILE_TARGETS[$index]}
|
||||
data=${PROFILE_DATA[$index]}
|
||||
if [[ -e "$target" ]] && cmp -s "$target" "$data"; then continue; fi
|
||||
mkdir -p "$(dirname "$target")"
|
||||
if [[ -e "$target" ]]; then
|
||||
backup=$(mktemp "$target.netssh-backup.XXXXXX")
|
||||
cp -p "$target" "$backup"
|
||||
info "backup : $backup"
|
||||
fi
|
||||
staged=$(mktemp "$(dirname "$target")/.netssh.XXXXXX")
|
||||
if [[ -e "$target" ]]; then cp -p "$target" "$staged"; else chmod 644 "$staged"; fi
|
||||
cat "$data" > "$staged"
|
||||
mv -f "$staged" "$target"
|
||||
info "updated : ${PROFILE_PATHS[$index]}"
|
||||
done
|
||||
COMPLETIONS=()
|
||||
for kind in "${SHELLS[@]}"; do
|
||||
if "$INSTALL_DIR/$BINARY" completion install --shell "$kind" >/dev/null; then
|
||||
COMPLETIONS+=("$kind")
|
||||
else
|
||||
info "warning: could not update $kind completion"
|
||||
fi
|
||||
done
|
||||
printf '\nnetssh %s installed / updated at %s/%s\n' "$TAG" "$INSTALL_DIR" "$BINARY"
|
||||
for kind in "${COMPLETIONS[@]}"; do
|
||||
case "$kind" in
|
||||
bash) info "completion: bash -> $HOME/.local/share/bash-completion/completions/netssh" ;;
|
||||
zsh) info "completion: zsh -> $HOME/.zfunc/_netssh (registered in ${ZSH_DIR}/.zshrc)" ;;
|
||||
fish) info "completion: fish -> $CONFIG_DIR/fish/completions/netssh.fish" ;;
|
||||
esac
|
||||
done
|
||||
info "Open a new terminal to load the current hooks and aliases."
|
||||
for kind in "${COMPLETIONS[@]}"; do
|
||||
if [[ "$kind" == zsh ]]; then
|
||||
info "zsh: if <Tab> still shows files, run: rm -f $(quote_sh "$ZSH_DIR")/.zcompdump* && exec zsh"
|
||||
fi
|
||||
done
|
||||
info "Remove netssh again with: netssh uninstall"
|
||||
@@ -0,0 +1,358 @@
|
||||
package netssh_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type installerFixture struct {
|
||||
home, bin, assets string
|
||||
env []string
|
||||
}
|
||||
|
||||
func newInstallerFixture(t *testing.T) installerFixture {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := installerFixture{home: filepath.Join(root, "home"), bin: filepath.Join(root, "tools"), assets: filepath.Join(root, "assets")}
|
||||
for _, dir := range []string{f.home, f.bin, f.assets} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeFixture(t, filepath.Join(f.bin, "curl"), `#!/bin/bash
|
||||
set -eu
|
||||
out=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in -o) out=$2; shift 2 ;; *) url=$1; shift ;; esac
|
||||
done
|
||||
case "$url" in
|
||||
*/releases/latest) printf '{"tag_name": "v0.0.99"}\n'; exit ;;
|
||||
*/checksums.txt) [[ "${CHECKSUM_MODE:-}" != missing ]] || exit 22; file="$FIXTURE_ASSETS/checksums.txt" ;;
|
||||
*) file="$FIXTURE_ASSETS/netssh_linux_amd64" ;;
|
||||
esac
|
||||
if [[ -n "$out" ]]; then cp "$file" "$out"; else cat "$file"; fi
|
||||
`, 0o755)
|
||||
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Linux;; -m) echo x86_64;; esac\n", 0o755)
|
||||
// Any accidental runtime dependency on Python must fail the installation.
|
||||
writeFixture(t, filepath.Join(f.bin, "python3"), "#!/bin/sh\nexit 99\n", 0o755)
|
||||
binary := `#!/bin/bash
|
||||
set -eu
|
||||
case "$1" in
|
||||
alias)
|
||||
if [[ "$3" == fish ]]; then printf "alias j 'netssh'\n"; else printf "alias j='netssh'\n"; fi ;;
|
||||
completion) exit 0 ;;
|
||||
shell-init) exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`
|
||||
writeFixture(t, filepath.Join(f.assets, "netssh_linux_amd64"), binary, 0o755)
|
||||
checksum := fmt.Sprintf("%x netssh_linux_amd64\n", sha256.Sum256([]byte(binary)))
|
||||
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), checksum, 0o644)
|
||||
f.env = append(os.Environ(), "HOME="+f.home, "XDG_CONFIG_HOME="+filepath.Join(f.home, "config"), "XDG_CACHE_HOME="+filepath.Join(f.home, "cache"), "ZDOTDIR="+f.home, "SHELL=/bin/bash", "PATH="+f.bin+":"+os.Getenv("PATH"), "FIXTURE_ASSETS="+f.assets, "INSTALL_DIR="+filepath.Join(f.home, "local bin's"), "BASH_ENV=", "CHECKSUM_MODE=")
|
||||
return f
|
||||
}
|
||||
|
||||
func writeFixture(t *testing.T, path, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f installerFixture) run(t *testing.T) (string, error) {
|
||||
t.Helper()
|
||||
// Piping stdin mirrors curl | bash, including EOF for any child commands.
|
||||
script, err := os.ReadFile("install.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("bash")
|
||||
cmd.Env = f.env
|
||||
cmd.Stdin = strings.NewReader(string(script))
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestInstallerUpdatesAndConsolidatesProfilesWithoutPython(t *testing.T) {
|
||||
f := newInstallerFixture(t)
|
||||
original := "export KEEP=yes\n# netssh shell-init is documented here\necho 'netssh shell-init'\nnetssh shell-init\nnetssh shell-init # duplicate\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias old='netssh old.example.com'\n"
|
||||
target := filepath.Join(f.home, "dotfiles", "bashrc")
|
||||
writeFixture(t, target, original, 0o600)
|
||||
bashrc := filepath.Join(f.home, ".bashrc")
|
||||
if err := os.Symlink(target, bashrc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFixture(t, filepath.Join(f.home, ".zshrc"), "export ZSH_KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||
writeFixture(t, filepath.Join(f.home, ".profile"), "export LOGIN_KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||
fish := filepath.Join(f.home, "config", "fish", "config.fish")
|
||||
writeFixture(t, fish, "set KEEP yes\nnetssh alias --shell fish | source\n", 0o644)
|
||||
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||
writeFixture(t, installed, "old binary", 0o755)
|
||||
if out, err := f.run(t); err != nil {
|
||||
t.Fatalf("install: %v\n%s", err, out)
|
||||
}
|
||||
for _, path := range []string{bashrc, filepath.Join(f.home, ".zshrc"), fish} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
||||
t.Fatalf("bad managed blocks: %s", data)
|
||||
}
|
||||
}
|
||||
zshrc, _ := os.ReadFile(filepath.Join(f.home, ".zshrc"))
|
||||
if !strings.Contains(string(zshrc), "compdef _netssh netssh") || !strings.Contains(string(zshrc), "/.zfunc' $fpath)") {
|
||||
t.Fatalf("zsh completion not registered: %s", zshrc)
|
||||
}
|
||||
data, _ := os.ReadFile(bashrc)
|
||||
if strings.Contains(string(data), "compdef") {
|
||||
t.Fatalf("zsh code in bash profile: %s", data)
|
||||
}
|
||||
for _, kept := range []string{"export KEEP=yes", "# netssh shell-init is documented here", "echo 'netssh shell-init'"} {
|
||||
if !strings.Contains(string(data), kept) {
|
||||
t.Fatalf("unrelated line lost: %s", kept)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(data), "alias old=") || strings.Contains(string(data), "eval ") {
|
||||
t.Fatalf("legacy entries remain: %s", data)
|
||||
}
|
||||
if info, err := os.Lstat(bashrc); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("symlink lost: %v", err)
|
||||
}
|
||||
if info, _ := os.Stat(target); info.Mode().Perm() != 0o600 {
|
||||
t.Fatal("permissions changed")
|
||||
}
|
||||
backups, _ := filepath.Glob(target + ".netssh-backup.*")
|
||||
if len(backups) != 1 {
|
||||
t.Fatalf("backups: %v", backups)
|
||||
}
|
||||
backup, _ := os.ReadFile(backups[0])
|
||||
if string(backup) != original {
|
||||
t.Fatal("backup lost original content")
|
||||
}
|
||||
before := string(data)
|
||||
if out, err := f.run(t); err != nil {
|
||||
t.Fatalf("second install: %v\n%s", err, out)
|
||||
}
|
||||
data, _ = os.ReadFile(bashrc)
|
||||
if string(data) != before {
|
||||
t.Fatalf("not idempotent:\n%s", data)
|
||||
}
|
||||
backups, _ = filepath.Glob(target + ".netssh-backup.*")
|
||||
if len(backups) != 1 {
|
||||
t.Fatal("unchanged profile backed up again")
|
||||
}
|
||||
// Execute the generated Bash block to verify paths containing quotes and spaces.
|
||||
cmd := exec.Command("bash", "-c", `. "$HOME/.bashrc"; alias j`)
|
||||
cmd.Env = f.env
|
||||
if out, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(out), "alias j='netssh'") {
|
||||
t.Fatalf("source: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerRejectsUnverifiedDownloads(t *testing.T) {
|
||||
for _, mode := range []string{"missing", "mismatch", "no-entry", "duplicate"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
f := newInstallerFixture(t)
|
||||
f.env = append(f.env, "CHECKSUM_MODE="+mode)
|
||||
sums := filepath.Join(f.assets, "checksums.txt")
|
||||
switch mode {
|
||||
case "mismatch":
|
||||
writeFixture(t, sums, strings.Repeat("0", 64)+" netssh_linux_amd64\n", 0o644)
|
||||
case "no-entry":
|
||||
writeFixture(t, sums, strings.Repeat("0", 64)+" other_binary\n", 0o644)
|
||||
case "duplicate":
|
||||
data, _ := os.ReadFile(sums)
|
||||
writeFixture(t, sums, string(data)+string(data), 0o644)
|
||||
}
|
||||
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||
writeFixture(t, installed, "old binary", 0o755)
|
||||
profile := filepath.Join(f.home, ".bashrc")
|
||||
writeFixture(t, profile, "old profile\n", 0o600)
|
||||
if out, err := f.run(t); err == nil {
|
||||
t.Fatalf("accepted invalid checksum: %s", out)
|
||||
}
|
||||
data, _ := os.ReadFile(installed)
|
||||
if string(data) != "old binary" {
|
||||
t.Fatal("binary changed")
|
||||
}
|
||||
data, _ = os.ReadFile(profile)
|
||||
if string(data) != "old profile\n" {
|
||||
t.Fatal("profile changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerRefusesBrokenManagedBlock(t *testing.T) {
|
||||
f := newInstallerFixture(t)
|
||||
profile := filepath.Join(f.home, ".bashrc")
|
||||
original := "export KEEP=yes\n# >>> netssh >>>\nunfinished\n"
|
||||
writeFixture(t, profile, original, 0o644)
|
||||
if out, err := f.run(t); err == nil {
|
||||
t.Fatalf("accepted broken block: %s", out)
|
||||
}
|
||||
data, _ := os.ReadFile(profile)
|
||||
if string(data) != original {
|
||||
t.Fatal("profile changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerUsesShasumAndFindsExistingBinary(t *testing.T) {
|
||||
if _, err := exec.LookPath("shasum"); err != nil {
|
||||
t.Skip("shasum not available")
|
||||
}
|
||||
f := newInstallerFixture(t)
|
||||
// Hide sha256sum (and Python) as on a standard macOS installation.
|
||||
for _, name := range []string{"shasum", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "perl"} {
|
||||
tool, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(tool, filepath.Join(f.bin, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR=")
|
||||
installed := filepath.Join(f.bin, "netssh")
|
||||
writeFixture(t, installed, "#!/bin/sh\nexit 1\n", 0o755)
|
||||
if out, err := f.run(t); err != nil {
|
||||
t.Fatalf("shasum update: %v\n%s", err, out)
|
||||
}
|
||||
data, _ := os.ReadFile(installed)
|
||||
expected, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
||||
if string(data) != string(expected) {
|
||||
t.Fatal("existing installation was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerRespectsZdotdir(t *testing.T) {
|
||||
f := newInstallerFixture(t)
|
||||
zshDir := filepath.Join(f.home, "custom-zsh")
|
||||
f.env = append(f.env, "SHELL=/bin/zsh", "ZDOTDIR="+zshDir)
|
||||
old := filepath.Join(f.home, ".zshrc")
|
||||
writeFixture(t, old, "export KEEP=yes\nnetssh shell-init\n", 0o644)
|
||||
if out, err := f.run(t); err != nil {
|
||||
t.Fatalf("zsh setup: %v\n%s", err, out)
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(zshDir, ".zshrc"))
|
||||
if strings.Count(string(data), "# >>> netssh >>>") != 1 {
|
||||
t.Fatalf("missing custom zsh setup: %s", data)
|
||||
}
|
||||
data, _ = os.ReadFile(old)
|
||||
if string(data) != "export KEEP=yes\n" {
|
||||
t.Fatalf("old zsh setup not cleaned: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerUsesMacOSCacheLayout(t *testing.T) {
|
||||
f := newInstallerFixture(t)
|
||||
writeFixture(t, filepath.Join(f.bin, "uname"), "#!/bin/sh\ncase \"$1\" in -s) echo Darwin;; -m) echo x86_64;; esac\n", 0o755)
|
||||
data, _ := os.ReadFile(filepath.Join(f.assets, "netssh_linux_amd64"))
|
||||
writeFixture(t, filepath.Join(f.assets, "checksums.txt"), fmt.Sprintf("%x netssh_darwin_amd64\n", sha256.Sum256(data)), 0o644)
|
||||
f.env = append(f.env, "XDG_CACHE_HOME=")
|
||||
if out, err := f.run(t); err != nil {
|
||||
t.Fatalf("macOS setup: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.home, "Library", "Caches", "netssh", "aliases.sh")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerChecksDependenciesBeforeDownloading(t *testing.T) {
|
||||
for _, missing := range [][]string{{"awk", "cmp"}, {"curl"}, {"sha256sum", "shasum"}} {
|
||||
t.Run(strings.Join(missing, "+"), func(t *testing.T) {
|
||||
f := newInstallerFixture(t)
|
||||
// Build an isolated PATH containing every dependency except the selected tools.
|
||||
for _, name := range []string{"curl", "uname", "sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
||||
skip := false
|
||||
for _, absent := range missing {
|
||||
if name == absent {
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
if err := os.Remove(filepath.Join(f.bin, name)); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.bin, name)); err == nil {
|
||||
continue
|
||||
}
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
marker := filepath.Join(f.assets, "curl-was-called")
|
||||
// Replacing curl with a marker makes any premature network operation observable.
|
||||
if missing[0] != "curl" {
|
||||
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
||||
}
|
||||
f.env = append(f.env, "PATH="+f.bin)
|
||||
installed := filepath.Join(f.home, "local bin's", "netssh")
|
||||
writeFixture(t, installed, "old binary", 0o755)
|
||||
out, err := f.run(t)
|
||||
if err == nil {
|
||||
t.Fatalf("missing dependencies accepted: %s", out)
|
||||
}
|
||||
for _, name := range missing {
|
||||
if !strings.Contains(out, name) {
|
||||
t.Errorf("missing diagnostic for %s: %s", name, out)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatal("curl ran before dependency checks completed")
|
||||
}
|
||||
data, _ := os.ReadFile(installed)
|
||||
if string(data) != "old binary" {
|
||||
t.Fatal("binary changed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.home, ".bashrc")); !os.IsNotExist(err) {
|
||||
t.Fatal("profile created")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallerChecksSudoBeforeDownloadingWhenNeeded(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can write directories regardless of mode bits")
|
||||
}
|
||||
f := newInstallerFixture(t)
|
||||
for _, name := range []string{"sed", "awk", "mktemp", "rm", "dirname", "readlink", "mkdir", "cat", "install", "mv", "cp", "chmod", "cmp", "sha256sum"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(path, filepath.Join(f.bin, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
locked := filepath.Join(f.home, "locked")
|
||||
if err := os.Mkdir(locked, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chmod(locked, 0o755) })
|
||||
writeFixture(t, filepath.Join(f.bin, "curl"), "#!/bin/bash\nprintf called > \"$FIXTURE_ASSETS/curl-was-called\"\nexit 99\n", 0o755)
|
||||
f.env = append(f.env, "PATH="+f.bin, "INSTALL_DIR="+filepath.Join(locked, "bin"))
|
||||
out, err := f.run(t)
|
||||
if err == nil || !strings.Contains(out, "sudo") {
|
||||
t.Fatalf("missing sudo diagnostic: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(f.assets, "curl-was-called")); !os.IsNotExist(err) {
|
||||
t.Fatal("download ran before sudo check")
|
||||
}
|
||||
}
|
||||
Vendored
+199
-17
@@ -2,11 +2,17 @@ package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -15,13 +21,25 @@ type Entry struct {
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
LastUsed time.Time `json:"last_used,omitempty"`
|
||||
}
|
||||
|
||||
type changeKind uint8
|
||||
|
||||
const (
|
||||
upsertChange changeKind = iota
|
||||
deleteChange
|
||||
usageChange
|
||||
)
|
||||
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]Entry
|
||||
path string
|
||||
ttl time.Duration
|
||||
mu sync.RWMutex
|
||||
dirty map[string]changeKind
|
||||
cleared bool
|
||||
entries map[string]Entry
|
||||
path string
|
||||
ttl time.Duration
|
||||
refreshStamp string // path to last_refresh timestamp file
|
||||
}
|
||||
|
||||
type diskFormat struct {
|
||||
@@ -29,11 +47,49 @@ type diskFormat struct {
|
||||
}
|
||||
|
||||
func New(path string, ttlSeconds int) *Cache {
|
||||
return &Cache{
|
||||
entries: make(map[string]Entry),
|
||||
path: path,
|
||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||
stamp := ""
|
||||
if path != "" {
|
||||
stamp = filepath.Join(filepath.Dir(path), "last_refresh")
|
||||
}
|
||||
return &Cache{
|
||||
entries: make(map[string]Entry),
|
||||
dirty: make(map[string]changeKind),
|
||||
path: path,
|
||||
ttl: time.Duration(ttlSeconds) * time.Second,
|
||||
refreshStamp: stamp,
|
||||
}
|
||||
}
|
||||
|
||||
// NeedsRefresh reports whether the last full cache refresh (via `cache refresh`)
|
||||
// is older than d, or has never happened. Always returns false when no path is set.
|
||||
func (c *Cache) NeedsRefresh(d time.Duration) bool {
|
||||
if c.refreshStamp == "" {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(c.refreshStamp)
|
||||
if err != nil {
|
||||
return true // file missing → never refreshed
|
||||
}
|
||||
var t time.Time
|
||||
if err := t.UnmarshalText(data); err != nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(t) >= d
|
||||
}
|
||||
|
||||
// SetRefreshed records the current time as the last successful full refresh.
|
||||
func (c *Cache) SetRefreshed() error {
|
||||
if c.refreshStamp == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := time.Now().MarshalText()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(c.refreshStamp), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.refreshStamp, data, 0o644)
|
||||
}
|
||||
|
||||
func (c *Cache) Load() error {
|
||||
@@ -53,6 +109,8 @@ func (c *Cache) Load() error {
|
||||
return err
|
||||
}
|
||||
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = false
|
||||
c.entries = make(map[string]Entry, len(df.Entries))
|
||||
for _, e := range df.Entries {
|
||||
c.entries[e.Name] = e
|
||||
@@ -60,32 +118,137 @@ func (c *Cache) Load() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save merges only local changes under a process-wide file lock. Atomic rename
|
||||
// alone prevents torn JSON but cannot prevent lost updates from another process.
|
||||
func (c *Cache) Save() error {
|
||||
c.mu.RLock()
|
||||
df := diskFormat{Entries: make([]Entry, 0, len(c.entries))}
|
||||
for _, e := range c.entries {
|
||||
df.Entries = append(df.Entries, e)
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := os.OpenFile(c.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Close()
|
||||
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
|
||||
|
||||
merged := make(map[string]Entry)
|
||||
if !c.cleared {
|
||||
data, err := os.ReadFile(c.path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
var df diskFormat
|
||||
if err := json.Unmarshal(data, &df); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range df.Entries {
|
||||
merged[e.Name] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, change := range c.dirty {
|
||||
if change == deleteChange {
|
||||
delete(merged, name)
|
||||
continue
|
||||
}
|
||||
e := c.entries[name]
|
||||
old, exists := merged[name]
|
||||
if change == usageChange && !exists {
|
||||
// A connection finishing after a refresh or clear must not
|
||||
// restore an entry that the other process removed.
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
lastUsed := e.LastUsed
|
||||
if change == usageChange || old.CachedAt.After(e.CachedAt) {
|
||||
e = old
|
||||
}
|
||||
if old.LastUsed.After(lastUsed) {
|
||||
lastUsed = old.LastUsed
|
||||
}
|
||||
e.LastUsed = lastUsed
|
||||
}
|
||||
merged[name] = e
|
||||
}
|
||||
df := diskFormat{Entries: make([]Entry, 0, len(merged))}
|
||||
for _, e := range merged {
|
||||
df.Entries = append(df.Entries, e)
|
||||
}
|
||||
sort.Slice(df.Entries, func(i, j int) bool { return df.Entries[i].Name < df.Entries[j].Name })
|
||||
data, err := json.MarshalIndent(df, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0o644)
|
||||
if err := storage.WriteFile(c.path, data, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
c.entries = merged
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Upsert(e Entry) {
|
||||
e.CachedAt = time.Now()
|
||||
c.mu.Lock()
|
||||
if old, ok := c.entries[e.Name]; ok && e.LastUsed.IsZero() {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
c.entries[e.Name] = e
|
||||
c.dirty[e.Name] = upsertChange
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Delete removes an entry when a refresh can no longer resolve it safely.
|
||||
func (c *Cache) Delete(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.entries, name)
|
||||
c.dirty[name] = deleteChange
|
||||
}
|
||||
|
||||
// MarkUsed records the current time as LastUsed for the named entry.
|
||||
// It is a no-op if the entry does not exist.
|
||||
func (c *Cache) MarkUsed(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if e, ok := c.entries[name]; ok {
|
||||
e.LastUsed = time.Now()
|
||||
c.entries[name] = e
|
||||
if _, changed := c.dirty[name]; !changed {
|
||||
c.dirty[name] = usageChange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc.
|
||||
// Entries that have never been used (LastUsed zero) are excluded.
|
||||
// If n <= 0, all used entries are returned.
|
||||
func (c *Cache) RecentlyUsed(n int) []Entry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
var used []Entry
|
||||
for _, e := range c.entries {
|
||||
if !e.LastUsed.IsZero() {
|
||||
used = append(used, e)
|
||||
}
|
||||
}
|
||||
sort.Slice(used, func(i, j int) bool {
|
||||
return used[i].LastUsed.After(used[j].LastUsed)
|
||||
})
|
||||
if n > 0 && len(used) > n {
|
||||
used = used[:n]
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// Search returns all entries whose name starts with prefix (case-insensitive).
|
||||
// TTL is intentionally ignored — this is used for shell completion.
|
||||
func (c *Cache) Search(prefix string) []Entry {
|
||||
@@ -95,13 +258,30 @@ func (c *Cache) Search(prefix string) []Entry {
|
||||
prefix = strings.ToLower(prefix)
|
||||
var out []Entry
|
||||
for name, e := range c.entries {
|
||||
if strings.HasPrefix(strings.ToLower(name), prefix) {
|
||||
_, hostname, qualified := strings.Cut(strings.ToLower(name), "/")
|
||||
if strings.HasPrefix(strings.ToLower(name), prefix) || (qualified && !strings.Contains(prefix, "/") && strings.HasPrefix(hostname, prefix)) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetByShortcut rejects collisions instead of choosing a random map entry.
|
||||
func (c *Cache) GetByShortcut(shortcut string, normalize func(string) string) (entry Entry, found bool, fresh bool, err error) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
norm := normalize(shortcut)
|
||||
for _, e := range c.entries {
|
||||
if normalize(e.Name) == norm {
|
||||
if found {
|
||||
return Entry{}, false, false, fmt.Errorf("ambiguous shortcut %q; use a full hostname", shortcut)
|
||||
}
|
||||
entry, found = e, true
|
||||
}
|
||||
}
|
||||
return entry, found, found && c.ttl > 0 && time.Since(entry.CachedAt) < c.ttl, nil
|
||||
}
|
||||
|
||||
// Get returns an entry and reports whether it is still within the TTL.
|
||||
func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||
c.mu.RLock()
|
||||
@@ -120,6 +300,8 @@ func (c *Cache) Get(name string) (entry Entry, fresh bool) {
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
c.entries = make(map[string]Entry)
|
||||
c.dirty = make(map[string]changeKind)
|
||||
c.cleared = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
Vendored
+262
@@ -220,6 +220,244 @@ func TestSave_ProducesValidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUsed_SetsLastUsed(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
before := time.Now()
|
||||
c.MarkUsed("host")
|
||||
|
||||
e, _ := c.Get("host")
|
||||
if e.LastUsed.Before(before) {
|
||||
t.Error("LastUsed should be set to current time by MarkUsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUsed_NoopForMissingEntry(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.MarkUsed("nonexistent") // should not panic
|
||||
}
|
||||
|
||||
func TestRecentlyUsed_ReturnsTopN(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "c", IP: "3.3.3.3", Kind: "device"})
|
||||
|
||||
c.MarkUsed("c")
|
||||
time.Sleep(time.Millisecond)
|
||||
c.MarkUsed("a")
|
||||
|
||||
results := c.RecentlyUsed(2)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("RecentlyUsed(2): got %d results, want 2", len(results))
|
||||
}
|
||||
if results[0].Name != "a" {
|
||||
t.Errorf("first result: got %q, want %q", results[0].Name, "a")
|
||||
}
|
||||
if results[1].Name != "c" {
|
||||
t.Errorf("second result: got %q, want %q", results[1].Name, "c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyUsed_ExcludesNeverUsed(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "used", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "unused", IP: "2.2.2.2", Kind: "device"})
|
||||
c.MarkUsed("used")
|
||||
|
||||
results := c.RecentlyUsed(10)
|
||||
if len(results) != 1 || results[0].Name != "used" {
|
||||
t.Errorf("RecentlyUsed should exclude entries with zero LastUsed, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyUsed_EmptyCache(t *testing.T) {
|
||||
c := New("", 60)
|
||||
if results := c.RecentlyUsed(10); len(results) != 0 {
|
||||
t.Errorf("RecentlyUsed on empty cache: got %d, want 0", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUsed_RoundtripViaSave(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
c := New(path, 3600)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
c.MarkUsed("host")
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
c2 := New(path, 3600)
|
||||
if err := c2.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
results := c2.RecentlyUsed(1)
|
||||
if len(results) != 1 || results[0].Name != "host" {
|
||||
t.Errorf("LastUsed not persisted: %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetByShortcut tests ---
|
||||
|
||||
func TestGetByShortcut_MatchFresh(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
normalize := func(s string) string {
|
||||
// strip .example.com
|
||||
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
|
||||
s = s[:len(s)-len(".example.com")]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
e, found, fresh, _ := c.GetByShortcut("web01", normalize)
|
||||
if !found {
|
||||
t.Fatal("expected entry to be found")
|
||||
}
|
||||
if !fresh {
|
||||
t.Error("entry just inserted should be fresh")
|
||||
}
|
||||
if e.IP != "10.0.0.1" {
|
||||
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_MatchStale(t *testing.T) {
|
||||
c := New("", 1) // 1 second TTL
|
||||
stale := Entry{Name: "web01.example.com", IP: "10.0.0.1", Kind: "device", CachedAt: time.Now().Add(-2 * time.Second)}
|
||||
c.mu.Lock()
|
||||
c.entries["web01.example.com"] = stale
|
||||
c.mu.Unlock()
|
||||
|
||||
normalize := func(s string) string {
|
||||
if len(s) > len(".example.com") && s[len(s)-len(".example.com"):] == ".example.com" {
|
||||
s = s[:len(s)-len(".example.com")]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
_, found, fresh, _ := c.GetByShortcut("web01", normalize)
|
||||
if !found {
|
||||
t.Fatal("expected entry to be found even when stale")
|
||||
}
|
||||
if fresh {
|
||||
t.Error("entry older than TTL should not be fresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_NotFound(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "db01.example.com", IP: "10.0.0.2", Kind: "device"})
|
||||
|
||||
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
||||
if found {
|
||||
t.Error("should not find an entry that does not match the shortcut")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_EmptyCache(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
_, found, _, _ := c.GetByShortcut("web01", func(s string) string { return s })
|
||||
if found {
|
||||
t.Error("empty cache should return found=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByShortcut_MultiDomain(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
// Entry uses second domain (.example.de)
|
||||
c.Upsert(Entry{Name: "web01.example.de", IP: "10.0.0.5", Kind: "vm"})
|
||||
|
||||
normalize := func(s string) string {
|
||||
for _, suffix := range []string{".example.com", ".example.de"} {
|
||||
if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix {
|
||||
return s[:len(s)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
e, found, _, _ := c.GetByShortcut("web01", normalize)
|
||||
if !found {
|
||||
t.Fatal("should match entry with second configured domain")
|
||||
}
|
||||
if e.IP != "10.0.0.5" {
|
||||
t.Errorf("IP: got %q, want %q", e.IP, "10.0.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
// --- NeedsRefresh / SetRefreshed tests ---
|
||||
// These tests require NeedsRefresh(time.Duration) bool and SetRefreshed() error
|
||||
// to be implemented on *Cache. The refreshStamp field (path to the timestamp file)
|
||||
// must be set before calling these methods.
|
||||
|
||||
func TestNeedsRefresh_NeverRefreshed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh should return true when last_refresh file does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRefresh_JustRefreshed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
t.Fatalf("SetRefreshed: %v", err)
|
||||
}
|
||||
if c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh should return false immediately after SetRefreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRefresh_Stale(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
// Write a timestamp 25 hours ago
|
||||
oldTime := time.Now().Add(-25 * time.Hour).Format(time.RFC3339)
|
||||
if err := os.WriteFile(stampPath, []byte(oldTime), 0o644); err != nil {
|
||||
t.Fatalf("writing stamp: %v", err)
|
||||
}
|
||||
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh should return true when last_refresh is older than duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRefreshed_Roundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stampPath := filepath.Join(dir, "last_refresh")
|
||||
c := New(filepath.Join(dir, "cache.json"), 3600)
|
||||
c.refreshStamp = stampPath
|
||||
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
t.Fatalf("SetRefreshed: %v", err)
|
||||
}
|
||||
|
||||
// Just refreshed: 24h window → not stale
|
||||
if c.NeedsRefresh(24 * time.Hour) {
|
||||
t.Error("NeedsRefresh(24h) should be false right after SetRefreshed")
|
||||
}
|
||||
|
||||
// Zero duration: everything is stale
|
||||
if !c.NeedsRefresh(0) {
|
||||
t.Error("NeedsRefresh(0) should always return true")
|
||||
}
|
||||
}
|
||||
|
||||
// tempFile writes content to a temp file and returns its path.
|
||||
func tempFile(t *testing.T, content []byte) string {
|
||||
t.Helper()
|
||||
@@ -233,3 +471,27 @@ func tempFile(t *testing.T, content []byte) string {
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestUpsertPreservesUsageHistory(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1"})
|
||||
c.MarkUsed("web01")
|
||||
before, _ := c.Get("web01")
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.2"})
|
||||
after, _ := c.Get("web01")
|
||||
if after.LastUsed != before.LastUsed || after.IP != "10.0.0.2" {
|
||||
t.Fatalf("history lost: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"})
|
||||
c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"})
|
||||
c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"})
|
||||
for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} {
|
||||
if got := len(c.Search(query)); got != want {
|
||||
t.Errorf("Search(%q)=%d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+182
@@ -0,0 +1,182 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestShortcutRejectsAmbiguousTargets(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "web.prod.example", IP: "10.0.0.1"})
|
||||
c.Upsert(Entry{Name: "web.dev.example", IP: "10.0.0.2"})
|
||||
normalize := func(s string) string { return strings.SplitN(s, ".", 2)[0] }
|
||||
for range 100 {
|
||||
entry, found, fresh, err := c.GetByShortcut("web", normalize)
|
||||
if err == nil || found || fresh || entry.Name != "" {
|
||||
t.Fatalf("ambiguous shortcut returned target: %+v, %v, %v, %v", entry, found, fresh, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveMergesIndependentlyLoadedCaches(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
a, b := New(path, 3600), New(path, 3600)
|
||||
for _, c := range []*Cache{a, b} {
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a.Upsert(Entry{Name: "a", IP: "10.0.0.1"})
|
||||
b.Upsert(Entry{Name: "b", IP: "10.0.0.2"})
|
||||
for _, c := range []*Cache{a, b} {
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.All()) != 2 {
|
||||
t.Fatalf("lost an update: %+v", a.All())
|
||||
}
|
||||
|
||||
// Saving a stale usage update must retain newer IPs and both usage times.
|
||||
if err := b.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.MarkUsed("a")
|
||||
used, _ := b.Get("a")
|
||||
a.Upsert(Entry{Name: "a", IP: "10.0.0.3"})
|
||||
if err := a.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := b.Get("a")
|
||||
if got.IP != "10.0.0.3" || !got.LastUsed.Equal(used.LastUsed) {
|
||||
t.Fatalf("lost refreshed IP or usage: %+v", got)
|
||||
}
|
||||
|
||||
b.Delete("a")
|
||||
if err := b.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := a.Get("a"); got.Name != "" {
|
||||
t.Fatalf("deleted entry survived: %+v", got)
|
||||
}
|
||||
a.Clear()
|
||||
if err := a.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.All()) != 0 {
|
||||
t.Fatal("clear merged old entries back in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAcrossProcesses(t *testing.T) {
|
||||
if path := os.Getenv("NETSSH_TEST_CACHE_WRITER_PATH"); path != "" {
|
||||
c := New(path, 3600)
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Upsert(Entry{Name: os.Getenv("NETSSH_TEST_CACHE_WRITER_NAME"), IP: "10.0.0.1"})
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
const writers = 8
|
||||
cmds := make([]*exec.Cmd, writers)
|
||||
for i := range cmds {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestSaveAcrossProcesses$")
|
||||
cmd.Env = append(os.Environ(), "NETSSH_TEST_CACHE_WRITER_PATH="+path, fmt.Sprintf("NETSSH_TEST_CACHE_WRITER_NAME=host-%d", i))
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmds[i] = cmd
|
||||
}
|
||||
for _, cmd := range cmds {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
c := New(path, 3600)
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.All()) != writers {
|
||||
t.Fatalf("lost concurrent updates: got %d, want %d", len(c.All()), writers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePreservesNewerUsageFromDisk(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
a := New(path, 3600)
|
||||
a.Upsert(Entry{Name: "web", IP: "10.0.0.1", LastUsed: time.Now()})
|
||||
if err := a.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := New(path, 3600)
|
||||
if err := b.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.MarkUsed("web")
|
||||
used, _ := a.Get("web")
|
||||
if err := a.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Upsert(Entry{Name: "web", IP: "10.0.0.2"})
|
||||
if err := b.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := b.Get("web")
|
||||
if got.IP != "10.0.0.2" || !got.LastUsed.Equal(used.LastUsed) {
|
||||
t.Fatalf("lost data: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageSaveDoesNotRestoreRemovedHosts(t *testing.T) {
|
||||
for _, clear := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("clear=%v", clear), func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts.json")
|
||||
a := New(path, 3600)
|
||||
a.Upsert(Entry{Name: "web", IP: "10.0.0.1"})
|
||||
if err := a.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := New(path, 3600)
|
||||
if err := b.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.MarkUsed("web")
|
||||
if clear {
|
||||
a.Clear()
|
||||
} else {
|
||||
a.Delete("web")
|
||||
}
|
||||
if err := a.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.All()) != 0 {
|
||||
t.Fatalf("usage update restored removed entry: %+v", b.All())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,33 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shell ShellConfig `mapstructure:"shell"`
|
||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
|
||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
Alias string `mapstructure:"alias"`
|
||||
}
|
||||
|
||||
type NetBoxConfig struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
Name string `mapstructure:"name"`
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
TokenVersion int `mapstructure:"token_version"`
|
||||
SSH SSHOptions `mapstructure:"ssh"`
|
||||
}
|
||||
|
||||
type ResolverConfig struct {
|
||||
@@ -33,6 +46,19 @@ type CacheConfig struct {
|
||||
|
||||
type SSHConfig struct {
|
||||
DefaultUser string `mapstructure:"default_user"`
|
||||
DefaultPort int `mapstructure:"default_port"`
|
||||
SSHOptions `mapstructure:",squash"`
|
||||
Rules []SSHRule `mapstructure:"rules"`
|
||||
}
|
||||
|
||||
type CompletionConfig struct {
|
||||
// MaxResults limits the number of hostnames offered per completion; 0 = unlimited.
|
||||
MaxResults int `mapstructure:"max_results"`
|
||||
}
|
||||
|
||||
type ShortcutsConfig struct {
|
||||
Domains []string `mapstructure:"domains"`
|
||||
StripHyphens bool `mapstructure:"strip_hyphens"`
|
||||
}
|
||||
|
||||
// Path returns the canonical config file path.
|
||||
@@ -60,7 +86,13 @@ 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"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
@@ -72,6 +104,40 @@ func Load() (*Config, error) {
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
// Legacy environment variables must not invent an additional unnamed source
|
||||
// when a file explicitly configures named instances (including wizard migration).
|
||||
if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") {
|
||||
cfg.NetBox = NetBoxConfig{}
|
||||
}
|
||||
|
||||
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.NetBox.TokenVersion == 0 && cfg.NetBox.Token != "" {
|
||||
if strings.HasPrefix(cfg.NetBox.Token, "nbt_") {
|
||||
cfg.NetBox.TokenVersion = 2
|
||||
} else {
|
||||
cfg.NetBox.TokenVersion = 1
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateSSH(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
if source.TokenVersion == 0 && source.Token != "" {
|
||||
source.TokenVersion = 1
|
||||
if strings.HasPrefix(source.Token, "nbt_") {
|
||||
source.TokenVersion = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Cache.Path == "" {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
@@ -83,3 +149,18 @@ func Load() (*Config, error) {
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// ValidateCommandAlias restricts aliases to portable shell identifiers.
|
||||
func ValidateCommandAlias(name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`).MatchString(name) {
|
||||
return fmt.Errorf("shell.alias must be a shell identifier (e.g. j)")
|
||||
}
|
||||
switch name {
|
||||
case "netssh", "alias", "unalias", "source", "eval", "exec", "command", "builtin", "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "in", "function", "select", "time", "coproc", "end", "begin", "switch", "set", "export", "return", "break", "continue", "not", "and", "or":
|
||||
return fmt.Errorf("shell.alias %q is reserved; choose another name", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "netssh.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func loadFromDir(t *testing.T, dir string) *Config {
|
||||
t.Helper()
|
||||
// Override UserConfigDir by pointing viper at our temp dir via env isn't
|
||||
// straightforward, so we exercise Load() by temporarily changing XDG_CONFIG_HOME.
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Cleanup(func() { os.Setenv("XDG_CONFIG_HOME", orig) })
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestLoad_V2TokenVersion_Preserved(t *testing.T) {
|
||||
dir := writeConfig(t, `
|
||||
netbox:
|
||||
url: "https://netbox.example.com"
|
||||
token: "nbt_abc123"
|
||||
token_version: 2
|
||||
`)
|
||||
cfg := loadFromDir(t, dir)
|
||||
if cfg.NetBox.TokenVersion != 2 {
|
||||
t.Errorf("TokenVersion: got %d, want 2", cfg.NetBox.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_V1TokenVersion_Preserved(t *testing.T) {
|
||||
dir := writeConfig(t, `
|
||||
netbox:
|
||||
url: "https://netbox.example.com"
|
||||
token: "legacyhex123"
|
||||
token_version: 1
|
||||
`)
|
||||
cfg := loadFromDir(t, dir)
|
||||
if cfg.NetBox.TokenVersion != 1 {
|
||||
t.Errorf("TokenVersion: got %d, want 1", cfg.NetBox.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoDetectsV2_WhenFieldMissing(t *testing.T) {
|
||||
dir := writeConfig(t, `
|
||||
netbox:
|
||||
url: "https://netbox.example.com"
|
||||
token: "nbt_mytoken"
|
||||
`)
|
||||
cfg := loadFromDir(t, dir)
|
||||
if cfg.NetBox.TokenVersion != 2 {
|
||||
t.Errorf("TokenVersion: got %d, want 2 (auto-detected from nbt_ prefix)", cfg.NetBox.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_AutoDetectsV1_WhenFieldMissing(t *testing.T) {
|
||||
dir := writeConfig(t, `
|
||||
netbox:
|
||||
url: "https://netbox.example.com"
|
||||
token: "abc123def456"
|
||||
`)
|
||||
cfg := loadFromDir(t, dir)
|
||||
if cfg.NetBox.TokenVersion != 1 {
|
||||
t.Errorf("TokenVersion: got %d, want 1 (auto-detected from plain token)", cfg.NetBox.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_TokenVersionZero_WhenNoToken(t *testing.T) {
|
||||
dir := writeConfig(t, `
|
||||
netbox:
|
||||
url: "https://netbox.example.com"
|
||||
`)
|
||||
cfg := loadFromDir(t, dir)
|
||||
if cfg.NetBox.TokenVersion != 0 {
|
||||
t.Errorf("TokenVersion: got %d, want 0 (no token present)", cfg.NetBox.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
dir := writeConfig(t, `
|
||||
netbox:
|
||||
url: "https://netbox.example.com"
|
||||
token: "nbt_x"
|
||||
`)
|
||||
cfg := loadFromDir(t, dir)
|
||||
|
||||
if cfg.Cache.TTL != 3600 {
|
||||
t.Errorf("default cache.ttl: got %d, want 3600", cfg.Cache.TTL)
|
||||
}
|
||||
if cfg.Cache.Path == "" {
|
||||
t.Error("cache.path should be auto-set when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFile_ReturnsEmptyConfig(t *testing.T) {
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", t.TempDir()) // dir exists but no netssh.yaml
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load on missing file should not error: %v", err)
|
||||
}
|
||||
if cfg.NetBox.URL != "" {
|
||||
t.Errorf("expected empty URL, got %q", cfg.NetBox.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidYAML_ReturnsError(t *testing.T) {
|
||||
dir := writeConfig(t, "not: valid: yaml: [[[")
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Error("invalid YAML should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPath_ReturnsNonEmpty(t *testing.T) {
|
||||
p := Path()
|
||||
if p == "" {
|
||||
t.Error("Path() should return a non-empty string")
|
||||
}
|
||||
if filepath.Base(p) != "netssh.yaml" {
|
||||
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_NETBOX_URL", "https://env.example")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "nbt_env")
|
||||
t.Setenv("NETSSH_SSH_DEFAULT_PORT", "2222")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.NetBox.URL != "https://env.example" || cfg.NetBox.Token != "nbt_env" || cfg.NetBox.TokenVersion != 2 || cfg.SSH.DefaultPort != 2222 {
|
||||
t.Fatalf("env ignored: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandAliasEnvironmentAndValidation(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", "j")
|
||||
cfg, err := Load()
|
||||
if err != nil || cfg.Shell.Alias != "j" {
|
||||
t.Fatalf("alias env: %+v, %v", cfg, err)
|
||||
}
|
||||
for _, name := range []string{"netssh", "if", "alias", "j;touch /tmp/bad", "a b", "1bad"} {
|
||||
t.Setenv("NETSSH_SHELL_ALIAS", name)
|
||||
if _, err := Load(); err == nil {
|
||||
t.Errorf("unsafe alias accepted: %q", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"", "j", "jump_server", "goSSH"} {
|
||||
if err := ValidateCommandAlias(name); err != nil {
|
||||
t.Errorf("valid alias rejected: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var instanceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||
|
||||
// ValidateNetBoxes keeps source names usable as instance/hostname selectors.
|
||||
func (c *Config) ValidateNetBoxes() error {
|
||||
if len(c.NetBoxes) > 0 && c.NetBox.URL != "" {
|
||||
return fmt.Errorf("configure either netbox or netboxes, not both")
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, source := range c.NetBoxes {
|
||||
if !instanceName.MatchString(source.Name) {
|
||||
return fmt.Errorf("netboxes.name %q must contain only letters, digits, underscores, or hyphens", source.Name)
|
||||
}
|
||||
name := strings.ToLower(source.Name)
|
||||
if seen[name] {
|
||||
return fmt.Errorf("duplicate NetBox instance name %q", source.Name)
|
||||
}
|
||||
seen[name] = true
|
||||
u, err := url.Parse(source.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name)
|
||||
}
|
||||
if source.TokenVersion < 0 || source.TokenVersion > 2 {
|
||||
return fmt.Errorf("NetBox %q: token_version must be 0, 1, or 2", source.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 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 {
|
||||
if len(c.NetBoxes) == 0 {
|
||||
return c.Cache.Path
|
||||
}
|
||||
var identities []string
|
||||
for _, source := range c.NetBoxes {
|
||||
identities = append(identities, strings.ToLower(source.Name)+"\x00"+strings.TrimRight(source.URL, "/"))
|
||||
}
|
||||
sort.Strings(identities)
|
||||
sum := sha256.Sum256([]byte(strings.Join(identities, "\x00")))
|
||||
return filepath.Join(filepath.Dir(c.Cache.Path), fmt.Sprintf("instances-%x", sum[:8]), filepath.Base(c.Cache.Path))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadNamedNetBoxes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("NETSSH_NETBOX_URL", "https://legacy-env.example")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "legacy-env-token")
|
||||
data := `netboxes:
|
||||
- name: Office
|
||||
url: https://office.example/netbox
|
||||
token: nbt_office
|
||||
- name: lab
|
||||
url: https://lab.example
|
||||
token: legacy-token
|
||||
cache:
|
||||
path: /tmp/netssh-tests/hosts.json
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.HasNetBox() || len(cfg.NetBoxes) != 2 || cfg.NetBoxes[0].Name != "office" || cfg.NetBoxes[0].TokenVersion != 2 || cfg.NetBoxes[1].TokenVersion != 1 {
|
||||
t.Fatalf("bad instances: %+v", cfg.NetBoxes)
|
||||
}
|
||||
if cfg.NetBox.URL != "" {
|
||||
t.Fatal("legacy environment variables added an unnamed instance")
|
||||
}
|
||||
first := cfg.CachePath()
|
||||
if first == cfg.Cache.Path {
|
||||
t.Fatal("named sources reuse legacy cache")
|
||||
}
|
||||
cfg.NetBoxes[0], cfg.NetBoxes[1] = cfg.NetBoxes[1], cfg.NetBoxes[0]
|
||||
cfg.NetBoxes[0].Token = "rotated"
|
||||
if cfg.CachePath() != first {
|
||||
t.Fatal("reordering/rotating credentials discarded cache")
|
||||
}
|
||||
cfg.NetBoxes[0].URL = "https://other.example"
|
||||
if cfg.CachePath() == first {
|
||||
t.Fatal("changed endpoint reuses old hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNetBoxes(t *testing.T) {
|
||||
good := NetBoxConfig{Name: "office", URL: "https://office.example"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cfg Config
|
||||
}{
|
||||
{"duplicate", Config{NetBoxes: []NetBoxConfig{good, {Name: "OFFICE", URL: "https://other.example"}}}},
|
||||
{"empty name", Config{NetBoxes: []NetBoxConfig{{URL: "https://office.example"}}}},
|
||||
{"invalid name", Config{NetBoxes: []NetBoxConfig{{Name: "a/b", URL: "https://office.example"}}}},
|
||||
{"missing URL", Config{NetBoxes: []NetBoxConfig{{Name: "office"}}}},
|
||||
{"invalid URL", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: "file:///tmp/netbox"}}}},
|
||||
{"conflicting config", Config{NetBox: good, NetBoxes: []NetBoxConfig{good}}},
|
||||
{"invalid token version", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: good.URL, TokenVersion: 3}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.cfg.ValidateNetBoxes(); err == nil {
|
||||
t.Fatal("invalid configuration accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
legacy := Config{NetBox: good, Cache: CacheConfig{Path: "hosts.json"}}
|
||||
if err := legacy.ValidateNetBoxes(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !legacy.HasNetBox() || legacy.CachePath() != "hosts.json" {
|
||||
t.Fatal("legacy behavior changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHOptions are connection settings that can be set globally, per NetBox
|
||||
// instance, and per rule. More specific layers override less specific ones.
|
||||
type SSHOptions struct {
|
||||
// ProxyJump is passed as -J; "none" disables a jump host set by a broader layer.
|
||||
ProxyJump string `mapstructure:"proxy_jump"`
|
||||
// HostKeyChecking is strict, accept-new, or off. Empty keeps ssh's own setting.
|
||||
HostKeyChecking string `mapstructure:"host_key_checking"`
|
||||
// Target selects the ssh destination: ip (default), name, or hostname.
|
||||
Target string `mapstructure:"target"`
|
||||
// AddressFamily is any, inet, or inet6.
|
||||
AddressFamily string `mapstructure:"address_family"`
|
||||
// ExtraArgs are inserted verbatim before the destination.
|
||||
ExtraArgs []string `mapstructure:"extra_args"`
|
||||
// Options are passed as -o Key=Value.
|
||||
Options map[string]string `mapstructure:"options"`
|
||||
}
|
||||
|
||||
// SSHRule applies SSH options to hosts that match every non-empty field.
|
||||
type SSHRule struct {
|
||||
Match SSHMatch `mapstructure:"match"`
|
||||
SSHOptions `mapstructure:",squash"`
|
||||
}
|
||||
|
||||
// SSHMatch selects hosts by NetBox attributes available in the cache.
|
||||
type SSHMatch struct {
|
||||
Tag string `mapstructure:"tag"`
|
||||
Instance string `mapstructure:"instance"`
|
||||
Kind string `mapstructure:"kind"`
|
||||
Name string `mapstructure:"name"` // shell-style glob, e.g. "*.dmz.example.com"
|
||||
}
|
||||
|
||||
// SSHHost identifies the resolved host that SSH options are computed for.
|
||||
type SSHHost struct {
|
||||
Instance string
|
||||
Name string // NetBox name without the instance prefix
|
||||
Kind string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
var sshOptionKey = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
|
||||
|
||||
// ValidateSSH rejects values that would be passed to ssh in an unexpected form.
|
||||
func (c *Config) ValidateSSH() error {
|
||||
if err := c.SSH.SSHOptions.validate("ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, rule := range c.SSH.Rules {
|
||||
if err := rule.SSHOptions.validate(fmt.Sprintf("ssh.rules[%d]", i)); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.Match == (SSHMatch{}) {
|
||||
return fmt.Errorf("ssh.rules[%d]: match must set at least one of tag, instance, kind, name", i)
|
||||
}
|
||||
if _, err := path.Match(rule.Match.Name, ""); err != nil {
|
||||
return fmt.Errorf("ssh.rules[%d]: invalid name pattern %q", i, rule.Match.Name)
|
||||
}
|
||||
}
|
||||
if err := c.NetBox.SSH.validate("netbox.ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
if err := source.SSH.validate("netboxes." + source.Name + ".ssh"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o SSHOptions) validate(scope string) error {
|
||||
switch o.HostKeyChecking {
|
||||
case "", "strict", "accept-new", "off":
|
||||
default:
|
||||
return fmt.Errorf("%s.host_key_checking must be strict, accept-new, or off", scope)
|
||||
}
|
||||
switch o.Target {
|
||||
case "", "ip", "name", "hostname":
|
||||
default:
|
||||
return fmt.Errorf("%s.target must be ip, name, or hostname", scope)
|
||||
}
|
||||
switch o.AddressFamily {
|
||||
case "", "any", "inet", "inet6":
|
||||
default:
|
||||
return fmt.Errorf("%s.address_family must be any, inet, or inet6", scope)
|
||||
}
|
||||
if strings.HasPrefix(o.ProxyJump, "-") || strings.ContainsAny(o.ProxyJump, " \t\n") {
|
||||
return fmt.Errorf("%s.proxy_jump %q is not a valid jump host", scope, o.ProxyJump)
|
||||
}
|
||||
for key, value := range o.Options {
|
||||
if !sshOptionKey.MatchString(key) || strings.ContainsAny(value, "\n\r") {
|
||||
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
merged = merged.merge(c.NetBox.SSH)
|
||||
}
|
||||
for _, source := range c.NetBoxes {
|
||||
if strings.EqualFold(source.Name, host.Instance) {
|
||||
merged = merged.merge(source.SSH)
|
||||
}
|
||||
}
|
||||
for _, rule := range c.SSH.Rules {
|
||||
if rule.Match.matches(host) {
|
||||
merged = merged.merge(rule.SSHOptions)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// merge returns o overridden by the non-empty fields of override.
|
||||
// Extra arguments accumulate; options override per key.
|
||||
func (o SSHOptions) merge(override SSHOptions) SSHOptions {
|
||||
out := o
|
||||
out.ExtraArgs = append(append([]string(nil), o.ExtraArgs...), override.ExtraArgs...)
|
||||
out.Options = make(map[string]string, len(o.Options)+len(override.Options))
|
||||
for key, value := range o.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
for key, value := range override.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
if override.ProxyJump != "" {
|
||||
out.ProxyJump = override.ProxyJump
|
||||
}
|
||||
if override.HostKeyChecking != "" {
|
||||
out.HostKeyChecking = override.HostKeyChecking
|
||||
}
|
||||
if override.Target != "" {
|
||||
out.Target = override.Target
|
||||
}
|
||||
if override.AddressFamily != "" {
|
||||
out.AddressFamily = override.AddressFamily
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m SSHMatch) matches(host SSHHost) bool {
|
||||
if m.Instance != "" && !strings.EqualFold(m.Instance, host.Instance) {
|
||||
return false
|
||||
}
|
||||
if m.Kind != "" && !strings.EqualFold(m.Kind, host.Kind) {
|
||||
return false
|
||||
}
|
||||
if m.Name != "" {
|
||||
if ok, _ := path.Match(strings.ToLower(m.Name), strings.ToLower(host.Name)); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if m.Tag != "" {
|
||||
found := false
|
||||
for _, tag := range host.Tags {
|
||||
if strings.EqualFold(tag, m.Tag) || strings.EqualFold(tagSlug(tag), m.Tag) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tagSlug approximates NetBox's slug so rules may use either the tag name or slug.
|
||||
func tagSlug(name string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(name)), "-")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadYAML(t *testing.T, data string) (*Config, error) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Load()
|
||||
}
|
||||
|
||||
func TestSSHOptionsLayering(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
netboxes:
|
||||
- name: prod
|
||||
url: https://prod.example.com
|
||||
ssh:
|
||||
proxy_jump: jh-prod.example.com
|
||||
host_key_checking: strict
|
||||
- name: staging
|
||||
url: https://staging.example.com
|
||||
ssh:
|
||||
host_key_checking: off
|
||||
ssh:
|
||||
proxy_jump: jh.example.com
|
||||
extra_args: ["-4"]
|
||||
options:
|
||||
ServerAliveInterval: 60
|
||||
ServerAliveCountMax: 5
|
||||
rules:
|
||||
- match: {tag: dmz}
|
||||
proxy_jump: jh-dmz.example.com
|
||||
options: {ServerAliveInterval: 10}
|
||||
- match: {instance: prod, name: "db*"}
|
||||
proxy_jump: none
|
||||
completion:
|
||||
max_results: 200
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Completion.MaxResults != 200 {
|
||||
t.Fatalf("max_results: %d", cfg.Completion.MaxResults)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
host SSHHost
|
||||
jump, keys, alive string
|
||||
}{
|
||||
{SSHHost{Name: "web"}, "jh.example.com", "", "60"},
|
||||
{SSHHost{Instance: "prod", Name: "web"}, "jh-prod.example.com", "strict", "60"},
|
||||
{SSHHost{Instance: "staging", Name: "web", Tags: []string{"DMZ"}}, "jh-dmz.example.com", "off", "10"},
|
||||
{SSHHost{Instance: "prod", Name: "db01", Tags: []string{"dmz"}}, "none", "strict", "10"},
|
||||
} {
|
||||
got := cfg.SSHOptionsFor(tc.host)
|
||||
if got.ProxyJump != tc.jump || got.HostKeyChecking != tc.keys || got.Options["serveraliveinterval"] != tc.alive || got.Options["serveralivecountmax"] != "5" {
|
||||
t.Errorf("%+v: got %+v", tc.host, got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.ExtraArgs, []string{"-4"}) {
|
||||
t.Errorf("extra args: %v", got.ExtraArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHOptionsValidation(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"ssh:\n host_key_checking: maybe\n",
|
||||
"ssh:\n target: dns\n",
|
||||
"ssh:\n address_family: ipx\n",
|
||||
"ssh:\n proxy_jump: \"-oProxyCommand=x\"\n",
|
||||
"ssh:\n options:\n \"bad key\": 1\n",
|
||||
"ssh:\n rules:\n - proxy_jump: jh\n",
|
||||
"netboxes:\n - name: a\n url: https://a.example\n ssh:\n target: nope\n",
|
||||
} {
|
||||
if _, err := loadYAML(t, data); err == nil {
|
||||
t.Errorf("accepted invalid config:\n%s", data)
|
||||
} else if !strings.Contains(err.Error(), "ssh") {
|
||||
t.Errorf("unclear error %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
// AliasEntry holds a single shell alias mapping.
|
||||
type AliasEntry struct {
|
||||
Name string // short alias name (e.g. "web01")
|
||||
Host string // canonical NetBox hostname (e.g. "web01.example.com")
|
||||
}
|
||||
|
||||
// AliasesPath returns the path for the bash/zsh aliases file.
|
||||
func AliasesPath() string {
|
||||
cacheDir := os.Getenv("XDG_CACHE_HOME")
|
||||
if cacheDir == "" {
|
||||
var err error
|
||||
cacheDir, err = os.UserCacheDir()
|
||||
if err != nil {
|
||||
cacheDir = filepath.Join(os.Getenv("HOME"), ".cache")
|
||||
}
|
||||
}
|
||||
return filepath.Join(cacheDir, "netssh", "aliases.sh")
|
||||
}
|
||||
|
||||
// FishAliasesPath returns the path for the fish conf.d aliases file.
|
||||
// Fish auto-sources every file in conf.d, so no explicit source line is needed.
|
||||
func FishAliasesPath() string {
|
||||
return filepath.Join(FishConfigDir(), "conf.d", "netssh.fish")
|
||||
}
|
||||
|
||||
// WriteAliasFiles writes alias definitions to disk for all supported shells.
|
||||
//
|
||||
// The bash/zsh file (~/.cache/netssh/aliases.sh) is always written.
|
||||
// The fish file (~/.config/fish/conf.d/netssh.fish) is written only when
|
||||
// ~/.config/fish/ already exists (i.e. fish is configured on this system).
|
||||
func WriteAliasFiles(entries []AliasEntry) error {
|
||||
if err := writeShAliasFile(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
writeFishAliasFile(entries) // best-effort — never blocks the caller on failure
|
||||
alias := ""
|
||||
for _, entry := range entries {
|
||||
if entry.Host == "" {
|
||||
alias = entry.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
return SyncCommandCompletion(alias)
|
||||
}
|
||||
|
||||
func writeShAliasFile(entries []AliasEntry) error {
|
||||
p := AliasesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return fmt.Errorf("creating aliases dir: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||
for _, e := range entries {
|
||||
b.WriteString(e.Definition("bash") + "\n")
|
||||
}
|
||||
return storage.WriteFile(p, []byte(b.String()), 0o600)
|
||||
}
|
||||
|
||||
func writeFishAliasFile(entries []AliasEntry) {
|
||||
// Only write if fish is configured on this system.
|
||||
if _, err := os.Stat(FishConfigDir()); err != nil {
|
||||
return
|
||||
}
|
||||
p := FishAliasesPath()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("# netssh aliases — generated automatically, do not edit\n")
|
||||
b.WriteString("# Regenerated on every shell start and after 'netssh cache refresh'.\n")
|
||||
for _, e := range entries {
|
||||
b.WriteString(e.Definition("fish") + "\n")
|
||||
}
|
||||
storage.WriteFile(p, []byte(b.String()), 0o600) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Empty Host represents an alias for the netssh command itself.
|
||||
func (e AliasEntry) Definition(shell string) string {
|
||||
command := "netssh"
|
||||
if e.Host != "" {
|
||||
host := e.Host
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`).MatchString(host) {
|
||||
host = shellQuote(host, shell)
|
||||
}
|
||||
command += " " + host
|
||||
}
|
||||
if shell == "fish" {
|
||||
return fmt.Sprintf("alias %s %s", e.Name, shellQuote(command, shell))
|
||||
}
|
||||
return fmt.Sprintf("alias %s=%s", e.Name, shellQuote(command, shell))
|
||||
}
|
||||
|
||||
func shellQuote(value, shell string) string {
|
||||
if shell == "fish" {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
return "'" + strings.ReplaceAll(value, "'", `\'`) + "'"
|
||||
}
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// BuildAliasEntries gives the configured command alias priority over host aliases.
|
||||
func BuildAliasEntries(entries []cache.Entry, cfg config.Config) []AliasEntry {
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
seen := map[string]bool{"netssh": true}
|
||||
var out []AliasEntry
|
||||
if cfg.Shell.Alias != "" && config.ValidateCommandAlias(cfg.Shell.Alias) == nil {
|
||||
seen[cfg.Shell.Alias] = true
|
||||
out = append(out, AliasEntry{Name: cfg.Shell.Alias})
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := shortcuts.AliasName(e.Name, cfg.Shortcuts)
|
||||
if seen[name] || name == "" || !regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*$`).MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
out = append(out, AliasEntry{Name: name, Host: e.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FishConfigDir follows fish's XDG layout on both Linux and macOS.
|
||||
func FishConfigDir() string {
|
||||
dir := os.Getenv("XDG_CONFIG_HOME")
|
||||
if dir == "" {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "fish")
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
func TestAliasesPath_UnderCacheDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", dir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
p := hook.AliasesPath()
|
||||
if !strings.HasPrefix(p, dir) {
|
||||
t.Errorf("AliasesPath should be under XDG_CACHE_HOME, got %q", p)
|
||||
}
|
||||
if !strings.HasSuffix(p, "aliases.sh") {
|
||||
t.Errorf("AliasesPath should end with aliases.sh, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFishAliasesPath_UnderConfigDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
p := hook.FishAliasesPath()
|
||||
if !strings.HasPrefix(p, dir) {
|
||||
t.Errorf("FishAliasesPath should be under XDG_CONFIG_HOME, got %q", p)
|
||||
}
|
||||
if !strings.HasSuffix(p, "netssh.fish") {
|
||||
t.Errorf("FishAliasesPath should end with netssh.fish, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_ShFile verifies the bash/zsh file is always written.
|
||||
func TestWriteAliasFiles_ShFile(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
entries := []hook.AliasEntry{
|
||||
{Name: "web01", Host: "web01.example.com"},
|
||||
{Name: "db01", Host: "db01.example.com"},
|
||||
}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("aliases.sh not created: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"alias web01='netssh web01.example.com'",
|
||||
"alias db01='netssh db01.example.com'",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("aliases.sh missing %q\ncontent:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_FishFile verifies the fish file is written when fish config dir exists.
|
||||
func TestWriteAliasFiles_FishFile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
// Create the fish config directory so WriteAliasFiles knows fish is set up.
|
||||
fishDir := filepath.Join(configDir, "fish")
|
||||
os.MkdirAll(fishDir, 0o755)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
origCache := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", origCache)
|
||||
|
||||
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(hook.FishAliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("netssh.fish not created: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "alias web01 'netssh web01.example.com'") {
|
||||
t.Errorf("fish alias file missing expected line\ncontent:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_SkipsFishWhenNotConfigured verifies that fish file is NOT written
|
||||
// when fish is not set up (no ~/.config/fish directory).
|
||||
func TestWriteAliasFiles_SkipsFishWhenNotConfigured(t *testing.T) {
|
||||
configDir := t.TempDir() // no fish subdir
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", configDir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
origCache := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", origCache)
|
||||
|
||||
entries := []hook.AliasEntry{{Name: "web01", Host: "web01.example.com"}}
|
||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||
t.Fatalf("WriteAliasFiles: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(hook.FishAliasesPath()); err == nil {
|
||||
t.Error("fish alias file should not be created when fish is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteAliasFiles_EmptyEntries verifies an empty (but valid) file is written.
|
||||
func TestWriteAliasFiles_EmptyEntries(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CACHE_HOME")
|
||||
os.Setenv("XDG_CACHE_HOME", cacheDir)
|
||||
defer os.Setenv("XDG_CACHE_HOME", orig)
|
||||
|
||||
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||
t.Fatalf("WriteAliasFiles with nil entries: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(hook.AliasesPath())
|
||||
if err != nil {
|
||||
t.Fatalf("aliases.sh not created: %v", err)
|
||||
}
|
||||
// Should contain only the header comment, no alias lines.
|
||||
if strings.Contains(string(data), "alias ") {
|
||||
t.Error("aliases.sh should have no alias lines for empty entries")
|
||||
}
|
||||
}
|
||||
|
||||
// --- InstallAliasesSource ---
|
||||
|
||||
func TestInstallAliasesSource_Fresh(t *testing.T) {
|
||||
profile := writeProfile(t, "existing line\n")
|
||||
|
||||
installed, err := hook.InstallAliasesSource(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAliasesSource: %v", err)
|
||||
}
|
||||
if !installed {
|
||||
t.Error("should report installed=true on first install")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if !strings.Contains(string(data), hook.AliasesMarker) {
|
||||
t.Error("profile should contain aliases source line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAliasesSource_Idempotent(t *testing.T) {
|
||||
profile := writeProfile(t, "")
|
||||
|
||||
hook.InstallAliasesSource(profile)
|
||||
installed, err := hook.InstallAliasesSource(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("second InstallAliasesSource: %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Error("second call should report installed=false")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if count := strings.Count(string(data), hook.AliasesMarker); count != 1 {
|
||||
t.Errorf("aliases source line should appear once, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUninstall_AlsoRemovesAliasesSourceLine verifies that Uninstall removes both lines.
|
||||
func TestUninstall_AlsoRemovesAliasesSourceLine(t *testing.T) {
|
||||
content := "export PATH=$PATH\n" +
|
||||
hook.Line + "\n" +
|
||||
hook.AliasesSourceLine + "\n" +
|
||||
"export FOO=bar\n"
|
||||
profile := writeProfile(t, content)
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("should report removed=true")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
s := string(data)
|
||||
if strings.Contains(s, hook.Marker) {
|
||||
t.Error("shell-init line should be removed")
|
||||
}
|
||||
if strings.Contains(s, hook.AliasesMarker) {
|
||||
t.Error("aliases source line should be removed")
|
||||
}
|
||||
if !strings.Contains(s, "export PATH") || !strings.Contains(s, "export FOO") {
|
||||
t.Error("other content should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandAliasGenerationAndCollision(t *testing.T) {
|
||||
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}}
|
||||
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "j"}, {Name: "netssh"}, {Name: "web-01"}}, cfg)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries: %+v", entries)
|
||||
}
|
||||
if got := entries[0].Definition("bash"); got != "alias j='netssh'" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := entries[0].Definition("fish"); got != "alias j 'netssh'" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := entries[1].Definition("bash"); got != "alias web-01='netssh web-01'" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasesIncludeInstanceName(t *testing.T) {
|
||||
cfg := config.Config{Shortcuts: config.ShortcutsConfig{Domains: []string{".example.com"}}}
|
||||
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "office/web01.example.com"}, {Name: "lab/web01.example.com"}}, cfg)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("missing qualified aliases: %+v", entries)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
got[entry.Name] = entry.Host
|
||||
}
|
||||
if got["office_web01"] != "office/web01.example.com" || got["lab_web01"] != "lab/web01.example.com" {
|
||||
t.Fatalf("alias lost source: %+v", entries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
const completionMarker = "# netssh command alias completion — generated automatically"
|
||||
|
||||
// SyncCommandCompletion installs lazy loaders for the configured command alias.
|
||||
// The loaders obtain the canonical completion from the current netssh binary,
|
||||
// so aliases configured by the wizard work without a separate completion install.
|
||||
func SyncCommandCompletion(alias string) error {
|
||||
if err := config.ValidateCommandAlias(alias); err != nil {
|
||||
return err
|
||||
}
|
||||
statePath := filepath.Join(filepath.Dir(AliasesPath()), "completion_alias")
|
||||
data, err := os.ReadFile(statePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
previous := strings.TrimSpace(string(data))
|
||||
if err := config.ValidateCommandAlias(previous); err != nil {
|
||||
return fmt.Errorf("invalid completion alias state: %w", err)
|
||||
}
|
||||
if alias == "" && previous == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
if previous != "" && previous != alias {
|
||||
path := commandCompletionPath(shell, previous)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
// Only delete files owned by netssh; preserve any user replacements.
|
||||
if strings.HasPrefix(string(data), completionMarker+"\n") || strings.HasPrefix(string(data), "#compdef "+previous+"\n"+completionMarker+"\n") {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if alias == "" {
|
||||
continue
|
||||
}
|
||||
if shell == "fish" {
|
||||
if _, err := os.Stat(FishConfigDir()); os.IsNotExist(err) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
path := commandCompletionPath(shell, alias)
|
||||
script := commandCompletionScript(shell, alias)
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if string(existing) == script {
|
||||
continue
|
||||
}
|
||||
if err := storage.WriteFile(path, []byte(script), 0o644); err != nil {
|
||||
return fmt.Errorf("writing %s alias completion: %w", shell, err)
|
||||
}
|
||||
}
|
||||
if alias == "" {
|
||||
if err := os.Remove(statePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return storage.WriteFile(statePath, []byte(alias+"\n"), 0o600)
|
||||
}
|
||||
|
||||
func commandCompletionPath(shell, alias string) string {
|
||||
switch shell {
|
||||
case "bash":
|
||||
return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias)
|
||||
case "zsh":
|
||||
return filepath.Join(ZshFuncDir(), "_"+alias)
|
||||
default:
|
||||
return filepath.Join(FishConfigDir(), "completions", alias+".fish")
|
||||
}
|
||||
}
|
||||
|
||||
func commandCompletionScript(shell, alias string) string {
|
||||
switch shell {
|
||||
case "bash":
|
||||
return fmt.Sprintf(`%s
|
||||
source <(netssh completion bash)
|
||||
if [[ $(type -t compopt) == builtin ]]; then
|
||||
complete -o default -F __start_netssh %s
|
||||
else
|
||||
complete -o default -o nospace -F __start_netssh %s
|
||||
fi
|
||||
`, completionMarker, alias, alias)
|
||||
case "zsh":
|
||||
return fmt.Sprintf(`#compdef %s
|
||||
%s
|
||||
source <(netssh completion zsh)
|
||||
compdef _netssh %s
|
||||
_netssh "$@"
|
||||
`, alias, completionMarker, alias)
|
||||
default:
|
||||
return fmt.Sprintf(`%s
|
||||
netssh completion fish | source
|
||||
complete --command %s --wraps netssh
|
||||
`, completionMarker, alias)
|
||||
}
|
||||
}
|
||||
|
||||
// CompletionFiles lists the completion and alias files netssh may have
|
||||
// installed for itself and for the given command aliases.
|
||||
func CompletionFiles(aliases ...string) []string {
|
||||
files := []string{
|
||||
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "netssh"),
|
||||
filepath.Join(ZshFuncDir(), "_netssh"),
|
||||
filepath.Join(FishConfigDir(), "completions", "netssh.fish"),
|
||||
FishAliasesPath(),
|
||||
}
|
||||
// oh-my-zsh caches generated completions separately.
|
||||
for _, dir := range []string{os.Getenv("ZSH_CACHE_DIR"), filepath.Join(os.Getenv("ZSH"), "cache"), filepath.Join(os.Getenv("HOME"), ".oh-my-zsh", "cache")} {
|
||||
if dir != "" && dir != "cache" {
|
||||
files = append(files, filepath.Join(dir, "completions", "_netssh"))
|
||||
}
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if config.ValidateCommandAlias(alias) != nil || alias == "" {
|
||||
continue
|
||||
}
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
files = append(files, commandCompletionPath(shell, alias))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// CompletionAliasState returns the command alias whose completion files were
|
||||
// last generated, or "" when there is none.
|
||||
func CompletionAliasState() string {
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(AliasesPath()), "completion_alias"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// IsGenerated reports whether a completion file was written by netssh,
|
||||
// so uninstall never removes a user's own file of the same name.
|
||||
func IsGenerated(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
return err == nil && strings.Contains(string(data), "netssh")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func completionEnvironment(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
||||
if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return map[string]string{
|
||||
"bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"),
|
||||
"zsh": filepath.Join(home, ".zfunc", "_j"),
|
||||
"fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandCompletionTracksAliasChanges(t *testing.T) {
|
||||
paths := completionEnvironment(t)
|
||||
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range paths {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A manually replaced completion file must survive removal of the old alias.
|
||||
custom := "# user-owned completion\n"
|
||||
if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for shell, path := range paths {
|
||||
if shell == "bash" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != custom {
|
||||
t.Fatal("user completion was removed")
|
||||
}
|
||||
} else if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale %s completion remains: %v", shell, err)
|
||||
}
|
||||
}
|
||||
if err := hook.WriteAliasFiles(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"),
|
||||
filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"),
|
||||
filepath.Join(hook.FishConfigDir(), "completions", "k.fish"),
|
||||
} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("disabled completion remains: %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) {
|
||||
for _, shell := range []string{"bash", "zsh", "fish"} {
|
||||
t.Run(shell, func(t *testing.T) {
|
||||
executable, err := exec.LookPath(shell)
|
||||
if err != nil {
|
||||
t.Skipf("%s unavailable", shell)
|
||||
}
|
||||
paths := completionEnvironment(t)
|
||||
if err := hook.SyncCommandCompletion("j"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtures := t.TempDir()
|
||||
root := &cobra.Command{Use: "netssh"}
|
||||
var canonical bytes.Buffer
|
||||
switch shell {
|
||||
case "bash":
|
||||
err = root.GenBashCompletionV2(&canonical, true)
|
||||
case "zsh":
|
||||
_, err = canonical.WriteString(hook.ZshCompletionScript)
|
||||
case "fish":
|
||||
err = root.GenFishCompletion(&canonical, true)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Exercise Cobra's actual generated shell code with a deterministic backend.
|
||||
backend := `#!/bin/bash
|
||||
case "$1" in
|
||||
completion) cat "$COMPLETION_FIXTURES/$2" ;;
|
||||
__complete|__completeNoDesc) printf 'server01\n:4\n' ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("COMPLETION_FIXTURES", fixtures)
|
||||
t.Setenv("ALIAS_COMPLETION", paths[shell])
|
||||
t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
var script string
|
||||
var args []string
|
||||
switch shell {
|
||||
case "bash":
|
||||
library := ""
|
||||
for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
library = path
|
||||
break
|
||||
}
|
||||
}
|
||||
if library == "" {
|
||||
t.Skip("bash-completion unavailable")
|
||||
}
|
||||
t.Setenv("BASH_COMPLETION_LIBRARY", library)
|
||||
args = []string{"--noprofile", "--norc", "-c"}
|
||||
script = `source "$BASH_COMPLETION_LIBRARY"
|
||||
shopt -s expand_aliases
|
||||
alias j=netssh
|
||||
_completion_loader j
|
||||
COMP_WORDS=(j ser)
|
||||
COMP_CWORD=1
|
||||
COMP_LINE='j ser'
|
||||
COMP_POINT=5
|
||||
COMP_TYPE=9
|
||||
COMP_KEY=9
|
||||
__start_netssh j ser j
|
||||
printf '%s\n' "${COMPREPLY[@]}"
|
||||
`
|
||||
case "zsh":
|
||||
args = []string{"-f", "-c"}
|
||||
script = `fpath=("$HOME/.zfunc" $fpath)
|
||||
autoload -Uz compinit
|
||||
compinit -u -D
|
||||
alias j=netssh
|
||||
autoload -Uz _j
|
||||
# Capture matches without requiring an interactive ZLE completion widget.
|
||||
_describe() { print -rl -- "${(P)4}"; }
|
||||
compadd() { print -rl -- "$@"; }
|
||||
words=(j ser)
|
||||
CURRENT=2
|
||||
_j
|
||||
`
|
||||
case "fish":
|
||||
args = []string{"--no-config", "-c"}
|
||||
script = `alias j netssh
|
||||
set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions"
|
||||
complete --do-complete 'j ser'
|
||||
`
|
||||
}
|
||||
cmd := exec.Command(executable, append(args, script)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "server01") {
|
||||
t.Fatalf("%s completion: %v\n%s", shell, err, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZshCompletionSplitsHostsAndCommands(t *testing.T) {
|
||||
zsh, err := exec.LookPath("zsh")
|
||||
if err != nil {
|
||||
t.Skip("zsh unavailable")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
backend := "#!/bin/sh\nprintf 'web01\\nweb:02\\ncache\\tManage the cache\\n:36\\n'\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh"), []byte(backend), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := `compdef() { :; }
|
||||
source "$DIR/_netssh"
|
||||
_describe() { print -r -- "describe ${(P)4}"; }
|
||||
compadd() { print -r -- "compadd $*"; }
|
||||
_files() { print -r -- files; }
|
||||
words=(netssh "")
|
||||
CURRENT=2
|
||||
_netssh
|
||||
`
|
||||
cmd := exec.Command(zsh, "-f", "-c", script)
|
||||
cmd.Env = append(os.Environ(), "DIR="+dir, "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
// Directive 36 = NoFileComp|KeepOrder: unsorted host group, no file fallback.
|
||||
want := "describe cache:Manage the cache\ncompadd -V hosts -X %Bhosts%b -M l:|=* -- web01 web:02\n"
|
||||
if string(out) != want {
|
||||
t.Fatalf("got:\n%s\nwant:\n%s", out, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
// Marker is the unique string used to detect an existing hook line.
|
||||
Marker = "netssh shell-init"
|
||||
BlockStart = "# >>> netssh >>>"
|
||||
BlockEnd = "# <<< netssh <<<"
|
||||
// Line is the full line written into the shell profile.
|
||||
Line = "netssh shell-init # netssh cache auto-refresh"
|
||||
|
||||
// AliasesMarker is the unique string used to detect the aliases source line.
|
||||
AliasesMarker = "netssh/aliases.sh"
|
||||
// AliasesSourceLine is the bash/zsh line that sources the generated aliases file.
|
||||
AliasesSourceLine = "source ~/.cache/netssh/aliases.sh 2>/dev/null # netssh aliases"
|
||||
)
|
||||
|
||||
// ProfilePath returns the canonical shell profile path for the given shell name.
|
||||
func ProfilePath(shell string) (string, error) {
|
||||
switch shell {
|
||||
case "bash":
|
||||
return filepath.Join(os.Getenv("HOME"), ".bashrc"), nil
|
||||
case "zsh":
|
||||
dir := os.Getenv("ZDOTDIR")
|
||||
if dir == "" {
|
||||
dir = os.Getenv("HOME")
|
||||
}
|
||||
return filepath.Join(dir, ".zshrc"), nil
|
||||
case "fish":
|
||||
return filepath.Join(FishConfigDir(), "config.fish"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported shell %q — supported: bash, zsh, fish", shell)
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadNote returns the shell-specific hint for reloading the profile.
|
||||
func ReloadNote(profilePath string) string {
|
||||
return "Reload with: source " + profilePath
|
||||
}
|
||||
|
||||
// IsInstalled reports whether the hook marker is present in the profile file.
|
||||
// Returns false if the file does not exist or cannot be read.
|
||||
func IsInstalled(profilePath string) bool {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == BlockStart || hookLinePattern.MatchString(strings.TrimSpace(line)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Install writes the managed netssh block into the profile. Entries written by
|
||||
// older versions without markers are replaced instead of duplicated; an
|
||||
// existing managed block (e.g. from install.sh) is left untouched.
|
||||
// Returns (true, nil) when the profile changed, (false, nil) when already present.
|
||||
// Creates the profile file (and any parent directories) if they do not exist.
|
||||
func Install(profilePath, shell string) (installed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == BlockStart {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
cleaned, _, err := clean(string(data), profilePath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cleaned != "" && !strings.HasSuffix(cleaned, "\n") {
|
||||
cleaned += "\n"
|
||||
}
|
||||
if cleaned != "" {
|
||||
cleaned += "\n"
|
||||
}
|
||||
cleaned += Block(shell)
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
return false, fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
if err := writeProfile(profilePath, cleaned); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Block returns the managed profile block for shell.
|
||||
func Block(shell string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(BlockStart + "\n")
|
||||
b.WriteString("netssh shell-init\n")
|
||||
if shell != "fish" {
|
||||
aliases := shellQuote(AliasesPath(), "bash")
|
||||
fmt.Fprintf(&b, "[ ! -f %s ] || . %s\n", aliases, aliases)
|
||||
}
|
||||
if shell == "zsh" {
|
||||
b.WriteString(ZshRegistration(ZshFuncDir()))
|
||||
}
|
||||
b.WriteString(BlockEnd + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ZshRegistration adds dir to fpath and registers the completion even when
|
||||
// compinit already ran earlier in .zshrc (e.g. via oh-my-zsh).
|
||||
func ZshRegistration(dir string) string {
|
||||
return fmt.Sprintf("fpath=(%s $fpath)\nif (( $+functions[compdef] )); then autoload -Uz _netssh && compdef _netssh netssh; fi\n", shellQuote(dir, "bash"))
|
||||
}
|
||||
|
||||
// InstallAliasesSource appends the aliases source line to a bash/zsh profile.
|
||||
// This is a no-op for fish (which uses conf.d auto-sourcing instead).
|
||||
// Returns (true, nil) when newly added, (false, nil) when already present.
|
||||
func InstallAliasesSource(profilePath string) (installed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == BlockStart || aliasesLinePattern.MatchString(strings.TrimSpace(line)) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(profilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("opening profile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := fmt.Fprintf(f, "%s\n", "source "+shellQuote(AliasesPath(), "bash")+" 2>/dev/null # netssh aliases"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var hookLinePattern = regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+shell-init\s*(?:#.*)?$`)
|
||||
var aliasesLinePattern = regexp.MustCompile(`^(?:source|\.)\s+["']?[^;|&]*netssh/aliases\.sh["']?(?:\s+2>/dev/null)?\s*(?:#.*)?$`)
|
||||
|
||||
// legacyPatterns match the unmarked lines written by older versions and by
|
||||
// manual setup instructions. They mirror the patterns in install.sh.
|
||||
var legacyPatterns = []*regexp.Regexp{
|
||||
hookLinePattern,
|
||||
aliasesLinePattern,
|
||||
regexp.MustCompile(`^eval\s+["']?\$\((?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+(?:bash|zsh))?\)["']?\s*(?:#.*)?$`),
|
||||
regexp.MustCompile(`^(?:[^\s]*/)?netssh\s+alias(?:\s+--shell\s+fish)?\s*\|\s*source\s*(?:#.*)?$`),
|
||||
regexp.MustCompile(`^source\s+<\((?:[^\s]*/)?netssh\s+alias\)\s*(?:#.*)?$`),
|
||||
regexp.MustCompile(`^alias\s+[A-Za-z0-9_.-]+(?:=|\s+)["']netssh(?:\s+[^"']+)?["']\s*(?:#.*)?$`),
|
||||
}
|
||||
|
||||
func isLegacyLine(line string) bool {
|
||||
for _, pattern := range legacyPatterns {
|
||||
if pattern.MatchString(line) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasEntries reports whether the profile contains a managed block or legacy lines.
|
||||
func HasEntries(profilePath string) bool {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, removed, err := clean(string(data), profilePath)
|
||||
return removed || err != nil
|
||||
}
|
||||
|
||||
// Uninstall removes legacy hook lines and complete installer-managed blocks.
|
||||
// Missing profiles are a no-op; malformed blocks leave the file unchanged.
|
||||
func Uninstall(profilePath string) (removed bool, err error) {
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reading profile: %w", err)
|
||||
}
|
||||
cleaned, removed, err := clean(string(data), profilePath)
|
||||
if err != nil || !removed {
|
||||
return false, err
|
||||
}
|
||||
return true, writeProfile(profilePath, cleaned)
|
||||
}
|
||||
|
||||
// clean strips managed blocks and legacy lines from profile content.
|
||||
func clean(data, profilePath string) (cleaned string, removed bool, err error) {
|
||||
if data == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
var kept []string
|
||||
inside := false
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
value := strings.TrimSpace(line)
|
||||
switch value {
|
||||
case BlockStart:
|
||||
if inside {
|
||||
return "", false, fmt.Errorf("nested netssh block in %s", profilePath)
|
||||
}
|
||||
inside = true
|
||||
removed = true
|
||||
continue
|
||||
case BlockEnd:
|
||||
if !inside {
|
||||
return "", false, fmt.Errorf("unmatched netssh block in %s", profilePath)
|
||||
}
|
||||
inside = false
|
||||
continue
|
||||
}
|
||||
if inside {
|
||||
continue
|
||||
}
|
||||
if isLegacyLine(value) {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
if inside {
|
||||
return "", false, fmt.Errorf("unclosed netssh block in %s", profilePath)
|
||||
}
|
||||
if !removed {
|
||||
return data, false, nil
|
||||
}
|
||||
cleaned = strings.ReplaceAll(strings.Join(kept, "\n"), "\n\n\n", "\n\n")
|
||||
// Drop the blank separator line Install puts in front of the block.
|
||||
if strings.HasSuffix(cleaned, "\n\n") {
|
||||
cleaned = strings.TrimRight(cleaned, "\n") + "\n"
|
||||
}
|
||||
return cleaned, true, nil
|
||||
}
|
||||
|
||||
// writeProfile replaces the profile atomically, following dotfile symlinks
|
||||
// and keeping the permissions of an existing file.
|
||||
func writeProfile(profilePath, content string) error {
|
||||
target, err := filepath.EvalSymlinks(profilePath)
|
||||
if os.IsNotExist(err) {
|
||||
target = profilePath
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if info, err := os.Stat(target); err == nil {
|
||||
mode = info.Mode().Perm()
|
||||
}
|
||||
if err := storage.WriteFile(target, []byte(content), mode); err != nil {
|
||||
return fmt.Errorf("writing profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProfileCandidates lists every shell profile netssh or install.sh may have
|
||||
// written to, in the same order as the installer.
|
||||
func ProfileCandidates() []string {
|
||||
home := os.Getenv("HOME")
|
||||
zdot := zshDir()
|
||||
paths := []string{
|
||||
filepath.Join(home, ".bashrc"),
|
||||
filepath.Join(home, ".bash_profile"),
|
||||
filepath.Join(home, ".bash_login"),
|
||||
filepath.Join(home, ".profile"),
|
||||
filepath.Join(zdot, ".zshrc"),
|
||||
filepath.Join(zdot, ".zprofile"),
|
||||
filepath.Join(zdot, ".zshenv"),
|
||||
}
|
||||
if zdot != home {
|
||||
paths = append(paths, filepath.Join(home, ".zshrc"), filepath.Join(home, ".zprofile"), filepath.Join(home, ".zshenv"))
|
||||
}
|
||||
return append(paths, filepath.Join(FishConfigDir(), "config.fish"))
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package hook_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
)
|
||||
|
||||
// --- ProfilePath ---
|
||||
|
||||
func TestProfilePath_Bash(t *testing.T) {
|
||||
orig := os.Getenv("HOME")
|
||||
os.Setenv("HOME", t.TempDir())
|
||||
defer os.Setenv("HOME", orig)
|
||||
|
||||
p, err := hook.ProfilePath("bash")
|
||||
if err != nil {
|
||||
t.Fatalf("ProfilePath(bash): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(p, ".bashrc") {
|
||||
t.Errorf("bash profile should end with .bashrc, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePath_Zsh(t *testing.T) {
|
||||
orig := os.Getenv("HOME")
|
||||
os.Setenv("HOME", t.TempDir())
|
||||
defer os.Setenv("HOME", orig)
|
||||
|
||||
p, err := hook.ProfilePath("zsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ProfilePath(zsh): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(p, ".zshrc") {
|
||||
t.Errorf("zsh profile should end with .zshrc, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePath_Fish(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
p, err := hook.ProfilePath("fish")
|
||||
if err != nil {
|
||||
t.Fatalf("ProfilePath(fish): %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(p, "config.fish") {
|
||||
t.Errorf("fish profile should end with config.fish, got %q", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePath_UnknownShell(t *testing.T) {
|
||||
if _, err := hook.ProfilePath("ksh"); err == nil {
|
||||
t.Error("unknown shell should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- IsInstalled ---
|
||||
|
||||
func TestIsInstalled_Present(t *testing.T) {
|
||||
profile := writeProfile(t, hook.Line+"\n")
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Error("IsInstalled should return true when marker is present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled_Absent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH\n")
|
||||
if hook.IsInstalled(profile) {
|
||||
t.Error("IsInstalled should return false when marker is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstalled_MissingFile(t *testing.T) {
|
||||
if hook.IsInstalled("/nonexistent/path/.bashrc") {
|
||||
t.Error("IsInstalled should return false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Install ---
|
||||
|
||||
func TestInstall_Fresh(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||
|
||||
installed, err := hook.Install(profile, "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if !installed {
|
||||
t.Error("should report installed=true on fresh install")
|
||||
}
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Error("profile should contain the hook line after Install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_Idempotent(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), ".bashrc")
|
||||
|
||||
hook.Install(profile, "bash")
|
||||
installed, err := hook.Install(profile, "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("second Install: %v", err)
|
||||
}
|
||||
if installed {
|
||||
t.Error("second Install should report installed=false")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if count := strings.Count(string(data), hook.Marker); count != 1 {
|
||||
t.Errorf("hook line should appear exactly once, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_CreatesProfileAndParentDirs(t *testing.T) {
|
||||
profile := filepath.Join(t.TempDir(), "nested", "dir", ".zshrc")
|
||||
|
||||
if _, err := hook.Install(profile, "bash"); err != nil {
|
||||
t.Fatalf("Install should create missing directories: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(profile); err != nil {
|
||||
t.Error("profile file should have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_PreservesExistingContent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH:/usr/local/bin\n")
|
||||
|
||||
hook.Install(profile, "bash")
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "export PATH") {
|
||||
t.Error("Install should not remove existing content")
|
||||
}
|
||||
if !strings.Contains(content, hook.Marker) {
|
||||
t.Error("hook line should be appended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstall_AppendedAtEnd(t *testing.T) {
|
||||
profile := writeProfile(t, "existing line\n")
|
||||
hook.Install(profile, "bash")
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
last := lines[len(lines)-1]
|
||||
if last != hook.BlockEnd {
|
||||
t.Errorf("managed block should end the profile, got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Uninstall ---
|
||||
|
||||
func TestUninstall_Removes(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH\n"+hook.Line+"\nexport FOO=bar\n")
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("should report removed=true")
|
||||
}
|
||||
if hook.IsInstalled(profile) {
|
||||
t.Error("hook line should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_PreservesOtherContent(t *testing.T) {
|
||||
profile := writeProfile(t, "export FOO=bar\n"+hook.Line+"\nexport BAZ=qux\n")
|
||||
|
||||
hook.Uninstall(profile)
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "export FOO=bar") {
|
||||
t.Error("content before hook should be preserved")
|
||||
}
|
||||
if !strings.Contains(content, "export BAZ=qux") {
|
||||
t.Error("content after hook should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_NotPresent(t *testing.T) {
|
||||
profile := writeProfile(t, "export PATH=$PATH\n")
|
||||
|
||||
removed, err := hook.Uninstall(profile)
|
||||
if err != nil {
|
||||
t.Fatalf("Uninstall: %v", err)
|
||||
}
|
||||
if removed {
|
||||
t.Error("should report removed=false when hook was not present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_MissingFile(t *testing.T) {
|
||||
removed, err := hook.Uninstall("/nonexistent/.bashrc")
|
||||
if err != nil {
|
||||
t.Errorf("Uninstall on missing file should not error: %v", err)
|
||||
}
|
||||
if removed {
|
||||
t.Error("should report removed=false for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall_CollapsesExtraBlankLines(t *testing.T) {
|
||||
// blank line before hook + hook + blank line after = three consecutive newlines after removal
|
||||
profile := writeProfile(t, "line1\n\n"+hook.Line+"\n\nline2\n")
|
||||
|
||||
hook.Uninstall(profile)
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if strings.Contains(string(data), "\n\n\n") {
|
||||
t.Error("three consecutive blank lines should be collapsed after uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Roundtrip ---
|
||||
|
||||
func TestInstallUninstall_Roundtrip(t *testing.T) {
|
||||
profile := writeProfile(t, "existing content\n")
|
||||
|
||||
hook.Install(profile, "bash")
|
||||
if !hook.IsInstalled(profile) {
|
||||
t.Fatal("should be installed after Install")
|
||||
}
|
||||
|
||||
hook.Uninstall(profile)
|
||||
if hook.IsInstalled(profile) {
|
||||
t.Fatal("should not be installed after Uninstall")
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profile)
|
||||
if !strings.Contains(string(data), "existing content") {
|
||||
t.Error("original content should survive install+uninstall cycle")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ReloadNote ---
|
||||
|
||||
func TestReloadNote_ContainsPath(t *testing.T) {
|
||||
note := hook.ReloadNote("/home/user/.bashrc")
|
||||
if !strings.Contains(note, "/home/user/.bashrc") {
|
||||
t.Errorf("ReloadNote should contain the profile path, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeProfile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f := filepath.Join(t.TempDir(), "profile")
|
||||
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestManagedBlockInstallAndUninstall(t *testing.T) {
|
||||
original := "export KEEP=yes\n# netssh shell-init documentation\necho 'netssh shell-init'\n"
|
||||
profile := writeProfile(t, original+hook.BlockStart+"\nexport PATH='/custom/bin':\"$PATH\"\n'/custom/bin/netssh' shell-init\n[ ! -f '/custom/cache/netssh/aliases.sh' ] || . '/custom/cache/netssh/aliases.sh'\n"+hook.BlockEnd+"\n")
|
||||
if installed, err := hook.Install(profile, "bash"); err != nil || installed {
|
||||
t.Fatalf("duplicate install: %v, %v", installed, err)
|
||||
}
|
||||
if installed, err := hook.InstallAliasesSource(profile); err != nil || installed {
|
||||
t.Fatalf("duplicate source: %v, %v", installed, err)
|
||||
}
|
||||
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
||||
t.Fatalf("uninstall: %v, %v", removed, err)
|
||||
}
|
||||
data, _ := os.ReadFile(profile)
|
||||
if string(data) != original {
|
||||
t.Fatalf("unrelated content changed: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePathRespectsZdotdir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZDOTDIR", dir)
|
||||
path, err := hook.ProfilePath("zsh")
|
||||
if err != nil || path != filepath.Join(dir, ".zshrc") {
|
||||
t.Fatalf("zsh path: %s, %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallReplacesLegacyEntriesWithBlock(t *testing.T) {
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
profile := writeProfile(t, "export KEEP=yes\nnetssh shell-init\nsource ~/.cache/netssh/aliases.sh 2>/dev/null\neval \"$(netssh alias)\"\nalias j='netssh'\necho 'netssh shell-init'\n")
|
||||
installed, err := hook.Install(profile, "zsh")
|
||||
if err != nil || !installed {
|
||||
t.Fatalf("install: %v, %v", installed, err)
|
||||
}
|
||||
data, _ := os.ReadFile(profile)
|
||||
content := string(data)
|
||||
for _, gone := range []string{"\nnetssh shell-init\nsource", "eval ", "alias j="} {
|
||||
if strings.Contains(content, gone) {
|
||||
t.Fatalf("legacy entry %q kept:\n%s", gone, content)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{"export KEEP=yes", "echo 'netssh shell-init'", hook.BlockStart, "compdef _netssh netssh", "fpath=("} {
|
||||
if !strings.Contains(content, kept) {
|
||||
t.Fatalf("missing %q:\n%s", kept, content)
|
||||
}
|
||||
}
|
||||
if strings.Count(content, hook.BlockStart) != 1 {
|
||||
t.Fatalf("duplicate blocks:\n%s", content)
|
||||
}
|
||||
if removed, err := hook.Uninstall(profile); err != nil || !removed {
|
||||
t.Fatalf("uninstall: %v, %v", removed, err)
|
||||
}
|
||||
data, _ = os.ReadFile(profile)
|
||||
if string(data) != "export KEEP=yes\necho 'netssh shell-init'\n" {
|
||||
t.Fatalf("unexpected remainder: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockPerShell(t *testing.T) {
|
||||
if b := hook.Block("fish"); strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
|
||||
t.Fatalf("fish block: %s", b)
|
||||
}
|
||||
if b := hook.Block("bash"); !strings.Contains(b, "aliases.sh") || strings.Contains(b, "compdef") {
|
||||
t.Fatalf("bash block: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZshBlockRegistersCompletionAfterCompinit(t *testing.T) {
|
||||
zsh, err := exec.LookPath("zsh")
|
||||
if err != nil {
|
||||
t.Skip("zsh unavailable")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("ZDOTDIR", home)
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache"))
|
||||
if err := os.MkdirAll(hook.ZshFuncDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(hook.ZshFuncDir(), "_netssh"), []byte(hook.ZshCompletionScript), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// compinit runs before the block, as with oh-my-zsh.
|
||||
rc := "autoload -Uz compinit && compinit -u -d \"$HOME/.zcompdump\"\nnetssh() { :; }\n" + hook.Block("zsh")
|
||||
if err := os.WriteFile(filepath.Join(home, ".zshrc"), []byte(rc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command(zsh, "-i", "-c", `print -r -- $_comps[netssh]`).CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "_netssh") {
|
||||
t.Fatalf("completion not registered: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ZshCompletionScript replaces Cobra's generated zsh completion. Cobra's script
|
||||
// spawns a subshell per candidate and passes every host through _describe,
|
||||
// which takes tens of seconds for thousands of hosts. This script splits the
|
||||
// candidates with parameter expansion only: entries with a description
|
||||
// (subcommands, flags) go through _describe, plain hostnames through compadd.
|
||||
//
|
||||
// The file works both from fpath (autoloaded as _netssh) and when sourced.
|
||||
const ZshCompletionScript = `#compdef netssh
|
||||
compdef _netssh netssh
|
||||
|
||||
# netssh zsh completion — generated by netssh, do not edit.
|
||||
_netssh() {
|
||||
local -a lines described plain
|
||||
local out directive=0 entry ret=1
|
||||
local -a nospace
|
||||
|
||||
out=$(netssh __complete "${(@)words[2,CURRENT]}" 2>/dev/null) || return 1
|
||||
lines=("${(@f)out}")
|
||||
if [[ ${lines[-1]} == :<-> ]]; then
|
||||
directive=${lines[-1]#:}
|
||||
lines[-1]=()
|
||||
fi
|
||||
# Directive 1: error.
|
||||
(( directive & 1 )) && return 1
|
||||
lines=(${lines:#_activeHelp_*})
|
||||
lines=(${lines:#})
|
||||
# Directive 2: no trailing space.
|
||||
(( directive & 2 )) && nospace=(-S '')
|
||||
|
||||
# Directive 8: the lines are file extensions to filter by.
|
||||
if (( directive & 8 )); then
|
||||
local -a globs
|
||||
for entry in $lines; do globs+=("*.${entry}"); done
|
||||
_files -g "(${(j:|:)globs})"
|
||||
return
|
||||
fi
|
||||
# Directive 16: complete directories only.
|
||||
if (( directive & 16 )); then
|
||||
_files -/
|
||||
return
|
||||
fi
|
||||
|
||||
described=(${(M)lines:#*$'\t'*})
|
||||
plain=(${lines:#*$'\t'*})
|
||||
if (( $#described )); then
|
||||
local -a items
|
||||
for entry in $described; do
|
||||
items+=("${${entry%%$'\t'*}//:/\\:}:${entry#*$'\t'}")
|
||||
done
|
||||
_describe -t commands 'netssh command' items $nospace && ret=0
|
||||
fi
|
||||
if (( $#plain )); then
|
||||
# netssh already filtered the hosts, also by the name after an instance
|
||||
# prefix: "fsn1" offers "prod/fsn1-…". 'l:|=*' lets zsh accept text
|
||||
# before the typed word instead of discarding these matches.
|
||||
local -a group=(-J hosts)
|
||||
# Directive 32: keep the order supplied by netssh.
|
||||
(( directive & 32 )) && group=(-V hosts)
|
||||
compadd $group -X '%Bhosts%b' -M 'l:|=*' $nospace -- $plain && ret=0
|
||||
fi
|
||||
# Directive 4: no file completion as a fallback.
|
||||
if (( ! $#lines && ! (directive & 4) )); then
|
||||
_files && ret=0
|
||||
fi
|
||||
return ret
|
||||
}
|
||||
|
||||
if [ "$funcstack[1]" = "_netssh" ]; then
|
||||
_netssh "$@"
|
||||
fi
|
||||
`
|
||||
|
||||
// ZshCompletionNote tells the user how zsh picks up a completion file in dir.
|
||||
func ZshCompletionNote(dir string) string {
|
||||
dump := filepath.Join(zshDir(), ".zcompdump")
|
||||
return fmt.Sprintf(`zsh loads it from fpath. The netssh block in your .zshrc adds %s
|
||||
(netssh hook install). Otherwise add this before compinit:
|
||||
fpath=(%s $fpath)
|
||||
Then refresh the completion cache: rm -f %s* && exec zsh`, dir, dir, dump)
|
||||
}
|
||||
|
||||
func zshDir() string {
|
||||
if dir := os.Getenv("ZDOTDIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return os.Getenv("HOME")
|
||||
}
|
||||
|
||||
// ZshFuncDir is the user directory netssh installs zsh completion files into.
|
||||
func ZshFuncDir() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".zfunc")
|
||||
}
|
||||
+307
-45
@@ -1,31 +1,110 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
sources map[string]*Client
|
||||
baseURL string
|
||||
token string
|
||||
tokenVersion int
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
// NewClient creates a NetBox API client. Pass tokenVersion=0 to auto-detect
|
||||
// from the token string (1 for legacy, 2 for nbt_-prefixed tokens).
|
||||
func NewClient(baseURL, token string, tokenVersion int) *Client {
|
||||
if tokenVersion == 0 {
|
||||
tokenVersion = TokenVersion(token)
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{},
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
tokenVersion: tokenVersion,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Search queries devices and VMs in parallel and merges the results.
|
||||
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||
// Use SearchOptions to restrict by kind or tag.
|
||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||
if c.sources != nil {
|
||||
return c.searchSources(ctx, query, opts, false)
|
||||
}
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
errs []error
|
||||
wg sync.WaitGroup
|
||||
started int
|
||||
)
|
||||
|
||||
if opts.Kind != "vm" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.searchDevices(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Kind != "device" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.searchVMs(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errs) == started {
|
||||
if started == 1 {
|
||||
return nil, errs[0]
|
||||
}
|
||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||
// Intended for cache refresh; use Search for interactive queries.
|
||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||
if c.sources != nil {
|
||||
return c.searchSources(ctx, query, opts, true)
|
||||
}
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
@@ -33,44 +112,54 @@ func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error)
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
if opts.Kind != "vm" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.searchDevices(ctx, query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.searchVMs(ctx, query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
if opts.Kind != "device" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errs) == 2 {
|
||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
||||
// that need more than just the primary IP.
|
||||
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
||||
if c.sources != nil {
|
||||
source, host, err := c.sourceForHost(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return source.GetIPs(ctx, host)
|
||||
}
|
||||
var apiURL string
|
||||
switch entry.Kind {
|
||||
case "device":
|
||||
@@ -95,6 +184,9 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error)
|
||||
|
||||
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
||||
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
||||
if c.sources != nil {
|
||||
return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost")
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
||||
var resp netboxIPListResponse
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
@@ -107,8 +199,11 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -120,8 +215,11 @@ func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry,
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxVM]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -133,28 +231,105 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
const pageSize = 50
|
||||
var all []HostEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range resp.Results {
|
||||
all = append(all, deviceToEntry(d))
|
||||
}
|
||||
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
const pageSize = 50
|
||||
var all []HostEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxVM]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range resp.Results {
|
||||
all = append(all, vmToEntry(v))
|
||||
}
|
||||
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
|
||||
func TokenVersion(token string) int {
|
||||
if strings.HasPrefix(token, "nbt_") {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
_, err := c.request(ctx, http.MethodGet, apiURL, nil, "", out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method, apiURL string, body io.Reader, etag string, out any) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, apiURL, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
scheme := "Token "
|
||||
if c.tokenVersion == 2 {
|
||||
scheme = "Bearer "
|
||||
}
|
||||
req.Header.Set("Authorization", scheme+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-Match", etag)
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
return "", fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
hint := "check token permissions in NetBox"
|
||||
if c.tokenVersion == 1 {
|
||||
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
|
||||
}
|
||||
return "", fmt.Errorf("%s: %s", apiURL, hint)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
return "", fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
return "", fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return nil
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
func deviceToEntry(d netboxDevice) HostEntry {
|
||||
@@ -192,3 +367,90 @@ func stripPrefix(cidr string) string {
|
||||
}
|
||||
return cidr
|
||||
}
|
||||
|
||||
// ErrHostNotFound distinguishes a missing exact match from API failures or ambiguity.
|
||||
var ErrHostNotFound = errors.New("host not found")
|
||||
|
||||
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
|
||||
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
|
||||
if kind != "" && kind != "device" && kind != "vm" {
|
||||
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||
_, hostname := SplitTarget(name)
|
||||
if err != nil {
|
||||
return HostEntry{}, err
|
||||
}
|
||||
var matches []HostEntry
|
||||
for _, e := range entries {
|
||||
if strings.EqualFold(e.Name, hostname) {
|
||||
matches = append(matches, e)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
if c.sources != nil {
|
||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use instance/hostname and --kind to select a unique host", name, len(matches))
|
||||
}
|
||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (c *Client) hostURL(host HostEntry) (string, error) {
|
||||
var path string
|
||||
switch host.Kind {
|
||||
case "device":
|
||||
path = "dcim/devices"
|
||||
case "vm":
|
||||
path = "virtualization/virtual-machines"
|
||||
default:
|
||||
return "", fmt.Errorf("invalid host kind %q", host.Kind)
|
||||
}
|
||||
if host.ID <= 0 {
|
||||
return "", fmt.Errorf("invalid host ID")
|
||||
}
|
||||
return fmt.Sprintf("%s/api/%s/%d/", c.baseURL, path, host.ID), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||
if c.sources != nil {
|
||||
source, host, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return source.GetComments(ctx, host)
|
||||
}
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var out struct {
|
||||
Comments string `json:"comments"`
|
||||
}
|
||||
etag, err := c.request(ctx, http.MethodGet, u, nil, "", &out)
|
||||
return out.Comments, etag, err
|
||||
}
|
||||
|
||||
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||
if c.sources != nil {
|
||||
source, host, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return source.SetComments(ctx, host, comments, etag)
|
||||
}
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(map[string]string{"comments": comments})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
_, err = c.request(ctx, http.MethodPatch, u, bytes.NewReader(data), etag, &out)
|
||||
return err
|
||||
}
|
||||
+256
-16
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -58,8 +59,8 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, err := c.Search(context.Background(), "")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -87,8 +88,8 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, _ := c.Search(context.Background(), "")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, _ := c.Search(context.Background(), "", SearchOptions{})
|
||||
|
||||
for _, r := range results {
|
||||
switch r.Name {
|
||||
@@ -113,8 +114,8 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, _ := c.Search(context.Background(), "host")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, _ := c.Search(context.Background(), "host", SearchOptions{})
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one result")
|
||||
}
|
||||
@@ -138,8 +139,8 @@ func TestSearch_TagsAreMapped(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, _ := c.Search(context.Background(), "")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, _ := c.Search(context.Background(), "", SearchOptions{})
|
||||
if len(results[0].Tags) != 2 {
|
||||
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
|
||||
}
|
||||
@@ -159,8 +160,8 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, err := c.Search(context.Background(), "")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should not return error, got: %v", err)
|
||||
}
|
||||
@@ -177,8 +178,8 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
_, err := c.Search(context.Background(), "")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
_, err := c.Search(context.Background(), "", SearchOptions{})
|
||||
if err == nil {
|
||||
t.Error("both endpoints failing should return an error")
|
||||
}
|
||||
@@ -190,7 +191,7 @@ func TestGetIPs_Device(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPs: %v", err)
|
||||
@@ -209,7 +210,7 @@ func TestGetIPs_VM(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPs: %v", err)
|
||||
@@ -220,7 +221,7 @@ func TestGetIPs_VM(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetIPs_UnknownKind(t *testing.T) {
|
||||
c := NewClient("http://localhost", "token")
|
||||
c := NewClient("http://localhost", "token", 0)
|
||||
_, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"})
|
||||
if err == nil {
|
||||
t.Error("unknown kind should return an error")
|
||||
@@ -233,7 +234,7 @@ func TestGetIPsWithFilter(t *testing.T) {
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPsWithFilter: %v", err)
|
||||
@@ -243,6 +244,185 @@ func TestGetIPsWithFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
token string
|
||||
want int
|
||||
}{
|
||||
{"nbt_abc123", 2},
|
||||
{"nbt_", 2},
|
||||
{"abc123def456", 1},
|
||||
{"", 1},
|
||||
{"Token abc", 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := TokenVersion(tt.token); got != tt.want {
|
||||
t.Errorf("TokenVersion(%q) = %d, want %d", tt.token, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_AutoDetectsVersion(t *testing.T) {
|
||||
c := NewClient("http://localhost", "nbt_secret", 0)
|
||||
if c.tokenVersion != 2 {
|
||||
t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion)
|
||||
}
|
||||
|
||||
c2 := NewClient("http://localhost", "legacytoken", 0)
|
||||
if c2.tokenVersion != 1 {
|
||||
t.Errorf("tokenVersion: got %d, want 1", c2.tokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_RespectsExplicitVersion(t *testing.T) {
|
||||
// Explicit version overrides auto-detection.
|
||||
c := NewClient("http://localhost", "legacytoken", 2)
|
||||
if c.tokenVersion != 2 {
|
||||
t.Errorf("tokenVersion: got %d, want 2", c.tokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func Test403_V1Token_HintsUpgrade(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "legacytoken", 1)
|
||||
_, err := c.Search(context.Background(), "host", SearchOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 403")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "v1 token") {
|
||||
t.Errorf("expected v1 hint in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Test403_V2Token_NoV1Hint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "nbt_secret", 2)
|
||||
_, err := c.Search(context.Background(), "host", SearchOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 403")
|
||||
}
|
||||
if strings.Contains(err.Error(), "v1 token") {
|
||||
t.Errorf("v1 hint should not appear for v2 token, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "check token permissions") {
|
||||
t.Errorf("expected permissions hint in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||
for _, token := range []string{"nbt_mytoken", "legacytoken"} {
|
||||
t.Run(token, func(t *testing.T) {
|
||||
want := "Token " + token
|
||||
if strings.HasPrefix(token, "nbt_") {
|
||||
want = "Bearer " + token
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != want {
|
||||
t.Errorf("Authorization: got %q, want %q", got, want)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
b, _ := json.Marshal(deviceListResponse())
|
||||
w.Write(b)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, token, 0)
|
||||
if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAll_PaginatesResults(t *testing.T) {
|
||||
// Simulate a NetBox endpoint with 3 total devices split across 2 pages.
|
||||
// count=3 throughout; first page has 2 results, second has 1.
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
offset := r.URL.Query().Get("offset")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
switch offset {
|
||||
case "", "0":
|
||||
resp = netboxListResponse[netboxDevice]{
|
||||
Count: 3,
|
||||
Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}},
|
||||
}
|
||||
default:
|
||||
resp = netboxListResponse[netboxDevice]{
|
||||
Count: 3,
|
||||
Results: []netboxDevice{{ID: 3, Name: "d-03"}},
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
w.Write(b)
|
||||
})
|
||||
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
b, _ := json.Marshal(vmListResponse())
|
||||
w.Write(b)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.SearchAll(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAll: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Errorf("SearchAll: got %d results, want 3", len(results))
|
||||
}
|
||||
if callCount < 2 {
|
||||
t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_KindFilterDeviceOnly(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(
|
||||
netboxDevice{ID: 1, Name: "sw-01"},
|
||||
),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Name != "sw-01" {
|
||||
t.Errorf("expected 1 device result, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_KindFilterVMOnly(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(
|
||||
netboxVM{ID: 1, Name: "vm-01"},
|
||||
),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Name != "vm-01" {
|
||||
t.Errorf("expected 1 vm result, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
@@ -259,3 +439,63 @@ func TestStripPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentsPreserveFieldAndSendConflictHeader(t *testing.T) {
|
||||
for _, kind := range []string{"device", "vm"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer nbt_test" {
|
||||
t.Error("wrong authentication")
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
if r.Header.Get("If-Match") != `W/"123"` {
|
||||
t.Error("missing conflict protection")
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if len(payload) != 1 || payload["comments"] != "new\nnotes" {
|
||||
t.Errorf("unexpected patch: %v", payload)
|
||||
}
|
||||
}
|
||||
w.Header().Set("ETag", `W/"123"`)
|
||||
w.Write([]byte(`{"comments":"existing notes"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "nbt_test", 0)
|
||||
host := HostEntry{ID: 1, Kind: kind}
|
||||
text, etag, err := c.GetComments(context.Background(), host)
|
||||
if err != nil || text != "existing notes" {
|
||||
t.Fatalf("read: %q, %v", text, err)
|
||||
}
|
||||
if err := c.SetComments(context.Background(), host, "new\nnotes", etag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullRefreshRejectsPartialFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "virtualization") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"count":0,"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
if _, err := c.SearchAll(context.Background(), "", SearchOptions{}); err == nil {
|
||||
t.Fatal("partial refresh reported as successful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentConflictIsReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPreconditionFailed) }))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
if err := c.SetComments(context.Background(), HostEntry{ID: 1, Kind: "device"}, "draft", "old"); err == nil {
|
||||
t.Fatal("conflict ignored")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
package netbox
|
||||
|
||||
import "strings"
|
||||
|
||||
// SearchOptions filters a Search or SearchAll query.
|
||||
// Zero value means no filtering (return all kinds, no tag filter).
|
||||
type SearchOptions struct {
|
||||
Tag string // filter by tag slug; empty = no filter
|
||||
Kind string // "device" | "vm" | "" (both)
|
||||
}
|
||||
|
||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||
type HostEntry struct {
|
||||
Source string // named NetBox instance; empty for the legacy single-instance config
|
||||
ID int
|
||||
Name string
|
||||
Kind string // "device" | "vm"
|
||||
@@ -51,3 +61,19 @@ type netboxIPListResponse struct {
|
||||
} `json:"assigned_object"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// Target returns an unambiguous selector for cache, completion, and SSH.
|
||||
func (h HostEntry) Target() string {
|
||||
if h.Source == "" {
|
||||
return h.Name
|
||||
}
|
||||
return h.Source + "/" + h.Name
|
||||
}
|
||||
|
||||
// SplitTarget separates an optional instance prefix from a hostname.
|
||||
func SplitTarget(target string) (source, name string) {
|
||||
if source, name, ok := strings.Cut(target, "/"); ok {
|
||||
return strings.ToLower(source), name
|
||||
}
|
||||
return "", target
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Source binds a name to its own API client and credentials.
|
||||
type Source struct {
|
||||
Name string
|
||||
URL string
|
||||
Token string
|
||||
TokenVersion int
|
||||
}
|
||||
|
||||
func NewMultiClient(sources []Source) *Client {
|
||||
c := &Client{sources: make(map[string]*Client)}
|
||||
for _, source := range sources {
|
||||
c.sources[strings.ToLower(source.Name)] = NewClient(source.URL, source.Token, source.TokenVersion)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) searchSources(ctx context.Context, query string, opts SearchOptions, all bool) ([]HostEntry, error) {
|
||||
requested, query := SplitTarget(query)
|
||||
if requested != "" {
|
||||
if _, ok := c.sources[requested]; !ok {
|
||||
return nil, fmt.Errorf("unknown NetBox instance %q", requested)
|
||||
}
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var entries []HostEntry
|
||||
var errs []error
|
||||
started := 0
|
||||
for name, client := range c.sources {
|
||||
if requested != "" && name != requested {
|
||||
continue
|
||||
}
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func(name string, client *Client) {
|
||||
defer wg.Done()
|
||||
var found []HostEntry
|
||||
var err error
|
||||
if all {
|
||||
found, err = client.SearchAll(ctx, query, opts)
|
||||
} else {
|
||||
found, err = client.Search(ctx, query, opts)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("NetBox %s: %w", name, err))
|
||||
return
|
||||
}
|
||||
for i := range found {
|
||||
found[i].Source = name
|
||||
}
|
||||
entries = append(entries, found...)
|
||||
}(name, client)
|
||||
}
|
||||
wg.Wait()
|
||||
// Full lookups must see every source to rule out ambiguous hostnames.
|
||||
if len(errs) > 0 && (all || len(errs) == started) {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||
return entries, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Client) sourceForHost(host HostEntry) (*Client, HostEntry, error) {
|
||||
prefix, name := SplitTarget(host.Name)
|
||||
if prefix != "" {
|
||||
if host.Source != "" && !strings.EqualFold(prefix, host.Source) {
|
||||
return nil, host, fmt.Errorf("conflicting NetBox instance for %q", host.Name)
|
||||
}
|
||||
host.Source, host.Name = prefix, name
|
||||
}
|
||||
if c.sources == nil {
|
||||
return c, host, nil
|
||||
}
|
||||
source, ok := c.sources[strings.ToLower(host.Source)]
|
||||
if !ok {
|
||||
return nil, host, fmt.Errorf("specify a configured NetBox instance as instance/hostname (got %q)", host.Target())
|
||||
}
|
||||
host.Source = strings.ToLower(host.Source)
|
||||
return source, host, nil
|
||||
}
|
||||
|
||||
// HostScope identifies a local note by its original NetBox URL and canonical
|
||||
// hostname, so renaming an instance does not move or mix its notes.
|
||||
func (c *Client) HostScope(host HostEntry) (HostEntry, string, error) {
|
||||
source, host, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return host, "", err
|
||||
}
|
||||
return host, source.baseURL, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetIPsWithFilterForHost(ctx context.Context, host HostEntry, filters string) ([]string, error) {
|
||||
source, _, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return source.GetIPsWithFilter(ctx, filters)
|
||||
}
|
||||
|
||||
func (c *Client) MultipleSources() bool { return c.sources != nil }
|
||||
@@ -0,0 +1,192 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Token "+token {
|
||||
t.Errorf("credentials crossed sources: %q, want %q", got, token)
|
||||
http.Error(w, "bad token", 401)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/dcim/devices/":
|
||||
json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}}))
|
||||
case "/api/virtualization/virtual-machines/":
|
||||
json.NewEncoder(w).Encode(vmListResponse())
|
||||
case "/api/ipam/ip-addresses/":
|
||||
if r.URL.Query().Get("device_id") != "1" {
|
||||
t.Errorf("wrong host ID: %s", r.URL.RawQuery)
|
||||
}
|
||||
json.NewEncoder(w).Encode(ipListResponse(ip + "/24"))
|
||||
case "/api/dcim/devices/1/":
|
||||
if r.Method == http.MethodPatch {
|
||||
var data map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if data["comments"] != comment+" updated" {
|
||||
t.Errorf("comment write crossed sources: %v", data)
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"comments": comment})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestMultiSourceSearchAndRouting(t *testing.T) {
|
||||
office := sourceTestServer(t, "office-token", "10.0.0.1", "office note")
|
||||
lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note")
|
||||
c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}})
|
||||
ctx := context.Background()
|
||||
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||
if err != nil || len(entries) != 2 {
|
||||
t.Fatalf("combined search: %+v, %v", entries, err)
|
||||
}
|
||||
if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" {
|
||||
t.Fatalf("source lost: %+v", entries)
|
||||
}
|
||||
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||
t.Fatal("ambiguous unqualified hostname accepted")
|
||||
}
|
||||
for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, err := c.FindHost(ctx, tc.name+"/web", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host.Source != tc.name || host.Name != "web" || host.ID != 1 {
|
||||
t.Fatalf("wrong host identity: %+v", host)
|
||||
}
|
||||
ips, err := c.GetIPs(ctx, host)
|
||||
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||
t.Fatalf("wrong IP source: %v, %v", ips, err)
|
||||
}
|
||||
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0")
|
||||
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||
t.Fatalf("wrong interface source: %v, %v", ips, err)
|
||||
}
|
||||
text, _, err := c.GetComments(ctx, host)
|
||||
if err != nil || text != tc.note {
|
||||
t.Fatalf("wrong comment source: %q, %v", text, err)
|
||||
}
|
||||
// TUI cache items carry a qualified name rather than a separate source.
|
||||
qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"}
|
||||
if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil {
|
||||
t.Fatal("unknown source accepted")
|
||||
}
|
||||
if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil {
|
||||
t.Fatal("unqualified note namespace accepted")
|
||||
}
|
||||
if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil {
|
||||
t.Fatal("IP filter without instance accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSourceSearchRunsConcurrently(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
makeServer := func() *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wg.Done()
|
||||
both := make(chan struct{})
|
||||
go func() { wg.Wait(); close(both) }()
|
||||
select {
|
||||
case <-both:
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
a, b := makeServer(), makeServer()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}})
|
||||
if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil {
|
||||
t.Fatalf("sources did not run concurrently: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSourcePartialFailure(t *testing.T) {
|
||||
good := sourceTestServer(t, "good-token", "10.0.0.1", "note")
|
||||
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) }))
|
||||
defer failed.Close()
|
||||
c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}})
|
||||
ctx := context.Background()
|
||||
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||
if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") {
|
||||
t.Fatalf("partial search lost results or warning: %+v, %v", entries, err)
|
||||
}
|
||||
if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil {
|
||||
t.Fatal("incomplete full search succeeded")
|
||||
}
|
||||
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||
t.Fatal("incomplete search claimed unique host")
|
||||
}
|
||||
if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" {
|
||||
t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) {
|
||||
office := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}),
|
||||
})
|
||||
defer office.Close()
|
||||
lab := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}),
|
||||
})
|
||||
defer lab.Close()
|
||||
client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
entries, err := client.SearchAll(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("independent inventories missing: %+v", entries)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if want[entry.Target()] != entry.Kind {
|
||||
t.Fatalf("inventory crossed source or kind: %+v", entry)
|
||||
}
|
||||
}
|
||||
vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||
if err != nil || len(vms) != 2 {
|
||||
t.Fatalf("VM filter across instances: %+v, %v", vms, err)
|
||||
}
|
||||
for _, host := range vms {
|
||||
if host.Kind != "vm" {
|
||||
t.Fatalf("device leaked into VM filter: %+v", host)
|
||||
}
|
||||
}
|
||||
host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm")
|
||||
if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" {
|
||||
t.Fatalf("wrong VM selected: %+v, %v", host, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package notes stores private server notes independently of the host cache.
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
Text, ETag string
|
||||
Host netbox.HostEntry
|
||||
}
|
||||
type Service struct {
|
||||
Dir, Namespace string
|
||||
Client *netbox.Client
|
||||
}
|
||||
|
||||
func New(namespace string, client *netbox.Client) *Service {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return &Service{Dir: filepath.Join(dir, "netssh", "notes"), Namespace: strings.TrimRight(namespace, "/"), Client: client}
|
||||
}
|
||||
|
||||
func (s *Service) path(host netbox.HostEntry) string {
|
||||
key := fmt.Sprintf("%s\x00%s\x00%s", s.Namespace, host.Kind, strings.ToLower(host.Name))
|
||||
return filepath.Join(s.Dir, fmt.Sprintf("%x.txt", sha256.Sum256([]byte(key))))
|
||||
}
|
||||
|
||||
func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool) (Note, error) {
|
||||
note := Note{Host: host}
|
||||
if remote {
|
||||
if s.Client == nil {
|
||||
return note, fmt.Errorf("NetBox is not configured")
|
||||
}
|
||||
if host.ID == 0 {
|
||||
resolved, err := s.Client.FindHost(ctx, host.Name, host.Kind)
|
||||
if err != nil {
|
||||
return note, err
|
||||
}
|
||||
note.Host = resolved
|
||||
}
|
||||
var err error
|
||||
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||
return note, err
|
||||
}
|
||||
path, err := s.localPath(host)
|
||||
if err != nil {
|
||||
return note, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return note, nil
|
||||
}
|
||||
note.Text = string(data)
|
||||
return note, err
|
||||
}
|
||||
|
||||
func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
||||
if remote {
|
||||
if s.Client == nil {
|
||||
return fmt.Errorf("NetBox is not configured")
|
||||
}
|
||||
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||
}
|
||||
path, err := s.localPath(note.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.WriteFile(path, []byte(note.Text), 0o600)
|
||||
}
|
||||
|
||||
func (s *Service) localPath(host netbox.HostEntry) (string, error) {
|
||||
if s.Client == nil {
|
||||
return s.path(host), nil
|
||||
}
|
||||
host, namespace, err := s.Client.HostScope(host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scoped := *s
|
||||
scoped.Namespace = namespace
|
||||
return scoped.path(host), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalNotesPersistAndStayIsolated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"}
|
||||
host := netbox.HostEntry{Name: "web01", Kind: "device"}
|
||||
n := Note{Host: host, Text: "Maintenance\nReady 🔧"}
|
||||
if err := s.Write(ctx, n, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded := &Service{Dir: s.Dir, Namespace: s.Namespace}
|
||||
got, err := reloaded.Read(ctx, host, false)
|
||||
if err != nil || got.Text != n.Text {
|
||||
t.Fatalf("round trip: %+v, %v", got, err)
|
||||
}
|
||||
info, err := os.Stat(s.path(host))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("permissions: %v", info.Mode())
|
||||
}
|
||||
host.Kind = "vm"
|
||||
got, err = s.Read(ctx, host, false)
|
||||
if err != nil || got.Text != "" {
|
||||
t.Fatalf("kind isolation: %+v, %v", got, err)
|
||||
}
|
||||
host.Kind = "device"
|
||||
reloaded.Namespace = "https://other.example"
|
||||
got, err = reloaded.Read(ctx, host, false)
|
||||
if err != nil || got.Text != "" {
|
||||
t.Fatalf("instance isolation: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
||||
s := &Service{Dir: t.TempDir()}
|
||||
host := netbox.HostEntry{Name: "web01", Kind: "vm"}
|
||||
if err := os.Mkdir(s.path(host), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Read(context.Background(), host, false); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalNotesSeparateInstancesAndSurviveRename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: "https://office.example"}, {Name: "lab", URL: "https://lab.example"}})
|
||||
service := &Service{Dir: t.TempDir(), Client: client}
|
||||
for _, source := range []string{"office", "lab"} {
|
||||
note := Note{Host: netbox.HostEntry{Name: source + "/web", Kind: "device"}, Text: source + " private note"}
|
||||
if err := service.Write(ctx, note, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, source := range []string{"office", "lab"} {
|
||||
note, err := service.Read(ctx, netbox.HostEntry{Name: source + "/web", Kind: "device"}, false)
|
||||
if err != nil || note.Text != source+" private note" {
|
||||
t.Fatalf("notes crossed instances: %+v, %v", note, err)
|
||||
}
|
||||
}
|
||||
service.Client = netbox.NewMultiClient([]netbox.Source{{Name: "renamed", URL: "https://office.example"}})
|
||||
note, err := service.Read(ctx, netbox.HostEntry{Name: "renamed/web", Kind: "device"}, false)
|
||||
if err != nil || note.Text != "office private note" {
|
||||
t.Fatalf("renaming source lost note: %+v, %v", note, err)
|
||||
}
|
||||
// This is also the namespace used before migrating a legacy single NetBox.
|
||||
legacy := &Service{Dir: service.Dir, Namespace: "https://office.example"}
|
||||
note, err = legacy.Read(ctx, netbox.HostEntry{Name: "web", Kind: "device"}, false)
|
||||
if err != nil || note.Text != "office private note" {
|
||||
t.Fatalf("legacy note namespace changed: %+v, %v", note, err)
|
||||
}
|
||||
if err := service.Write(ctx, Note{Host: netbox.HostEntry{Name: "web", Kind: "device"}}, false); err == nil {
|
||||
t.Fatal("unqualified notes accepted for multiple sources")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func TestVMResolversStayWithinTheirNetBoxInstance(t *testing.T) {
|
||||
sources := make([]netbox.Source, 0, 2)
|
||||
for i, name := range []string{"office", "lab"} {
|
||||
ip := fmt.Sprintf("10.0.0.%d", i+1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("virtual_machine_id") != "7" {
|
||||
t.Errorf("wrong VM filter: %s", r.URL.RawQuery)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Token "+name {
|
||||
t.Error("VM lookup used another instance's credentials")
|
||||
}
|
||||
fmt.Fprintf(w, `{"count":1,"results":[{"address":%q}]}`, ip+"/24")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
sources = append(sources, netbox.Source{Name: name, URL: srv.URL, Token: name})
|
||||
}
|
||||
client := netbox.NewMultiClient(sources)
|
||||
management, err := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, source := range sources {
|
||||
host := &netbox.HostEntry{Source: source.Name, Name: "vm", Kind: "vm", ID: 7}
|
||||
for _, strategy := range []Strategy{management, &InterfaceNameStrategy{name: "mgmt0"}} {
|
||||
ip, err := strategy.Resolve(context.Background(), host, client)
|
||||
if want := fmt.Sprintf("10.0.0.%d", i+1); err != nil || ip != want {
|
||||
t.Fatalf("%s VM resolved through wrong instance: %q, %v", strategy.Name(), ip, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE
|
||||
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
||||
}
|
||||
|
||||
ips, err := client.GetIPsWithFilter(ctx, filterParam)
|
||||
ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestManagementSubnetStrategy_MatchesSubnet(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
client := netbox.NewClient(srv.URL, "token", 0)
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != nil {
|
||||
@@ -52,7 +52,7 @@ func TestManagementSubnetStrategy_NoMatch(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
client := netbox.NewClient(srv.URL, "token", 0)
|
||||
|
||||
_, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != ErrNoIP {
|
||||
@@ -65,7 +65,7 @@ func TestManagementSubnetStrategy_FirstMatchWins(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
client := netbox.NewClient(srv.URL, "token", 0)
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != nil {
|
||||
@@ -81,7 +81,7 @@ func TestManagementSubnetStrategy_VMKind(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"172.16.0.0/12"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
client := netbox.NewClient(srv.URL, "token", 0)
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 2, Kind: "vm"}, client)
|
||||
if err != nil {
|
||||
@@ -97,7 +97,7 @@ func TestManagementSubnetStrategy_IPv6Subnet(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s, _ := NewManagementSubnetStrategy([]string{"fd00::/8"})
|
||||
client := netbox.NewClient(srv.URL, "token")
|
||||
client := netbox.NewClient(srv.URL, "token", 0)
|
||||
|
||||
ip, err := s.Resolve(context.Background(), &netbox.HostEntry{ID: 1, Kind: "device"}, client)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// configureInstances edits a copy; nothing is written until the whole wizard succeeds.
|
||||
func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
||||
sources := append([]config.NetBoxConfig(nil), cfg.NetBoxes...)
|
||||
if len(sources) == 0 && cfg.NetBox.URL != "" {
|
||||
source := cfg.NetBox
|
||||
source.Name = "default"
|
||||
sources = append(sources, source)
|
||||
}
|
||||
for {
|
||||
if len(sources) == 0 {
|
||||
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, source)
|
||||
}
|
||||
action := "done"
|
||||
var options []huh.Option[string]
|
||||
for _, source := range sources {
|
||||
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
|
||||
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
|
||||
}
|
||||
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
|
||||
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("NetBox instances").Description("All configured instances are searched together.").Options(options...).Value(&action))).Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action == "done" {
|
||||
return sources, nil
|
||||
}
|
||||
if action == "add" {
|
||||
source, err := editInstance(config.NetBoxConfig{}, sources, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, source)
|
||||
continue
|
||||
}
|
||||
verb, name, _ := strings.Cut(action, ":")
|
||||
for i, source := range sources {
|
||||
if source.Name != name {
|
||||
continue
|
||||
}
|
||||
if verb == "edit" {
|
||||
edited, err := editInstance(source, sources, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources[i] = edited
|
||||
} else {
|
||||
remove := false
|
||||
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remove {
|
||||
sources = append(sources[:i], sources[i+1:]...)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func editInstance(source config.NetBoxConfig, sources []config.NetBoxConfig, original string) (config.NetBoxConfig, error) {
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().Title("Instance name").Description("Used in selectors such as office/web01.").Value(&source.Name).Validate(func(name string) error {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
candidate := config.Config{NetBoxes: []config.NetBoxConfig{{Name: name, URL: "https://example.invalid"}}}
|
||||
if err := candidate.ValidateNetBoxes(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, existing := range sources {
|
||||
if strings.EqualFold(existing.Name, name) && !strings.EqualFold(existing.Name, original) {
|
||||
return fmt.Errorf("instance name %q is already used", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("NetBox URL").Placeholder("https://netbox.example.com").Value(&source.URL).Validate(func(value string) error {
|
||||
return (&config.Config{NetBoxes: []config.NetBoxConfig{{Name: "instance", URL: strings.TrimSpace(value)}}}).ValidateNetBoxes()
|
||||
}),
|
||||
huh.NewInput().Title("NetBox API token").EchoMode(huh.EchoModePassword).Value(&source.Token).Validate(func(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("NetBox connection")).Run()
|
||||
if err != nil {
|
||||
return source, err
|
||||
}
|
||||
source.Name = strings.ToLower(strings.TrimSpace(source.Name))
|
||||
source.URL = strings.TrimRight(strings.TrimSpace(source.URL), "/")
|
||||
source.Token = strings.TrimSpace(source.Token)
|
||||
source.TokenVersion = netbox.TokenVersion(source.Token)
|
||||
if source.TokenVersion == 1 {
|
||||
fmt.Fprintf(os.Stderr, "Note: NetBox %s uses a legacy v1 token. A v2 token starts with nbt_.\n", source.Name)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
+236
-48
@@ -5,74 +5,79 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"charm.land/huh/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
||||
// It saves the result to the config file on success.
|
||||
func RunWizard(cfg *config.Config) error {
|
||||
url := cfg.NetBox.URL
|
||||
token := cfg.NetBox.Token
|
||||
sources, err := configureInstances(cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defaultUser := cfg.SSH.DefaultUser
|
||||
strategies := cfg.Resolver.Strategies
|
||||
defaultPort := ""
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
defaultPort = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||
}
|
||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
||||
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||
interfaceName := cfg.Resolver.InterfaceName
|
||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||
shortcutDomains := strings.Join(cfg.Shortcuts.Domains, ", ")
|
||||
stripHyphens := cfg.Shortcuts.StripHyphens
|
||||
commandAlias := cfg.Shell.Alias
|
||||
|
||||
if len(strategies) == 0 {
|
||||
strategies = []string{"primary_ip"}
|
||||
if strategiesRaw == "" {
|
||||
strategiesRaw = "primary_ip"
|
||||
}
|
||||
if cacheTTL == "0" {
|
||||
cacheTTL = "3600"
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("NetBox URL").
|
||||
Description("e.g. https://netbox.example.com").
|
||||
Placeholder("https://").
|
||||
Value(&url).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("NetBox API token").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&token).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("NetBox connection"),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Default SSH user").
|
||||
Description("Leave empty to use your system user ($USER).").
|
||||
Value(&defaultUser),
|
||||
huh.NewInput().
|
||||
Title("Default SSH port").
|
||||
Description("Leave empty to use the standard port (22).").
|
||||
Placeholder("22").
|
||||
Value(&defaultPort).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return errors.New("must be a port number between 1 and 65535")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("SSH defaults"),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
huh.NewInput().
|
||||
Title("Resolver strategies").
|
||||
Description("Order matters: first match wins.").
|
||||
Options(
|
||||
huh.NewOption("primary_ip — NetBox primary IPv4/IPv6", "primary_ip"),
|
||||
huh.NewOption("management_subnet — first IP inside a subnet", "management_subnet"),
|
||||
huh.NewOption("interface_name — IP on a named interface", "interface_name"),
|
||||
).
|
||||
Value(&strategies),
|
||||
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
||||
Placeholder("primary_ip, management_subnet").
|
||||
Value(&strategiesRaw).
|
||||
Validate(validateStrategies),
|
||||
huh.NewInput().
|
||||
Title("Management subnets").
|
||||
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
|
||||
@@ -92,6 +97,24 @@ func RunWizard(cfg *config.Config) error {
|
||||
return nil
|
||||
}),
|
||||
).Title("Resolver & cache"),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Domain suffixes").
|
||||
Description("Comma-separated suffixes stripped for shortcuts, e.g. .example.com, .example.de\nAllows typing 'web01' instead of 'web01.example.com'.").
|
||||
Placeholder(".example.com").
|
||||
Value(&shortcutDomains),
|
||||
huh.NewConfirm().
|
||||
Title("Strip hyphens").
|
||||
Description("When enabled, fsn1-web01.example.com can be accessed as fsn1web01.\nOnly works for hosts already in the cache.").
|
||||
Value(&stripHyphens),
|
||||
huh.NewInput().
|
||||
Title("Command alias").
|
||||
Description("Optional alias for netssh itself, e.g. j. Leave empty to disable.").
|
||||
Placeholder("j").
|
||||
Value(&commandAlias).
|
||||
Validate(func(s string) error { return config.ValidateCommandAlias(strings.TrimSpace(s)) }),
|
||||
).Title("Shortcuts"),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
@@ -102,8 +125,15 @@ func RunWizard(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
strategies := parseStrategies(strategiesRaw)
|
||||
|
||||
ttl, _ := strconv.Atoi(cacheTTL)
|
||||
|
||||
port := 0
|
||||
if p, err := strconv.Atoi(strings.TrimSpace(defaultPort)); err == nil {
|
||||
port = p
|
||||
}
|
||||
|
||||
var subnetList []string
|
||||
for _, s := range strings.Split(subnets, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
@@ -111,13 +141,22 @@ func RunWizard(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
var domainList []string
|
||||
for _, s := range strings.Split(shortcutDomains, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
if !strings.HasPrefix(s, ".") {
|
||||
s = "." + s
|
||||
}
|
||||
domainList = append(domainList, s)
|
||||
}
|
||||
}
|
||||
|
||||
out := config.Config{
|
||||
NetBox: config.NetBoxConfig{
|
||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||
Token: strings.TrimSpace(token),
|
||||
},
|
||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||
NetBoxes: sources,
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: strings.TrimSpace(defaultUser),
|
||||
DefaultPort: port,
|
||||
},
|
||||
Resolver: config.ResolverConfig{
|
||||
Strategies: strategies,
|
||||
@@ -125,23 +164,102 @@ func RunWizard(cfg *config.Config) error {
|
||||
InterfaceName: strings.TrimSpace(interfaceName),
|
||||
},
|
||||
Cache: config.CacheConfig{
|
||||
TTL: ttl,
|
||||
TTL: ttl,
|
||||
Path: cfg.Cache.Path,
|
||||
},
|
||||
Shortcuts: config.ShortcutsConfig{
|
||||
Domains: domainList,
|
||||
StripHyphens: stripHyphens,
|
||||
},
|
||||
}
|
||||
|
||||
return save(out)
|
||||
if err := save(out); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return fmt.Errorf("updating aliases: %w", err)
|
||||
}
|
||||
if err := hook.WriteAliasFiles(hook.BuildAliasEntries(c.All(), *loaded)); err != nil {
|
||||
return err
|
||||
}
|
||||
if loaded.Shell.Alias != "" {
|
||||
shell := filepath.Base(os.Getenv("SHELL"))
|
||||
profile, err := hook.ProfilePath(shell)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Run 'netssh hook install --shell bash|zsh|fish' to activate your alias.\n")
|
||||
return nil
|
||||
}
|
||||
if _, err := hook.Install(profile, shell); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, hook.ReloadNote(profile))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var knownStrategies = map[string]bool{
|
||||
"primary_ip": true,
|
||||
"management_subnet": true,
|
||||
"interface_name": true,
|
||||
}
|
||||
|
||||
func validateStrategies(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("at least one strategy is required")
|
||||
}
|
||||
for _, name := range parseStrategies(s) {
|
||||
if !knownStrategies[name] {
|
||||
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStrategies(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if name := strings.TrimSpace(part); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func save(cfg config.Config) error {
|
||||
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ValidateNetBoxes(); 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)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("netbox:\n")
|
||||
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
|
||||
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
|
||||
if len(cfg.NetBoxes) > 0 {
|
||||
b.WriteString("netboxes:\n")
|
||||
for _, source := range cfg.NetBoxes {
|
||||
fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion)
|
||||
if !sshOptionsEmpty(source.SSH) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", source.SSH)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", cfg.NetBox.SSH)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nresolver:\n")
|
||||
b.WriteString(" strategies:\n")
|
||||
@@ -160,16 +278,86 @@ func save(cfg config.Config) error {
|
||||
|
||||
b.WriteString("\ncache:\n")
|
||||
fmt.Fprintf(&b, " ttl: %d\n", cfg.Cache.TTL)
|
||||
if cfg.Cache.Path != "" {
|
||||
fmt.Fprintf(&b, " path: %q\n", cfg.Cache.Path)
|
||||
}
|
||||
|
||||
b.WriteString("\nssh:\n")
|
||||
if cfg.SSH.DefaultUser != "" {
|
||||
fmt.Fprintf(&b, " default_user: %q\n", cfg.SSH.DefaultUser)
|
||||
}
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
|
||||
}
|
||||
writeSSHOptions(&b, " ", cfg.SSH.SSHOptions)
|
||||
if len(cfg.SSH.Rules) > 0 {
|
||||
b.WriteString(" rules:\n")
|
||||
for _, rule := range cfg.SSH.Rules {
|
||||
b.WriteString(" - match:\n")
|
||||
for _, field := range [][2]string{{"tag", rule.Match.Tag}, {"instance", rule.Match.Instance}, {"kind", rule.Match.Kind}, {"name", rule.Match.Name}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(&b, " %s: %q\n", field[0], field[1])
|
||||
}
|
||||
}
|
||||
writeSSHOptions(&b, " ", rule.SSHOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString("\nshortcuts:\n")
|
||||
if len(cfg.Shortcuts.Domains) > 0 {
|
||||
b.WriteString(" domains:\n")
|
||||
for _, d := range cfg.Shortcuts.Domains {
|
||||
fmt.Fprintf(&b, " - %s\n", d)
|
||||
}
|
||||
}
|
||||
if cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString(" strip_hyphens: true\n")
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Shell.Alias != "" {
|
||||
fmt.Fprintf(&b, "\nshell:\n alias: %q\n", cfg.Shell.Alias)
|
||||
}
|
||||
|
||||
if err := storage.WriteFile(path, []byte(b.String()), 0o600); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\nConfig saved → %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshOptionsEmpty(o config.SSHOptions) bool {
|
||||
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
|
||||
}
|
||||
|
||||
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
||||
func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
|
||||
for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1])
|
||||
}
|
||||
}
|
||||
if len(o.ExtraArgs) > 0 {
|
||||
fmt.Fprintf(b, "%sextra_args:\n", indent)
|
||||
for _, arg := range o.ExtraArgs {
|
||||
fmt.Fprintf(b, "%s - %q\n", indent, arg)
|
||||
}
|
||||
}
|
||||
if len(o.Options) > 0 {
|
||||
keys := make([]string, 0, len(o.Options))
|
||||
for key := range o.Options {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Fprintf(b, "%soptions:\n", indent)
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestSave_WritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg := config.Config{
|
||||
NetBox: config.NetBoxConfig{
|
||||
URL: "https://netbox.example.com",
|
||||
Token: "nbt_abc123",
|
||||
TokenVersion: 2,
|
||||
},
|
||||
SSH: config.SSHConfig{DefaultUser: "admin"},
|
||||
Resolver: config.ResolverConfig{
|
||||
Strategies: []string{"primary_ip", "management_subnet"},
|
||||
ManagementSubnets: []string{"10.0.0.0/8"},
|
||||
},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
}
|
||||
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("reading saved file: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
`"https://netbox.example.com"`,
|
||||
`"nbt_abc123"`,
|
||||
`token_version: 2`,
|
||||
`- primary_ip`,
|
||||
`- management_subnet`,
|
||||
`- 10.0.0.0/8`,
|
||||
`ttl: 3600`,
|
||||
`"admin"`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("saved config missing %q\nfull content:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_FilePermissions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
if err := save(config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(filepath.Join(dir, "netssh.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("file permissions: got %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_OmitsEmptyOptionalFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg := config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
// No DefaultUser, no ManagementSubnets, no InterfaceName
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
|
||||
content := string(data)
|
||||
|
||||
for _, absent := range []string{"default_user", "management_subnets", "interface_name"} {
|
||||
if strings.Contains(content, absent) {
|
||||
t.Errorf("config should not contain %q when field is empty\nfull content:\n%s", absent, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_CreatesConfigDir(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "does", "not", "exist")
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
if err := save(config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
}); err != nil {
|
||||
t.Fatalf("save should create missing directories: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_DefaultPort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg := config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
SSH: config.SSHConfig{DefaultPort: 2222},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
|
||||
if !strings.Contains(string(data), "default_port: 2222") {
|
||||
t.Errorf("expected default_port: 2222 in config, got:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_Shortcuts_WritesSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg := config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
Shortcuts: config.ShortcutsConfig{
|
||||
Domains: []string{".example.com"},
|
||||
StripHyphens: true,
|
||||
},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
|
||||
content := string(data)
|
||||
for _, want := range []string{"shortcuts:", "domains:", ".example.com", "strip_hyphens: true"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("expected %q in config, got:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_OmitsDefaultPortWhenZero(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg := config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
SSH: config.SSHConfig{DefaultPort: 0},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
|
||||
if strings.Contains(string(data), "default_port") {
|
||||
t.Errorf("default_port should be omitted when zero, got:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_OmitsShortcutsWhenEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
cfg := config.Config{
|
||||
NetBox: config.NetBoxConfig{URL: "http://x", Token: "t", TokenVersion: 1},
|
||||
Cache: config.CacheConfig{TTL: 60},
|
||||
Shortcuts: config.ShortcutsConfig{}, // empty
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "netssh.yaml"))
|
||||
if strings.Contains(string(data), "shortcuts:") {
|
||||
t.Errorf("shortcuts section should be omitted when empty, got:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_Roundtrip_WithNewFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
original := config.Config{
|
||||
NetBox: config.NetBoxConfig{
|
||||
URL: "https://netbox.example.com",
|
||||
Token: "nbt_test",
|
||||
TokenVersion: 2,
|
||||
},
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: "admin",
|
||||
DefaultPort: 2222,
|
||||
},
|
||||
Resolver: config.ResolverConfig{
|
||||
Strategies: []string{"primary_ip"},
|
||||
},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
Shortcuts: config.ShortcutsConfig{
|
||||
Domains: []string{".example.com", ".example.de"},
|
||||
StripHyphens: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := save(original); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load after save: %v", err)
|
||||
}
|
||||
|
||||
if loaded.SSH.DefaultPort != original.SSH.DefaultPort {
|
||||
t.Errorf("DefaultPort: got %d, want %d", loaded.SSH.DefaultPort, original.SSH.DefaultPort)
|
||||
}
|
||||
if len(loaded.Shortcuts.Domains) != len(original.Shortcuts.Domains) {
|
||||
t.Errorf("Shortcuts.Domains length: got %d, want %d", len(loaded.Shortcuts.Domains), len(original.Shortcuts.Domains))
|
||||
} else {
|
||||
for i, d := range original.Shortcuts.Domains {
|
||||
if loaded.Shortcuts.Domains[i] != d {
|
||||
t.Errorf("Shortcuts.Domains[%d]: got %q, want %q", i, loaded.Shortcuts.Domains[i], d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if loaded.Shortcuts.StripHyphens != original.Shortcuts.StripHyphens {
|
||||
t.Errorf("StripHyphens: got %v, want %v", loaded.Shortcuts.StripHyphens, original.Shortcuts.StripHyphens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStrategies(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"primary_ip", []string{"primary_ip"}},
|
||||
{"management_subnet, primary_ip", []string{"management_subnet", "primary_ip"}},
|
||||
{"primary_ip,management_subnet,interface_name", []string{"primary_ip", "management_subnet", "interface_name"}},
|
||||
{" primary_ip , management_subnet ", []string{"primary_ip", "management_subnet"}},
|
||||
{"", nil},
|
||||
{" , ", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseStrategies(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseStrategies(%q): got %v, want %v", tt.in, got, tt.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("parseStrategies(%q)[%d]: got %q, want %q", tt.in, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStrategies_PreservesOrder(t *testing.T) {
|
||||
got := parseStrategies("interface_name, management_subnet, primary_ip")
|
||||
want := []string{"interface_name", "management_subnet", "primary_ip"}
|
||||
for i, s := range got {
|
||||
if s != want[i] {
|
||||
t.Errorf("order not preserved at [%d]: got %q, want %q", i, s, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategies_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"primary_ip",
|
||||
"management_subnet, primary_ip",
|
||||
"interface_name, management_subnet, primary_ip",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := validateStrategies(c); err != nil {
|
||||
t.Errorf("validateStrategies(%q) should be valid, got: %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategies_Invalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"unknown_strategy",
|
||||
"primary_ip, typo",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := validateStrategies(c); err == nil {
|
||||
t.Errorf("validateStrategies(%q) should return an error", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_RoundtripViaLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", dir)
|
||||
defer os.Setenv("XDG_CONFIG_HOME", orig)
|
||||
|
||||
original := config.Config{
|
||||
NetBox: config.NetBoxConfig{
|
||||
URL: "https://netbox.zb-server.de",
|
||||
Token: "nbt_supersecret",
|
||||
TokenVersion: 2,
|
||||
},
|
||||
SSH: config.SSHConfig{DefaultUser: "root"},
|
||||
Resolver: config.ResolverConfig{
|
||||
Strategies: []string{"primary_ip"},
|
||||
ManagementSubnets: []string{"192.168.0.0/16"},
|
||||
InterfaceName: "eth0",
|
||||
},
|
||||
Cache: config.CacheConfig{TTL: 7200},
|
||||
}
|
||||
|
||||
if err := save(original); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load after save: %v", err)
|
||||
}
|
||||
|
||||
if loaded.NetBox.URL != original.NetBox.URL {
|
||||
t.Errorf("URL: got %q, want %q", loaded.NetBox.URL, original.NetBox.URL)
|
||||
}
|
||||
if loaded.NetBox.Token != original.NetBox.Token {
|
||||
t.Errorf("Token: got %q, want %q", loaded.NetBox.Token, original.NetBox.Token)
|
||||
}
|
||||
if loaded.NetBox.TokenVersion != original.NetBox.TokenVersion {
|
||||
t.Errorf("TokenVersion: got %d, want %d", loaded.NetBox.TokenVersion, original.NetBox.TokenVersion)
|
||||
}
|
||||
if loaded.SSH.DefaultUser != original.SSH.DefaultUser {
|
||||
t.Errorf("DefaultUser: got %q, want %q", loaded.SSH.DefaultUser, original.SSH.DefaultUser)
|
||||
}
|
||||
if loaded.Cache.TTL != original.Cache.TTL {
|
||||
t.Errorf("TTL: got %d, want %d", loaded.Cache.TTL, original.Cache.TTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
cfg := config.Config{Shell: config.ShellConfig{Alias: "j"}, Cache: config.CacheConfig{Path: filepath.Join(t.TempDir(), "hosts.json")}}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil || loaded.Shell.Alias != "j" || loaded.Cache.Path != cfg.Cache.Path {
|
||||
t.Fatalf("roundtrip: %+v, %v", loaded, err)
|
||||
}
|
||||
cfg.Shell.Alias = ""
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = config.Load()
|
||||
if err != nil || loaded.Shell.Alias != "" {
|
||||
t.Fatalf("disable: %+v, %v", loaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{
|
||||
{Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2},
|
||||
{Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1},
|
||||
},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" {
|
||||
t.Fatalf("instances lost during save: %+v", loaded)
|
||||
}
|
||||
for i, want := range cfg.NetBoxes {
|
||||
if !reflect.DeepEqual(loaded.NetBoxes[i], want) {
|
||||
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(config.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatal("credentials are not private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKeepsSSHOptions(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
opts := config.SSHOptions{
|
||||
ProxyJump: "jh.example.com",
|
||||
HostKeyChecking: "accept-new",
|
||||
Target: "name",
|
||||
AddressFamily: "inet",
|
||||
ExtraArgs: []string{"-A"},
|
||||
Options: map[string]string{"serveraliveinterval": "60"},
|
||||
}
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: "root",
|
||||
SSHOptions: opts,
|
||||
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
|
||||
},
|
||||
Completion: config.CompletionConfig{MaxResults: 100},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion {
|
||||
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
// Normalize strips configured domain suffixes and optionally hyphens from a hostname.
|
||||
// The result is lowercased for case-insensitive comparison.
|
||||
func Normalize(name string, cfg config.ShortcutsConfig) string {
|
||||
s := strings.ToLower(name)
|
||||
for _, domain := range cfg.Domains {
|
||||
suffix := strings.ToLower(domain)
|
||||
if !strings.HasPrefix(suffix, ".") {
|
||||
suffix = "." + suffix
|
||||
}
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
s = s[:len(s)-len(suffix)]
|
||||
break
|
||||
}
|
||||
}
|
||||
if cfg.StripHyphens {
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// MakeNormalizer returns a closure bound to cfg, suitable for Cache.GetByShortcut.
|
||||
func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
|
||||
return func(name string) string {
|
||||
return Normalize(name, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// AliasName returns a shell-safe alias name for the given host.
|
||||
// It normalizes the name (strips configured domains and optionally hyphens) then
|
||||
// replaces any remaining dots with underscores so the result is a valid identifier.
|
||||
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
||||
s := Normalize(name, cfg)
|
||||
return strings.NewReplacer(".", "_", "/", "_").Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package shortcuts_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cfg := config.ShortcutsConfig{
|
||||
Domains: []string{".example.com", ".example.de"},
|
||||
StripHyphens: true,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"fsn1-web01.example.com", "fsn1web01"},
|
||||
{"fsn1-web01.example.de", "fsn1web01"},
|
||||
{"FSN1-WEB01.EXAMPLE.COM", "fsn1web01"},
|
||||
{"fsn1-web01", "fsn1web01"},
|
||||
{"web01.example.com", "web01"},
|
||||
{"web01.other.com", "web01.other.com"}, // unknown domain not stripped
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := shortcuts.Normalize(c.input, cfg)
|
||||
if got != c.want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", c.input, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNoHyphenStrip(t *testing.T) {
|
||||
cfg := config.ShortcutsConfig{
|
||||
Domains: []string{".example.com"},
|
||||
StripHyphens: false,
|
||||
}
|
||||
|
||||
got := shortcuts.Normalize("fsn1-web01.example.com", cfg)
|
||||
want := "fsn1-web01"
|
||||
if got != want {
|
||||
t.Errorf("Normalize = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasName_WithDomainAndHyphens(t *testing.T) {
|
||||
cfg := config.ShortcutsConfig{
|
||||
Domains: []string{".example.com"},
|
||||
StripHyphens: true,
|
||||
}
|
||||
got := shortcuts.AliasName("fsn1-web01.example.com", cfg)
|
||||
want := "fsn1web01"
|
||||
if got != want {
|
||||
t.Errorf("AliasName = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasName_DotReplacement(t *testing.T) {
|
||||
cfg := config.ShortcutsConfig{
|
||||
Domains: []string{".example.com"},
|
||||
}
|
||||
// "web01.other.com" — domain not stripped, remaining dots → underscores
|
||||
got := shortcuts.AliasName("web01.other.com", cfg)
|
||||
want := "web01_other_com"
|
||||
if got != want {
|
||||
t.Errorf("AliasName = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasName_NoConfig(t *testing.T) {
|
||||
cfg := config.ShortcutsConfig{}
|
||||
got := shortcuts.AliasName("web01.example.com", cfg)
|
||||
want := "web01_example_com"
|
||||
if got != want {
|
||||
t.Errorf("AliasName = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
+43
-20
@@ -4,7 +4,7 @@ import "strings"
|
||||
|
||||
// flagsWithArg lists all SSH flags that consume the following argument.
|
||||
var flagsWithArg = map[byte]bool{
|
||||
'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
|
||||
'B': true, 'P': true, 'b': true, 'c': true, 'D': true, 'E': true, 'e': true,
|
||||
'F': true, 'I': true, 'i': true, 'J': true, 'L': true,
|
||||
'l': true, 'm': true, 'o': true, 'O': true, 'p': true,
|
||||
'Q': true, 'R': true, 'S': true, 'w': true, 'W': true,
|
||||
@@ -32,18 +32,16 @@ func Parse(args []string) *ParsedArgs {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
||||
flag := arg[1]
|
||||
if flagsWithArg[flag] {
|
||||
if len(arg) > 2 {
|
||||
// argument is attached, e.g. -p2222
|
||||
i++
|
||||
} else {
|
||||
// argument is the next element, e.g. -p 2222
|
||||
i += 2
|
||||
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if flagsWithArg[arg[j]] {
|
||||
if j == len(arg)-1 {
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -86,16 +84,41 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// HasUserFlag reports whether a user was specified via -l in args.
|
||||
// Used to avoid overriding an explicit -l with the configured default user.
|
||||
func HasUserFlag(args []string) bool {
|
||||
for i, a := range args {
|
||||
if a == "-l" && i+1 < len(args) {
|
||||
return true
|
||||
// HasPortFlag reports whether a port was explicitly supplied.
|
||||
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
|
||||
|
||||
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
|
||||
|
||||
func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||
break
|
||||
}
|
||||
// handle attached form: -lroot
|
||||
if len(a) > 2 && a[0] == '-' && a[1] == 'l' {
|
||||
return true
|
||||
for j := 1; j < len(arg); j++ {
|
||||
flag := arg[j]
|
||||
if !flagsWithArg[flag] {
|
||||
continue
|
||||
}
|
||||
value := arg[j+1:]
|
||||
if value == "" {
|
||||
i++
|
||||
if i >= len(args) {
|
||||
return false
|
||||
}
|
||||
value = args[i]
|
||||
}
|
||||
if flag == wanted {
|
||||
return true
|
||||
}
|
||||
if flag == 'o' {
|
||||
fields := strings.FieldsFunc(value, func(r rune) bool { return r == '=' || r == ' ' || r == '\t' })
|
||||
if len(fields) >= 2 && strings.EqualFold(fields[0], keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package ssh
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseOptionClusters(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"-vp", "2222", "server"},
|
||||
{"-vp2222", "server"},
|
||||
{"-vAl", "admin", "server"},
|
||||
{"-vladmin", "server"},
|
||||
{"-B", "eth0", "server"},
|
||||
{"-P", "production", "server"},
|
||||
} {
|
||||
t.Run(args[0], func(t *testing.T) {
|
||||
assertParsed(t, Parse(args), "server", "", len(args)-1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitSSHOptions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
user, port bool
|
||||
}{
|
||||
{"cluster port separated", []string{"-vp", "2222", "server"}, false, true},
|
||||
{"cluster user attached", []string{"-vladmin", "server"}, true, false},
|
||||
{"o separated", []string{"-o", "User=admin", "-o", "Port=2222", "server"}, true, true},
|
||||
{"o attached and case insensitive", []string{"-ouSeR=admin", "-oport=2222", "server"}, true, true},
|
||||
{"o whitespace", []string{"-o", " User = admin", "-o", "Port\t2222", "server"}, true, true},
|
||||
{"cluster o", []string{"-voUser=admin", "server"}, true, false},
|
||||
{"option value looks like flag", []string{"-i", "-lroot", "-F", "-p2222", "server"}, false, false},
|
||||
{"remote flags", []string{"server", "-p2222", "-o", "User=admin"}, false, false},
|
||||
{"after double dash", []string{"--", "-lroot", "-p2222"}, false, false},
|
||||
{"missing value", []string{"-vp"}, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := HasUserFlag(tc.args); got != tc.user {
|
||||
t.Errorf("HasUserFlag(%v) = %v, want %v", tc.args, got, tc.user)
|
||||
}
|
||||
if got := HasPortFlag(tc.args); got != tc.port {
|
||||
t.Errorf("HasPortFlag(%v) = %v, want %v", tc.args, got, tc.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,37 @@ func TestHasUserFlag_LFlagAtEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_FlagSeparated(t *testing.T) {
|
||||
if !HasPortFlag([]string{"-p", "2222", "host"}) {
|
||||
t.Error("should detect -p <port>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_FlagAttached(t *testing.T) {
|
||||
if !HasPortFlag([]string{"-p2222", "host"}) {
|
||||
t.Error("should detect -p<port> (attached form)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_NotPresent(t *testing.T) {
|
||||
if HasPortFlag([]string{"-l", "admin", "host"}) {
|
||||
t.Error("should not detect port flag when absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_EmptyArgs(t *testing.T) {
|
||||
if HasPortFlag([]string{}) {
|
||||
t.Error("empty args should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPortFlag_PAtEnd(t *testing.T) {
|
||||
// -p at the very end with no value — should return false
|
||||
if HasPortFlag([]string{"-p"}) {
|
||||
t.Error("-p with no value should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
@@ -159,3 +190,10 @@ func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int)
|
||||
t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommandFlagsDoNotOverrideDefaults(t *testing.T) {
|
||||
args := []string{"web01", "command", "-p", "1234", "-lroot"}
|
||||
if HasPortFlag(args) || HasUserFlag(args) {
|
||||
t.Fatal("remote command interpreted as SSH options")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
|
||||
// so these never collide with flags meant for ssh.
|
||||
type WrapperFlags struct {
|
||||
DryRun bool
|
||||
Jump string // --jump HOST overrides the configured jump host
|
||||
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
||||
}
|
||||
|
||||
// ExtractWrapperFlags removes netssh's long options that appear before the
|
||||
// destination. Anything after the destination belongs to the remote command.
|
||||
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||
var flags WrapperFlags
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--dry-run":
|
||||
flags.DryRun = true
|
||||
case arg == "--no-jump":
|
||||
flags.NoJump = true
|
||||
case arg == "--jump":
|
||||
if i+1 >= len(args) {
|
||||
return flags, nil, fmt.Errorf("--jump requires a host")
|
||||
}
|
||||
i++
|
||||
flags.Jump = args[i]
|
||||
case strings.HasPrefix(arg, "--jump="):
|
||||
flags.Jump = strings.TrimPrefix(arg, "--jump=")
|
||||
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
|
||||
// Destination or end of options: keep the rest unchanged.
|
||||
return flags, append(out, args[i:]...), validateJump(flags)
|
||||
case strings.HasPrefix(arg, "--"):
|
||||
return flags, nil, fmt.Errorf("unknown option %s", arg)
|
||||
default:
|
||||
out = append(out, arg)
|
||||
// Keep the value of an ssh option such as -l USER together with it.
|
||||
if takesSeparateValue(arg) && i+1 < len(args) {
|
||||
i++
|
||||
out = append(out, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags, out, validateJump(flags)
|
||||
}
|
||||
|
||||
func validateJump(flags WrapperFlags) error {
|
||||
if flags.Jump == "" && flags.NoJump {
|
||||
return nil
|
||||
}
|
||||
if flags.Jump != "" && flags.NoJump {
|
||||
return fmt.Errorf("--jump and --no-jump are mutually exclusive")
|
||||
}
|
||||
if strings.HasPrefix(flags.Jump, "-") {
|
||||
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// takesSeparateValue reports whether a short option cluster ends with a flag
|
||||
// whose value is the next argument (e.g. "-l" or "-vp").
|
||||
func takesSeparateValue(arg string) bool {
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if flagsWithArg[arg[j]] {
|
||||
return j == len(arg)-1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Host is a resolved NetBox host.
|
||||
type Host struct {
|
||||
Name string // NetBox name without the instance prefix
|
||||
IP string
|
||||
}
|
||||
|
||||
// Result is the final ssh argument list and an optional notice for stderr.
|
||||
type Result struct {
|
||||
Args []string
|
||||
Notice string
|
||||
}
|
||||
|
||||
var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
|
||||
|
||||
// Apply replaces the destination at destIdx and inserts the configured -o
|
||||
// options directly before it. Explicit options given by the user come first,
|
||||
// and ssh uses the first -o value it obtains, so the user's choice wins.
|
||||
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
var inserted []string
|
||||
add := func(keyword, value string) {
|
||||
if !hasOption(args[:destIdx], 0, keyword) {
|
||||
inserted = append(inserted, "-o", keyword+"="+value)
|
||||
}
|
||||
}
|
||||
|
||||
dest := host.IP
|
||||
if opts.Target == "name" || opts.Target == "hostname" {
|
||||
if plainHostname.MatchString(host.Name) {
|
||||
dest = host.Name
|
||||
if opts.Target == "name" {
|
||||
add("HostName", host.IP)
|
||||
add("HostKeyAlias", host.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
|
||||
switch {
|
||||
case userJump:
|
||||
case flags.NoJump:
|
||||
inserted = append(inserted, "-o", "ProxyJump=none")
|
||||
case flags.Jump != "":
|
||||
inserted = append(inserted, "-J", flags.Jump)
|
||||
case opts.ProxyJump != "" && opts.ProxyJump != "none":
|
||||
inserted = append(inserted, "-J", opts.ProxyJump)
|
||||
}
|
||||
|
||||
var notice string
|
||||
switch opts.HostKeyChecking {
|
||||
case "strict":
|
||||
add("StrictHostKeyChecking", "yes")
|
||||
case "accept-new":
|
||||
add("StrictHostKeyChecking", "accept-new")
|
||||
case "off":
|
||||
if !hasOption(args[:destIdx], 0, "StrictHostKeyChecking") {
|
||||
add("StrictHostKeyChecking", "no")
|
||||
add("UserKnownHostsFile", "/dev/null")
|
||||
add("LogLevel", "ERROR")
|
||||
notice = "host key checking is disabled for this connection (ssh.host_key_checking: off)"
|
||||
}
|
||||
}
|
||||
|
||||
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
|
||||
add("AddressFamily", opts.AddressFamily)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(opts.Options))
|
||||
for key := range opts.Options {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
add(key, opts.Options[key])
|
||||
}
|
||||
|
||||
// Extra arguments go first: short flags such as -4 or -A take the last
|
||||
// value given, so the user's own flags later on the line still win.
|
||||
replaced := ReplaceHost(args, destIdx, dest)
|
||||
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
|
||||
out = append(out, opts.ExtraArgs...)
|
||||
out = append(out, replaced[:destIdx]...)
|
||||
out = append(out, inserted...)
|
||||
out = append(out, replaced[destIdx:]...)
|
||||
return Result{Args: out, Notice: notice}
|
||||
}
|
||||
|
||||
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
||||
func hasBoolFlag(args []string, wanted byte) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||
break
|
||||
}
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if arg[j] == wanted {
|
||||
return true
|
||||
}
|
||||
if flagsWithArg[arg[j]] {
|
||||
if j == len(arg)-1 {
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Quote renders an argument list as a copy-pasteable POSIX shell command.
|
||||
func Quote(argv []string) string {
|
||||
quoted := make([]string, len(argv))
|
||||
for i, arg := range argv {
|
||||
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
|
||||
quoted[i] = arg
|
||||
continue
|
||||
}
|
||||
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestExtractWrapperFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
flags WrapperFlags
|
||||
rest []string
|
||||
}{
|
||||
{[]string{"--dry-run", "web"}, WrapperFlags{DryRun: true}, []string{"web"}},
|
||||
{[]string{"-l", "--dry-run", "--jump", "jh", "web"}, WrapperFlags{Jump: "jh"}, []string{"-l", "--dry-run", "web"}},
|
||||
{[]string{"--jump=jh", "-vp", "22", "web", "--dry-run"}, WrapperFlags{Jump: "jh"}, []string{"-vp", "22", "web", "--dry-run"}},
|
||||
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
||||
} {
|
||||
flags, rest, err := ExtractWrapperFlags(tc.args)
|
||||
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
|
||||
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"--jump"}, {"--jump", "-x", "web"}, {"--jump=a", "--no-jump", "web"}, {"--version-typo", "web"}} {
|
||||
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||
t.Errorf("%v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apply(args []string, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
return Apply(args, len(args)-1, Host{Name: "web.example.com", IP: "192.0.2.10"}, opts, flags)
|
||||
}
|
||||
|
||||
func TestApplyDefaultConnectsToIP(t *testing.T) {
|
||||
got := apply([]string{"-l", "root", "web"}, config.SSHOptions{}, WrapperFlags{})
|
||||
if want := []string{"-l", "root", "192.0.2.10"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTargetName(t *testing.T) {
|
||||
got := apply([]string{"admin@web"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web.example.com", "admin@web.example.com"}
|
||||
if !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
got = apply([]string{"web"}, config.SSHOptions{Target: "hostname"}, WrapperFlags{})
|
||||
if want := []string{"web.example.com"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
// Names that are not plain hostnames fall back to the IP address.
|
||||
got = Apply([]string{"x"}, 0, Host{Name: "rack 1 switch", IP: "192.0.2.1"}, config.SSHOptions{Target: "name"}, WrapperFlags{})
|
||||
if want := []string{"192.0.2.1"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyJumpPrecedence(t *testing.T) {
|
||||
opts := config.SSHOptions{ProxyJump: "jh.example.com"}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
flags WrapperFlags
|
||||
want string
|
||||
}{
|
||||
{[]string{"web"}, WrapperFlags{}, "-J jh.example.com 192.0.2.10"},
|
||||
{[]string{"web"}, WrapperFlags{Jump: "other"}, "-J other 192.0.2.10"},
|
||||
{[]string{"web"}, WrapperFlags{NoJump: true}, "-o ProxyJump=none 192.0.2.10"},
|
||||
{[]string{"-J", "mine", "web"}, WrapperFlags{}, "-J mine 192.0.2.10"},
|
||||
{[]string{"-oProxyCommand=nc %h %p", "web"}, WrapperFlags{}, "-oProxyCommand=nc %h %p 192.0.2.10"},
|
||||
} {
|
||||
if got := strings.Join(apply(tc.args, opts, tc.flags).Args, " "); got != tc.want {
|
||||
t.Errorf("%v %+v: got %q, want %q", tc.args, tc.flags, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := apply([]string{"web"}, config.SSHOptions{ProxyJump: "none"}, WrapperFlags{}); len(got.Args) != 1 {
|
||||
t.Fatalf("proxy_jump none still jumps: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostKeyChecking(t *testing.T) {
|
||||
got := apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||
want := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR 192.0.2.10"
|
||||
if strings.Join(got.Args, " ") != want || got.Notice == "" {
|
||||
t.Fatalf("got %v, notice %q", got.Args, got.Notice)
|
||||
}
|
||||
// An explicit user choice disables the whole "off" bundle.
|
||||
got = apply([]string{"-o", "StrictHostKeyChecking=yes", "web"}, config.SSHOptions{HostKeyChecking: "off"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=yes 192.0.2.10" || got.Notice != "" {
|
||||
t.Fatalf("user choice overridden: %v", got.Args)
|
||||
}
|
||||
got = apply([]string{"web"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-o StrictHostKeyChecking=accept-new 192.0.2.10" {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOptionsAndExtraArgs(t *testing.T) {
|
||||
opts := config.SSHOptions{
|
||||
AddressFamily: "inet",
|
||||
ExtraArgs: []string{"-A"},
|
||||
Options: map[string]string{"serveraliveinterval": "60", "user": "ignored"},
|
||||
}
|
||||
got := apply([]string{"-o", "User=me", "web"}, opts, WrapperFlags{})
|
||||
want := "-A -o User=me -o AddressFamily=inet -o serveraliveinterval=60 192.0.2.10"
|
||||
if strings.Join(got.Args, " ") != want {
|
||||
t.Fatalf("got %q", strings.Join(got.Args, " "))
|
||||
}
|
||||
got = apply([]string{"-v6", "web"}, config.SSHOptions{AddressFamily: "inet"}, WrapperFlags{})
|
||||
if strings.Join(got.Args, " ") != "-v6 192.0.2.10" {
|
||||
t.Fatalf("address family overrides -6: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKeepsRemoteCommand(t *testing.T) {
|
||||
got := Apply([]string{"web", "uptime", "-o", "x"}, 0, Host{Name: "web", IP: "192.0.2.1"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{})
|
||||
if want := []string{"-J", "jh", "192.0.2.1", "uptime", "-o", "x"}; !reflect.DeepEqual(got.Args, want) {
|
||||
t.Fatalf("got %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote(t *testing.T) {
|
||||
got := Quote([]string{"ssh", "-o", "ProxyCommand=nc %h %p", "it's", "", "a@b:22"})
|
||||
if want := `ssh -o 'ProxyCommand=nc %h %p' 'it'\''s' '' a@b:22`; got != want {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package storage provides atomic file replacement in the destination directory.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".netssh-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
if err := f.Chmod(mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func tuiSourceServer(t *testing.T, fail bool) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail {
|
||||
http.Error(w, "offline", 503)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/dcim/devices/" {
|
||||
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"web","primary_ip4":{"address":"10.0.0.1/24"}}]}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestCombinedSearchDoesNotStopAtFirstCachedInstance(t *testing.T) {
|
||||
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, false)
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
c := cache.New("", 3600)
|
||||
c.Upsert(cache.Entry{Name: "office/web", Kind: "device", IP: "10.0.0.9"})
|
||||
m := New(client, c, "")
|
||||
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||
if msg.err != nil || len(msg.entries) != 2 {
|
||||
t.Fatalf("cache hid another instance: %+v, %v", msg.entries, msg.err)
|
||||
}
|
||||
m.input.SetValue("web")
|
||||
m.seq = 1
|
||||
m.Update(msg)
|
||||
if len(m.list.Items()) != 2 {
|
||||
t.Fatal("combined results not displayed")
|
||||
}
|
||||
for _, item := range m.list.Items() {
|
||||
if !strings.Contains(item.(hostItem).name, "/") {
|
||||
t.Fatal("instance not visible in results")
|
||||
}
|
||||
}
|
||||
m.list.Select(0)
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "lab/web" {
|
||||
t.Fatalf("selection lost source: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialOutageKeepsLiveAndCachedResultsVisible(t *testing.T) {
|
||||
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, true)
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
c := cache.New("", 3600)
|
||||
c.Upsert(cache.Entry{Name: "lab/web", Kind: "device", IP: "10.0.0.2"})
|
||||
m := New(client, c, "")
|
||||
m.input.SetValue("web")
|
||||
m.seq = 1
|
||||
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||
if len(msg.entries) != 2 || msg.err == nil {
|
||||
t.Fatalf("partial outage lost cache or warning: %+v, %v", msg.entries, msg.err)
|
||||
}
|
||||
m.Update(msg)
|
||||
view := m.View().Content
|
||||
if !strings.Contains(view, "warning:") || !strings.Contains(view, "lab/web") || !strings.Contains(view, "office/web") {
|
||||
t.Fatalf("partial results hidden: %s", view)
|
||||
}
|
||||
if rows := strings.Count(view, "\n"); rows > 24 {
|
||||
t.Fatalf("outage warning overflowed view: %d", rows)
|
||||
}
|
||||
}
|
||||
+485
-66
@@ -4,40 +4,59 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/list"
|
||||
"charm.land/bubbles/v2/textarea"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
)
|
||||
|
||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||
type SelectedHost struct {
|
||||
Kind string
|
||||
Name string
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
Port string // empty = use default port
|
||||
}
|
||||
|
||||
// --- bubbletea messages ---
|
||||
|
||||
type debounceMsg struct{ query string }
|
||||
type debounceMsg struct {
|
||||
query string
|
||||
seq int
|
||||
}
|
||||
type noteResultMsg struct {
|
||||
note notes.Note
|
||||
err error
|
||||
saved bool
|
||||
}
|
||||
|
||||
type searchResultMsg struct {
|
||||
query string
|
||||
seq int
|
||||
entries []netbox.HostEntry
|
||||
err error
|
||||
recent bool // true when this is a recently-used list, not a search result
|
||||
}
|
||||
|
||||
// --- list item ---
|
||||
|
||||
type hostItem struct {
|
||||
name string
|
||||
id int
|
||||
ip string
|
||||
kind string
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (h hostItem) Title() string { return h.name }
|
||||
@@ -60,119 +79,377 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
|
||||
if index == m.Index() {
|
||||
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
fmt.Fprint(w, line)
|
||||
}
|
||||
|
||||
// --- filter ---
|
||||
|
||||
type filterOpts struct {
|
||||
tags []string
|
||||
kind string
|
||||
}
|
||||
|
||||
func parseFilter(s string) filterOpts {
|
||||
var f filterOpts
|
||||
for _, part := range strings.Fields(s) {
|
||||
if after, ok := strings.CutPrefix(part, "tag:"); ok {
|
||||
f.tags = append(f.tags, after)
|
||||
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
|
||||
f.kind = after
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
||||
if len(f.tags) == 0 && f.kind == "" {
|
||||
return entries
|
||||
}
|
||||
out := make([]netbox.HostEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if f.kind != "" && e.Kind != f.kind {
|
||||
continue
|
||||
}
|
||||
if len(f.tags) > 0 {
|
||||
tagSet := make(map[string]bool, len(e.Tags))
|
||||
for _, t := range e.Tags {
|
||||
tagSet[strings.ToLower(t)] = true
|
||||
}
|
||||
allMatch := true
|
||||
for _, want := range f.tags {
|
||||
if !tagSet[strings.ToLower(want)] {
|
||||
allMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Model ---
|
||||
|
||||
type Model struct {
|
||||
input textinput.Model
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
lastSent string // last query sent to NetBox (or served from cache)
|
||||
seq int // sequence number to discard stale results
|
||||
loading bool
|
||||
err error
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
noteErr error
|
||||
editHost *hostItem
|
||||
noteInput textarea.Model
|
||||
noteService *notes.Service
|
||||
note notes.Note
|
||||
noteMode, noteRemote, noteBusy bool
|
||||
noteLoaded bool
|
||||
defaultPort int
|
||||
input textinput.Model
|
||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||
editInput textinput.Model // user@host:port inline editor
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
defaultUser string
|
||||
lastSent string // last query sent to NetBox (or served from cache)
|
||||
lastResults []netbox.HostEntry // raw results before filter applied
|
||||
seq int // sequence number to discard stale results
|
||||
loading bool
|
||||
err error
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // Ctrl+E on the selected item
|
||||
}
|
||||
|
||||
func New(client *netbox.Client, c *cache.Cache) *Model {
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hostname…"
|
||||
ti.Focus()
|
||||
|
||||
fi := textinput.New()
|
||||
fi.Placeholder = "tag:prod kind:vm"
|
||||
fi.Prompt = "Filter: "
|
||||
|
||||
ei := textinput.New()
|
||||
ei.Prompt = ""
|
||||
|
||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.DisableQuitKeybindings()
|
||||
port := 22
|
||||
if len(defaultPorts) > 0 && defaultPorts[0] > 0 {
|
||||
port = defaultPorts[0]
|
||||
}
|
||||
|
||||
ni := textarea.New()
|
||||
ni.CharLimit = 0
|
||||
ni.SetHeight(10)
|
||||
return &Model{
|
||||
input: ti,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
noteInput: ni,
|
||||
defaultPort: port,
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
defaultUser: defaultUser,
|
||||
recentMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
return tea.Batch(textinput.Blink, m.loadRecent())
|
||||
}
|
||||
|
||||
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
|
||||
func (m *Model) loadRecent() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.cache == nil {
|
||||
return searchResultMsg{query: "", entries: nil, recent: true}
|
||||
}
|
||||
recent := m.cache.RecentlyUsed(10)
|
||||
entries := make([]netbox.HostEntry, len(recent))
|
||||
for i, e := range recent {
|
||||
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
|
||||
}
|
||||
return searchResultMsg{query: "", entries: entries, recent: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
defer m.resizeList()
|
||||
switch msg := msg.(type) {
|
||||
case noteResultMsg:
|
||||
m.noteBusy = false
|
||||
m.noteErr = msg.err
|
||||
if msg.err == nil {
|
||||
m.noteLoaded = true
|
||||
m.note = msg.note
|
||||
m.noteInput.SetValue(msg.note.Text)
|
||||
if msg.saved {
|
||||
m.noteMode = false
|
||||
m.noteInput.Blur()
|
||||
m.input.Focus()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetSize(msg.Width, msg.Height-4)
|
||||
m.input.SetWidth(max(1, msg.Width-lipgloss.Width(m.input.Prompt)))
|
||||
m.filterInput.SetWidth(max(1, msg.Width-lipgloss.Width(m.filterInput.Prompt)))
|
||||
m.editInput.SetWidth(max(1, msg.Width-lipgloss.Width("Connect as: ")))
|
||||
m.noteInput.SetWidth(max(10, msg.Width-4))
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.noteMode {
|
||||
if m.noteBusy {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.noteMode = false
|
||||
m.noteInput.Blur()
|
||||
m.input.Focus()
|
||||
m.noteErr = nil
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
if !m.noteLoaded {
|
||||
return m, nil
|
||||
}
|
||||
note := m.note
|
||||
note.Text = m.noteInput.Value()
|
||||
remote, service := m.noteRemote, m.noteService
|
||||
m.noteBusy = true
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// --- Edit mode ---
|
||||
if m.editMode {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.editMode = false
|
||||
m.editInput.Blur()
|
||||
m.input.Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.editHost != nil {
|
||||
item := *m.editHost
|
||||
value := m.editInput.Value()
|
||||
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
||||
port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:]))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
m.err = fmt.Errorf("port must be between 1 and 65535")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.selected = m.buildSelected(item)
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.editInput, cmd = m.editInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// --- Filter focused ---
|
||||
if m.filterOpen {
|
||||
switch msg.String() {
|
||||
case "ctrl+f", "esc":
|
||||
m.filterOpen = false
|
||||
m.filterInput.Blur()
|
||||
m.filterInput.SetValue("")
|
||||
m.input.Focus()
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.filterOpen = false
|
||||
m.filterInput.Blur()
|
||||
m.input.Focus()
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// --- Normal mode ---
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
|
||||
case "ctrl+f":
|
||||
m.filterOpen = true
|
||||
m.input.Blur()
|
||||
m.filterInput.Focus()
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "ctrl+e":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.openEdit(item)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+n", "ctrl+b":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||
m.noteMode = true
|
||||
m.noteLoaded = false
|
||||
m.noteBusy = true
|
||||
m.noteRemote = msg.String() == "ctrl+b"
|
||||
m.input.Blur()
|
||||
m.noteInput.Focus()
|
||||
m.noteInput.SetValue("")
|
||||
m.noteErr = nil
|
||||
host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind}
|
||||
m.note = notes.Note{Host: host}
|
||||
remote, service := m.noteRemote, m.noteService
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
note, err := service.Read(ctx, host, remote)
|
||||
return noteResultMsg{note: note, err: err}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "tab":
|
||||
// Copy the top result into the search field.
|
||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||
m.input.SetValue(item.name)
|
||||
m.input.CursorEnd()
|
||||
m.seq++
|
||||
return m, m.startDebounce()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case debounceMsg:
|
||||
// Only query if the input has changed since the last request.
|
||||
q := m.input.Value()
|
||||
if q == m.lastSent {
|
||||
if msg.seq != m.seq || msg.query != q {
|
||||
return m, nil
|
||||
}
|
||||
m.lastSent = q
|
||||
m.recentMode = false
|
||||
m.loading = true
|
||||
m.seq++
|
||||
seq := m.seq
|
||||
return m, m.doSearch(q, seq)
|
||||
|
||||
case searchResultMsg:
|
||||
if msg.query != m.lastSent {
|
||||
if msg.recent {
|
||||
if m.input.Value() != "" || m.seq > 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.recentMode = true
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
return m, nil
|
||||
}
|
||||
if msg.seq != m.seq || msg.query != m.input.Value() {
|
||||
return m, nil // discard stale result
|
||||
}
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
items := make([]list.Item, len(msg.entries))
|
||||
for i, e := range msg.entries {
|
||||
ip := e.PrimaryIP4
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.err = nil
|
||||
m.err = msg.err
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Forward to text input and restart the debounce timer.
|
||||
if m.noteMode {
|
||||
if m.noteBusy {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// Paste and cursor events are separate from key presses in Bubble Tea v2.
|
||||
// Route them to the focused editor as well.
|
||||
if m.editMode {
|
||||
var cmd tea.Cmd
|
||||
m.editInput, cmd = m.editInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if m.filterOpen {
|
||||
var cmd tea.Cmd
|
||||
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, cmd
|
||||
}
|
||||
// Forward to search input and restart debounce timer.
|
||||
var cmds []tea.Cmd
|
||||
var inputCmd tea.Cmd
|
||||
oldQuery := m.input.Value()
|
||||
m.input, inputCmd = m.input.Update(msg)
|
||||
cmds = append(cmds, inputCmd)
|
||||
cmds = append(cmds, m.startDebounce())
|
||||
if m.input.Value() != oldQuery {
|
||||
m.seq++
|
||||
cmds = append(cmds, m.startDebounce())
|
||||
}
|
||||
|
||||
var listCmd tea.Cmd
|
||||
m.list, listCmd = m.list.Update(msg)
|
||||
@@ -181,21 +458,67 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
func (m *Model) View() tea.View {
|
||||
view := tea.NewView(m.viewContent())
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
|
||||
func (m *Model) viewContent() string {
|
||||
if m.noteMode {
|
||||
target := "local"
|
||||
if m.noteRemote {
|
||||
target = "NetBox comments"
|
||||
}
|
||||
status := "ctrl+s save esc cancel"
|
||||
if m.noteBusy {
|
||||
status = "Loading / saving…"
|
||||
}
|
||||
errText := ""
|
||||
if m.noteErr != nil {
|
||||
errText = "\nError: " + m.noteErr.Error()
|
||||
}
|
||||
return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText)
|
||||
}
|
||||
var sb strings.Builder
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
||||
sb.WriteString(title + "\n\n")
|
||||
sb.WriteString(m.input.View() + "\n")
|
||||
|
||||
if m.filterOpen {
|
||||
sb.WriteString(m.filterInput.View() + "\n")
|
||||
}
|
||||
|
||||
if m.loading {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
||||
} else if m.err != nil {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
||||
} else {
|
||||
if m.err != nil {
|
||||
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
if m.width > 0 {
|
||||
style = style.MaxWidth(m.width)
|
||||
}
|
||||
sb.WriteString(style.Render(warning) + "\n")
|
||||
}
|
||||
if m.recentMode && m.input.Value() == "" {
|
||||
if len(m.list.Items()) > 0 {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString(m.list.View())
|
||||
}
|
||||
|
||||
if m.editMode {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
||||
sb.WriteString(m.editInput.View() + "\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||
} else {
|
||||
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -205,33 +528,129 @@ func (m *Model) Selected() *SelectedHost {
|
||||
}
|
||||
|
||||
func (m *Model) startDebounce() tea.Cmd {
|
||||
query, seq := m.input.Value(), m.seq
|
||||
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
||||
return debounceMsg{query: m.input.Value()}
|
||||
return debounceMsg{query: query, seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||
opts := m.netboxSearchOpts()
|
||||
return func() tea.Msg {
|
||||
// Return cache hits immediately without a network round-trip.
|
||||
var cached []netbox.HostEntry
|
||||
if m.cache != nil {
|
||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
||||
entries := make([]netbox.HostEntry, len(cached))
|
||||
for i, c := range cached {
|
||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind}
|
||||
}
|
||||
return searchResultMsg{query: query, entries: entries}
|
||||
for _, entry := range m.cache.Search(query) {
|
||||
source, name := netbox.SplitTarget(entry.Name)
|
||||
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
||||
}
|
||||
}
|
||||
|
||||
if m.client == nil {
|
||||
return searchResultMsg{query: query, entries: nil}
|
||||
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
||||
return searchResultMsg{query: query, seq: seq, entries: cached}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := m.client.Search(ctx, query)
|
||||
_ = seq
|
||||
return searchResultMsg{query: query, entries: entries, err: err}
|
||||
entries, err := m.client.Search(ctx, query, opts)
|
||||
// A cached hit in one instance must not hide live hosts in other instances.
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
seen[e.Target()+"\x00"+e.Kind] = true
|
||||
}
|
||||
for _, e := range cached {
|
||||
if !seen[e.Target()+"\x00"+e.Kind] {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// netboxSearchOpts derives SearchOptions from the current filter input.
|
||||
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
|
||||
f := parseFilter(m.filterInput.Value())
|
||||
var tag string
|
||||
if len(f.tags) > 0 {
|
||||
tag = f.tags[0]
|
||||
}
|
||||
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
|
||||
}
|
||||
|
||||
// updateListItems applies the active filter and sets the list items.
|
||||
func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
f := parseFilter(m.filterInput.Value())
|
||||
filtered := applyFilter(entries, f)
|
||||
items := make([]list.Item, len(filtered))
|
||||
for i, e := range filtered {
|
||||
ip := e.PrimaryIP4
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.resizeList()
|
||||
}
|
||||
|
||||
// resizeList keeps the footer visible when the recent heading or editors change.
|
||||
func (m *Model) resizeList() {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return
|
||||
}
|
||||
extraRows := 5 // title, blank line, search, footer separator, footer
|
||||
if m.err != nil {
|
||||
extraRows++
|
||||
}
|
||||
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||
extraRows++
|
||||
}
|
||||
if m.filterOpen {
|
||||
extraRows++
|
||||
}
|
||||
if m.editMode {
|
||||
extraRows++
|
||||
}
|
||||
height := max(1, m.height-extraRows)
|
||||
if m.list.Width() != m.width || m.list.Height() != height {
|
||||
m.list.SetSize(m.width, height)
|
||||
}
|
||||
}
|
||||
|
||||
// openEdit switches to edit mode for the given list item.
|
||||
func (m *Model) openEdit(item hostItem) {
|
||||
m.editMode = true
|
||||
m.editHost = &item
|
||||
m.input.Blur()
|
||||
|
||||
user := m.defaultUser
|
||||
prefix := ""
|
||||
if user != "" {
|
||||
prefix = user + "@"
|
||||
}
|
||||
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
|
||||
m.editInput.CursorEnd()
|
||||
m.editInput.Focus()
|
||||
}
|
||||
|
||||
// buildSelected parses the editInput to extract user/port overrides.
|
||||
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
s := strings.TrimSpace(m.editInput.Value())
|
||||
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||
|
||||
// Extract user: everything before @
|
||||
if idx := strings.Index(s, "@"); idx != -1 {
|
||||
sel.User = strings.TrimSpace(s[:idx])
|
||||
s = s[idx+1:]
|
||||
}
|
||||
|
||||
// Extract port: after the last colon, if it's a number
|
||||
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
||||
portStr := strings.TrimSpace(s[idx+1:])
|
||||
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
|
||||
sel.Port = portStr
|
||||
}
|
||||
}
|
||||
|
||||
return sel
|
||||
}
|
||||
|
||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
)
|
||||
|
||||
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
||||
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
|
||||
if m.editMode || m.input.Value() != "e" {
|
||||
t.Fatal("e intercepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleResultsAndDebounceAreIgnored(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.input.SetValue("web")
|
||||
m.seq = 3
|
||||
m.Update(searchResultMsg{query: "web", seq: 1, entries: []netbox.HostEntry{{Name: "old"}}})
|
||||
if len(m.list.Items()) != 0 {
|
||||
t.Fatal("stale result accepted")
|
||||
}
|
||||
_, cmd := m.Update(debounceMsg{query: "w", seq: 1})
|
||||
if cmd != nil {
|
||||
t.Fatal("stale debounce accepted")
|
||||
}
|
||||
m.Update(searchResultMsg{query: "web", seq: 3, entries: []netbox.HostEntry{{Name: "web01"}}})
|
||||
if len(m.list.Items()) != 1 {
|
||||
t.Fatal("current result ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditPreservesDefaultAndExplicitPort22(t *testing.T) {
|
||||
m := New(nil, nil, "", 2222)
|
||||
item := hostItem{name: "web01", ip: "10.0.0.1"}
|
||||
m.openEdit(item)
|
||||
if m.editInput.Value() != "web01:2222" {
|
||||
t.Fatal(m.editInput.Value())
|
||||
}
|
||||
m.editInput.SetValue("ops@web01:22")
|
||||
selected := m.buildSelected(item)
|
||||
if selected.Port != "22" || selected.User != "ops" {
|
||||
t.Fatalf("bad override: %+v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedNoteLoadCannotBeSaved(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
||||
m.noteMode = true
|
||||
_, cmd := m.Update(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
|
||||
if cmd != nil {
|
||||
t.Fatal("save allowed before successful load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||
m := New(nil, nil, "admin")
|
||||
first := hostItem{name: "first", ip: "10.0.0.1"}
|
||||
m.openEdit(first)
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "first" {
|
||||
t.Fatalf("edited host changed: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func TestV2PasteFollowsFocusedEditor(t *testing.T) {
|
||||
t.Run("search", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
_, cmd := m.Update(tea.PasteMsg{Content: "web-ä"})
|
||||
if m.input.Value() != "web-ä" || m.seq != 1 || cmd == nil {
|
||||
t.Fatalf("paste did not start search: %q, seq %d", m.input.Value(), m.seq)
|
||||
}
|
||||
})
|
||||
t.Run("connection editor", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.openEdit(hostItem{name: "web", ip: "10.0.0.1"})
|
||||
m.editInput.SetValue("")
|
||||
m.Update(tea.PasteMsg{Content: "ops@web:2222"})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.User != "ops" || m.selected.Port != "2222" {
|
||||
t.Fatalf("paste lost in editor: %+v", m.selected)
|
||||
}
|
||||
if m.input.Value() != "" {
|
||||
t.Fatal("editor paste changed search")
|
||||
}
|
||||
})
|
||||
t.Run("filter", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.lastResults = []netbox.HostEntry{{Name: "vm", Kind: "vm"}, {Name: "device", Kind: "device"}}
|
||||
m.updateListItems(m.lastResults)
|
||||
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
m.Update(tea.PasteMsg{Content: "kind:vm"})
|
||||
if m.filterInput.Value() != "kind:vm" || len(m.list.Items()) != 1 {
|
||||
t.Fatalf("paste did not filter: %q, %d results", m.filterInput.Value(), len(m.list.Items()))
|
||||
}
|
||||
if m.input.Value() != "" {
|
||||
t.Fatal("filter paste changed search")
|
||||
}
|
||||
})
|
||||
t.Run("notes", func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.noteMode = true
|
||||
m.input.Blur()
|
||||
m.noteInput.Focus()
|
||||
m.Update(tea.PasteMsg{Content: "First line\nSecond line"})
|
||||
if m.noteInput.Value() != "First line\nSecond line" {
|
||||
t.Fatalf("multiline paste lost: %q", m.noteInput.Value())
|
||||
}
|
||||
m.noteBusy = true
|
||||
m.Update(tea.PasteMsg{Content: "must not change busy note"})
|
||||
if m.noteInput.Value() != "First line\nSecond line" {
|
||||
t.Fatal("paste edited note while loading/saving")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestV2ViewKeepsAlternateScreenInEachMode(t *testing.T) {
|
||||
for _, mode := range []string{"search", "edit", "notes"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
if mode == "edit" {
|
||||
m.openEdit(hostItem{name: "web"})
|
||||
}
|
||||
if mode == "notes" {
|
||||
m.noteMode = true
|
||||
}
|
||||
view := m.View()
|
||||
if !view.AltScreen || strings.TrimSpace(view.Content) == "" {
|
||||
t.Fatalf("invalid %s view: %+v", mode, view)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2KeyReleaseDoesNotTypeTwice(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.Update(tea.KeyPressMsg{Code: 'e', Text: "e"})
|
||||
m.Update(tea.KeyReleaseMsg{Code: 'e', Text: "e"})
|
||||
if m.input.Value() != "e" {
|
||||
t.Fatalf("key release changed input: %q", m.input.Value())
|
||||
}
|
||||
m.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
|
||||
if m.input.Value() != "e " {
|
||||
t.Fatalf("space not inserted: %q", m.input.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2ResizeShowsFullSearchPromptAndFooter(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||
var entries []netbox.HostEntry
|
||||
for range 30 {
|
||||
entries = append(entries, netbox.HostEntry{Name: "web", Kind: "device"})
|
||||
}
|
||||
m.updateListItems(entries)
|
||||
view := m.View()
|
||||
if m.input.Width() != 98 || m.filterInput.Width() != 92 || m.editInput.Width() != 88 {
|
||||
t.Fatalf("editors were not sized for terminal: search=%d filter=%d edit=%d", m.input.Width(), m.filterInput.Width(), m.editInput.Width())
|
||||
}
|
||||
if !strings.Contains(view.Content, "earch hostname") || !strings.Contains(view.Content, "ctrl+c quit") {
|
||||
t.Fatalf("search prompt or footer missing: %s", view.Content)
|
||||
}
|
||||
assertFits := func() {
|
||||
t.Helper()
|
||||
if rows := strings.Count(m.View().Content, "\n"); rows > 24 {
|
||||
t.Fatalf("view overflows terminal: %d rows", rows)
|
||||
}
|
||||
}
|
||||
assertFits()
|
||||
m.lastResults = entries
|
||||
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
assertFits()
|
||||
m.Update(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
m.Update(tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl})
|
||||
assertFits()
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package netssh_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReleaseNotesUseReleaseAssetAndVersionRange(t *testing.T) {
|
||||
script, err := filepath.Abs("scripts/release-notes.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Release Test", "GIT_AUTHOR_EMAIL=release@example.com", "GIT_COMMITTER_NAME=Release Test", "GIT_COMMITTER_EMAIL=release@example.com", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
git("init")
|
||||
git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "Initial version")
|
||||
git("tag", "v0.0.1")
|
||||
render := func(tag string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("bash", script, tag, "https://git.example.com/team/netssh/")
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v\n%s", err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
initial := render("v0.0.1")
|
||||
if !strings.Contains(initial, "Initial release:") || !strings.Contains(initial, "Initial version") {
|
||||
t.Fatalf("first release: %s", initial)
|
||||
}
|
||||
git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", `Add installer and "quoted" release notes`)
|
||||
git("tag", "v0.0.2")
|
||||
git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "Unreleased changes")
|
||||
notes := render("v0.0.2")
|
||||
for _, want := range []string{"https://git.example.com/team/netssh/releases/download/v0.0.2/install.sh", "Changes since `v0.0.1`:", `Add installer and "quoted" release notes`, "checksums.txt"} {
|
||||
if !strings.Contains(notes, want) {
|
||||
t.Errorf("missing %q:\n%s", want, notes)
|
||||
}
|
||||
}
|
||||
for _, excluded := range []string{"Initial version", "Unreleased changes", "raw/branch/main"} {
|
||||
if strings.Contains(notes, excluded) {
|
||||
t.Errorf("unexpected %q:\n%s", excluded, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"dependencyDashboard": true,
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Keep GitHub Actions in the Gitea release workflow up to date",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"description": "Include indirect Go modules in dependency updates",
|
||||
"matchManagers": [
|
||||
"gomod"
|
||||
],
|
||||
"matchDepTypes": [
|
||||
"indirect"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"description": "Update the Go version declared in go.mod",
|
||||
"matchManagers": [
|
||||
"gomod"
|
||||
],
|
||||
"matchDepNames": [
|
||||
"go"
|
||||
],
|
||||
"matchDepTypes": [
|
||||
"golang"
|
||||
],
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 2 ]]; then
|
||||
printf 'Usage: %s TAG REPOSITORY_URL\n' "$0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG=$1
|
||||
REPOSITORY_URL=${2%/}
|
||||
git rev-parse --verify "$TAG^{commit}" >/dev/null
|
||||
# Start at the parent so the release being built cannot match itself.
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' "$TAG^" 2>/dev/null || true)
|
||||
|
||||
printf '## Installation / Update\n\n'
|
||||
printf '```sh\ncurl -fsSL %s/releases/download/%s/install.sh | bash\n```\n\n' "$REPOSITORY_URL" "$TAG"
|
||||
printf 'The installer is attached to this release. It checks dependencies and verifies binary SHA-256 checksums before installation. Python is not required.\n\n'
|
||||
printf 'Download `checksums.txt` to verify the attached binaries and `install.sh`.\n\n'
|
||||
printf '## Changes\n\n'
|
||||
if [[ -n "$PREVIOUS_TAG" ]]; then
|
||||
printf 'Changes since `%s`:\n\n' "$PREVIOUS_TAG"
|
||||
git log --no-merges --format='- %s (%h)' "$PREVIOUS_TAG..$TAG"
|
||||
else
|
||||
printf 'Initial release:\n\n'
|
||||
git log --no-merges --format='- %s (%h)' "$TAG"
|
||||
fi
|
||||
Reference in new issue
Block a user