diff --git a/docs/changelog-20260714-c4-sandbox-loop-and-upstream-guard.md b/docs/changelog-20260714-c4-sandbox-loop-and-upstream-guard.md new file mode 100644 index 0000000..5814dd3 --- /dev/null +++ b/docs/changelog-20260714-c4-sandbox-loop-and-upstream-guard.md @@ -0,0 +1,54 @@ +# 2026-07-14 -- C4 (sandbox loop documented) + the upstream-write guard it exposed + +## C4 -- the NetBox sandbox loop is now documented (Stage 2 close-out) + +Two placements, no duplication of rationale: +- `docs/dc-dc-netbox-buildout-scope.md` **section 8** -- the design: why a sandbox, why a fidelity + check (the 17-of-90 silent scope drop), the 2026-07-14 hardening (do not trust a pre-hardening + "faithful" verdict), the five-tool write-guard table, the WAF trap, and the C2 preconditions. +- `runbooks/dc-dc-phase1-office1-standup.md` **Step 10b** -- the operational sequence (dump -> + seed -> apply delta -> fidelity-check -> operator-gated upstream write), with the two-host / + two-token separation called out as the safety property. Step 10's stale "the literals do not + exist yet" note was corrected (D-115/117/118 assigned them). + +Drafted by a subagent, then corrected against this session's own changes before placement (the +draft predated the two fixes below and would otherwise have documented a now-false state). + +## The guard C4 exposed: the two pynetbox importers had NO upstream gate + +Documenting the write-guard architecture surfaced that `roles-aggregates-import.py` and +`dc-dc-prefixes-import.py` had **no hostname gate at all** -- unlike `sandbox-seed.py` (structural +refusal) and `d115-office-carve.py` (`SANDBOX_HOSTS` + `--yes-write-upstream`). Until earlier today +the WAF's 403 on the default User-Agent was the ONLY thing stopping an accidental `--commit` against +the production apex -- and the WAF fix (`changelog-20260714-netbox-importers-waf-useragent.md`) +removed that accidental safety. So an explicit gate was required, and is now added: + +- both importers gain `--yes-write-upstream`; a `--commit` whose `NETBOX_URL` host is not in + `SANDBOX_HOSTS = {localhost, 127.0.0.1, 10.10.1.201}` REFUSES without it. Same pattern as + `d115-office-carve.py`, so all three write-capable importers now behave identically. + +This is defence-in-depth with the whole-plan preflights (which prevent a half-write); the guard +prevents the write from *starting* against the wrong target. + +## Tests + +- `tests/dc-dc-prefixes-import/` 86 -> **91**: `--commit` to a non-sandbox host is REFUSED without + `--yes-write-upstream` (and writes nothing); WITH the flag it proceeds; a sandbox host needs no + flag. The write-path fake tests now target the known sandbox host so the guard passes transparently. +- `tests/roles-aggregates-import/` 20 -> **24**: asserts the flag, the `SANDBOX_HOSTS` gate, the + refusal message, and that the guard actually checks the flag. + +GAUNTLET ALL GREEN (58); repo-lint 0 fail. + +## Stage 2 close-out after this change + +C1 DONE, C3 DONE, **C4 DONE**. C2 (feed the carve upstream) is the ONLY remaining item -- a +production write, still gated, with its preconditions now documented (section 8.6): re-verify the +sandbox under the hardened check, run the pynetbox importers from office1-netbox, pass +`--yes-write-upstream`, approve each mutation. + +## Revert + + git revert + +Docs + one argparse flag and guard block per importer + their tests. No NetBox was written. diff --git a/docs/dc-dc-deployment-workflow.md b/docs/dc-dc-deployment-workflow.md index a040c78..bcec3ae 100644 --- a/docs/dc-dc-deployment-workflow.md +++ b/docs/dc-dc-deployment-workflow.md @@ -135,7 +135,7 @@ | C1 | Office1 LAN IPv6 deployed (`2602:f3e2:f01:100::1/64` + RA on the edge LAN). WAN v6 leg DEFERRED: v6 does not egress the lab. | **DONE 2026-07-14** -- live and PROVEN ON THE WIRE: address on the kernel, radvd advertising the prefix (`mode=unmanaged`), `voffice1` autoconfigured `...:5054:ff:fe6a:87e5` by SLAAC, edge->voffice1 GUA ping 0.0% loss. NOTE the D-113 AMENDMENT: the ADDRESS half is **not** REST-API-doable (`scripts/opnsense-set-interface-v6.sh`); only the RA half is. `docs/changelog-20260714-office1-lan-ipv6-executed.md` | | C2 | The validated carve fed UPSTREAM to `netbox.baldurkeep.com` -- the gate says *authoritative*, and the sandbox is not it. Operator-gated write. | **OPEN** | | C3 | The `$DC` shell-selector collision resolved. | **DONE 2026-07-14 (D-119)** -- selectors are REGION-QUALIFIED (`vr0-dc0`/`vr1-dc0`/`vr1-dc1`) across `lib-net.sh`, `lib-hosts.sh`, `opentofu/`, the importer and every runbook call site. Bare `dcN` is REJECTED. The importer's DC->site map is now an IDENTITY, so the off-by-one is structurally impossible. Gap #19 CLOSED. | -| C4 | The NetBox sandbox loop documented here and in `docs/dc-dc-netbox-buildout-scope.md`. | **OPEN** | +| C4 | The NetBox sandbox loop documented. | **DONE 2026-07-14** -- `docs/dc-dc-netbox-buildout-scope.md` section 8 (rationale + write-guard table + WAF trap) and `runbooks/dc-dc-phase1-office1-standup.md` Step 10b (operational sequence). | | C5 | This doc's Stage 2 row reconciled against reality (it has drifted three times). | **DONE 2026-07-14** | --- diff --git a/docs/dc-dc-netbox-buildout-scope.md b/docs/dc-dc-netbox-buildout-scope.md index 4a5594c..d992dd1 100644 --- a/docs/dc-dc-netbox-buildout-scope.md +++ b/docs/dc-dc-netbox-buildout-scope.md @@ -188,3 +188,96 @@ | `DC_GUA_PREFIX` **dc0** | **MEASURED** (D-117) | `2602:f3e2:f02::/48` -- the apex's `vr1-dc0` block. Not renamed; the repo adopted the apex's name. | | `DC_GUA_PREFIX` **dc1** | **MEASURED** (D-117) | `2602:f3e2:f03::/48` -- the apex's `vr1-dc1` block. | | `DC2_V4_SUPERNET` | **ASSIGNED (D-115)** | **`10.12.64.0/19`** -- INSIDE the Cloud `/16`, free (DC1 holds `10.12.{4,8,12,16,32,36}`), DC1<->DC2 stay routable. The old `10.13.0.0/19` recommendation was OUTSIDE every allocated block (it would have squatted); D-115 supersedes it. Tool needs >= `/19` and re-packs 6 sequential `/22`s -- see 5.3. | + +## 8. The NetBox sandbox loop (C4) + +### 8.1 Why a sandbox exists at all + +NetBox `netbox.baldurkeep.com` is the IPAM apex and source of truth (D-010, D-103). The section-4 +buildout mutates it (roles, aggregates, org ULA /48, two DCs of six-plane prefixes). We do NOT +rehearse those mutations on the apex. A SECOND NetBox -- `office1-netbox`, `10.10.1.201:8000`, +NetBox 4.6.4 on the Office1 VM (`docs/changelog-20260713-office1-netbox-deployed.md`) -- is the +SANDBOX. The upstream draft is copied in, the planned changes are applied and proven there, and only +then is the validated delta fed back upstream as a separate, operator-gated write. + + upstream (draft source, READ-ONLY) --[prod-draft-dump.py]--> netbox/draft/vr1-draft.json + vr1-draft.json --[sandbox-seed.py]------> Office1 sandbox + ...apply the planned delta in the sandbox (the section-4 importers)... + ...prove sandbox == upstream draft + EXACTLY the planned delta (fidelity check)... + validated refinements --[operator-gated write]-> upstream + +The sim NEVER `--commit`s upstream as a side effect. Feeding the apex is always a deliberate +operator act (8.4, 8.6). + +### 8.2 Why a FIDELITY CHECK, and not just counts + +Counts and idempotency CANNOT prove fidelity. On 2026-07-13 the seeder silently dropped the scope on +17 of 90 prefixes -- it mapped only `dcim.site`, and most of the VR1 draft is `dcim.region`-scoped -- +and every count still matched and re-running stayed idempotent. It was caught by luck on a spot-check +printing `site None`. `netbox/sandbox-fidelity-check.py` is the check that catches it on purpose: it +diffs FIELD BY FIELD on every shared object, so a dropped or mangled field is a hard failure. + +### 8.3 The 2026-07-14 hardening -- do NOT trust a pre-hardening "faithful" verdict + +Until 2026-07-14 the fidelity check could return a FALSE GREEN. It only proved the sandbox delta was +a SUBSET of the plan (`unexpected = extra - expected`); it NEVER proved the plan was CARRIED OUT. +With ZERO of the 36 DC prefixes created, `extra` and `unexpected` were both empty, and it printed +"Nothing lost, nothing stray" and exited 0. `EXPECTED_NEW` was an upper bound masquerading as an +assertion. Fixed 2026-07-14 (`docs/changelog-20260714-netbox-write-path-hardening.md`): `EXPECTED_NEW` +is now BOTH bounds (`EXPECTED-BUT-ABSENT` is a hard failure), and delta prefixes are checked for scope +and role. A NEW harness `tests/sandbox-fidelity-check/run-tests.sh` (10/10; T3 IS the false-green +scenario) ships with it -- the checker had shipped with NO harness, which is why the false green +survived. + +CONSEQUENCE: any "sandbox is faithful" claim dated before 2026-07-14 was made by a checker that could +not see an unapplied or under-applied plan. **Re-run the current checker before trusting fidelity** +(this is why the ledger flags a sandbox re-verify owed before C2). + +Two blind spots remain OPEN (logged, not fixed): SAME-DUMPER (both sides use the same field list, so +a field the dumper omits is invisible on both), and DUPLICATE-CIDR collapse (prefix-keyed dicts +collapse duplicates; latent -- none upstream today). "Faithful" means faithful within the dumped +field list, not absolutely. + +### 8.4 Write-guard architecture -- FIVE tools, and how each is kept off the apex + +"The sim never writes upstream" is enforced differently per tool. As of 2026-07-14 the three +write-capable importers ALL gate an upstream `--commit`, but by two different mechanisms: + +| Tool | Default | Upstream write-guard | +|---|---|---| +| `prod-draft-dump.py` | read-only; no `--commit` | N/A -- performs no writes | +| `sandbox-seed.py` | DRY; `--commit` writes | STRUCTURAL: holds no upstream URL/token and dies if pointed at the draft's own source host -- it CANNOT reach upstream | +| `d115-office-carve.py` | DRY; `--commit` writes | `SANDBOX_HOSTS` allowlist `{localhost,127.0.0.1,10.10.1.201}`; a non-sandbox `--commit` REFUSES without `--yes-write-upstream` | +| `roles-aggregates-import.py` | PREVIEW; `--commit` writes | **`SANDBOX_HOSTS` + `--yes-write-upstream` (added 2026-07-14)** -- plus a whole-plan PREFLIGHT so it cannot half-write | +| `dc-dc-prefixes-import.py` | DRY; `--commit` writes | **`SANDBOX_HOSTS` + `--yes-write-upstream` (added 2026-07-14)** -- plus a whole-plan role PREFLIGHT so it cannot half-write | + +Before 2026-07-14 the two pynetbox importers had NO target guard at all; the only thing stopping an +accidental upstream `--commit` was the WAF 403 (8.5) -- and fixing the WAF (below) removed that +accidental safety, which is exactly why the explicit `--yes-write-upstream` gate was added in the +same change (`docs/changelog-20260714-netbox-importers-waf-useragent.md`). The whole-plan preflights +are the OTHER layer: both importers had previously died mid-loop and left the production IPAM +half-populated (`docs/changelog-20260714-netbox-write-path-hardening.md`). + +### 8.5 The upstream WAF trap (FIXED 2026-07-14) + +Upstream NetBox sits behind a User-Agent-filtering WAF: `urllib`/`requests`/`pynetbox` get a 403 +that looks EXACTLY like an auth failure, while the SAME token works from `curl` (measured 2026-07-13, +re-measured 2026-07-14: `curl` -> 200, `urllib`/pynetbox -> 403; `references/platform-traps.md`). Do +NOT diagnose it as a token problem and re-mint credentials -- confirm with `curl` first. + +The three stdlib tools always sent an accepted UA (`curl/8.5.0`). The two pynetbox importers did NOT +until 2026-07-14 -- meaning they could NEVER have written to the apex, only to the WAF-less sandbox. +`get_nb()` in both now sets `nb.http_session.headers["User-Agent"] = "curl/8.5.0"` +(`docs/changelog-20260714-netbox-importers-waf-useragent.md`). NOTE: `pynetbox` is NOT installed on +`vcloud` -- the two pynetbox importers must run on `office1-netbox` (pynetbox 7.0.0), which reaches +upstream through the edge. + +### 8.6 Feeding the delta upstream (C2) -- STILL PENDING + +C2 is a production IPAM write and is NOT part of the sim. Preconditions before it runs: (a) re-run +the HARDENED fidelity check against the sandbox -- the "faithful" verdict predates the hardening +(8.3); (b) run the two pynetbox importers from `office1-netbox` (pynetbox + upstream reach); (c) each +importer needs `--yes-write-upstream` on top of `--commit` for the non-sandbox target; (d) present +and individually approve each mutation -- never batched. As of 2026-07-14 C2 has NOT been run; the +upstream apex still lacks the six-plane roles, the RIRs/aggregates, the `vr1-off1` carve, and the +36 DC prefixes (measured read-only). diff --git a/docs/session-ledger.md b/docs/session-ledger.md index d8d90f8..0117c38 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -170,7 +170,7 @@ only DC1 *deployment* is gated). Unconditional `cpu = { mode = "host-passthrough" }` -- DC nodes run `nova-compute` and MUST run KVM guests. Without it they would have come up looking healthy and been unable to launch a single instance. -2. **NetBox sandbox loop -- BUILT 2026-07-13/14, still UNDOCUMENTED in the runbook (close-out C4).** +2. **NetBox sandbox loop -- DOCUMENTED 2026-07-14 (close-out C4 DONE).** scope-doc section 8 + phase1 runbook Step 10b. (Was: BUILT 2026-07-13/14, undocumented.) Architecture: draft-source `netbox.baldurkeep.com` (READ ONLY) -> a NEW NetBox on the Office1 VM (sandbox) -> import draft + planned changes -> simulate VR1 there -> feed validated refinements BACK. The sim NEVER `--commit`s upstream. **The tooling now exists and is proven:** diff --git a/netbox/dc-dc-prefixes-import.py b/netbox/dc-dc-prefixes-import.py index 5e97cdb..2f2666f 100644 --- a/netbox/dc-dc-prefixes-import.py +++ b/netbox/dc-dc-prefixes-import.py @@ -127,6 +127,7 @@ import ipaddress import os import sys +import urllib.parse try: import pynetbox @@ -156,6 +157,10 @@ # 2602:f3e2:f03::/48 -> site vr1-dc1 "Virtual Region 1 (VR1) Datacenter 1 (DC1)" # They are NOT a repo-local invention. Do not "fix" them to dc1/dc2 -- that is the # exact off-by-one D-117 closed and D-119 made unrepresentable. +# A sandbox is local or the known Office1 sandbox address; anything else is treated +# as the production apex and needs --yes-write-upstream (see the guard in main()). +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.201"} + SITES = { "vr1-dc0": {"slug": "vr1-dc0", "name": "VR1 DC0"}, "vr1-dc1": {"slug": "vr1-dc1", "name": "VR1 DC1"}, @@ -469,8 +474,25 @@ parser.add_argument("--update", action="store_true", help="Update existing prefixes in place (requires --commit)") parser.add_argument("--commit", action="store_true", help="WRITE to NetBox. Default is a DRY RUN that writes nothing (D-117 -- this tool used to write by default)") parser.add_argument("--verify-only", action="store_true", help="Skip the plan entirely; only print the verification block") + parser.add_argument("--yes-write-upstream", action="store_true", + help="Required (with --commit) to write to a NON-sandbox NetBox. Feeding " + "the production apex is an operator decision, not an import side effect.") args = parser.parse_args() + # UPSTREAM-WRITE GUARD (2026-07-14). This tool had NO hostname gate, and until + # today the WAF's 403 on the default User-Agent was the only thing stopping an + # accidental --commit against the production apex. Now that get_nb() sends an + # accepted UA, that accidental-safety is GONE -- so an explicit gate is required, + # matching d115-office-carve.py. A sandbox is local or the known Office1 address; + # anything else is treated as production. + if args.commit and not args.verify_only: + _host = (urllib.parse.urlparse(os.environ.get("NETBOX_URL", "")).hostname or "").lower() + if _host not in SANDBOX_HOSTS and not args.yes_write_upstream: + die(f"REFUSING to --commit to '{_host or os.environ.get('NETBOX_URL','?')}': it is not a " + "known sandbox, so this is treated as the PRODUCTION apex. Feeding validated prefixes " + "upstream is an operator decision (C2), not a side effect of running an import. " + "Re-run with --yes-write-upstream if that is genuinely what you intend.") + # D-119: reject BOTH historical names by NAME rather than ignoring them. An # operator with muscle memory could plausibly export either -- and both are now # ambiguous ("DC1" could mean VR0's dc0 OR VR1's second DC). Silence would give diff --git a/netbox/roles-aggregates-import.py b/netbox/roles-aggregates-import.py index 5abc442..6e96a34 100644 --- a/netbox/roles-aggregates-import.py +++ b/netbox/roles-aggregates-import.py @@ -61,6 +61,7 @@ import ipaddress import os import sys +import urllib.parse try: import pynetbox @@ -99,6 +100,10 @@ # --- Data (facts / adopted model; the only operator literal is ORG_ULA_48 via env) --- +# A sandbox is local or the known Office1 sandbox address; anything else is treated +# as the production apex and needs --yes-write-upstream (guard in main()). +SANDBOX_HOSTS = {"localhost", "127.0.0.1", "10.10.1.201"} + ROLES = [ ("provider-public", "Provider Public", 100, "Six-plane: provider public API VIPs + FIP/ext_net (GUA). D-052/D-101."), @@ -156,8 +161,23 @@ ap = argparse.ArgumentParser(description="Create VR1 six-plane roles + aggregates (idempotent).") ap.add_argument("--commit", action="store_true", help="write to NetBox (default: preview only, no writes)") + ap.add_argument("--yes-write-upstream", action="store_true", + help="Required (with --commit) to write to a NON-sandbox NetBox. Feeding " + "the production apex is an operator decision, not an import side effect.") args = ap.parse_args() commit = args.commit + + # UPSTREAM-WRITE GUARD (2026-07-14). This tool had NO hostname gate; until today + # the WAF's 403 on the default User-Agent was the only thing stopping an accidental + # --commit against the production apex. get_nb() now sends an accepted UA, so that + # accidental-safety is gone -- an explicit gate is required (matches d115-office-carve.py). + if commit: + _host = (urllib.parse.urlparse(os.environ.get("NETBOX_URL", "")).hostname or "").lower() + if _host not in SANDBOX_HOSTS and not args.yes_write_upstream: + die(f"REFUSING to --commit to '{_host or os.environ.get('NETBOX_URL','?')}': it is not a " + "known sandbox, so this is treated as the PRODUCTION apex. Feeding roles/aggregates " + "upstream is an operator decision (C2), not a side effect of running an import. " + "Re-run with --yes-write-upstream if that is genuinely what you intend.") mode = "COMMIT" if commit else "PREVIEW (no writes -- add --commit to apply)" nb = get_nb() diff --git a/runbooks/dc-dc-phase1-office1-standup.md b/runbooks/dc-dc-phase1-office1-standup.md index 9afe578..d073e0a 100644 --- a/runbooks/dc-dc-phase1-office1-standup.md +++ b/runbooks/dc-dc-phase1-office1-standup.md @@ -645,12 +645,16 @@ `netbox/dc-dc-prefixes-import.py` (DOCFIX-152) extends the v1 single-site import to VR1's per-DC, dual-stack model per D-101. It is idempotent and -`--verify-only`-capable. **It does NOT, and cannot, close the DATA half of -tooling gap #3**: the real org ULA /48, the per-DC GUA carve, and DC2's v4 -supernet do not exist yet as assigned values anywhere in this repo or session. -Running it for DC1 requires the two v6 literals (`ORG_ULA_48`, `DC_GUA_PREFIX`); -running it for DC2 additionally requires `VR1_DC1_V4_SUPERNET` -- none of the three -has a default, by the script's own design, and none is invented here. +`--verify-only`-capable. **UPDATE 2026-07-14: the literals ARE now assigned** -- `ORG_ULA_48 = +fd50:840e:74e2::/48` (D-118), `DC_GUA_PREFIX` `2602:f3e2:f02::/48` / `f03::/48` +(D-117, measured off the apex), `VR1_DC1_V4_SUPERNET = 10.12.64.0/19` (D-115). The +"do not exist yet" framing below is HISTORICAL. What remains is running the loop +against a real NetBox and feeding the result upstream (C2). See **Step 10b** for the +full loop and **`docs/dc-dc-netbox-buildout-scope.md` section 8** for the rationale. + +Running it for VR1 DC0 requires the two v6 literals (`ORG_ULA_48`, `DC_GUA_PREFIX`); +running it for VR1 DC1 additionally requires `VR1_DC1_V4_SUPERNET` -- none has a +default, by the script's own design, and none is invented here. **CHECK -- dry run, no writes** ```bash @@ -687,6 +691,51 @@ --- +## Step 10b -- the NetBox sandbox loop (C4) [dry-run first; upstream write OPERATOR-GATED] + +Step 10 is one importer inside a larger loop. The loop rehearses EVERY NetBox +mutation on a sandbox before the apex is touched. Full rationale + the write-guard +table + the WAF trap live in **`docs/dc-dc-netbox-buildout-scope.md` section 8**; +this is the operational sequence only. + +**Two hosts, two tokens -- do not cross them (the separation IS the safety property):** + +| Phase | Host | NetBox | Token | +|---|---|---|---| +| dump (read upstream) | `vcloud` | `netbox.baldurkeep.com` (apex, READ-ONLY) | upstream, `~/.vr1-netbox.env` | +| seed / carve / import / verify | `office1-netbox` (`10.10.1.201`) | sandbox `localhost:8000` | sandbox (operator-held on the box) | + +`pynetbox` is on `office1-netbox`, NOT on vcloud -- the two pynetbox importers +(`roles-aggregates-import.py`, `dc-dc-prefixes-import.py`) MUST run there. + +1. **Dump upstream (READ-ONLY, vcloud):** `python3 netbox/prod-draft-dump.py --out + netbox/draft/vr1-draft.json`. A 403 here is the WAF User-Agent trap, NOT the + token (section 8.5) -- confirm with `curl` before touching credentials. +2. **Seed the sandbox (office1-netbox, DRY then `--commit`):** `sandbox-seed.py + --draft draft/vr1-draft.json`. It STRUCTURALLY cannot reach upstream. NetBox 4.6 + needs the assembled v2 token `nbt_.`. +3. **Apply the planned delta in the SANDBOX (DRY then `--commit`), in order:** + `roles-aggregates-import.py` (roles + aggregates + org ULA) -> `d115-office-carve.py` + (site `vr1-off1`, edge role, 8 prefixes) -> `dc-dc-prefixes-import.py --dc vr1-dc0` + then `--dc vr1-dc1` (D-119: the selector IS the apex slug). Literals as in the + Step 10 UPDATE note. The prefix importer PREFLIGHT-fails if the six-plane roles + are absent, so roles-aggregates MUST precede it. +4. **Prove fidelity (READ-ONLY):** dump the sandbox with the same dumper, then + `sandbox-fidelity-check.py --upstream netbox/draft/vr1-draft.json --sandbox + <sandbox-dump>`. Exit 0 = upstream draft + EXACTLY the planned delta. Use the + HARDENED checker (post-2026-07-14) -- a pre-hardening green does not count (8.3). +5. **Feed upstream (C2 -- PRODUCTION WRITE, OPERATOR-GATED, STILL PENDING):** run the + same importers against `netbox.baldurkeep.com` with `--commit` AND + `--yes-write-upstream` (the non-sandbox target requires the explicit override). + Present and individually approve each mutation; never batch. Precondition: re-run + the fidelity check first -- the sandbox's "faithful" verdict predates the hardening. + +**GATE:** the loop's tooling is complete and green; the sandbox-side steps 1-4 are +runnable now. Step 5 (C2) has NOT run -- the apex still lacks the six-plane roles, +RIRs/aggregates, the `vr1-off1` carve, and the 36 DC prefixes (measured 2026-07-14). + +--- + ## Step 11 -- GitBucket: REMOVED FROM SCOPE (D-116) **DO NOT BUILD AN OFFICE1-LOCAL GITBUCKET.** Operator ruling 2026-07-13 (D-116): the existing diff --git a/tests/dc-dc-prefixes-import/test_logic.py b/tests/dc-dc-prefixes-import/test_logic.py index 8dc8a3c..977d61b 100644 --- a/tests/dc-dc-prefixes-import/test_logic.py +++ b/tests/dc-dc-prefixes-import/test_logic.py @@ -211,7 +211,7 @@ sys.argv = old_argv -os.environ["NETBOX_URL"] = "https://fake.example/netbox" +os.environ["NETBOX_URL"] = "http://10.10.1.201:8000" # the known sandbox (guard passes) os.environ["NETBOX_TOKEN"] = "fake-token" os.environ["ORG_ULA_48"] = "fd12:3456:789a::/48" @@ -409,6 +409,43 @@ "preflight: the rejected run created NO prefixes") # ----------------------------------------------------------------------------- +# UPSTREAM-WRITE GUARD (2026-07-14). This tool had NO hostname gate; the WAF 403 on +# the default User-Agent was the only thing stopping an accidental --commit to the +# production apex, and that accidental-safety was removed by the UA fix. So a +# --commit to a NON-sandbox host must now REFUSE without --yes-write-upstream. +# ----------------------------------------------------------------------------- +fake_up = fake_pynetbox.FakeApi() +fake_up.preseed_roles(T.PLANE_ORDER) +os.environ["NETBOX_URL"] = "https://netbox.baldurkeep.com" # the PRODUCTION apex +os.environ["ORG_ULA_48"] = "fd50:840e:74e2::/48" +os.environ["DC_GUA_PREFIX"] = "2602:f3e2:f02::/48" +try: + with captured_stdout(): + run_main(["--dc", "vr1-dc0", "--commit"], fake_up) + no("guard: --commit to the production apex WITHOUT --yes-write-upstream is REFUSED") +except SystemExit: + ok("guard: --commit to the production apex WITHOUT --yes-write-upstream is REFUSED") +check(fake_up.dcim.sites.get(slug="vr1-dc0") is None, + "guard: the refused upstream --commit wrote NOTHING") + +# WITH the flag, the same upstream --commit proceeds (guard is an opt-in gate, not a wall). +fake_up2 = fake_pynetbox.FakeApi() +fake_up2.preseed_roles(T.PLANE_ORDER) +with captured_stdout(): + rc_up = run_main(["--dc", "vr1-dc0", "--commit", "--yes-write-upstream"], fake_up2) +check(rc_up == 0, "guard: --commit --yes-write-upstream to the apex is ALLOWED") +check(fake_up2.dcim.sites.get(slug="vr1-dc0") is not None, + "guard: the authorized upstream --commit DID write") + +# A sandbox host needs NO flag -- the default C2-safe path for the sim. +fake_sb = fake_pynetbox.FakeApi() +fake_sb.preseed_roles(T.PLANE_ORDER) +os.environ["NETBOX_URL"] = "http://10.10.1.201:8000" # known sandbox +with captured_stdout(): + rc_sb = run_main(["--dc", "vr1-dc0", "--commit"], fake_sb) +check(rc_sb == 0, "guard: --commit to a SANDBOX host needs no flag") + +# ----------------------------------------------------------------------------- # Summary # ----------------------------------------------------------------------------- print() diff --git a/tests/roles-aggregates-import/run-tests.sh b/tests/roles-aggregates-import/run-tests.sh index 1ee133a..4d0736c 100755 --- a/tests/roles-aggregates-import/run-tests.sh +++ b/tests/roles-aggregates-import/run-tests.sh @@ -79,6 +79,18 @@ grep -q 'http_session.headers\["User-Agent"\] = "curl/8.5.0"' "$S" && ok \ || bad "get_nb does not set the WAF-safe User-Agent -- upstream writes will 403" +# UPSTREAM-WRITE GUARD: --commit to a non-sandbox host must require --yes-write-upstream. +# The WAF 403 used to be the only accidental-safety; the UA fix removed it. +grep -q -- '--yes-write-upstream' "$S" && ok \ + || bad "no --yes-write-upstream flag -- an accidental --commit can hit the production apex" +grep -q 'SANDBOX_HOSTS' "$S" && ok \ + || bad "no SANDBOX_HOSTS gate -- the upstream-write guard is gone" +grep -q 'REFUSING to --commit' "$S" && ok \ + || bad "the upstream-write refusal message is gone" +# the guard must gate on args.yes_write_upstream before writing +grep -q 'not args.yes_write_upstream' "$S" && ok \ + || bad "the guard does not actually check the --yes-write-upstream flag" + # 3. the six-plane slugs must match lib-net.sh SPACES6 exactly. for slug in provider-public metal-admin metal-internal data-tenant replication; do grep -q "\"$slug\"" "$S" && ok || bad "six-plane role slug '$slug' is gone"