test: add a local Docker test environment for all features
testenv/ starts two real NetBox 4.7 instances (v2 and v1 token), SSH targets on public, mgmt, and prod networks, a jump host, an IPv6-only host, a static host, and a client with ssh, sftp, scp, tmux, zellij, bash, zsh, and fish. A seed script fills NetBox through the REST API. 141 end-to-end checks cover cache and refresh filters, connecting, all resolver strategies, multiple instances, shortcuts and rewrites, SSH options and rules, static hosts, sftp/scp incl. IPv6 and jump hosts, tunnels, local notes and NetBox comments, completion and shell hooks, the TUI (driven through tmux), the configurator, and uninstall. Local only: testenv/up.sh, test.sh, shell.sh, down.sh. Not part of CI/CD. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
55e7a5106b
commit
dbb99bdded
34 files changed
+1215
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
.git
|
||||||
|
testenv/.keys
|
||||||
|
testenv/.state
|
||||||
@@ -19,3 +19,7 @@ dist/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# local test environment (testenv/up.sh)
|
||||||
|
testenv/.keys/
|
||||||
|
testenv/.state/
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Fix the `interface_name` resolver strategy: it sent filter names that NetBox ignores and therefore returned the host's first IP address instead of the named interface's. It now uses NetBox's `interface` and `vminterface` filters; a missing interface falls through to the next strategy.
|
||||||
|
- Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths.
|
||||||
|
- Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`).
|
||||||
|
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 141 end-to-end checks of all features (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
|
||||||
|
|
||||||
## v0.0.23
|
## v0.0.23
|
||||||
|
|
||||||
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
|
- `netssh configure` now covers every setting: after the instances, a menu opens with SSH defaults, SSH options (jump host, host key checking, connection target, address family, extra arguments, `-o` options), SSH rules (add, edit, remove by tag, instance, kind, name pattern), resolver & cache (including the cache file), shortcuts & command alias, and completion (`max_results`). SSH options can also be set per NetBox instance from the instance list.
|
||||||
|
|||||||
@@ -1055,6 +1055,14 @@ go test ./... # includes offline installer/update tests
|
|||||||
go build ./... # build all packages
|
go build ./... # build all packages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
End-to-end tests run against real services in a local Docker environment — two NetBox instances, SSH targets on separate networks, a jump host, an IPv6 host, tmux, zellij, and bash/zsh/fish. See [testenv/README.md](testenv/README.md). It is not part of CI/CD.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
testenv/up.sh # build and start (first start ~6 min)
|
||||||
|
testenv/test.sh # 141 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator
|
||||||
|
testenv/down.sh
|
||||||
|
```
|
||||||
|
|
||||||
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, the argument parsers for ssh, sftp, and scp, SSH options and rules, port forwards, config loading, the setup wizard, shortcut normalization and rewrites, completion, the TUI keys, and shell hook install/uninstall. Wrapper tests run the real command (`sftp`, `scp`, `tunnel` with `--dry-run`) in a subprocess against a pre-filled cache, so no NetBox or ssh server is needed.
|
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, the argument parsers for ssh, sftp, and scp, SSH options and rules, port forwards, config loading, the setup wizard, shortcut normalization and rewrites, completion, the TUI keys, and shell hook install/uninstall. Wrapper tests run the real command (`sftp`, `scp`, `tunnel` with `--dry-run`) in a subprocess against a pre-filled cache, so no NetBox or ssh server is needed.
|
||||||
|
|
||||||
Project layout:
|
Project layout:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# netssh test environment
|
||||||
|
|
||||||
|
A local Docker environment for testing every netssh feature against real
|
||||||
|
services: two NetBox instances, SSH targets on three networks, a jump host,
|
||||||
|
and a client with ssh, sftp, scp, tmux, zellij, bash, zsh, and fish. It is for
|
||||||
|
local use only and is not part of CI/CD.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
testenv/up.sh # build, start, seed NetBox (first start ~6 min, then ~1 min)
|
||||||
|
testenv/test.sh # run all tests; testenv/test.sh tunnel runs only matching files
|
||||||
|
testenv/shell.sh # interactive shell in the client, netssh configured
|
||||||
|
testenv/down.sh # stop and delete everything (volumes, generated state)
|
||||||
|
```
|
||||||
|
|
||||||
|
`test.sh` rebuilds the client image first, so the tests always run the current
|
||||||
|
source. `NO_BUILD=1 testenv/test.sh` skips that. Exit code 1 if any check fails.
|
||||||
|
|
||||||
|
Requirements: Docker with Compose v2, about 3 GB RAM, ports 18000/18001 on
|
||||||
|
127.0.0.1 free.
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
|
||||||
|
```
|
||||||
|
public 10.231.0.0/24 + fd00:30::/64 mgmt 10.231.1.0/24 prod 10.231.2.0/24
|
||||||
|
client .10 ────────────┬─ bastion .30 ───────────────────────────────┬─ (only via bastion)
|
||||||
|
netbox-fsn .20 │ fsn1-pve1..3 .11-.13 (mgmt0) │ fsn1-pve1..3 .11-.13 (eth0)
|
||||||
|
netbox-lab .21 │ lab-db1 .41, lab-web1 .42 │ web1 .21, web2 .22
|
||||||
|
legacy-box .50 client .10 ───────┘
|
||||||
|
v6-host fd00:30::60
|
||||||
|
```
|
||||||
|
|
||||||
|
| NetBox | Host | Kind | Primary IP | Other | Tags |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| fsn | `fsn1-pve1..3.hoster.test` | device, cluster `pve-fsn1` | eth0 = prod (needs bastion) | mgmt0 = mgmt (direct) | `pve` |
|
||||||
|
| fsn | `bastion.hoster.test` | device | 10.231.0.30 | | `bastion` |
|
||||||
|
| fsn | `v6-host` | device | fd00:30::60 (IPv6 only) | | `ipv6` |
|
||||||
|
| fsn | `web1`, `web2` | VM, cluster `pve-fsn1` | prod (needs bastion) | | `web` |
|
||||||
|
| lab | `lab-db1` | device | 10.231.1.41 | | `db` |
|
||||||
|
| lab | `web1` | VM, cluster `lab-k8s` | 10.231.1.42 | | `web` |
|
||||||
|
| — | `legacy-box` (10.231.0.50) | static host | | | |
|
||||||
|
|
||||||
|
- **fsn** uses a v2 token (`nbt_…`), **lab** a legacy v1 token; both come from `.state/env`.
|
||||||
|
- `web1` exists in both instances (ambiguity, `instance/host`).
|
||||||
|
- Docker DNS resolves the `*.hoster.test` names on the mgmt network (`hostname` strategy).
|
||||||
|
- Every target runs sshd on ports 22 and 2222 with users `ops` and `admin`, and an HTTP server on its own 127.0.0.1:8081 for tunnel tests ("hello from HOST").
|
||||||
|
- NetBox UI: http://127.0.0.1:18000 (fsn), http://127.0.0.1:18001 (lab), login `admin` / `admin`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `compose.yaml` | All services and networks. |
|
||||||
|
| `client.Dockerfile`, `client-entrypoint.sh` | Client image; builds netssh from the repository. |
|
||||||
|
| `targets/` | SSH target image (sshd config, entrypoint). |
|
||||||
|
| `seed/seed.py` | Fills both NetBox instances via the REST API (idempotent), writes `.state/`. |
|
||||||
|
| `tests/run.sh`, `tests/lib.sh` | Test runner and helpers (`check`, `expect_out`, `expect_eq`, `expect_fail`, `wait_for`). |
|
||||||
|
| `tests/NN-*.sh` | Test files; each runs in a fresh `HOME`. |
|
||||||
|
| `tests/drive.py` | Drives `netssh configure` in huh's accessible mode. |
|
||||||
|
| `.keys/`, `.state/` | Generated test key and seed output (git-ignored). |
|
||||||
|
|
||||||
|
## Writing tests
|
||||||
|
|
||||||
|
Each `tests/NN-name.sh` is sourced by `run.sh` in a subshell with a new
|
||||||
|
`HOME`, `XDG_CONFIG_HOME`, and `XDG_CACHE_HOME`. Write the netssh config with
|
||||||
|
`config <<'EOF' … EOF` (both NetBox instances plus your YAML) or `config_only`
|
||||||
|
(no NetBox), then use the helpers:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
config <<'EOF'
|
||||||
|
ssh: {default_user: ops}
|
||||||
|
EOF
|
||||||
|
expect_eq "connect" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
|
||||||
|
expect_out "dry run" "-J ops@10.231.0.30" netssh --dry-run fsn/web1
|
||||||
|
expect_fail "ambiguous" "found 2 exact matches" netssh web1
|
||||||
|
```
|
||||||
|
|
||||||
|
ssh always reads `/home/tester/.ssh/config` (OpenSSH ignores `$HOME`): it uses
|
||||||
|
the test key, `BatchMode`, `StrictHostKeyChecking accept-new`, and
|
||||||
|
`LogLevel ERROR`. Tests that need their own known_hosts set
|
||||||
|
`ssh.options.UserKnownHostsFile`. The TUI test runs netssh inside tmux and
|
||||||
|
reads the screen with `tmux capture-pane`.
|
||||||
|
|
||||||
|
## Not covered
|
||||||
|
|
||||||
|
- The installer (`install.sh`) downloads release assets from Gitea; it is tested by the Go tests with local fixtures.
|
||||||
|
- macOS paths and behaviour.
|
||||||
|
- `netssh uninstall` runs only with `--dry-run`, so the client keeps its binary.
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ssh reads ~/.ssh from the passwd entry, not $HOME, so the key and ssh config
|
||||||
|
# live in /home/tester for every test; tests isolate netssh via HOME/XDG_*.
|
||||||
|
set -eu
|
||||||
|
mkdir -p /home/tester/.ssh
|
||||||
|
if [ -f /keys/id_ed25519 ]; then
|
||||||
|
install -m 600 /keys/id_ed25519 /home/tester/.ssh/id_ed25519
|
||||||
|
fi
|
||||||
|
cat >/home/tester/.ssh/config <<'EOF'
|
||||||
|
Host *
|
||||||
|
IdentityFile ~/.ssh/id_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
BatchMode yes
|
||||||
|
ConnectTimeout 5
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
LogLevel ERROR
|
||||||
|
EOF
|
||||||
|
chmod 700 /home/tester/.ssh
|
||||||
|
chmod 600 /home/tester/.ssh/config
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Client for the netssh test environment: netssh built from this repository
|
||||||
|
# plus everything it drives or integrates with (ssh, sftp, scp, tmux, zellij,
|
||||||
|
# bash, zsh, fish). Rebuild after code changes: testenv/up.sh or
|
||||||
|
# docker compose -f testenv/compose.yaml build client
|
||||||
|
FROM golang:1.27.4-alpine3.23 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY internal ./internal
|
||||||
|
RUN CGO_ENABLED=0 go build -o /out/netssh ./cmd/netssh
|
||||||
|
|
||||||
|
FROM alpine:3.23
|
||||||
|
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
|
||||||
|
curl python3 jq ncurses coreutils \
|
||||||
|
&& adduser -D -s /bin/bash tester
|
||||||
|
COPY --from=build /out/netssh /usr/local/bin/netssh
|
||||||
|
COPY testenv/client-entrypoint.sh /entrypoint.sh
|
||||||
|
USER tester
|
||||||
|
WORKDIR /home/tester
|
||||||
|
ENV TERM=xterm-256color LANG=C.UTF-8
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["sleep", "infinity"]
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# netssh test environment — local only, not used by CI/CD. See testenv/README.md.
|
||||||
|
#
|
||||||
|
# public 10.231.0.0/24 + fd00:30::/64 client, NetBox x2, bastion, static host, IPv6 host
|
||||||
|
# mgmt 10.231.1.0/24 client, Proxmox nodes (mgmt0), lab hosts
|
||||||
|
# prod 10.231.2.0/24 bastion, Proxmox nodes (eth0), VMs — not reachable from the client
|
||||||
|
name: netssh-test
|
||||||
|
|
||||||
|
x-netbox: &netbox
|
||||||
|
image: netboxcommunity/netbox:v4.7.4-5.1.1
|
||||||
|
depends_on:
|
||||||
|
postgres: {condition: service_healthy}
|
||||||
|
redis: {condition: service_healthy}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -sf http://localhost:8080/login/ >/dev/null"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 60
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
x-netbox-env: &netbox-env
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_USER: netbox
|
||||||
|
DB_PASSWORD: netbox
|
||||||
|
REDIS_HOST: redis
|
||||||
|
SECRET_KEY: netssh-test-environment-secret-key-0123456789-abcdefghijklmnopqrstuvwxyz
|
||||||
|
API_TOKEN_PEPPER_1: netssh-test-environment-pepper-0123456789-abcdefghijklmnop
|
||||||
|
SUPERUSER_NAME: admin
|
||||||
|
SUPERUSER_EMAIL: admin@example.com
|
||||||
|
SUPERUSER_PASSWORD: admin
|
||||||
|
SUPERUSER_API_KEY: netsshtest01
|
||||||
|
SUPERUSER_API_TOKEN: 0123456789abcdef0123456789abcdef01234567
|
||||||
|
SKIP_SUPERUSER: "false"
|
||||||
|
|
||||||
|
x-target: &target
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: testenv/targets/target.Dockerfile
|
||||||
|
image: netssh-test-target
|
||||||
|
volumes:
|
||||||
|
- ./.keys/authorized_keys:/keys/authorized_keys:ro
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: netbox
|
||||||
|
POSTGRES_PASSWORD: netbox
|
||||||
|
POSTGRES_DB: netbox
|
||||||
|
volumes:
|
||||||
|
- ./postgres-init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
- postgres:/var/lib/postgresql
|
||||||
|
networks: [backend]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U netbox"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8-alpine
|
||||||
|
networks: [backend]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
netbox-fsn:
|
||||||
|
<<: *netbox
|
||||||
|
hostname: netbox-fsn
|
||||||
|
environment:
|
||||||
|
<<: *netbox-env
|
||||||
|
DB_NAME: netbox_fsn
|
||||||
|
REDIS_DATABASE: "0"
|
||||||
|
REDIS_CACHE_DATABASE: "1"
|
||||||
|
ports: ["127.0.0.1:18000:8080"]
|
||||||
|
networks:
|
||||||
|
backend: {}
|
||||||
|
public: {ipv4_address: 10.231.0.20}
|
||||||
|
|
||||||
|
netbox-lab:
|
||||||
|
<<: *netbox
|
||||||
|
hostname: netbox-lab
|
||||||
|
environment:
|
||||||
|
<<: *netbox-env
|
||||||
|
DB_NAME: netbox_lab
|
||||||
|
REDIS_DATABASE: "2"
|
||||||
|
REDIS_CACHE_DATABASE: "3"
|
||||||
|
ports: ["127.0.0.1:18001:8080"]
|
||||||
|
networks:
|
||||||
|
backend: {}
|
||||||
|
public: {ipv4_address: 10.231.0.21}
|
||||||
|
|
||||||
|
# Jump host: the only way from the client into the prod network.
|
||||||
|
bastion:
|
||||||
|
<<: *target
|
||||||
|
hostname: bastion
|
||||||
|
networks:
|
||||||
|
public: {ipv4_address: 10.231.0.30, aliases: [bastion.hoster.test]}
|
||||||
|
prod: {ipv4_address: 10.231.2.30}
|
||||||
|
|
||||||
|
fsn1-pve1:
|
||||||
|
<<: *target
|
||||||
|
hostname: fsn1-pve1
|
||||||
|
networks:
|
||||||
|
mgmt: {ipv4_address: 10.231.1.11, aliases: [fsn1-pve1.hoster.test]}
|
||||||
|
prod: {ipv4_address: 10.231.2.11}
|
||||||
|
fsn1-pve2:
|
||||||
|
<<: *target
|
||||||
|
hostname: fsn1-pve2
|
||||||
|
networks:
|
||||||
|
mgmt: {ipv4_address: 10.231.1.12, aliases: [fsn1-pve2.hoster.test]}
|
||||||
|
prod: {ipv4_address: 10.231.2.12}
|
||||||
|
fsn1-pve3:
|
||||||
|
<<: *target
|
||||||
|
hostname: fsn1-pve3
|
||||||
|
networks:
|
||||||
|
mgmt: {ipv4_address: 10.231.1.13, aliases: [fsn1-pve3.hoster.test]}
|
||||||
|
prod: {ipv4_address: 10.231.2.13}
|
||||||
|
|
||||||
|
# VMs of cluster pve-fsn1: only in prod, so they need the bastion.
|
||||||
|
web1:
|
||||||
|
<<: *target
|
||||||
|
hostname: web1
|
||||||
|
networks:
|
||||||
|
prod: {ipv4_address: 10.231.2.21}
|
||||||
|
web2:
|
||||||
|
<<: *target
|
||||||
|
hostname: web2
|
||||||
|
networks:
|
||||||
|
prod: {ipv4_address: 10.231.2.22}
|
||||||
|
|
||||||
|
# NetBox instance "lab": a device and a VM named web1 like the fsn VM.
|
||||||
|
lab-db1:
|
||||||
|
<<: *target
|
||||||
|
hostname: lab-db1
|
||||||
|
networks:
|
||||||
|
mgmt: {ipv4_address: 10.231.1.41}
|
||||||
|
lab-web1:
|
||||||
|
<<: *target
|
||||||
|
hostname: lab-web1
|
||||||
|
networks:
|
||||||
|
mgmt: {ipv4_address: 10.231.1.42}
|
||||||
|
|
||||||
|
# Not in NetBox: tested as a static host.
|
||||||
|
legacy-box:
|
||||||
|
<<: *target
|
||||||
|
hostname: legacy-box
|
||||||
|
networks:
|
||||||
|
public: {ipv4_address: 10.231.0.50}
|
||||||
|
|
||||||
|
# In NetBox with an IPv6 primary address only.
|
||||||
|
v6-host:
|
||||||
|
<<: *target
|
||||||
|
hostname: v6-host
|
||||||
|
networks:
|
||||||
|
public: {ipv4_address: 10.231.0.60, ipv6_address: "fd00:30::60"}
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: testenv/client.Dockerfile
|
||||||
|
image: netssh-test-client
|
||||||
|
hostname: client
|
||||||
|
init: true
|
||||||
|
volumes:
|
||||||
|
- ./.keys:/keys:ro
|
||||||
|
- ./.state:/state
|
||||||
|
- ./tests:/tests:ro
|
||||||
|
networks:
|
||||||
|
public: {ipv4_address: 10.231.0.10, ipv6_address: "fd00:30::10"}
|
||||||
|
mgmt: {ipv4_address: 10.231.1.10}
|
||||||
|
|
||||||
|
# Fills both NetBox instances and writes /state/netssh.yaml, then exits.
|
||||||
|
seed:
|
||||||
|
image: netssh-test-client
|
||||||
|
depends_on:
|
||||||
|
client: {condition: service_started}
|
||||||
|
netbox-fsn: {condition: service_healthy}
|
||||||
|
netbox-lab: {condition: service_healthy}
|
||||||
|
volumes:
|
||||||
|
- ./.state:/state
|
||||||
|
- ./seed:/seed:ro
|
||||||
|
networks:
|
||||||
|
public: {}
|
||||||
|
command: ["python3", "/seed/seed.py"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
# Fixed subnets outside Docker's default address pools (172.17-31.x, 192.168.x),
|
||||||
|
# so automatically numbered networks of other projects cannot collide.
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 10.231.3.0/24
|
||||||
|
public:
|
||||||
|
enable_ipv6: true
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 10.231.0.0/24
|
||||||
|
- subnet: fd00:30::/64
|
||||||
|
mgmt:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 10.231.1.0/24
|
||||||
|
prod:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 10.231.2.0/24
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres: {}
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop the netssh test environment and remove its volumes and state.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
docker compose -f compose.yaml down -v --remove-orphans
|
||||||
|
rm -rf .state
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- One Postgres serves both NetBox instances of the test environment.
|
||||||
|
CREATE DATABASE netbox_fsn OWNER netbox;
|
||||||
|
CREATE DATABASE netbox_lab OWNER netbox;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Fill both NetBox instances of the netssh test environment.
|
||||||
|
|
||||||
|
Idempotent: objects are looked up before they are created, so running the
|
||||||
|
seed again leaves NetBox unchanged. Writes /state/env (tokens) and
|
||||||
|
/state/netssh.yaml (base config for the tests).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
FSN = "http://10.231.0.20:8080"
|
||||||
|
LAB = "http://10.231.0.21:8080"
|
||||||
|
# Superuser v2 token from compose.yaml (SUPERUSER_API_KEY / SUPERUSER_API_TOKEN).
|
||||||
|
V2_TOKEN = "nbt_netsshtest01.0123456789abcdef0123456789abcdef01234567"
|
||||||
|
STATE = "/state"
|
||||||
|
|
||||||
|
|
||||||
|
class NetBox:
|
||||||
|
def __init__(self, url):
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def call(self, method, path, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(self.url + "/api/" + path, data=data, method=method)
|
||||||
|
req.add_header("Authorization", "Bearer " + V2_TOKEN)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
sys.exit(f"{method} {path}: HTTP {err.code}: {err.read().decode(errors='replace')}")
|
||||||
|
|
||||||
|
def find(self, path, **filters):
|
||||||
|
query = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in filters.items())
|
||||||
|
results = self.call("GET", f"{path}?{query}&limit=2")["results"]
|
||||||
|
return results[0] if results else None
|
||||||
|
|
||||||
|
def ensure(self, path, lookup, body):
|
||||||
|
"""Return the object matching lookup, creating it from body if missing."""
|
||||||
|
found = self.find(path, **lookup)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return self.call("POST", path, body)
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
self.call("GET", "status/")
|
||||||
|
return
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
except (urllib.error.URLError, ConnectionError, TimeoutError):
|
||||||
|
pass
|
||||||
|
time.sleep(5)
|
||||||
|
sys.exit(f"NetBox {self.url} did not become ready")
|
||||||
|
|
||||||
|
|
||||||
|
def base(nb, site, cluster_type, cluster):
|
||||||
|
for tag in ("pve", "web", "bastion", "db", "ipv6"):
|
||||||
|
nb.ensure("extras/tags/", {"slug": tag}, {"name": tag, "slug": tag})
|
||||||
|
site = nb.ensure("dcim/sites/", {"slug": site}, {"name": site, "slug": site, "status": "active"})
|
||||||
|
mfr = nb.ensure("dcim/manufacturers/", {"slug": "generic"}, {"name": "Generic", "slug": "generic"})
|
||||||
|
role = nb.ensure("dcim/device-roles/", {"slug": "server"}, {"name": "Server", "slug": "server", "color": "2196f3"})
|
||||||
|
dtype = nb.ensure("dcim/device-types/", {"slug": "generic-server"},
|
||||||
|
{"manufacturer": mfr["id"], "model": "Generic Server", "slug": "generic-server"})
|
||||||
|
ctype = nb.ensure("virtualization/cluster-types/", {"slug": cluster_type},
|
||||||
|
{"name": cluster_type, "slug": cluster_type})
|
||||||
|
cluster = nb.ensure("virtualization/clusters/", {"name": cluster},
|
||||||
|
{"name": cluster, "type": ctype["id"], "status": "active"})
|
||||||
|
return {"site": site["id"], "role": role["id"], "type": dtype["id"], "cluster": cluster["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def device(nb, ids, name, ips, tags=(), cluster=False, comments=""):
|
||||||
|
"""ips: list of (interface, address, primary) with primary in (None, 4, 6)."""
|
||||||
|
body = {"name": name, "role": ids["role"], "device_type": ids["type"], "site": ids["site"],
|
||||||
|
"status": "active", "tags": [{"slug": t} for t in tags], "comments": comments}
|
||||||
|
if cluster:
|
||||||
|
body["cluster"] = ids["cluster"]
|
||||||
|
dev = nb.ensure("dcim/devices/", {"name": name}, body)
|
||||||
|
primary = {}
|
||||||
|
for ifname, address, family in ips:
|
||||||
|
iface = nb.ensure("dcim/interfaces/", {"device_id": dev["id"], "name": ifname},
|
||||||
|
{"device": dev["id"], "name": ifname, "type": "1000base-t"})
|
||||||
|
ip = nb.ensure("ipam/ip-addresses/", {"address": address},
|
||||||
|
{"address": address, "assigned_object_type": "dcim.interface", "assigned_object_id": iface["id"]})
|
||||||
|
if family:
|
||||||
|
primary[f"primary_ip{family}"] = ip["id"]
|
||||||
|
if primary:
|
||||||
|
nb.call("PATCH", f"dcim/devices/{dev['id']}/", primary)
|
||||||
|
|
||||||
|
|
||||||
|
def vm(nb, ids, name, address, tags=()):
|
||||||
|
machine = nb.ensure("virtualization/virtual-machines/", {"name": name},
|
||||||
|
{"name": name, "cluster": ids["cluster"], "status": "active",
|
||||||
|
"tags": [{"slug": t} for t in tags]})
|
||||||
|
iface = nb.ensure("virtualization/interfaces/", {"virtual_machine_id": machine["id"], "name": "eth0"},
|
||||||
|
{"virtual_machine": machine["id"], "name": "eth0"})
|
||||||
|
ip = nb.ensure("ipam/ip-addresses/", {"address": address},
|
||||||
|
{"address": address, "assigned_object_type": "virtualization.vminterface",
|
||||||
|
"assigned_object_id": iface["id"]})
|
||||||
|
nb.call("PATCH", f"virtualization/virtual-machines/{machine['id']}/", {"primary_ip4": ip["id"]})
|
||||||
|
|
||||||
|
|
||||||
|
def v1_token(nb):
|
||||||
|
"""A legacy v1 token for the lab instance, reused from /state/env if present."""
|
||||||
|
env = read_env()
|
||||||
|
if env.get("LAB_TOKEN"):
|
||||||
|
return env["LAB_TOKEN"]
|
||||||
|
user = nb.find("users/users/", username="admin")
|
||||||
|
token = nb.call("POST", "users/tokens/", {"user": user["id"], "version": 1, "description": "netssh v1 test"})
|
||||||
|
plaintext = token.get("token") or token.get("plaintext")
|
||||||
|
if not plaintext:
|
||||||
|
sys.exit(f"v1 token response without plaintext: {token}")
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
|
||||||
|
def read_env():
|
||||||
|
env = {}
|
||||||
|
try:
|
||||||
|
with open(os.path.join(STATE, "env")) as f:
|
||||||
|
for line in f:
|
||||||
|
key, _, value = line.strip().partition("=")
|
||||||
|
env[key] = value
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
fsn, lab = NetBox(FSN), NetBox(LAB)
|
||||||
|
fsn.wait()
|
||||||
|
lab.wait()
|
||||||
|
|
||||||
|
ids = base(fsn, "fsn1", "proxmox", "pve-fsn1")
|
||||||
|
for n in (1, 2, 3):
|
||||||
|
device(fsn, ids, f"fsn1-pve{n}.hoster.test",
|
||||||
|
[("eth0", f"10.231.2.1{n}/24", 4), ("mgmt0", f"10.231.1.1{n}/24", None)],
|
||||||
|
tags=("pve",), cluster=True, comments=f"Proxmox node {n}")
|
||||||
|
device(fsn, ids, "bastion.hoster.test", [("eth0", "10.231.0.30/24", 4)], tags=("bastion",))
|
||||||
|
device(fsn, ids, "v6-host", [("eth0", "fd00:30::60/64", 6)], tags=("ipv6",))
|
||||||
|
vm(fsn, ids, "web1", "10.231.2.21/24", tags=("web",))
|
||||||
|
vm(fsn, ids, "web2", "10.231.2.22/24", tags=("web",))
|
||||||
|
|
||||||
|
ids = base(lab, "lab", "kubernetes", "lab-k8s")
|
||||||
|
device(lab, ids, "lab-db1", [("eth0", "10.231.1.41/24", 4)], tags=("db",))
|
||||||
|
vm(lab, ids, "web1", "10.231.1.42/24", tags=("web",))
|
||||||
|
|
||||||
|
lab_token = v1_token(lab)
|
||||||
|
os.makedirs(STATE, exist_ok=True)
|
||||||
|
with open(os.path.join(STATE, "env"), "w") as f:
|
||||||
|
f.write(f"FSN_URL={FSN}\nLAB_URL={LAB}\nFSN_TOKEN={V2_TOKEN}\nLAB_TOKEN={lab_token}\n")
|
||||||
|
with open(os.path.join(STATE, "netssh.yaml"), "w") as f:
|
||||||
|
f.write(f"""netboxes:
|
||||||
|
- name: fsn
|
||||||
|
url: "{FSN}"
|
||||||
|
token: "{V2_TOKEN}"
|
||||||
|
- name: lab
|
||||||
|
url: "{LAB}"
|
||||||
|
token: "{lab_token}"
|
||||||
|
""")
|
||||||
|
print("seed: done (fsn: 3 nodes, bastion, v6-host, 2 VMs; lab: lab-db1, web1)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Interactive shell in the client with a ready netssh config (fresh home).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
exec docker compose -f compose.yaml exec -it client bash -c '
|
||||||
|
. /tests/lib.sh
|
||||||
|
new_home
|
||||||
|
base_config >"$XDG_CONFIG_HOME/netssh.yaml" <<EOF
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
host_key_checking: accept-new
|
||||||
|
rules:
|
||||||
|
- match: {kind: vm, instance: fsn}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
EOF
|
||||||
|
echo "netssh is configured (HOME=$HOME). Try: netssh cache refresh && netssh"
|
||||||
|
exec bash -i'
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prepares host keys and the test users, starts a small HTTP server for the
|
||||||
|
# tunnel tests (reachable only on the target itself), then runs sshd.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -q -t ed25519 -N '' -f /etc/ssh/ssh_host_ed25519_key
|
||||||
|
|
||||||
|
for user in ops admin; do
|
||||||
|
home="/home/$user"
|
||||||
|
mkdir -p "$home/.ssh"
|
||||||
|
cp /keys/authorized_keys "$home/.ssh/authorized_keys"
|
||||||
|
chown -R "$user:$user" "$home/.ssh"
|
||||||
|
chmod 700 "$home/.ssh"
|
||||||
|
chmod 600 "$home/.ssh/authorized_keys"
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p /srv/www
|
||||||
|
echo "hello from $(hostname)" > /srv/www/index.html
|
||||||
|
python3 -m http.server 8081 --bind 127.0.0.1 --directory /srv/www >/dev/null 2>&1 &
|
||||||
|
|
||||||
|
exec /usr/sbin/sshd -D -e
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Test targets accept the generated test key only. Port 2222 tests ssh.default_port.
|
||||||
|
Port 22
|
||||||
|
Port 2222
|
||||||
|
ListenAddress 0.0.0.0
|
||||||
|
ListenAddress ::
|
||||||
|
HostKey /etc/ssh/ssh_host_ed25519_key
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
AuthorizedKeysFile .ssh/authorized_keys
|
||||||
|
AllowTcpForwarding yes
|
||||||
|
AllowAgentForwarding yes
|
||||||
|
GatewayPorts no
|
||||||
|
PermitTunnel no
|
||||||
|
X11Forwarding no
|
||||||
|
PrintMotd no
|
||||||
|
Subsystem sftp /usr/lib/ssh/sftp-server
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# SSH target for the netssh test environment: every NetBox host, the bastion,
|
||||||
|
# and the static host run this image with a different hostname.
|
||||||
|
FROM alpine:3.23
|
||||||
|
|
||||||
|
# Alpine locks new accounts with "!"; sshd refuses key logins for locked
|
||||||
|
# accounts, so the password field is set to "*" (no password, not locked).
|
||||||
|
RUN apk add --no-cache openssh-server openssh-sftp-server python3 \
|
||||||
|
&& for user in ops admin; do \
|
||||||
|
adduser -D -s /bin/sh "$user" && sed -i "s/^$user:!/$user:*/" /etc/shadow; \
|
||||||
|
done
|
||||||
|
|
||||||
|
COPY testenv/targets/sshd_config /etc/ssh/sshd_config
|
||||||
|
COPY testenv/targets/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 22 2222
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the test scripts in the client container. Optional FILTER selects files
|
||||||
|
# by substring, e.g. testenv/test.sh tunnel. The client is rebuilt first so the
|
||||||
|
# tests always run the current source.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
compose=(docker compose -f compose.yaml)
|
||||||
|
if [ "${NO_BUILD:-}" != 1 ]; then
|
||||||
|
"${compose[@]}" build -q client
|
||||||
|
"${compose[@]}" up -d --wait client
|
||||||
|
fi
|
||||||
|
exec "${compose[@]}" exec -T client bash /tests/run.sh "${1:-}"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Cache refresh across both NetBox instances, filters, list, search, clear.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
expect_out "refresh both instances" "9 entries written" netssh cache refresh
|
||||||
|
expect_out "list shows mgmt address" "fsn/fsn1-pve1.hoster.test 10.231.1.11" netssh cache list
|
||||||
|
expect_out "list shows lab VM" "lab/web1 10.231.1.42 vm" netssh cache list
|
||||||
|
expect_out "IPv6-only host" "fsn/v6-host fd00:30::60" netssh cache list
|
||||||
|
expect_out "refresh one instance" "2 entries written" netssh cache refresh --instance lab
|
||||||
|
expect_out "refresh by tag" "3 entries written" netssh cache refresh --tag pve
|
||||||
|
expect_out "refresh by kind" "3 entries written" netssh cache refresh --kind vm
|
||||||
|
expect_eq "search" "fsn/fsn1-pve1.hoster.test
|
||||||
|
fsn/fsn1-pve2.hoster.test
|
||||||
|
fsn/fsn1-pve3.hoster.test" bash -c 'netssh search fsn1 | sort'
|
||||||
|
check "clear" netssh cache clear
|
||||||
|
expect_eq "empty after clear" "" bash -c 'netssh search fsn'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Connecting with the resolved address, default user and port, overrides.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
EOF
|
||||||
|
|
||||||
|
expect_eq "connect via NetBox" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
|
||||||
|
expect_eq "unique name without instance" "fsn1-pve2" netssh fsn1-pve2.hoster.test hostname
|
||||||
|
expect_eq "default user" "ops" netssh fsn/fsn1-pve1.hoster.test whoami
|
||||||
|
expect_eq "user@ wins" "admin" netssh admin@fsn/fsn1-pve1.hoster.test whoami
|
||||||
|
expect_eq "-l wins" "admin" netssh -l admin fsn/fsn1-pve1.hoster.test whoami
|
||||||
|
expect_eq "remote command with arguments" "a b" netssh fsn/fsn1-pve1.hoster.test echo a b
|
||||||
|
expect_eq "dry run" "ssh -l ops 10.231.1.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
|
||||||
|
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
default_port: 2222
|
||||||
|
EOF
|
||||||
|
expect_out "default port" " 2222" netssh fsn/fsn1-pve1.hoster.test 'echo $SSH_CONNECTION'
|
||||||
|
expect_out "-p wins over default port" " 22" netssh -p 22 fsn/fsn1-pve1.hoster.test 'echo $SSH_CONNECTION'
|
||||||
|
expect_eq "dry run with port" "ssh -p 2222 -l ops 10.231.1.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
|
||||||
|
expect_eq "version" "netssh" bash -c 'netssh --version | cut -d" " -f1'
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Every resolver strategy against real NetBox data. cache.ttl 0 makes every
|
||||||
|
# connection ask NetBox, so each config change takes effect immediately.
|
||||||
|
jump=' - match: {instance: fsn}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
- match: {name: "bastion*"}
|
||||||
|
proxy_jump: none'
|
||||||
|
|
||||||
|
config <<EOF
|
||||||
|
cache: {ttl: 0}
|
||||||
|
resolver: {strategies: [primary_ip]}
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
rules:
|
||||||
|
$jump
|
||||||
|
EOF
|
||||||
|
expect_eq "primary_ip uses prod address via bastion" "ssh -l ops -J ops@10.231.0.30 10.231.2.11" netssh --dry-run fsn/fsn1-pve1.hoster.test
|
||||||
|
expect_eq "primary_ip connects through the jump host" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
|
||||||
|
expect_eq "bastion rule disables the jump" "ssh -l ops 10.231.0.30" netssh --dry-run fsn/bastion.hoster.test
|
||||||
|
expect_eq "IPv6 primary address" "ssh -l ops -J ops@10.231.0.30 fd00:30::60" netssh --dry-run fsn/v6-host
|
||||||
|
|
||||||
|
config <<'EOF'
|
||||||
|
cache: {ttl: 0}
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh: {default_user: ops}
|
||||||
|
EOF
|
||||||
|
expect_eq "management_subnet" "ssh -l ops 10.231.1.12" netssh --dry-run fsn/fsn1-pve2.hoster.test
|
||||||
|
expect_eq "management_subnet falls back to primary_ip" "ssh -l ops 10.231.2.21" netssh --dry-run fsn/web1
|
||||||
|
|
||||||
|
config <<'EOF'
|
||||||
|
cache: {ttl: 0}
|
||||||
|
resolver:
|
||||||
|
strategies: [interface_name, primary_ip]
|
||||||
|
interface_name: mgmt0
|
||||||
|
ssh: {default_user: ops}
|
||||||
|
EOF
|
||||||
|
expect_eq "interface_name" "ssh -l ops 10.231.1.13" netssh --dry-run fsn/fsn1-pve3.hoster.test
|
||||||
|
expect_eq "interface_name connects" "fsn1-pve3" netssh fsn/fsn1-pve3.hoster.test hostname
|
||||||
|
expect_eq "interface_name falls back for VMs" "ssh -l ops 10.231.2.22" netssh --dry-run fsn/web2
|
||||||
|
|
||||||
|
# eth0 holds the prod address, which sorts after mgmt: catches an ignored filter.
|
||||||
|
sed -i 's/interface_name: mgmt0/interface_name: eth0/' "$XDG_CONFIG_HOME/netssh.yaml"
|
||||||
|
expect_eq "interface_name picks the named interface" "ssh -l ops 10.231.2.13" netssh --dry-run fsn/fsn1-pve3.hoster.test
|
||||||
|
|
||||||
|
config <<'EOF'
|
||||||
|
cache: {ttl: 0}
|
||||||
|
resolver:
|
||||||
|
strategies: [hostname]
|
||||||
|
hostname_domain: example.org
|
||||||
|
ssh: {default_user: ops}
|
||||||
|
EOF
|
||||||
|
expect_eq "hostname strategy" "ssh -l ops fsn1-pve1.hoster.test" netssh --dry-run fsn/fsn1-pve1.hoster.test
|
||||||
|
expect_eq "hostname connects via DNS" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
|
||||||
|
expect_eq "hostname domain for names without a dot" "ssh -l ops lab-db1.example.org" netssh --dry-run lab/lab-db1
|
||||||
|
|
||||||
|
config <<'EOF'
|
||||||
|
cache: {ttl: 0}
|
||||||
|
resolver:
|
||||||
|
strategies: [interface_name]
|
||||||
|
interface_name: mgmt0
|
||||||
|
EOF
|
||||||
|
expect_fail "no strategy finds an address" "no strategy resolved an IP" netssh --dry-run fsn/web1
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Two NetBox instances: qualified names, ambiguity, an unreachable instance.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
rules:
|
||||||
|
- match: {kind: vm, instance: fsn}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
EOF
|
||||||
|
expect_fail "same name in both instances is ambiguous" "found 2 exact matches" netssh --dry-run web1
|
||||||
|
expect_eq "fsn/web1 through the bastion" "web1" netssh fsn/web1 hostname
|
||||||
|
expect_eq "lab/web1 directly" "lab-web1" netssh lab/web1 hostname
|
||||||
|
expect_eq "lab uses its v1 token" "lab-db1" netssh lab/lab-db1 hostname
|
||||||
|
|
||||||
|
# lab unreachable: qualified fsn targets still work, unqualified ones need every instance.
|
||||||
|
sed -i 's#http://10.231.0.21:8080#http://10.231.0.21:9#' "$XDG_CONFIG_HOME/netssh.yaml"
|
||||||
|
expect_eq "qualified target with another instance down" "fsn1-pve1" netssh fsn/fsn1-pve1.hoster.test hostname
|
||||||
|
expect_fail "unqualified target needs every instance" "10.231.0.21:9" netssh --dry-run fsn1-pve1.hoster.test
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Domains, hyphen folding, rewrite rules, alias --test, completion, aliases.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh: {default_user: ops}
|
||||||
|
shortcuts:
|
||||||
|
domains: [.hoster.test]
|
||||||
|
rewrites:
|
||||||
|
- pattern: '^([a-z]+)[0-9]+-'
|
||||||
|
replace: '$1'
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
expect_eq "domain expansion via NetBox" "fsn1-pve1" netssh fsn/fsn1-pve1 hostname
|
||||||
|
expect_eq "rewrite shortcut, qualified" "fsn1-pve2" netssh fsn/fsnpve2 hostname
|
||||||
|
expect_eq "rewrite shortcut, unqualified" "fsn1-pve3" netssh fsnpve3 hostname
|
||||||
|
expect_out "alias --test" "fsn1-pve1.hoster.test → fsnpve1" netssh alias --test fsn1-pve1.hoster.test
|
||||||
|
expect_out "completion offers shortcuts" "fsnpve1" netssh __complete fsnp
|
||||||
|
expect_out "aliases use the shortcut" "alias fsn_fsnpve1='netssh " netssh alias --shell bash
|
||||||
|
expect_fail "unknown shortcut" "host not found" netssh --dry-run fsnpve9
|
||||||
|
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
shortcuts:
|
||||||
|
domains: [.hoster.test]
|
||||||
|
strip_hyphens: true
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
expect_eq "hyphen folding" "ssh 10.231.1.12" netssh --dry-run fsn/fsn1pve2
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Jump hosts, rules, host key checking, connection target, options, extra args.
|
||||||
|
known="$HOME/known_hosts"
|
||||||
|
config <<EOF
|
||||||
|
cache: {ttl: 0}
|
||||||
|
resolver: {strategies: [primary_ip]}
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
options:
|
||||||
|
UserKnownHostsFile: $known
|
||||||
|
ServerAliveInterval: "30"
|
||||||
|
extra_args: ["-T"]
|
||||||
|
rules:
|
||||||
|
- match: {tag: web}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
- match: {tag: pve}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
host_key_checking: strict
|
||||||
|
- match: {name: "fsn1-pve2*"}
|
||||||
|
host_key_checking: accept-new
|
||||||
|
- match: {name: "fsn1-pve3*"}
|
||||||
|
host_key_checking: "off"
|
||||||
|
target: name
|
||||||
|
EOF
|
||||||
|
expect_eq "rule by tag adds jump host" "ssh -T -l ops -J ops@10.231.0.30 -o serveraliveinterval=30 -o userknownhostsfile=$known 10.231.2.21" netssh --dry-run fsn/web1
|
||||||
|
expect_eq "connect through rule jump" "web1" netssh fsn/web1 hostname
|
||||||
|
expect_out "--no-jump removes it" "-o ProxyJump=none" netssh --dry-run --no-jump fsn/web1
|
||||||
|
expect_out "--jump overrides it" "-J ops@10.231.0.30" netssh --dry-run --jump ops@10.231.0.30 fsn/v6-host
|
||||||
|
expect_fail "strict rejects an unknown host key" "Host key verification failed" netssh fsn/fsn1-pve1.hoster.test hostname
|
||||||
|
expect_eq "later rule accept-new wins" "fsn1-pve2" netssh fsn/fsn1-pve2.hoster.test hostname
|
||||||
|
expect_out "accept-new stored the key" "10.231.2.12" cat "$known"
|
||||||
|
expect_out "off prints a notice" "host key checking is disabled" netssh fsn/fsn1-pve3.hoster.test hostname
|
||||||
|
expect_out "target name keeps the address" "-o HostName=10.231.2.13 -o HostKeyAlias=fsn1-pve3.hoster.test" netssh --dry-run fsn/fsn1-pve3.hoster.test
|
||||||
|
expect_out "target name destination" " fsn1-pve3.hoster.test" netssh --dry-run fsn/fsn1-pve3.hoster.test
|
||||||
|
expect_out "user -o wins over config" "-o ServerAliveInterval=5" netssh --dry-run -o ServerAliveInterval=5 fsn/web1
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Static hosts without any NetBox: config, CLI, own user/port/options, rules.
|
||||||
|
config_only <<'EOF'
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
rules:
|
||||||
|
- match: {kind: static, tag: legacy}
|
||||||
|
options: {ServerAliveInterval: "15"}
|
||||||
|
hosts:
|
||||||
|
- name: legacy-box
|
||||||
|
address: 10.231.0.50
|
||||||
|
user: admin
|
||||||
|
tags: [legacy]
|
||||||
|
EOF
|
||||||
|
expect_eq "connect to a static host" "legacy-box" netssh legacy-box hostname
|
||||||
|
expect_eq "static user" "admin" netssh legacy-box whoami
|
||||||
|
expect_out "rule by kind and tag" "-o serveraliveinterval=15" netssh --dry-run legacy-box
|
||||||
|
expect_eq "-l still wins" "ops" netssh -l ops legacy-box whoami
|
||||||
|
check "hosts add" netssh hosts add box2 10.231.0.50 --port 2222 --tag lab
|
||||||
|
expect_eq "hosts list" "box2 10.231.0.50 2222 lab" bash -c 'netssh hosts list | grep "^box2" | tr -s " "'
|
||||||
|
expect_out "static port" " 2222" netssh box2 'echo $SSH_CONNECTION'
|
||||||
|
expect_out "completion" "box2" netssh __complete bo
|
||||||
|
expect_fail "duplicate" "already exists" netssh hosts add box2 10.0.0.1
|
||||||
|
check "hosts remove" netssh hosts remove box2
|
||||||
|
expect_fail "removed host is gone" "no NetBox is configured" netssh --dry-run box2
|
||||||
|
expect_eq "sftp to a static host" "Remote working directory: /home/admin" bash -c 'echo pwd | netssh sftp -b - legacy-box | grep Remote'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# sftp and scp: upload, download, jump host, IPv6, errors.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
rules:
|
||||||
|
- match: {kind: vm, instance: fsn}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
EOF
|
||||||
|
echo "payload $$" >"$HOME/up.txt"
|
||||||
|
check "scp upload" netssh scp "$HOME/up.txt" fsn/fsn1-pve1.hoster.test:/tmp/up.txt
|
||||||
|
expect_eq "uploaded content" "payload $$" netssh fsn/fsn1-pve1.hoster.test cat /tmp/up.txt
|
||||||
|
check "scp download" netssh scp fsn/fsn1-pve1.hoster.test:/tmp/up.txt "$HOME/down.txt"
|
||||||
|
expect_eq "downloaded content" "payload $$" cat "$HOME/down.txt"
|
||||||
|
check "scp to a VM through the bastion" netssh scp "$HOME/up.txt" fsn/web1:/tmp/up.txt
|
||||||
|
expect_eq "VM content" "payload $$" netssh fsn/web1 cat /tmp/up.txt
|
||||||
|
check "scp to IPv6 host" netssh scp "$HOME/up.txt" fsn/v6-host:/tmp/v6.txt
|
||||||
|
expect_eq "IPv6 content" "payload $$" netssh fsn/v6-host cat /tmp/v6.txt
|
||||||
|
expect_out "sftp starts in the path" "Remote working directory: /tmp" bash -c 'echo pwd | netssh sftp -b - fsn/fsn1-pve2.hoster.test:/tmp'
|
||||||
|
expect_out "sftp to IPv6 host" "v6.txt" bash -c 'echo "ls /tmp" | netssh sftp -b - fsn/v6-host'
|
||||||
|
expect_out "sftp through the bastion" "up.txt" bash -c 'echo "ls /tmp" | netssh sftp -b - fsn/web1'
|
||||||
|
expect_fail "two remote hosts" "not supported" netssh scp fsn/web1:/a fsn/web2:/b
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Port forwards: named, ad hoc, --forward with a shell, port in use.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
forwards:
|
||||||
|
web: {local: "18081:127.0.0.1:8081"}
|
||||||
|
rules:
|
||||||
|
- match: {kind: vm, instance: fsn}
|
||||||
|
proxy_jump: ops@10.231.0.30
|
||||||
|
EOF
|
||||||
|
tunnel_test() { # NAME PORT WANT ARGS...
|
||||||
|
local name=$1 port=$2 want=$3 pid out
|
||||||
|
shift 3
|
||||||
|
"$@" >"$HOME/tunnel.log" 2>&1 &
|
||||||
|
pid=$!
|
||||||
|
if wait_for 15 curl -sf "http://127.0.0.1:$port/"; then
|
||||||
|
out=$(curl -s "http://127.0.0.1:$port/")
|
||||||
|
[[ $out == *"$want"* ]] && ok "$name" || bad "$name" "curl: $out"
|
||||||
|
else
|
||||||
|
bad "$name" "tunnel did not open: $(cat "$HOME/tunnel.log")"
|
||||||
|
fi
|
||||||
|
kill "$pid" 2>/dev/null
|
||||||
|
wait "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
tunnel_test "named forward" 18081 "hello from fsn1-pve1" netssh tunnel fsn/fsn1-pve1.hoster.test web
|
||||||
|
expect_out "tunnel notice" "forwarding -L 18081:127.0.0.1:8081 via fsn1-pve1.hoster.test" cat "$HOME/tunnel.log"
|
||||||
|
tunnel_test "ad hoc spec through the bastion" 18082 "hello from web1" netssh tunnel fsn/web1 18082:127.0.0.1:8081
|
||||||
|
tunnel_test "--forward with a remote command" 18081 "hello from fsn1-pve2" netssh --forward web fsn/fsn1-pve2.hoster.test sleep 30
|
||||||
|
|
||||||
|
netssh tunnel fsn/fsn1-pve1.hoster.test web >/dev/null 2>&1 &
|
||||||
|
holder=$!
|
||||||
|
wait_for 15 curl -sf http://127.0.0.1:18081/
|
||||||
|
expect_fail "port in use fails instead of hanging" "Address in use" timeout 20 netssh tunnel fsn/fsn1-pve2.hoster.test web
|
||||||
|
kill "$holder"; wait "$holder" 2>/dev/null
|
||||||
|
|
||||||
|
expect_fail "unknown forward lists the configured ones" "available: web" netssh tunnel fsn/fsn1-pve1.hoster.test db
|
||||||
|
expect_fail "tunnel without forward" "forwards for fsn/fsn1-pve1.hoster.test: web" netssh tunnel fsn/fsn1-pve1.hoster.test
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Local notes and NetBox comments (v2 token on fsn, v1 token on lab).
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
host=fsn/fsn1-pve1.hoster.test
|
||||||
|
check "set local note" netssh notes set $host "first line"
|
||||||
|
check "append local note" netssh notes append $host "second line"
|
||||||
|
expect_eq "show local note" "first line
|
||||||
|
second line" netssh notes show $host
|
||||||
|
expect_eq "note from stdin" "piped" bash -c "echo piped | netssh notes set $host && netssh notes show $host"
|
||||||
|
|
||||||
|
expect_eq "read NetBox comments (v2 token)" "Proxmox node 1" netssh notes show $host --netbox
|
||||||
|
check "append NetBox comments" netssh notes append $host --netbox "checked by netssh test"
|
||||||
|
expect_out "NetBox has the new line" "checked by netssh test" curl -s -H "Authorization: Bearer $FSN_TOKEN" "$FSN_URL/api/dcim/devices/?name=fsn1-pve1.hoster.test"
|
||||||
|
check "restore NetBox comments" netssh notes set $host --netbox "Proxmox node 1"
|
||||||
|
|
||||||
|
check "set NetBox comments (v1 token)" netssh notes set lab/lab-db1 --netbox "lab note"
|
||||||
|
expect_eq "read them back" "lab note" netssh notes show lab/lab-db1 --netbox
|
||||||
|
check "clear them" netssh notes set lab/lab-db1 --netbox ""
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Completion, aliases, hook install for bash, zsh, fish, and the command alias.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
forwards:
|
||||||
|
web: {local: "18081:127.0.0.1:8081"}
|
||||||
|
shortcuts:
|
||||||
|
domains: [.hoster.test]
|
||||||
|
shell:
|
||||||
|
alias: j
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
expect_out "complete hosts" "fsn/fsn1-pve2.hoster.test" netssh __complete fsn/fsn1
|
||||||
|
expect_out "complete scp host:" "fsn/fsn1-pve1.hoster.test:" netssh __complete scp a.txt fsn/fsn1-pve1
|
||||||
|
expect_out "complete forwards after the host" "web" netssh __complete tunnel fsn/fsn1-pve1.hoster.test ""
|
||||||
|
expect_out "complete --forward" "web" netssh __complete --forward ""
|
||||||
|
expect_out "fish aliases" "alias fsn_fsn1-pve1 " netssh alias --shell fish
|
||||||
|
|
||||||
|
check "hook install bash" netssh hook install --shell bash
|
||||||
|
# Interactive bash without a terminal warns about job control on stderr.
|
||||||
|
expect_eq "bash loads host aliases" "fsn1-pve1" sh -c "bash -ic 'fsn_fsn1-pve1 hostname' 2>/dev/null </dev/null"
|
||||||
|
expect_eq "bash command alias" "fsn1-pve2" sh -c "bash -ic 'j fsn/fsn1-pve2.hoster.test hostname' 2>/dev/null </dev/null"
|
||||||
|
check "hook install zsh" netssh hook install --shell zsh
|
||||||
|
expect_eq "zsh loads host aliases" "fsn1-pve3" zsh -ic 'fsn_fsn1-pve3 hostname' </dev/null
|
||||||
|
mkdir -p "$XDG_CONFIG_HOME/fish"
|
||||||
|
check "hook install fish" netssh hook install --shell fish
|
||||||
|
expect_eq "fish loads host aliases" "fsn1-pve1" fish -c 'fsn_fsn1-pve1 hostname' </dev/null
|
||||||
|
check "completion install bash" netssh completion install --shell bash
|
||||||
|
expect_out "bash completes hosts" "fsn/fsn1-pve2.hoster.test" bash -ic '. ~/.local/share/bash-completion/completions/netssh; COMP_WORDS=(netssh fsn/fsn1-pve2); COMP_CWORD=1; COMP_LINE="netssh fsn/fsn1-pve2"; COMP_POINT=${#COMP_LINE}; __start_netssh netssh; printf "%s\n" "${COMPREPLY[@]}"' </dev/null
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# The interactive TUI, driven through tmux: search, connect, sftp, tunnel, edit.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver:
|
||||||
|
strategies: [management_subnet, primary_ip]
|
||||||
|
management_subnets: [10.231.1.0/24]
|
||||||
|
ssh:
|
||||||
|
default_user: ops
|
||||||
|
forwards:
|
||||||
|
web: {local: "18085:127.0.0.1:8081"}
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
export TMUX_TMPDIR="$HOME/tmux"
|
||||||
|
mkdir -p "$TMUX_TMPDIR"
|
||||||
|
|
||||||
|
screen() { tmux capture-pane -p -t tui; }
|
||||||
|
shows() { screen | grep -qF -- "$1"; }
|
||||||
|
tui() { # start netssh in a fresh tmux session
|
||||||
|
tmux kill-server 2>/dev/null
|
||||||
|
tmux new-session -d -s tui -x 160 -y 40 -e HOME="$HOME" -e XDG_CONFIG_HOME="$XDG_CONFIG_HOME" -e XDG_CACHE_HOME="$XDG_CACHE_HOME" netssh
|
||||||
|
wait_for 10 shows "Search hostname"
|
||||||
|
}
|
||||||
|
step() { # NAME TEXT — wait until the pane shows TEXT
|
||||||
|
if wait_for 15 shows "$2"; then ok "$1"; else bad "$1" "$(screen)"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
tui && ok "TUI starts" || bad "TUI starts" "$(screen)"
|
||||||
|
tmux send-keys -t tui "fsn1-pve2"
|
||||||
|
step "search shows the host" "fsn/fsn1-pve2.hoster.test"
|
||||||
|
tmux send-keys -t tui Enter
|
||||||
|
sleep 1
|
||||||
|
tmux send-keys -t tui "hostname" Enter
|
||||||
|
step "enter connects" "fsn1-pve2"
|
||||||
|
tmux send-keys -t tui "exit" Enter
|
||||||
|
|
||||||
|
tui
|
||||||
|
tmux send-keys -t tui "fsn1-pve3"
|
||||||
|
wait_for 10 shows "fsn/fsn1-pve3.hoster.test"
|
||||||
|
tmux send-keys -t tui C-o
|
||||||
|
step "ctrl+o opens sftp" "sftp>"
|
||||||
|
tmux send-keys -t tui "pwd" Enter
|
||||||
|
step "sftp works" "Remote working directory: /home/ops"
|
||||||
|
tmux send-keys -t tui "bye" Enter
|
||||||
|
|
||||||
|
tui
|
||||||
|
tmux send-keys -t tui "fsn1-pve1"
|
||||||
|
wait_for 10 shows "fsn/fsn1-pve1.hoster.test"
|
||||||
|
tmux send-keys -t tui C-t
|
||||||
|
step "ctrl+t lists configured forwards" "forwards: web"
|
||||||
|
tmux send-keys -t tui "web" Enter
|
||||||
|
step "tunnel notice" "forwarding -L 18085:127.0.0.1:8081"
|
||||||
|
expect_out "tunnel from the TUI works" "hello from fsn1-pve1" curl -s --retry 10 --retry-connrefused --retry-delay 1 http://127.0.0.1:18085/
|
||||||
|
tmux send-keys -t tui C-c
|
||||||
|
|
||||||
|
tui
|
||||||
|
tmux send-keys -t tui "fsn1-pve1"
|
||||||
|
wait_for 10 shows "fsn/fsn1-pve1.hoster.test"
|
||||||
|
tmux send-keys -t tui C-e
|
||||||
|
step "ctrl+e opens the editor" "Connect as: ops@fsn/fsn1-pve1.hoster.test:22"
|
||||||
|
tmux send-keys -t tui C-u "admin@fsn/fsn1-pve1.hoster.test:22" Enter
|
||||||
|
sleep 1
|
||||||
|
tmux send-keys -t tui "whoami" Enter
|
||||||
|
step "edited user is used" "admin"
|
||||||
|
tmux send-keys -t tui "exit" Enter
|
||||||
|
tmux kill-server 2>/dev/null
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# The configurator in huh's accessible mode: without NetBox, add a static host,
|
||||||
|
# save, then connect. Menu numbers follow the order in internal/setup.
|
||||||
|
config_only </dev/null
|
||||||
|
rm -f "$XDG_CONFIG_HOME/netssh.yaml"
|
||||||
|
# no NetBox, static hosts, add, name, address, user, port, tags, back, save
|
||||||
|
out=$(python3 /tests/drive.py 2 4 1 legacy-box 10.231.0.50 admin "" legacy 5 8)
|
||||||
|
[[ $out == *"Config saved"* ]] && ok "configure saves" || bad "configure saves" "$out"
|
||||||
|
expect_out "config has the host" 'address: "10.231.0.50"' cat "$XDG_CONFIG_HOME/netssh.yaml"
|
||||||
|
expect_eq "connect to the configured host" "admin" netssh legacy-box whoami
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Uninstall in dry-run mode lists everything netssh created.
|
||||||
|
config <<'EOF'
|
||||||
|
resolver: {strategies: [primary_ip]}
|
||||||
|
EOF
|
||||||
|
check "refresh" netssh cache refresh
|
||||||
|
check "hook install" netssh hook install --shell bash
|
||||||
|
expect_out "lists the profile" ".bashrc" netssh uninstall --dry-run
|
||||||
|
expect_out "lists the config" "netssh.yaml" netssh uninstall --dry-run
|
||||||
|
expect_out "lists the cache" ".cache/netssh" netssh uninstall --dry-run
|
||||||
|
out=$(netssh uninstall --dry-run --keep-config 2>&1)
|
||||||
|
[[ $out != *"netssh.yaml"* ]] && ok "--keep-config keeps the config" || bad "--keep-config keeps the config" "$out"
|
||||||
|
expect_out "nothing was removed" "netssh shell-init" cat "$HOME/.bashrc"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Drive `netssh configure` in huh's accessible mode (TERM=dumb): one answer
|
||||||
|
per prompt, sent after a short pause so each form reads its own line."""
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
answers = sys.argv[1:]
|
||||||
|
pid, fd = pty.fork()
|
||||||
|
if pid == 0:
|
||||||
|
os.execvpe("netssh", ["netssh", "configure"], dict(os.environ, TERM="dumb"))
|
||||||
|
out = b""
|
||||||
|
|
||||||
|
|
||||||
|
def drain(seconds):
|
||||||
|
global out
|
||||||
|
end = time.time() + seconds
|
||||||
|
while time.time() < end:
|
||||||
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
out += os.read(fd, 4096)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
for answer in answers:
|
||||||
|
drain(0.5)
|
||||||
|
os.write(fd, (answer + "\n").encode())
|
||||||
|
drain(2)
|
||||||
|
sys.stdout.write(out.decode(errors="replace"))
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Helpers for the netssh test environment. Sourced by run.sh, the tests, and
|
||||||
|
# shell.sh inside the client container.
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /state/env
|
||||||
|
RESULTS=${RESULTS:-/tmp/netssh-results}
|
||||||
|
|
||||||
|
# new_home gives each test file its own netssh config, cache, notes, and rc files.
|
||||||
|
new_home() {
|
||||||
|
HOME=$(mktemp -d /tmp/home.XXXXXX)
|
||||||
|
export HOME XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache" SHELL=/bin/bash
|
||||||
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
|
||||||
|
unset TMUX ZELLIJ NETSSH_NETBOX_URL NETSSH_NETBOX_TOKEN
|
||||||
|
cd "$HOME" || exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# base_config prints both NetBox instances followed by stdin.
|
||||||
|
base_config() { cat /state/netssh.yaml -; }
|
||||||
|
|
||||||
|
# config writes the netssh config: both NetBox instances plus stdin.
|
||||||
|
config() { base_config >"$XDG_CONFIG_HOME/netssh.yaml"; }
|
||||||
|
|
||||||
|
# config_only writes stdin as the whole config (no NetBox).
|
||||||
|
config_only() { cat >"$XDG_CONFIG_HOME/netssh.yaml"; }
|
||||||
|
|
||||||
|
ok() {
|
||||||
|
echo "ok $CURRENT: $1" >>"$RESULTS"
|
||||||
|
printf ' \033[32mok\033[0m %s\n' "$1"
|
||||||
|
}
|
||||||
|
bad() {
|
||||||
|
echo "FAIL $CURRENT: $1" >>"$RESULTS"
|
||||||
|
printf ' \033[31mFAIL\033[0m %s\n' "$1"
|
||||||
|
printf '%s\n' "$2" | sed 's/^/ /'
|
||||||
|
}
|
||||||
|
|
||||||
|
# check NAME CMD... — CMD must succeed.
|
||||||
|
check() {
|
||||||
|
local name=$1 out
|
||||||
|
shift
|
||||||
|
if out=$("$@" 2>&1); then ok "$name"; else bad "$name" "exit $?: $out"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expect_out NAME WANT CMD... — CMD must succeed and print WANT (substring).
|
||||||
|
expect_out() {
|
||||||
|
local name=$1 want=$2 out rc
|
||||||
|
shift 2
|
||||||
|
out=$("$@" 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "$rc" = 0 ] && [[ $out == *"$want"* ]]; then ok "$name"; else bad "$name" "want: $want
|
||||||
|
got (exit $rc): $out"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expect_eq NAME WANT CMD... — CMD must succeed and print exactly WANT.
|
||||||
|
expect_eq() {
|
||||||
|
local name=$1 want=$2 out rc
|
||||||
|
shift 2
|
||||||
|
out=$("$@" 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "$rc" = 0 ] && [ "$out" = "$want" ]; then ok "$name"; else bad "$name" "want: $want
|
||||||
|
got (exit $rc): $out"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expect_fail NAME WANT CMD... — CMD must fail and print WANT (substring).
|
||||||
|
expect_fail() {
|
||||||
|
local name=$1 want=$2 out rc
|
||||||
|
shift 2
|
||||||
|
out=$("$@" 2>&1) && rc=0 || rc=$?
|
||||||
|
if [ "$rc" != 0 ] && [[ $out == *"$want"* ]]; then ok "$name"; else bad "$name" "want failure with: $want
|
||||||
|
got (exit $rc): $out"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_for TIMEOUT CMD... — retry CMD every 0.2 s until it succeeds.
|
||||||
|
wait_for() {
|
||||||
|
local deadline=$((SECONDS + $1))
|
||||||
|
shift
|
||||||
|
until "$@" >/dev/null 2>&1; do
|
||||||
|
[ $SECONDS -ge $deadline ] && return 1
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs every tests/NN-*.sh (or those matching $1) in a fresh netssh home and
|
||||||
|
# prints a summary. Exit code 1 if any check failed.
|
||||||
|
set -u
|
||||||
|
export RESULTS=/tmp/netssh-results
|
||||||
|
: >"$RESULTS"
|
||||||
|
filter=${1:-}
|
||||||
|
for file in /tests/[0-9][0-9]-*.sh; do
|
||||||
|
name=$(basename "$file" .sh)
|
||||||
|
[[ -n $filter && $name != *"$filter"* ]] && continue
|
||||||
|
echo "== $name"
|
||||||
|
(
|
||||||
|
export CURRENT=$name
|
||||||
|
. /tests/lib.sh
|
||||||
|
new_home
|
||||||
|
. "$file"
|
||||||
|
)
|
||||||
|
done
|
||||||
|
passed=$(grep -c '^ok ' "$RESULTS" || true)
|
||||||
|
failed=$(grep -c '^FAIL ' "$RESULTS" || true)
|
||||||
|
echo
|
||||||
|
echo "passed: $passed, failed: $failed"
|
||||||
|
if [ "$failed" -gt 0 ]; then
|
||||||
|
grep '^FAIL ' "$RESULTS" | sed 's/^/ /'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the netssh test environment: generate the test key, build the images
|
||||||
|
# (netssh from the current source), start everything, and seed NetBox.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
compose=(docker compose -f compose.yaml)
|
||||||
|
|
||||||
|
mkdir -p .keys .state
|
||||||
|
chmod a+rwx .state # written by the seed container's user
|
||||||
|
if [ ! -f .keys/id_ed25519 ]; then
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -C netssh-test -f .keys/id_ed25519
|
||||||
|
cp .keys/id_ed25519.pub .keys/authorized_keys
|
||||||
|
fi
|
||||||
|
# Throwaway test key: readable by the container user; tests copy it with 0600.
|
||||||
|
chmod 644 .keys/id_ed25519 .keys/authorized_keys
|
||||||
|
|
||||||
|
"${compose[@]}" build
|
||||||
|
"${compose[@]}" up -d --wait --wait-timeout 600 \
|
||||||
|
postgres redis netbox-fsn netbox-lab bastion fsn1-pve1 fsn1-pve2 fsn1-pve3 \
|
||||||
|
web1 web2 lab-db1 lab-web1 legacy-box v6-host client
|
||||||
|
"${compose[@]}" run --rm seed
|
||||||
|
echo
|
||||||
|
echo "Test environment is up."
|
||||||
|
echo " Tests: testenv/test.sh [FILTER]"
|
||||||
|
echo " Shell: testenv/shell.sh"
|
||||||
|
echo " NetBox UI: http://127.0.0.1:18000 (fsn), http://127.0.0.1:18001 (lab) — admin / admin"
|
||||||
Reference in new issue
Block a user