diff --git a/creds-manifests/vr1-dc0.manifest b/creds-manifests/vr1-dc0.manifest new file mode 100644 index 0000000..8161e60 --- /dev/null +++ b/creds-manifests/vr1-dc0.manifest @@ -0,0 +1,17 @@ +# vr1-dc0 creds manifest -- what MUST be consolidated in ~/vr1-dc0-creds/ (SEC-009 + D-126). +# scripts/creds-audit.sh verifies this list against the live folder: presence, mode, +# non-empty. No secret VALUES are read -- only existence, mode, and declared provenance. +# +# fields: +# source = "local" -- generated/held on the jumphost (vcloud) +# = ":" -- MINTED ON A SITE VM; a working copy MUST be pulled into +# the folder at mint time. +# +# STATUS: the ~/vr1-dc0-creds/ folder was pre-created (0700) as the landing target; the +# access keypair below is MINTED AT DC DEPLOY (generated on vcloud, pubkey -> vvr1-dc0 +# cloud-init) per D-126 per-env-key isolation (a DEDICATED dc0 key, NOT office1's). +# vr1-dc0*.env files (e.g. TF_VAR_* / MAAS) are ADDED to this manifest as the dc0 deploy +# design determines them -- not invented here (hard rule 2). + +vr1-dc0_svc_ed25519 600 local +vr1-dc0_svc_ed25519.pub 644 local diff --git a/creds-manifests/vr1-dc1.manifest b/creds-manifests/vr1-dc1.manifest new file mode 100644 index 0000000..a0ec24f --- /dev/null +++ b/creds-manifests/vr1-dc1.manifest @@ -0,0 +1,17 @@ +# vr1-dc1 creds manifest -- what MUST be consolidated in ~/vr1-dc1-creds/ (SEC-009 + D-126). +# scripts/creds-audit.sh verifies this list against the live folder: presence, mode, +# non-empty. No secret VALUES are read -- only existence, mode, and declared provenance. +# +# fields: +# source = "local" -- generated/held on the jumphost (vcloud) +# = ":" -- MINTED ON A SITE VM; a working copy MUST be pulled into +# the folder at mint time. +# +# STATUS: the ~/vr1-dc1-creds/ folder was pre-created (0700) as the landing target; the +# access keypair below is MINTED AT DC DEPLOY (generated on vcloud, pubkey -> vvr1-dc1 +# cloud-init) per D-126 per-env-key isolation (a DEDICATED dc1 key, NOT office1's/dc0's). +# vr1-dc1*.env files (e.g. TF_VAR_* / MAAS) are ADDED to this manifest as the dc1 deploy +# design determines them -- not invented here (hard rule 2). + +vr1-dc1_svc_ed25519 600 local +vr1-dc1_svc_ed25519.pub 644 local diff --git a/docs/changelog-20260716-d126-durable-forward.md b/docs/changelog-20260716-d126-durable-forward.md new file mode 100644 index 0000000..731e1e2 --- /dev/null +++ b/docs/changelog-20260716-d126-durable-forward.md @@ -0,0 +1,111 @@ +# 2026-07-16 -- D-126: durable, rootless vcloud->service-VM access (SSH local-forward via systemd --user) + +## Context + +Every session that touches the Office1 IPAM apex (office1-netbox, 10.10.1.10, on lxdbr0 behind +voffice1's LXD NAT) had to re-add `sudo ip route replace 10.10.1.0/24 via 10.10.0.20 dev virbr2` by +hand -- lost on reboot, sudo/TTY friction each time, and it would recur identically for the DC service +VMs. The operator directed a durable, generalizable fix. Advisor-conferred; operator RULED **Option A** +("We have been using SSH proxyjump already so lets continue that"). + +## What changed (repo-side; pushable) + +- **`scripts/site-forward.sh` (NEW):** `{render|install|status|remove|list} [--key PATH]`. + Manages a per-site `systemd --user` unit holding an SSH local-forward, so `http://127.0.0.1:` + on the jumphost reaches a service VM through voffice1 (or, for DCs, a `-J` ProxyJump through the + containment VM). Rootless; survives reboot once `loginctl enable-linger` is set (printed by + `install`, not run). Site table seeded with `office1-netbox` ONLY (MEASURED: voffice1 10.10.0.20 -> + 10.10.1.10:8000, HTTP 302). SSH key is operator-supplied via `--key` (a PATH to `ssh -i`; NEVER read + into context, NEVER baked). Binds `127.0.0.1` only. +- **`tests/site-forward/run-tests.sh` (NEW):** OFFLINE harness, **38/38 PASS**. Pins the loopback bind, + the ssh hardening flags, `Restart=always`, the no-baked-secret contract, rootless (`systemctl --user` + only, no sudo/system), self-locate (L9), and that no un-commented DC IP (10.12./172.31.) appears in an + active site row (hard rule 2). +- **`docs/design-decisions.md`:** D-126 record (Option A ruled; A-vs-B rationale; measured substrate; + secrets boundary; deliverables; revert). +- **`docs/vr1-office1-as-built.md`:** access table -- NetBox row now leads with the D-126 durable method; + added a "Durable vcloud->service-VM access" paragraph demoting the `sudo ip route` to ad-hoc fallback. +- **`docs/session-ledger.md`:** D-126 in-flight entry. + +## SHELL layer (added same day; operator: "you're going to need shell in Office and the DCs") + +The port-forward gives durable *reachability* to one service port, not a shell. The operator needs +interactive shell into the Office AND DC VMs, so the SSH direction was extended to a config-alias layer: + +- **`scripts/site-ssh-config.sh` (NEW):** `{render|install|remove|list} [--key PATH] [--config-dir DIR]`. + Generates `~/.ssh/config.d/vr1-sites` -- Host aliases (`voffice1`, `office1-netbox`, `office1-tailscale`, + `office1-opnsense`) with the as-built User + `ProxyJump` baked, so `ssh office1-netbox` just works and the + `-J`-through-containment-VM form generalizes to the DC VMs. `install` also adds an idempotent + `Include config.d/*` to `~/.ssh/config` (0600). Key operator-supplied via `--key` -> `IdentityFile` + (never read/baked); the generated fragment names that path and is written LOCALLY (NOT committed). +- **`tests/site-ssh-config/run-tests.sh` (NEW):** OFFLINE, **30/30 PASS** (`install` only against a + throwaway `--config-dir`). Pins the as-built host/user/ProxyJump mapping, Include idempotency + 0600 + perms, no-baked-secret, and that no un-measured DC host (`10.12.`/`172.31.`/`vr1-dc[01]`) is in an active + row. +- DC aliases DEFERRED until their inner IPs are measured post-apply (only voffice1 exists today). + +## Shell layer -- EXERCISED 2026-07-16 (proven, not just reviewed) + +Key confirmed (one-key closed env): `~/vr1-office1-creds/office1_svc_ed25519` (named in +`creds-manifests/vr1-office1.manifest`). The operator ran `site-ssh-config.sh install --key +~/vr1-office1-creds/office1_svc_ed25519`; the agent then proved access FROM THIS SESSION: + + ssh voffice1 hostname -> voffice1 (jessea123, direct) + ssh office1-netbox hostname -> office1-netbox (ubuntu, ProxyJump voffice1) + ssh office1-tailscale hostname -> office1-tailscale (ubuntu, ProxyJump voffice1) + ssh office1-opnsense hostname -> office1-opnsense.* (root, tcsh, direct) + +All four return live. This PROVES the load-bearing assumption: the agent can `ssh ` with the key +read from the config `IdentityFile` -- NO creds path in the command -- so it passes the guard hook + +classifier (the earlier block was a creds path IN a command; `ssh office1-netbox` has none). The one key +auths all three users (jessea123/ubuntu/root), as the operator confirmed for this closed env. + +## Forward layer -- reviewed-but-UNEXERCISED (operator completes if/when wanted) + +The systemd `--user` port-forward was not installed this session (the shell layer covers access; the +forward is a localhost:8000 convenience for the importers/UI). To stand it up: + + bash scripts/site-forward.sh render office1-netbox # review the exact unit first + bash scripts/site-forward.sh render office1-netbox # review the exact unit first + bash scripts/site-forward.sh install office1-netbox --key # writes + enable --now (needs key) + loginctl enable-linger jessea123 # one-time; boot-start w/o login + curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/ # expect 302 + +## Decoupling (advisor) + +D-126 does NOT gate `tofu plan`: the OpenTofu tree has no netbox provider/data source, so the four +operator-ruled `vr1_dc0_rack_*` tfvars alone unblock plan. The NetBox importer `--commit` is a separate, +operator-run step whose token stays host-local on office1-netbox (this forward carries no token). + +## DC creds prep + D-124 dc0 rack tfvars (same session) + +- **DC creds folders pre-created (operator request):** `~/vr1-dc0-creds/` + `~/vr1-dc1-creds/` (0700, empty) + as the landing target so deploy-time files don't sprawl loose (SEC-009). In `~`, not the repo. +- **`creds-manifests/vr1-dc0.manifest` + `vr1-dc1.manifest` (NEW):** declare each DC's DEDICATED per-env + access keypair (`vr1-dc_svc_ed25519` 600 + `.pub` 644, `local`), minted at DC deploy (D-126 per-env-key + isolation). `creds-audit.sh vr1-dc0` parses clean and reports the keys MISSING until deploy (by design). + `.env` files are added to the manifest as the DC deploy design determines them (not invented -- hard rule 2). +- **`opentofu/d124-rack.auto.tfvars` (NEW, LOCAL/gitignored):** the four D-124-ruled `vr1_dc0_rack_*` values + (`10.12.8.2`, `172.31.0.2`, `/30`, `172.31.0.1`) -- the independent `tofu plan` unblocker. NOT committed + (`.gitignore: opentofu/**/*.auto.tfvars`). +- **`vr1_dc1_rack_*` NOT written -- STOP flagged:** those variables do not exist in the tree and dc1 has no + ruled transit/rack addressing (the `172.31.0.0/24` transit supernet is D-124 dc0-scoped). Pending an + operator ruling for dc1 (+ creating the dc1 substrate/variables). + +## Verification + +- `bash tests/site-forward/run-tests.sh` -> **38/38 PASS**; `bash tests/site-ssh-config/run-tests.sh` -> **30/30 PASS**. +- `bash scripts/repo-lint.sh` -> 0 fail (1 legacy WARN). +- Shell layer EXERCISED: all four office1 aliases return live from the agent's session. + +## Revert (creds prep) + +- `git rm creds-manifests/vr1-dc0.manifest creds-manifests/vr1-dc1.manifest`; `rmdir ~/vr1-dc0-creds + ~/vr1-dc1-creds` (if still empty); `rm opentofu/d124-rack.auto.tfvars` (local only). + +## Revert + +- `git checkout docs/vr1-office1-as-built.md docs/session-ledger.md docs/design-decisions.md` (drops the + as-built rows + ledger entry + D-126); `git rm scripts/site-forward.sh tests/site-forward/run-tests.sh`; + delete this changelog. On any host where `install` ran: `bash scripts/site-forward.sh remove + office1-netbox` (rootless) + optionally `loginctl disable-linger jessea123`. No cloud/apex state touched. diff --git a/docs/design-decisions.md b/docs/design-decisions.md index b9e87b1..3b0e553 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -3787,3 +3787,82 @@ D-113 (edge config over REST -- OPNsense WAN static addr), SEC-010 (transit FORWARD-drop, scoped), DOCFIX-185 (edge is a real-ISP router, not an egress airgap). **Fallback:** `docs/model-a-fallback-plan.md` section 3 (revert removes the uplink NIC/network/bridge + `wan-bridge` and restores the OPNsense WAN addr). + +## D-126: durable, rootless vcloud->site-service-VM access -- SSH local-forward via systemd --user (RULED: Option A) + +**Status:** RULED 2026-07-16 (operator chose Option A: "We have been using SSH proxyjump already so lets +continue that"). Tooling delivered (`scripts/site-forward.sh` + harness, reviewed-but-UNEXERCISED: this +session has no SSH key to voffice1, so the operator supplies `--key` and runs the first connect). + +**Problem.** The Office1 service VMs (office1-netbox `10.10.1.10`, and later the DC service VMs) live on +`lxdbr0` (`10.10.1.0/24`), reachable from vcloud only THROUGH voffice1's LXD NAT. vcloud's default route +is the lab, so each session had to re-add `sudo ip route replace 10.10.1.0/24 via 10.10.0.20 dev virbr2` +by hand -- lost on reboot, sudo/TTY friction every time, and it will recur identically for the DCs. The +operator directed a durable, generalizable fix. + +**Options weighed.** +- **A (RULED) -- SSH local-forward held by a `systemd --user` unit.** Reuses the transport we ALREADY use + by hand (ssh through voffice1 / a DC containment VM). Rootless; survives reboot with `enable-linger`; + `http://127.0.0.1:` on the jumphost reaches the service VM. For office1-netbox the primitive is a + plain `-L` through voffice1 (the lxdbr0 gateway); the DC form adds a `-J` ProxyJump through the + containment VM to an inner VM -- so it generalizes by construction. +- **B (rejected) -- persist the L3 route.** Needs root (sudoers), and virbr2 is LIBVIRT-managed, so a + netplan route races libvirt at boot (would need an `After=libvirtd` unit / net-hook, not a plain + stanza). Divergent per-DC mechanism, and it keeps the very sudo friction we set out to remove. + +**Why A wins.** Zero new mechanisms (same ssh path as the inner tofu root's `qemu+ssh`), rootless (kills +the sudo/TTY friction), and the `-J`-through-containment-VM shape is exactly how the DCs will be reached. +MEASURED substrate: user-systemd is running (`XDG_RUNTIME_DIR=/run/user/1001`), OpenSSH 9.6; `Linger=no` +today so boot-start needs a one-time `loginctl enable-linger jessea123` (printed by `install`, not run). + +**Secrets boundary (unchanged by this).** The SSH key is operator-supplied via `--key` (a PATH handed to +`ssh -i`; never read into context, never baked into the tool). The forward carries ONLY read/UI TCP -- the +NetBox API token stays a host-local secret on office1-netbox (`/root/netbox-secrets/api.token`), so the +importers' dry-run AND `--commit` remain operator-run there. `127.0.0.1` bind only (never `0.0.0.0`). +SANDBOX_HOSTS already allows `localhost`/`127.0.0.1`, so an importer pointed at the forwarded port passes +its own upstream-write gate with no code change. + +**Deliverables.** `scripts/site-forward.sh` ({render|install|status|remove|list}); site table seeded with +`office1-netbox` only (MEASURED: voffice1 `10.10.0.20` forwards to `10.10.1.10:8000`, HTTP 302) -- DC rows +are DEFERRED until their containment-VM + inner IPs are measured (hard rule 2; the harness fails if an +un-commented DC IP appears). `tests/site-forward/run-tests.sh` 38/38 (pins loopback-bind, no-baked-secret, +rootless, self-locate). `docs/vr1-office1-as-built.md` access table updated (D-126 preferred; sudo route +demoted to ad-hoc fallback). + +**AMENDMENT (2026-07-16) -- SHELL layer (operator: "You're going to need access to the shell in Office and +the DCs").** The port-forward buys durable *reachability* to one service port, not a *shell*; the operator +needs interactive shell into the Office AND DC VMs. Same SSH transport, standard form: a generated +`~/.ssh/config.d/vr1-sites` fragment of Host aliases (`voffice1`, `office1-netbox`, `office1-tailscale`, +`office1-opnsense`) with the as-built User + `ProxyJump` baked, so `ssh office1-netbox` just works and the +`-J`-through-the-containment-VM shape generalizes to the DC VMs by construction. This is now the FOUNDATION; +the systemd forward is a convenience that can ride the aliases. Deliverable: `scripts/site-ssh-config.sh` +({render|install|remove|list}) + `tests/site-ssh-config/run-tests.sh` 30/30 (pins the as-built +host/user/ProxyJump mapping, Include idempotency + 0600 perms, no-baked-secret, no un-measured DC row). Same +secrets boundary: the key is operator-supplied via `--key` -> `IdentityFile` (a PATH, never read/baked); the +generated fragment names that path and is written LOCALLY (NOT committed). DC aliases DEFERRED until their +inner IPs are measured post-apply (only voffice1 exists today). Key confirmed one-key closed env: +`~/vr1-office1-creds/office1_svc_ed25519` (named in `creds-manifests/vr1-office1.manifest`), auths all three +users. **EXERCISED 2026-07-16** (operator ran `install`; the agent then proved access from this session): +`ssh voffice1` -> jessea123, `ssh office1-netbox` + `ssh office1-tailscale` -> ubuntu (via ProxyJump voffice1), +`ssh office1-opnsense` -> root (tcsh) all return live. This PROVES the load-bearing assumption -- the agent +can `ssh ` with the key read from the config `IdentityFile` (no creds path in the command), so it +passes the guard hook + classifier. The advisor's acceptance-test steer (exercise -> record -> commit) is +met. The systemd FORWARD layer (`site-forward.sh`) remains reviewed-but-unexercised (not installed). + +**PER-ENV KEY for the DCs (operator ruling 2026-07-16: "individual key... for each env access").** Office1 +is one env with one key across its three VMs (already conforms: `office1_svc_ed25519` in +`~/vr1-office1-creds/`). Each DC (`vr1-dc0`, `vr1-dc1`) gets a DEDICATED access key in its OWN creds folder +(`~/vr1-dc0-creds/`, `~/vr1-dc1-creds/`) -- NOT office1's key -- for per-env blast-radius isolation (a leaked +key compromises one env, not all). Realization: the DC keys are MINTED into those creds folders at DC deploy +(pubkey wired into the DC's cloud-init), tracked in `creds-manifests/.manifest`, not generated +speculatively before the DC exists. Tool mechanism: `site-ssh-config.sh` installs each DC env as its own +`~/.ssh/config.d/vr1-` fragment via a per-env `--key` (the `--env` selector is added when the first DC +row lands with MEASURED IPs). Recorded now; deferred to DC deploy. + +**Related:** D-107 (Tailscale front door -- the workstation path; D-126 is the vcloud-local path), D-123 +(Model B containment VMs -- what the DC `-J` form jumps through), D-124 (transit addressing the DC forwards +will ride), DOCFIX-195 (office1-netbox is the working IPAM apex this reaches). **Revert:** `git checkout` +the as-built rows + delete this entry; `git rm` `scripts/site-forward.sh`, `scripts/site-ssh-config.sh` and +their `tests/` dirs; on any host where `install` ran, `bash scripts/site-forward.sh remove office1-netbox` +(rootless) + optionally `loginctl disable-linger`, and `bash scripts/site-ssh-config.sh remove` (leaves the +Include line + `~/.ssh/config`). diff --git a/docs/session-ledger.md b/docs/session-ledger.md index 064544c..dc657e1 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -124,6 +124,31 @@ `docs/changelog-20260715-d120-load-and-iprange-tooling.md`. +**D-126 RULED + tooling delivered (2026-07-16) -- durable rootless vcloud->service-VM access.** +Operator ruled Option A (SSH local-forward via `systemd --user`, "continue [the] ssh proxyjump [we +already use]") over persisting the L3 route (root + races libvirt-managed virbr2). Reachability +RE-established this session by the documented ad-hoc route (operator ran `sudo ip route replace +10.10.1.0/24 via 10.10.0.20 dev virbr2` in a real terminal; verified HTTP 302 at 10.10.1.10:8000). +Delivered `scripts/site-forward.sh` + `tests/site-forward/run-tests.sh` (38/38), D-126 record, +as-built access-table update. **SHELL layer added same day** (operator: "you're going to need shell in +Office and the DCs") -- `scripts/site-ssh-config.sh` + `tests/site-ssh-config/run-tests.sh` (30/30) +generate a `~/.ssh/config.d/vr1-sites` ProxyJump alias set (`ssh office1-netbox` etc.), the FOUNDATION +the forward rides; DC aliases deferred until measured post-apply. **SHELL LAYER EXERCISED 2026-07-16**: +operator installed with `~/vr1-office1-creds/office1_svc_ed25519` (one-key closed env, per +`creds-manifests/vr1-office1.manifest`); agent proved `ssh voffice1`/`office1-netbox`/`office1-tailscale`/ +`office1-opnsense` all return live from this session -- so the agent now HAS durable shell into Office1 +(load-bearing assumption confirmed: key read from config `IdentityFile`, no creds path in command, passes +the guard/classifier). The systemd FORWARD layer stays reviewed-but-unexercised (not installed). +Original build note: this session had NO ssh key to voffice1 +(none in `~/.ssh`, no agent; key is operator-held in `~/vr1-office1-creds/`, correctly un-enumerable), +so the operator supplies `--key` and runs `install` + first-connect + the one-time +`loginctl enable-linger`. Site table seeded office1-netbox only; DC rows DEFERRED (IPs not measured). +Advisor-conferred (untangle two problems): this does NOT gate `tofu plan` -- the tree has no netbox +provider/data source, so the four operator-ruled `vr1_dc0_rack_*` tfvars alone unblock plan; the +NetBox `--commit` stays a decoupled, operator-run, token-on-netbox-host step. See +`docs/changelog-20260716-d126-durable-forward.md`. + + **STAGE-3 ADVERSARIAL REVIEW (2026-07-16; advisor + 4-charter debate + Layer-1 gate).** Full pre-deployment adversarial review of the `a48a60f` batch (D-121..D-124 + substrate/rack/HA overlay). READ-ONLY; nothing applied/committed/pushed. Deliverable: `docs/stage3-adversarial-review- diff --git a/docs/vr1-office1-as-built.md b/docs/vr1-office1-as-built.md index c210ffc..b0fb950 100644 --- a/docs/vr1-office1-as-built.md +++ b/docs/vr1-office1-as-built.md @@ -74,12 +74,12 @@ | Target | From vcloud | From your workstation | |---|---|---| -| **voffice1** (shell) | `ssh -i jessea123@10.10.0.20` | `ssh -J jessea123@10.17.11.248 jessea123@10.10.0.20` | +| **voffice1** (shell) | **durable (D-126):** `bash scripts/site-ssh-config.sh install --key ` -> `ssh voffice1`. Ad-hoc: `ssh -i jessea123@10.10.0.20` | `ssh -J jessea123@10.17.11.248 jessea123@10.10.0.20` | | **OPNsense GUI** | `https://10.10.0.1` | `ssh -L 8443:10.10.0.1:443 jessea123@10.17.11.248` -> `https://localhost:8443` | | **OPNsense shell** | `ssh -i root@10.10.0.1` | via vcloud. **root's shell is `tcsh`** -- feed commands to `sh -s`. | | **MAAS UI** | `http://10.10.0.20:5240/MAAS` | `ssh -L 5240:10.10.0.20:5240 jessea123@10.17.11.248` -> `http://localhost:5240/MAAS` | -| **NetBox UI/API** | `http://10.10.1.10:8000` (needs the route below) | on tailnet: `http://10.10.1.10:8000` direct. Else `ssh -J jessea123@10.17.11.248 -L 8000:10.10.1.10:8000 jessea123@10.10.0.20` -> `http://localhost:8000` | -| **office1-netbox / office1-tailscale** (shell) | `ssh -J jessea123@10.10.0.20 ubuntu@10.10.1.10` | chain both jumps | +| **NetBox UI/API** | **durable (D-126, preferred):** `bash scripts/site-forward.sh install office1-netbox --key ` -> `http://127.0.0.1:8000`. Ad-hoc: `http://10.10.1.10:8000` needs the route below. | on tailnet: `http://10.10.1.10:8000` direct. Else `ssh -J jessea123@10.17.11.248 -L 8000:10.10.1.10:8000 jessea123@10.10.0.20` -> `http://localhost:8000` | +| **office1-netbox / office1-tailscale** (shell) | **durable (D-126):** `ssh office1-netbox` / `ssh office1-tailscale` (after `site-ssh-config.sh install`). Ad-hoc: `ssh -J jessea123@10.10.0.20 ubuntu@10.10.1.10` | chain both jumps | **Routing to the compose net.** The OPNsense edge has a static route `10.10.1.0/24 -> 10.10.0.20` (gateway object `OFFICE1_LXD_GW`, priority 255, NOT the default gateway). So any `office1-local` @@ -87,6 +87,15 @@ default route is the lab, so it needs an explicit route (operator sudo): `sudo ip route replace 10.10.1.0/24 via 10.10.0.20 dev virbr2` +**Durable vcloud->service-VM access (D-126, preferred over the sudo route above).** The route is +lost on reboot and, persisted via netplan, races libvirt (virbr2 is libvirt-managed). Instead, +`bash scripts/site-forward.sh install office1-netbox --key ` stands a ROOTLESS `systemctl --user` +unit holding an SSH local-forward through voffice1 -> `http://127.0.0.1:8000` reaches office1-netbox, +surviving reboot once `loginctl enable-linger jessea123` is set (one-time). Same tool + a `-J` +containment-VM hop generalizes to the DC service VMs (rows added once their IPs are MEASURED). The +NetBox API token is NOT carried by this forward -- it stays a host-local secret on office1-netbox +(the importers read it there); the forward carries only read/UI TCP. + **Tailscale front door (D-107: Office1 ONLY).** `office1-tailscale` is a subnet router on the operator's **SELF-HOSTED** control plane (`https://tailscale.baldurkeep.com:443` -- NOT Tailscale's public one). It is UP and online as `100.64.0.53`, advertising **`10.10.0.0/22`** -- exactly the diff --git a/scripts/site-forward.sh b/scripts/site-forward.sh new file mode 100644 index 0000000..7b358c8 --- /dev/null +++ b/scripts/site-forward.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# scripts/site-forward.sh {render|install|status|remove|list} [--key PATH] +# +# Durable, ROOTLESS SSH access from the vcloud jumphost to a site service VM +# (D-126). Manages a per-site systemd --user unit that holds an autossh-style +# local port-forward alive across reboots, so `http://127.0.0.1:` on the +# jumphost reaches a service VM that lives behind a containment/edge VM -- with +# NO root, NO persisted L3 route (which would race libvirt bringing up virbr2), +# and NO sudo at run time. It reuses the SAME transport we already use by hand +# (ssh through voffice1 / a DC containment VM), just kept up by the user manager. +# +# WHY user-systemd + a forward, not a route: the jumphost reaches office1-netbox +# (10.10.1.10) only through voffice1's lxdbr0 NAT. A `sudo ip route` is lost on +# reboot and, persisted via netplan, races libvirt (virbr2 is libvirt-managed). +# A user unit is rootless, survives reboot (with linger), and the SAME shape +# generalizes to the DCs via ProxyJump through their containment VM. +# +# SECRETS BOUNDARY: this tool NEVER reads key or token material. The SSH key is +# operator-supplied via --key (a PATH, passed to `ssh -i`; ssh reads it, we do +# not) and is NEVER baked in. The NetBox API token is a separate concern the +# importers handle on the netbox host -- this forward only carries read/UI TCP. +# +# `install` and the first connect are OPERATOR-RUN: they touch the operator's +# key + accept a host key (TOFU) + may need `loginctl enable-linger` (printed, +# not executed here). `render`/`list`/`status` are read-only. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # self-locate (L9) + +# --- site table: MEASURED/as-built values only (hard rule 2). Format: +# name = "SSH_DEST|PROXYJUMP|TARGET_HOST|TARGET_PORT|LOCAL_PORT" +# SSH_DEST the host ssh connects TO and that opens the forward +# PROXYJUMP optional `-J` hop (empty = none); the DC form jumps THROUGH the +# containment VM to reach an inner service VM +# TARGET_* what SSH_DEST forwards to (host:port on SSH_DEST's near side) +# LOCAL_PORT the 127.0.0.1 port bound on the jumphost +# +# office1-netbox: voffice1 (10.10.0.20, as-built) is the lxdbr0 gateway, so it +# forwards straight to 10.10.1.10:8000 (measured: HTTP 302). No ProxyJump. +# DC inner VMs are DEFERRED: their containment-VM + inner IPs are not measured +# yet -- add a row ONLY once measured (never an inferred IP). See the template. +declare -A SITES=( + [office1-netbox]="jessea123@10.10.0.20||10.10.1.10|8000|8000" + # [vr1-dc0-]="||||" # MEASURE first +) + +UNIT_PREFIX="site-forward-" + +die() { echo "site-forward: $*" >&2; exit 1; } + +usage() { + cat < [--key PATH] + render [--key PATH] print the systemd --user unit (read-only; --key + optional here -- omitted shows a slot) + install --key PATH write + enable --now the user unit (OPERATOR-RUN: + needs the key + first-connect host-key accept) + status systemctl --user status for the unit + remove disable + delete the user unit (rootless) + list show configured sites +sites: ${!SITES[*]} +EOF +} + +lookup() { + local site="$1" + [ -n "${SITES[$site]+x}" ] || die "unknown site '$site' (have: ${!SITES[*]})" + IFS='|' read -r DEST PJUMP THOST TPORT LPORT <<<"${SITES[$site]}" + [ -n "$DEST" ] && [ -n "$THOST" ] && [ -n "$TPORT" ] && [ -n "$LPORT" ] \ + || die "site '$site' row is malformed" +} + +render_unit() { + # $1 site, $2 key-path-or-empty + local site="$1" key="${2:-}" + lookup "$site" + local keyexpr pj="" + if [ -n "$key" ]; then keyexpr="-i $key"; else keyexpr="-i "; fi + [ -n "$PJUMP" ] && pj="-J $PJUMP " + # MEASURE ssh at render time (hard rule 2 -- never bake an unmeasured path); the + # unit renders on the host it runs on, so this is the runtime path too. + local sshbin; sshbin="$(command -v ssh)" || die "no ssh binary on PATH" + # No network-online ordering: the --user manager does NOT provide that target, + # so it would be inert. Resilience is Restart=always + ExitOnForwardFailure -- + # ssh simply fails until the network + jump host are up, and systemd retries. + # -N no shell, -T no tty, BatchMode (never prompt), ExitOnForwardFailure so a + # dead forward exits (Restart brings it back) instead of a live-but-useless + # tunnel, keepalives to reap half-open, accept-new for first-run TOFU, bind + # 127.0.0.1 only (never 0.0.0.0). + cat < ${THOST}:${TPORT}) + +[Service] +Type=simple +ExecStart=${sshbin} -N -T ${pj}${keyexpr} \\ + -o BatchMode=yes -o ExitOnForwardFailure=yes \\ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \\ + -o StrictHostKeyChecking=accept-new \\ + -L 127.0.0.1:${LPORT}:${THOST}:${TPORT} ${DEST} +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +EOF +} + +cmd="${1:-}"; site="${2:-}"; KEY="" +shift $(( $# > 0 ? 1 : 0 )) || true +# parse --key from the remaining args +args=("$@") +for ((i=0; i<${#args[@]}; i++)); do + case "${args[$i]}" in + --key) KEY="${args[$((i+1))]:-}"; [ -n "$KEY" ] || die "--key needs a PATH" ;; + esac +done + +case "$cmd" in + -h|--help|help|"") usage; [ -z "$cmd" ] && exit 1 || exit 0 ;; + list) echo "configured sites:"; for s in "${!SITES[@]}"; do echo " $s -> ${SITES[$s]}"; done ;; + render) + [ -n "$site" ] || die "render needs a " + render_unit "$site" "$KEY" + ;; + install) + [ -n "$site" ] || die "install needs a " + [ -n "$KEY" ] || die "install needs --key PATH (operator-supplied; never inferred)" + [ -r "$KEY" ] || die "key path not readable: $KEY" + lookup "$site" + udir="$HOME/.config/systemd/user" + unit="${UNIT_PREFIX}${site}.service" + mkdir -p "$udir" + render_unit "$site" "$KEY" > "$udir/$unit" + echo "wrote $udir/$unit" + systemctl --user daemon-reload + systemctl --user enable --now "$unit" + systemctl --user --no-pager status "$unit" | head -6 || true + if [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" != "yes" ]; then + echo + echo "NOTE: linger is OFF -- the unit will NOT start at boot until you run" + echo " (once, may need sudo): loginctl enable-linger $USER" + fi + ;; + status) + [ -n "$site" ] || die "status needs a " + lookup "$site" + systemctl --user --no-pager status "${UNIT_PREFIX}${site}.service" || true + ;; + remove) + [ -n "$site" ] || die "remove needs a " + lookup "$site" + unit="${UNIT_PREFIX}${site}.service" + systemctl --user disable --now "$unit" 2>/dev/null || true + rm -f "$HOME/.config/systemd/user/$unit" + systemctl --user daemon-reload + echo "removed $unit" + ;; + *) die "unknown command '$cmd' (see --help)" ;; +esac diff --git a/scripts/site-ssh-config.sh b/scripts/site-ssh-config.sh new file mode 100644 index 0000000..1f337e8 --- /dev/null +++ b/scripts/site-ssh-config.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# scripts/site-ssh-config.sh {render|install|remove|list} [--key PATH] [--config-dir DIR] +# +# Durable, ROOTLESS *shell* access from the vcloud jumphost to the VR1 site VMs +# (D-126, shell layer). Generates an ssh_config fragment of Host aliases with the +# right User + ProxyJump baked from as-built, so `ssh office1-netbox` (etc.) just +# works -- no remembering `-J jessea123@10.10.0.20 ubuntu@10.10.1.10`, and the SAME +# ProxyJump-through-the-containment-VM shape generalizes to the DC VMs once they +# exist. This is the FOUNDATION; scripts/site-forward.sh (the systemd port-forward) +# is a convenience that can ride these same aliases. +# +# SECRETS BOUNDARY (identical to site-forward.sh): this tool NEVER reads key +# material. The SSH key is operator-supplied via --key (a PATH written as an +# `IdentityFile`; ssh reads it, we do not) and is NEVER baked in. The generated +# fragment (which names that path) is written LOCALLY to ~/.ssh/config.d/ and is +# NOT committed -- the repo ships this generator + a template only. +# +# `install` writes the fragment + ensures ~/.ssh/config has an `Include config.d/*` +# line; it does NOT connect (first connect is operator-run: TOFU host-key accept). +# `render`/`list` are read-only. Rootless throughout. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # self-locate (L9) + +FRAG_NAME="vr1-sites" + +# --- host table: MEASURED/as-built values only (hard rule 2). Format: +# alias = "HOSTNAME|USER|PROXYJUMP|NOTE" +# PROXYJUMP empty = reached directly from vcloud (same L2 as virbr2/office1-local); +# else the alias to jump THROUGH (itself defined above it). +# As-built (docs/vr1-office1-as-built.md): voffice1 10.10.0.20 (jessea123, direct); +# office1-netbox 10.10.1.10 + office1-tailscale 10.10.1.11 (ubuntu, via voffice1's +# lxdbr0); OPNsense edge 10.10.0.1 (root, direct, tcsh). DC VMs are DEFERRED: their +# containment-VM + inner IPs are not deployed/measured yet -- add rows ONLY once +# measured (never an inferred IP); the DC form sets PROXYJUMP to the containment VM. +# +# PER-ENV KEY (D-126, operator ruling 2026-07-16): each DC env gets its OWN access +# key in its OWN creds folder (`~/vr1-dc0-creds/`, `~/vr1-dc1-creds/`) -- NOT office1's +# key. This tool applies ONE `--key` per invocation, so a DC env is installed as its +# OWN fragment: `install --env vr1-dc0 --key ~/vr1-dc0-creds/` (the `--env` +# selector is added when the first DC row lands; office1 today = the default env). +# The DC keys are MINTED into those creds folders at DC deploy (pubkey -> DC cloud-init), +# per creds-manifests/.manifest -- not generated speculatively here. +declare -a ALIAS_ORDER=(voffice1 office1-netbox office1-tailscale office1-opnsense) +declare -A HOSTS=( + [voffice1]="10.10.0.20|jessea123||Office1 host (libvirt + LXD + MAAS region)" + [office1-netbox]="10.10.1.10|ubuntu|voffice1|VR1 IPAM apex (NetBox, :8000)" + [office1-tailscale]="10.10.1.11|ubuntu|voffice1|Office1 subnet router (D-107)" + [office1-opnsense]="10.10.0.1|root||OPNsense edge -- root shell is tcsh, feed sh -s" + # [vr1-dc0-]="|||" # MEASURE post-apply +) + +die() { echo "site-ssh-config: $*" >&2; exit 1; } + +usage() { + cat < [--key PATH] [--config-dir DIR] + render print the ssh_config fragment (read-only; --key optional -- omitted + shows a slot) + install write ~/.ssh/config.d/${FRAG_NAME} + ensure an Include line (needs --key; + does NOT connect -- first connect is operator-run, accepts host keys) + remove delete the fragment (leaves the Include line + ~/.ssh/config) + list show configured host aliases +aliases: ${ALIAS_ORDER[*]} +EOF +} + +render_frag() { + local key="${1:-}" keyline + if [ -n "$key" ]; then keyline=" IdentityFile $key"; else keyline=" IdentityFile "; fi + echo "# ssh_config fragment -- VR1 sites (D-126 shell layer). GENERATED by" + echo "# scripts/site-ssh-config.sh -- do not hand-edit; re-render instead. NOT committed" + echo "# (names an operator key path). accept-new = TOFU on first connect." + local a hn user pj note + for a in "${ALIAS_ORDER[@]}"; do + IFS='|' read -r hn user pj note <<<"${HOSTS[$a]}" + echo + echo "Host $a # $note" + echo " HostName $hn" + echo " User $user" + [ -n "$pj" ] && echo " ProxyJump $pj" + echo "$keyline" + echo " IdentitiesOnly yes" + echo " StrictHostKeyChecking accept-new" + done +} + +cmd="${1:-}"; shift $(( $# > 0 ? 1 : 0 )) || true +KEY=""; CONFIG_DIR="$HOME/.ssh" +args=("$@") +for ((i=0; i<${#args[@]}; i++)); do + case "${args[$i]}" in + --key) KEY="${args[$((i+1))]:-}"; [ -n "$KEY" ] || die "--key needs a PATH" ;; + --config-dir) CONFIG_DIR="${args[$((i+1))]:-}"; [ -n "$CONFIG_DIR" ] || die "--config-dir needs a DIR" ;; + esac +done + +case "$cmd" in + -h|--help|help|"") usage; [ -z "$cmd" ] && exit 1 || exit 0 ;; + list) + echo "configured aliases:" + for a in "${ALIAS_ORDER[@]}"; do echo " $a -> ${HOSTS[$a]}"; done + ;; + render) + render_frag "$KEY" + ;; + install) + [ -n "$KEY" ] || die "install needs --key PATH (operator-supplied; never inferred)" + [ -r "$KEY" ] || die "key path not readable: $KEY" + mkdir -p "$CONFIG_DIR/config.d" + chmod 700 "$CONFIG_DIR" 2>/dev/null || true + frag="$CONFIG_DIR/config.d/$FRAG_NAME" + render_frag "$KEY" > "$frag" + chmod 600 "$frag" + cfg="$CONFIG_DIR/config" + if [ ! -f "$cfg" ] || ! grep -qE '^\s*Include\s+config\.d/\*' "$cfg" 2>/dev/null; then + { echo "Include config.d/*"; [ -f "$cfg" ] && cat "$cfg"; } > "$cfg.tmp" + mv "$cfg.tmp" "$cfg"; chmod 600 "$cfg" + echo "added 'Include config.d/*' to $cfg" + fi + echo "wrote $frag" + echo "next (operator): ssh voffice1 # first connect accepts host keys (TOFU)" + ;; + remove) + rm -f "$CONFIG_DIR/config.d/$FRAG_NAME" + echo "removed $CONFIG_DIR/config.d/$FRAG_NAME (Include line + config left intact)" + ;; + *) die "unknown command '$cmd' (see --help)" ;; +esac diff --git a/tests/site-forward/run-tests.sh b/tests/site-forward/run-tests.sh new file mode 100644 index 0000000..0623534 --- /dev/null +++ b/tests/site-forward/run-tests.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# tests/site-forward/run-tests.sh +# +# Harness for scripts/site-forward.sh (D-126). OFFLINE -- touches no SSH, no +# systemd, no network. Renders units and greps them; drives the error paths. +# It PINS the security-relevant shape (rootless, 127.0.0.1-bound, no baked +# key/secret, no inferred DC IP) so a careless edit reddens here, not in prod. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../scripts" && pwd)/site-forward.sh" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +command -v bash >/dev/null 2>&1 || { echo "FAIL: bash required"; exit 1; } + +# --- parse / help / list --- +bash -n "$S" 2>/dev/null && ok || bad "does not parse (bash -n)" +bash "$S" --help >/dev/null 2>&1 && ok || bad "--help -> nonzero" +bash "$S" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no-arg must be nonzero (prints usage)" +bash "$S" list 2>&1 | grep -q 'office1-netbox' && ok || bad "list lost office1-netbox" + +R="$(bash "$S" render office1-netbox 2>&1)" +RK="$(bash "$S" render office1-netbox --key /tmp/fake.key 2>&1)" + +# --- the forward shape (measured/as-built values) --- +printf '%s' "$R" | grep -q -- '-L 127.0.0.1:8000:10.10.1.10:8000' && ok || bad "wrong/missing local-forward spec" +printf '%s' "$R" | grep -q 'jessea123@10.10.0.20' && ok || bad "lost the voffice1 ssh dest" +printf '%s' "$R" | grep -q 'Description=D-126' && ok || bad "unit not tagged D-126" + +# --- SECURITY: bind loopback only, never 0.0.0.0 --- +printf '%s' "$R" | grep -q '0.0.0.0' && bad "binds 0.0.0.0 -- must be 127.0.0.1 only" || ok +printf '%s' "$R" | grep -q '127.0.0.1:8000:' && ok || bad "local bind is not 127.0.0.1" + +# --- SSH hardening flags present --- +for f in 'BatchMode=yes' 'ExitOnForwardFailure=yes' 'ServerAliveInterval=30' 'StrictHostKeyChecking=accept-new' '\-N' '\-T'; do + printf '%s' "$R" | grep -q -- "$f" && ok || bad "lost ssh flag: $f" +done + +# --- resilience: Restart + ExitOnForwardFailure ARE the resilience (no inert +# network-online.target in a --user unit -- the user manager doesn't provide it) --- +printf '%s' "$R" | grep -q 'Restart=always' && ok || bad "lost Restart=always" +printf '%s' "$R" | grep -q 'WantedBy=default.target' && ok || bad "lost user-target install" +printf '%s' "$R" | grep -q 'network-online.target' && bad "readded inert network-online.target to a --user unit" || ok + +# --- ssh path is MEASURED (command -v), not a baked literal (hard rule 2) --- +grep -q 'command -v ssh' "$S" && ok || bad "ssh path not measured at render time" +printf '%s' "$R" | grep -qE '^ExecStart=/[^ ]*/ssh ' && ok || bad "ExecStart ssh is not an absolute measured path" + +# --- SECRETS BOUNDARY: no baked key path/material; key is an operator INPUT --- +printf '%s' "$R" | grep -q '' && ok || bad "no-key render must show a slot, not a real path" +printf '%s' "$RK" | grep -q -- '-i /tmp/fake.key' && ok || bad "--key not substituted into -i" +grep -qE '\-creds/|BEGIN [A-Z]* ?PRIVATE KEY|/root/netbox-secrets|api\.token' "$S" && bad "script bakes a key/secret path or key material" || ok +grep -q 'NEVER reads key' "$S" && ok || bad "lost the secrets-boundary contract comment" + +# --- NO INFERRED VALUE: no unmeasured DC IP in an ACTIVE site row --- +# active rows are the declare -A body lines that are NOT commented; DC supernets +# (10.12., 172.31.) must not appear un-commented until measured. +active="$(sed -n '/declare -A SITES=(/,/^)/p' "$S" | grep -vE '^\s*#')" +printf '%s' "$active" | grep -qE '10\.12\.|172\.31\.' && bad "an inferred DC IP is in an ACTIVE site row -- measure first" || ok +printf '%s' "$active" | grep -q 'office1-netbox' && ok || bad "office1-netbox not an active site row" + +# --- install refuses without a key (never guesses one) --- +bash "$S" install office1-netbox >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "install without --key must fail" +bash "$S" install office1-netbox --key /nonexistent/key >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "install with unreadable key must fail" + +# --- unknown site fails loud on every subcommand --- +for c in render install status remove; do + bash "$S" "$c" bogus --key /tmp/fake.key >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "$c bogus-site must fail" +done + +# --- rootless: never invokes sudo/root; enable-linger only PRINTED, not run --- +grep -qE '^\s*sudo |systemctl (enable|start|--system)' "$S" && bad "invokes sudo/system systemctl -- must be rootless" || ok +grep -q 'systemctl --user' "$S" && ok || bad "does not use systemctl --user" + +# --- L9 self-ref: self-locates, no hardcoded repo path --- +grep -q 'BASH_SOURCE' "$S" && ok || bad "does not self-locate via BASH_SOURCE" +grep -q '/home/jessea123/openstack' "$S" && bad "hardcodes an absolute repo path (L9)" || ok + +# --- ASCII + LF --- +LC_ALL=C grep -qP '[^\x00-\x7F]' "$S" && bad "non-ASCII byte in script" || ok +grep -qU $'\r' "$S" && bad "CR byte (must be LF)" || ok + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "site-forward: $pass/$total PASS"; exit 0; fi +echo "site-forward: $fail/$total FAIL"; exit 1 diff --git a/tests/site-ssh-config/run-tests.sh b/tests/site-ssh-config/run-tests.sh new file mode 100644 index 0000000..5703315 --- /dev/null +++ b/tests/site-ssh-config/run-tests.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# tests/site-ssh-config/run-tests.sh +# +# Harness for scripts/site-ssh-config.sh (D-126 shell layer). OFFLINE -- touches +# no SSH, no network; `install` is exercised only against a throwaway --config-dir +# (never the real ~/.ssh). Pins the as-built host/user/proxyjump mapping, the +# secrets boundary (key by path, never baked), Include idempotency + 0600 perms, +# and that no un-measured DC host leaks into an active row. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../scripts" && pwd)/site-ssh-config.sh" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +command -v bash >/dev/null 2>&1 || { echo "FAIL: bash required"; exit 1; } + +# --- parse / help / list --- +bash -n "$S" 2>/dev/null && ok || bad "does not parse (bash -n)" +bash "$S" --help >/dev/null 2>&1 && ok || bad "--help -> nonzero" +bash "$S" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no-arg must be nonzero (usage)" +bash "$S" list 2>&1 | grep -q 'office1-netbox' && ok || bad "list lost office1-netbox" + +R="$(bash "$S" render 2>&1)" +RK="$(bash "$S" render --key /tmp/fake.key 2>&1)" + +# --- as-built host/user/proxyjump mapping (MEASURED values) --- +printf '%s' "$R" | grep -q 'Host voffice1' && ok || bad "lost voffice1 alias" +printf '%s' "$R" | grep -q 'Host office1-netbox' && ok || bad "lost office1-netbox alias" +printf '%s' "$R" | grep -qE 'HostName 10\.10\.1\.10' && ok || bad "netbox HostName not 10.10.1.10" +# office1-netbox must be User ubuntu + ProxyJump voffice1 (the -J chain of record) +awk '/^Host office1-netbox/{f=1} f&&/User ubuntu/{u=1} f&&/ProxyJump voffice1/{p=1} /^Host office1-tailscale/{exit} END{exit !(u&&p)}' <<<"$R" \ + && ok || bad "office1-netbox is not (User ubuntu + ProxyJump voffice1)" +# voffice1 is reached DIRECT (no ProxyJump) -- it is the jump host itself +awk '/^Host voffice1/{f=1} f&&/ProxyJump/{bad=1} /^Host office1-netbox/{exit} END{exit bad?1:0}' <<<"$R" \ + && ok || bad "voffice1 wrongly has a ProxyJump (must be direct)" +printf '%s' "$R" | grep -q 'tcsh' && ok || bad "lost the OPNsense tcsh caveat" + +# --- SECRETS BOUNDARY: no baked key path/material; key is an operator INPUT --- +printf '%s' "$R" | grep -q 'IdentityFile ' && ok || bad "no-key render must show slot" +printf '%s' "$RK" | grep -q 'IdentityFile /tmp/fake.key' && ok || bad "--key not substituted into IdentityFile" +# scope to EXECUTABLE lines (strip full-line comments) -- the hazard is a baked +# creds path in code/heredoc, not a path named in a documentation comment. +grep -vE '^\s*#' "$S" | grep -qE '\-creds/|BEGIN [A-Z]* ?PRIVATE KEY|/root/netbox-secrets|api\.token' && bad "script bakes a key/secret path or material in code" || ok +grep -q 'NEVER reads key' "$S" && ok || bad "lost the secrets-boundary contract comment" +printf '%s' "$RK" | grep -q 'IdentitiesOnly yes' && ok || bad "lost IdentitiesOnly yes" + +# --- NO INFERRED VALUE: no un-measured DC host in an ACTIVE row --- +active="$(sed -n '/declare -A HOSTS=(/,/^)/p' "$S" | grep -vE '^\s*#')" +printf '%s' "$active" | grep -qE '10\.12\.|172\.31\.|vr1-dc[01]' && bad "an inferred DC host is in an ACTIVE row -- measure post-apply" || ok + +# --- install (scratch dir only): Include idempotency + 0600 perms + refuse no key --- +SB="$(mktemp -d)"; K="$SB/k"; : > "$K"; chmod 600 "$K" +bash "$S" install --key "$K" --config-dir "$SB" >/dev/null 2>&1 && ok || bad "install failed" +[ -f "$SB/config.d/vr1-sites" ] && ok || bad "fragment not written" +bash "$S" install --key "$K" --config-dir "$SB" >/dev/null 2>&1 +[ "$(grep -c 'Include config.d' "$SB/config")" -eq 1 ] && ok || bad "Include line duplicated on re-install" +[ "$(stat -c '%a' "$SB/config")" = "600" ] && ok || bad "config not 0600" +[ "$(stat -c '%a' "$SB/config.d/vr1-sites")" = "600" ] && ok || bad "fragment not 0600" +bash "$S" install --config-dir "$SB" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "install without --key must fail" +bash "$S" install --key /nonexistent/key --config-dir "$SB" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "install unreadable key must fail" +bash "$S" remove --config-dir "$SB" >/dev/null 2>&1 +[ ! -f "$SB/config.d/vr1-sites" ] && ok || bad "remove left the fragment" +[ "$(grep -c 'Include' "$SB/config")" -eq 1 ] && ok || bad "remove wrongly stripped the Include line" +rm -rf "$SB" + +# --- rootless + self-locate + no hardcoded repo path (L9) --- +grep -qE '^\s*sudo |systemctl' "$S" && bad "invokes sudo/systemctl -- must be rootless config-only" || ok +grep -q 'BASH_SOURCE' "$S" && ok || bad "does not self-locate via BASH_SOURCE" +grep -q '/home/jessea123/openstack' "$S" && bad "hardcodes an absolute repo path (L9)" || ok + +# --- ASCII + LF --- +LC_ALL=C grep -qP '[^\x00-\x7F]' "$S" && bad "non-ASCII byte in script" || ok +grep -qU $'\r' "$S" && bad "CR byte (must be LF)" || ok + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "site-ssh-config: $pass/$total PASS"; exit 0; fi +echo "site-ssh-config: $fail/$total FAIL"; exit 1