diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f727a7d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +testenv/.keys +testenv/.state diff --git a/.gitignore b/.gitignore index 7d3d065..60627d9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ dist/ # OS .DS_Store Thumbs.db + +# local test environment (testenv/up.sh) +testenv/.keys/ +testenv/.state/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 831378d..f8ebdab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 - `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. diff --git a/README.md b/README.md index 6f118cb..a8d859d 100644 --- a/README.md +++ b/README.md @@ -1055,6 +1055,14 @@ go test ./... # includes offline installer/update tests go build ./... # build all packages ``` +End-to-end tests run against real services in a local Docker environment — two NetBox instances, SSH targets on separate networks, a jump host, an IPv6 host, tmux, zellij, and bash/zsh/fish. See [testenv/README.md](testenv/README.md). It is not part of CI/CD. + +```sh +testenv/up.sh # build and start (first start ~6 min) +testenv/test.sh # 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. Project layout: diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index b8b679d..cff6248 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -130,6 +130,7 @@ func findDestination(client internalssh.Client, args []string) (*destination, er // runClient resolves the NetBox host in args and execs ssh, sftp, or scp. func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) { cfg := loadConfigOrSetup() + client = client.WithInstances(instanceNames(cfg)) dest, err := findDestination(client, args) if err != nil { @@ -175,6 +176,15 @@ func hostDefaults(cfg *config.Config, host resolvedHost) (string, int) { return user, port } +// instanceNames lists the named NetBox instances, which may prefix hostnames. +func instanceNames(cfg *config.Config) []string { + names := make([]string, 0, len(cfg.NetBoxes)) + for _, source := range cfg.NetBoxes { + names = append(names, source.Name) + } + return names +} + // prependArgs puts extra before args and shifts the destination indices. func prependArgs(extra, args []string, dests []int) []string { for i := range dests { diff --git a/internal/netbox/client_test.go b/internal/netbox/client_test.go index a167107..373efe1 100644 --- a/internal/netbox/client_test.go +++ b/internal/netbox/client_test.go @@ -235,7 +235,7 @@ func TestGetIPsWithFilter(t *testing.T) { defer srv.Close() c := NewClient(srv.URL, "token", 0) - ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0") + ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface=mgmt0") if err != nil { t.Fatalf("GetIPsWithFilter: %v", err) } diff --git a/internal/netbox/sources_test.go b/internal/netbox/sources_test.go index a3686c6..0f56205 100644 --- a/internal/netbox/sources_test.go +++ b/internal/netbox/sources_test.go @@ -78,7 +78,7 @@ func TestMultiSourceSearchAndRouting(t *testing.T) { if err != nil || len(ips) != 1 || ips[0] != tc.ip { t.Fatalf("wrong IP source: %v, %v", ips, err) } - ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0") + ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface=mgmt0") if err != nil || len(ips) != 1 || ips[0] != tc.ip { t.Fatalf("wrong interface source: %v, %v", ips, err) } diff --git a/internal/resolver/interface_name.go b/internal/resolver/interface_name.go index 2b3483f..a39d6f3 100644 --- a/internal/resolver/interface_name.go +++ b/internal/resolver/interface_name.go @@ -16,13 +16,16 @@ type InterfaceNameStrategy struct { func (s *InterfaceNameStrategy) Name() string { return "interface_name" } func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostEntry, client *netbox.Client) (string, error) { - // Build filter parameters for IP addresses attached to the named interface. + // NetBox filters IP addresses by interface name with interface= (devices) + // and vminterface= (VMs); unknown parameters would be ignored silently and + // return every address of the host. A missing interface is an HTTP 400, + // which lets the chain try the next strategy. var filterParam string switch entry.Kind { case "device": - filterParam = fmt.Sprintf("device_id=%d&interface_name=%s", entry.ID, url.QueryEscape(s.name)) + filterParam = fmt.Sprintf("device_id=%d&interface=%s", entry.ID, url.QueryEscape(s.name)) case "vm": - filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface_name=%s", entry.ID, url.QueryEscape(s.name)) + filterParam = fmt.Sprintf("virtual_machine_id=%d&vminterface=%s", entry.ID, url.QueryEscape(s.name)) default: return "", fmt.Errorf("unknown kind %q", entry.Kind) } diff --git a/internal/resolver/interface_name_test.go b/internal/resolver/interface_name_test.go new file mode 100644 index 0000000..3d06c7d --- /dev/null +++ b/internal/resolver/interface_name_test.go @@ -0,0 +1,56 @@ +package resolver + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" +) + +// NetBox ignores unknown filters, so the strategy must use the real filter +// names; otherwise it silently returns the host's first address. +func TestInterfaceNameStrategyFilters(t *testing.T) { + for kind, want := range map[string]string{ + "device": "device_id=7&interface=mgmt0", + "vm": "virtual_machine_id=7&vminterface=mgmt0", + } { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + q.Del("limit") + q.Del("offset") + got = q.Encode() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"count":1,"results":[{"address":"10.0.0.7/24"}]}`)) + })) + ip, err := (&InterfaceNameStrategy{name: "mgmt0"}).Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: kind}, netbox.NewClient(srv.URL, "t", 1)) + srv.Close() + if err != nil || ip != "10.0.0.7" { + t.Fatalf("%s: %q %v", kind, ip, err) + } + if parsed, _ := urlQuery(want); got != parsed { + t.Errorf("%s: query %q, want %q", kind, got, parsed) + } + } +} + +func TestInterfaceNameStrategyMissingInterfaceFallsThrough(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"vminterface":["Select a valid choice."]}`, http.StatusBadRequest) + })) + defer srv.Close() + client := netbox.NewClient(srv.URL, "t", 1) + chain := &Chain{strategies: []Strategy{&InterfaceNameStrategy{name: "mgmt0"}, &PrimaryIPStrategy{}}} + ip, err := chain.Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: "vm", PrimaryIP4: "10.0.0.9"}, client) + if err != nil || ip != "10.0.0.9" { + t.Fatalf("got %q %v", ip, err) + } +} + +func urlQuery(s string) (string, error) { + q, err := url.ParseQuery(s) + return q.Encode(), err +} diff --git a/internal/shortcuts/shortcuts.go b/internal/shortcuts/shortcuts.go index f1d5ffb..e2f99ad 100644 --- a/internal/shortcuts/shortcuts.go +++ b/internal/shortcuts/shortcuts.go @@ -59,6 +59,11 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string { // It normalizes the name (strips configured domains, applies rewrites, optionally strips hyphens) then // replaces any remaining dots with underscores so the result is a valid identifier. func AliasName(name string, cfg config.ShortcutsConfig) string { - s := Normalize(name, cfg) + // Shorten only the hostname: anchored rewrite rules must not see "instance/". + prefix := "" + if source, host, ok := strings.Cut(name, "/"); ok { + prefix, name = strings.ToLower(source)+"_", host + } + s := prefix + Normalize(name, cfg) return strings.NewReplacer(".", "_", "/", "_").Replace(s) } diff --git a/internal/shortcuts/shortcuts_test.go b/internal/shortcuts/shortcuts_test.go index 51e96c0..67b15b1 100644 --- a/internal/shortcuts/shortcuts_test.go +++ b/internal/shortcuts/shortcuts_test.go @@ -115,3 +115,13 @@ func TestNormalizeRewritesOrder(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestAliasNameRewritesInstanceQualifiedHosts(t *testing.T) { + cfg := config.ShortcutsConfig{ + Domains: []string{".hoster.test"}, + Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}}, + } + if got := shortcuts.AliasName("fsn/fsn1-pve1.hoster.test", cfg); got != "fsn_fsnpve1" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/ssh/args.go b/internal/ssh/args.go index b36ea5a..6262568 100644 --- a/internal/ssh/args.go +++ b/internal/ssh/args.go @@ -59,7 +59,7 @@ func (c Client) Parse(args []string) *ParsedArgs { func (c Client) parsed(args []string, i int) *ParsedArgs { dest, path := args[i], "" if c.paths { - if d, p, remote := splitRemote(dest); remote { + if d, p, remote := c.splitRemote(dest); remote { dest, path = d, p } } @@ -88,7 +88,7 @@ func (c Client) replaceHost(args []string, dests []int, newHost string) []string prefix, original = original[:at+1], original[at+1:] } if c.paths { - if _, path, remote := splitRemote(original); remote { + if _, path, remote := c.splitRemote(original); remote { suffix = ":" + path } } diff --git a/internal/ssh/client.go b/internal/ssh/client.go index 3ffb926..1395f92 100644 --- a/internal/ssh/client.go +++ b/internal/ssh/client.go @@ -13,6 +13,18 @@ type Client struct { port byte // flag for the port: -p (ssh) or -P (sftp, scp) user byte // flag for the login user; 0 when only user@ or -o User work paths bool // destinations are host[:path] + // instances are the configured NetBox instance names; "inst/host:path" + // is remote only for these, so local paths like "dir/file:x" stay local. + instances map[string]bool +} + +// WithInstances returns c aware of the NetBox instance prefixes in host:path. +func (c Client) WithInstances(names []string) Client { + c.instances = make(map[string]bool, len(names)) + for _, name := range names { + c.instances[strings.ToLower(name)] = true + } + return c } var ( @@ -61,7 +73,7 @@ func (c Client) Operands(args []string) ([]Operand, error) { if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") { return nil, fmt.Errorf("%q: use host:path instead of a URI", arg) } - dest, path, remote := splitRemote(arg) + dest, path, remote := c.splitRemote(arg) if !remote { continue } @@ -105,9 +117,22 @@ func (c Client) insertionPoint(args []string) int { return len(args) } -// splitRemote mirrors scp's colon(): an argument is remote when a ':' comes +// splitRemote is splitHostPath with netssh's [user@]instance/host:path form. +func (c Client) splitRemote(arg string) (dest, path string, remote bool) { + if head, rest, ok := strings.Cut(arg, "/"); ok { + instance := head[strings.LastIndex(head, "@")+1:] + if c.instances[strings.ToLower(instance)] { + if d, p, r := splitHostPath(rest); r { + return head + "/" + d, p, true + } + } + } + return splitHostPath(arg) +} + +// splitHostPath mirrors scp's colon(): an argument is remote when a ':' comes // before any '/'. A leading "[" starts a bracketed IPv6 address. -func splitRemote(arg string) (dest, path string, remote bool) { +func splitHostPath(arg string) (dest, path string, remote bool) { if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") { return "", "", false } diff --git a/internal/ssh/client_test.go b/internal/ssh/client_test.go index 3825a0a..2edf3d7 100644 --- a/internal/ssh/client_test.go +++ b/internal/ssh/client_test.go @@ -154,3 +154,27 @@ func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) { t.Fatalf("got %v, want %v", got, want) } } + +// Instance-qualified hosts contain "/" before the ":" of host:path; only +// configured instances make such an argument remote. +func TestInstanceQualifiedHostPath(t *testing.T) { + scp := SCP.WithInstances([]string{"prod"}) + ops, err := scp.Operands([]string{"a.txt", "ops@prod/web01:/tmp/", "dir/file:x"}) + if err != nil { + t.Fatal(err) + } + if want := []Operand{{Idx: 1, User: "ops", Host: "prod/web01", Path: "/tmp/"}}; !reflect.DeepEqual(ops, want) { + t.Fatalf("got %+v", ops) + } + got := scp.Apply([]string{"a.txt", "ops@prod/web01:/tmp/"}, []int{1}, Host{Name: "web01", IP: "192.0.2.1"}, config.SSHOptions{}, WrapperFlags{}).Args + if want := []string{"a.txt", "ops@192.0.2.1:/tmp/"}; !reflect.DeepEqual(got, want) { + t.Fatalf("apply: %v", got) + } + sftp := SFTP.WithInstances([]string{"prod"}) + if p := sftp.Parse([]string{"prod/web01:/var/log"}); p == nil || p.Host != "prod/web01" || p.Path != "/var/log" { + t.Fatalf("sftp: %+v", p) + } + if ops, _ := SCP.Operands([]string{"prod/web01:/tmp/", "."}); len(ops) != 0 { + t.Fatalf("unknown instance must stay local: %+v", ops) + } +} diff --git a/testenv/README.md b/testenv/README.md new file mode 100644 index 0000000..a0959f7 --- /dev/null +++ b/testenv/README.md @@ -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. diff --git a/testenv/client-entrypoint.sh b/testenv/client-entrypoint.sh new file mode 100755 index 0000000..145bb5c --- /dev/null +++ b/testenv/client-entrypoint.sh @@ -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 "$@" diff --git a/testenv/client.Dockerfile b/testenv/client.Dockerfile new file mode 100644 index 0000000..8cf9ff5 --- /dev/null +++ b/testenv/client.Dockerfile @@ -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"] diff --git a/testenv/compose.yaml b/testenv/compose.yaml new file mode 100644 index 0000000..65b6c5b --- /dev/null +++ b/testenv/compose.yaml @@ -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: {} diff --git a/testenv/down.sh b/testenv/down.sh new file mode 100755 index 0000000..f96ad52 --- /dev/null +++ b/testenv/down.sh @@ -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 diff --git a/testenv/postgres-init.sql b/testenv/postgres-init.sql new file mode 100644 index 0000000..c78cfc0 --- /dev/null +++ b/testenv/postgres-init.sql @@ -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; diff --git a/testenv/seed/seed.py b/testenv/seed/seed.py new file mode 100644 index 0000000..3ae6872 --- /dev/null +++ b/testenv/seed/seed.py @@ -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() diff --git a/testenv/shell.sh b/testenv/shell.sh new file mode 100755 index 0000000..7b77060 --- /dev/null +++ b/testenv/shell.sh @@ -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" < /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 diff --git a/testenv/targets/sshd_config b/testenv/targets/sshd_config new file mode 100644 index 0000000..a716813 --- /dev/null +++ b/testenv/targets/sshd_config @@ -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 diff --git a/testenv/targets/target.Dockerfile b/testenv/targets/target.Dockerfile new file mode 100644 index 0000000..751cc40 --- /dev/null +++ b/testenv/targets/target.Dockerfile @@ -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"] diff --git a/testenv/test.sh b/testenv/test.sh new file mode 100755 index 0000000..623b3ea --- /dev/null +++ b/testenv/test.sh @@ -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:-}" diff --git a/testenv/tests/01-cache.sh b/testenv/tests/01-cache.sh new file mode 100644 index 0000000..daf49cd --- /dev/null +++ b/testenv/tests/01-cache.sh @@ -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' diff --git a/testenv/tests/02-connect.sh b/testenv/tests/02-connect.sh new file mode 100644 index 0000000..27e51df --- /dev/null +++ b/testenv/tests/02-connect.sh @@ -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' diff --git a/testenv/tests/03-resolver.sh b/testenv/tests/03-resolver.sh new file mode 100644 index 0000000..930795f --- /dev/null +++ b/testenv/tests/03-resolver.sh @@ -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 <"$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 diff --git a/testenv/tests/09-tunnel.sh b/testenv/tests/09-tunnel.sh new file mode 100644 index 0000000..3ed7382 --- /dev/null +++ b/testenv/tests/09-tunnel.sh @@ -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 diff --git a/testenv/tests/10-notes.sh b/testenv/tests/10-notes.sh new file mode 100644 index 0000000..6000774 --- /dev/null +++ b/testenv/tests/10-notes.sh @@ -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 "" diff --git a/testenv/tests/11-shell.sh b/testenv/tests/11-shell.sh new file mode 100644 index 0000000..af40f54 --- /dev/null +++ b/testenv/tests/11-shell.sh @@ -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 /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 diff --git a/testenv/tests/13-configure.sh b/testenv/tests/13-configure.sh new file mode 100644 index 0000000..62f526b --- /dev/null +++ b/testenv/tests/13-configure.sh @@ -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 &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" diff --git a/testenv/tests/drive.py b/testenv/tests/drive.py new file mode 100644 index 0000000..027c9bb --- /dev/null +++ b/testenv/tests/drive.py @@ -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")) diff --git a/testenv/tests/lib.sh b/testenv/tests/lib.sh new file mode 100644 index 0000000..ed4efac --- /dev/null +++ b/testenv/tests/lib.sh @@ -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 +} diff --git a/testenv/tests/run.sh b/testenv/tests/run.sh new file mode 100755 index 0000000..b64646a --- /dev/null +++ b/testenv/tests/run.sh @@ -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 diff --git a/testenv/up.sh b/testenv/up.sh new file mode 100755 index 0000000..4e951ea --- /dev/null +++ b/testenv/up.sh @@ -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"