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,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