diff --git a/creds-manifests/vr1-dc0.manifest b/creds-manifests/vr1-dc0.manifest index 18b296b..3311f8d 100644 --- a/creds-manifests/vr1-dc0.manifest +++ b/creds-manifests/vr1-dc0.manifest @@ -49,5 +49,8 @@ vr1-dc0-maas-power_ed25519 600 local vr1-dc0-maas-power_ed25519.pub 644 local +# [substrate-tfstate-backup] see creds-matrix-notes.md ## n-tfstate-backup +vr1-dc0-substrate.tfstate.gz 600 local + # [octavia-pki-backup] see creds-matrix-notes.md ## n-pki-backup octavia-pki-vr1-dc0.tar.gz 600 local diff --git a/creds-manifests/vr1-dc1.manifest b/creds-manifests/vr1-dc1.manifest index d53a563..fd4bc46 100644 --- a/creds-manifests/vr1-dc1.manifest +++ b/creds-manifests/vr1-dc1.manifest @@ -36,5 +36,8 @@ # an UNDECLARED creds-audit FAIL until then. maas-api-key.txt 600 local +# [substrate-tfstate-backup] see creds-matrix-notes.md ## n-tfstate-backup +vr1-dc1-substrate.tfstate.gz 600 local + # [octavia-pki-backup] see creds-matrix-notes.md ## n-pki-backup octavia-pki-vr1-dc1.tar.gz 600 local diff --git a/creds-matrix-notes.md b/creds-matrix-notes.md index 59b5574..1ae7430 100644 --- a/creds-matrix-notes.md +++ b/creds-matrix-notes.md @@ -222,6 +222,47 @@ radius reaches `~/vault-init/`, which CLAUDE.md designates operator-only one-shot territory, so it was deliberately NOT bundled into an R7-conformance edit. +## n-tfstate-backup +**Recovery copy of a per-DC INNER substrate tfstate, added 2026-07-30 (operator-directed: +"extend the same pattern to the tfstates").** One gzip per DC in that DC's jumphost creds +folder, produced by `dc-dc-phase2-tofu-dc-substrate.md` step 13. + +**WHY.** The inner `terraform.tfstate` is the substrate's STATE-OF-RECORD for 20 node VMs across +two DCs, lives ONLY on the Office1 headend, and is gitignored AND untracked by design. +CURRENT-STATE said it should be added to "the site backup set"; measured 2026-07-30, **no such +set existed**. Lose the state and `tofu` no longer knows those resources exist -- the next plan +proposes to CREATE what is already running. + +**REGISTERED DESPITE PROBABLY NOT BEING A CREDENTIAL, and the reasoning matters.** The OUTER +state IS credential-bearing (DOCFIX-175: the MAAS API key in PLAINTEXT, id +`office1-tofu-maas-apikey`). The INNER states are different -- a name-based inspection found NO +`*api_key*`, `*password*`, `*secret*`, `*private_key*`, `*token*`, `user_data`, `cloudinit` or +`authorized*` keys in either, consistent with the inner root talking only to libvirt over +`qemu+ssh`, whose key is not in state. **That is strong evidence, not proof:** a name-based +absence cannot exclude material embedded inside a value. So it is registered and stored as if +sensitive. Two reasons, stated so a later reader does not "correct" it: uncertainty about +content should resolve toward MORE auditing, not less; and leaving an unaudited backup directory +beside audited ones is precisely how the SEC-022 headend shadow stores came about. **If the +classification is ever settled properly, this row is the thing to revisit.** + +**DIFFERENT FROM `n-pki-backup` IN ONE WAY THAT BITES.** The Octavia CAs are static (10-year), so +one backup holds indefinitely. **tfstate is DYNAMIC** -- it changes on every apply, so the backup +is only meaningful with its `serial`, and restoring a STALE state is ACTIVELY DANGEROUS because +`tofu` will believe anything created since that serial does not exist. The procedure records the +serial on both sides and the step must be **re-run after every apply**. + +**Restore is PROVEN, not assumed:** the pull block compares sha256 against the source AND +decompresses the archive to check it parses as JSON and reports a serial, because a truncated +gzip passes a size check and fails only on decompression. + +**KNOWN, NOT FIXED:** both live inner states are mode **664** -- group-writable, and they are the +authority `tofu` trusts, so a group member who rewrites one can make `tofu` destroy or orphan +real resources. Tightening needs its own gated change plus verification that the provider +preserves the mode across the rewrite it performs on every apply. + +**Same forward requirement as `n-pki-backup`:** the pinned secrets-storage solution must carry a +certificate/credential backup process, and this step is one of the steps that must fold into it. + ## n-pki-backup **Recovery copy of a per-DC Octavia PKI workspace, added 2026-07-30 (operator-directed).** One gzipped archive per DC in that DC's jumphost creds folder, produced by diff --git a/creds-matrix.tsv b/creds-matrix.tsv index 77fedb6..6d933c6 100644 --- a/creds-matrix.tsv +++ b/creds-matrix.tsv @@ -110,7 +110,8 @@ octavia-controller-bundle per-DC vr1-dc0 headend controller.bundle.pem none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:517 SEC-023 n-outside-convention octavia-controller-cert per-DC vr1-dc0 headend controller.cert.pem none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:521 SEC-023 n-outside-convention octavia-controller-ca-serial per-DC vr1-dc0 headend controller-ca.cert.srl none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:519 SEC-023 n-outside-convention -octavia-pki-backup per-DC vr1-dc0 jumphost octavia-pki-vr1-dc0.tar.gz none service consolidated stage5 runbook:runbooks/phase-01-bundle-deploy.md:628 SEC-023 n-pki-backup +substrate-tfstate-backup per-DC vr1-dc0 jumphost vr1-dc0-substrate.tfstate.gz none service consolidated stage3 runbook:runbooks/dc-dc-phase2-tofu-dc-substrate.md:783 SEC-023 n-tfstate-backup +octavia-pki-backup per-DC vr1-dc0 jumphost octavia-pki-vr1-dc0.tar.gz none service consolidated stage5 runbook:runbooks/phase-01-bundle-deploy.md:631 SEC-023 n-pki-backup octavia-pki-overlay per-DC vr1-dc0 headend vr1-dc0-octavia-pki.yaml none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:553 SEC-004 n-overlay-in-clone octavia-issuing-ca-passphrase per-DC vr1-dc1 headend passphrase.txt none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:383 SEC-023 n-outside-convention octavia-issuing-ca-key per-DC vr1-dc1 headend issuing-ca.key.enc none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:387 SEC-023 n-outside-convention @@ -122,7 +123,8 @@ octavia-controller-bundle per-DC vr1-dc1 headend controller.bundle.pem none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:517 SEC-023 n-outside-convention octavia-controller-cert per-DC vr1-dc1 headend controller.cert.pem none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:521 SEC-023 n-outside-convention octavia-controller-ca-serial per-DC vr1-dc1 headend controller-ca.cert.srl none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:519 SEC-023 n-outside-convention -octavia-pki-backup per-DC vr1-dc1 jumphost octavia-pki-vr1-dc1.tar.gz none service consolidated stage5 runbook:runbooks/phase-01-bundle-deploy.md:628 SEC-023 n-pki-backup +substrate-tfstate-backup per-DC vr1-dc1 jumphost vr1-dc1-substrate.tfstate.gz none service consolidated stage3 runbook:runbooks/dc-dc-phase2-tofu-dc-substrate.md:783 SEC-023 n-tfstate-backup +octavia-pki-backup per-DC vr1-dc1 jumphost octavia-pki-vr1-dc1.tar.gz none service consolidated stage5 runbook:runbooks/phase-01-bundle-deploy.md:631 SEC-023 n-pki-backup octavia-pki-overlay per-DC vr1-dc1 headend vr1-dc1-octavia-pki.yaml none service off-manifest-known stage5 runbook:runbooks/phase-01-bundle-deploy.md:553 SEC-004 n-overlay-in-clone tenant-domain-admin per-tenant - jumphost -domain-admin-cred.txt gui human off-manifest-known tenant-onboard script:scripts/tenant-onboard.sh:64 SEC-023 n-tenant-dir tenant-cluster-user per-tenant - jumphost -cluster-cred.txt api human off-manifest-known tenant-onboard script:scripts/tenant-onboard.sh:90 SEC-023 n-tenant-dir diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e7c88a..cf966fb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2347,6 +2347,49 @@ **STILL NOT BACKED UP, and out of this step's scope:** the two inner `terraform.tfstate` files on the headend -- gitignored, untracked, the substrate's state-of-record for 20 VMs, and named by this document as owed to "the site backup set" that has just been shown not to exist. +- **PKI BACKUP TAKEN AND PROVEN RESTORABLE 2026-07-30; E2 AND E3 CLOSED AT THE ARTIFACT LEVEL; + THE TFSTATE BACKUP PATH IS BUILT.** + **BACKUP VERIFIED BY RESTORE, not by existence:** both archives extracted and compared + byte-for-byte against the live tree -- **12/12 sha256 MATCH across both DCs**, covering both + encrypted CA keys, both passphrases, both CA serials and both controller bundles. Archives are + mode 600 inside 700 folders and DIFFER between DCs, so per-DC independence survives into the + backup. Both build intermediates are also inside the archives, so even the deleted files are + recoverable. + **E2/E3 CLOSED LIVE:** operator ran the `chmod` and the intermediate removal; + `octavia-pki.sh verify` now reads **PASS 26/0 on BOTH DCs**, workspaces 12 -> 10 files, and P5 + on the headend fell **18 -> 8**. + **A GATE DISAGREEMENT FOUND AND FIXED, worth recording as the pattern rather than the + incident:** `verify` read 26/0 while `creds-matrix` E2 still reported + `controller-ca.cert.srl` mode **664** on both DCs. The chmod list had been written BEFORE the + serial was declared, and `verify`'s mode assertions covered six private files and three certs + -- the serial was in neither list. **Two gates that can both see a file must not disagree about + it.** Fixed in three places: `verify` A3 now mode-checks the serial (integrity, not secrecy -- + rewriting it forces the next issuance to reuse a serial), the generator's chmod includes it so + the next DC is correct by construction, and harness **T18** pins it. The harness then caught + its own fixture building the serial at the inherited umask -- baseline went red until the + fixture matched the corrected generator. Harness **18/18** (was 17). + **TFSTATE BACKUP BUILT** as `dc-dc-phase2-tofu-dc-substrate.md` **step 13**, 2 register rows, + notes key `n-tfstate-backup`; register now **99 rows**, S1/S3-drift/S4/S7 all clean. + **THREE DELIBERATE DIFFERENCES FROM THE PKI BACKUP:** (i) tfstate is **DYNAMIC**, so the serial + is recorded on both sides and the step must be **re-run after every apply** -- restoring a + STALE state is actively dangerous because `tofu` then believes everything created since does + not exist; (ii) the pull block proves RESTORE, decompressing the archive to check it parses as + JSON and reports a serial, because a truncated gzip passes a size check and fails only on + decompression; (iii) **mint-stage is `stage3`, which is `reached`**, so unlike the stage5 + Octavia rows these are EXPECTED NOW -- P5 correctly reports 2 EXPECTED-BUT-ABSENT findings until + the step runs, which is the register demanding the backup rather than deferring it. + **CLASSIFICATION RECORDED HONESTLY:** the OUTER state is credential-bearing (DOCFIX-175, MAAS + API key in plaintext); the INNER states show NO credential-shaped key names, consistent with + the inner root using only libvirt over `qemu+ssh`. **Strong evidence, not proof** -- a + name-based absence cannot exclude material inside a value -- so they are registered and stored + as if sensitive, because uncertainty should resolve toward more auditing and an unaudited + backup directory beside audited ones is how the SEC-022 shadow stores happened. + **KNOWN, NOT FIXED:** both live inner states are mode **664**, group-writable, and they are the + authority `tofu` trusts -- a rewrite can make it destroy or orphan real resources. Tightening + needs its own gated change plus proof the provider preserves the mode across the rewrite it + performs on every apply. **ALSO CORRECTED:** `phase-01:605` said the overlay must be in the + backup set; it is DERIVABLE from the archived workspace by 1.0-GEN.d, so the workspace-only + archive is sufficient -- the line implied a gap that does not exist. **F3 -- both `~/octavia-pki/` and `overlays/octavia-pki.yaml` are ABSENT here** (existence checked, no contents read). So this is generation FROM SCRATCH for both DCs: there is nothing to reuse, which retires the reuse-vs-regenerate choice diff --git a/runbooks/dc-dc-phase2-tofu-dc-substrate.md b/runbooks/dc-dc-phase2-tofu-dc-substrate.md index 3bb5108..98dc9b3 100644 --- a/runbooks/dc-dc-phase2-tofu-dc-substrate.md +++ b/runbooks/dc-dc-phase2-tofu-dc-substrate.md @@ -738,6 +738,87 @@ --- +## Step 13 -- BACK UP the inner tfstate to the jumphost [MUTATION: gated, RE-RUN AFTER EVERY APPLY] + +> **WHY THIS EXISTS (added 2026-07-30).** The inner `terraform.tfstate` is the substrate's +> STATE-OF-RECORD, it lives ONLY on the Office1 headend, and it is gitignored AND untracked -- +> proven both, deliberately, because it must never enter the repo. This document said it should +> be "add[ed] to the site backup set"; **measured 2026-07-30, no such backup set existed** -- +> `scripts/cloud-snapshot.sh` is a juju-layer capture and mentions octavia, tfstate and +> terraform ZERO times. So the state describing 20 node VMs across two DCs sat in one place on +> one VM with no copy. Lose it and `tofu` no longer knows those resources exist: the next plan +> proposes to CREATE what is already running, and the substrate has to be reconciled by import +> or rebuilt. +> +> **IT IS NOT THE SAME AS THE PKI BACKUP, in one way that matters.** The Octavia CAs are static +> (10-year), so one backup holds indefinitely. **tfstate is DYNAMIC** -- it changes on every +> apply, so a backup is only as good as its `serial`, and restoring a STALE state is actively +> dangerous: `tofu` will believe resources created since that serial do not exist. **Record the +> serial every time, and re-run this step after every apply.** +> +> **CLASSIFICATION, measured rather than assumed.** The OUTER state on the jumphost IS a +> declared credential location -- DOCFIX-175, the Office1 MAAS API key reaches it in PLAINTEXT +> (`creds-matrix.tsv` id `office1-tofu-maas-apikey`). The INNER states are different: a +> name-based inspection found NO `*api_key*`, `*password*`, `*secret*`, `*private_key*`, +> `*token*`, `user_data`, `cloudinit` or `authorized*` keys in either, consistent with the inner +> root talking only to libvirt over `qemu+ssh` (whose key is not in state). **That is strong +> evidence, NOT proof** -- a name-based absence cannot exclude material embedded in a value. The +> backup is therefore registered and stored as if sensitive: uncertainty about content resolves +> toward MORE auditing, and an unaudited directory beside audited ones is how the SEC-022 shadow +> stores happened. + +**RUN -- voffice1** +```bash +# RUN: voffice1 -- one gzip per DC, serial RECORDED (a tfstate backup without its serial is ambiguous) +( { + DC="${DC:?export DC=vr1-dc0|vr1-dc1}" + # $REPO, never a literal clone name: this repo has been renamed once already (D-110) and + # repo-lint L9 fails on a hardcoded path. On voffice1 $REPO is the HEADEND clone. + REPO="${REPO:?REPO not set -- export REPO=}" + cd "$REPO" || exit 1 + S="opentofu/${DC}-substrate/terraform.tfstate" + [ -f "$S" ] || { echo "ABORT: no inner state at $S"; exit 1; } + SER=$(python3 -c "import json,sys;print(json.load(open(sys.argv[1]))['serial'])" "$S") + umask 077 + gzip -c "$S" > "$HOME/${DC}-substrate.tfstate.gz" + chmod 600 "$HOME/${DC}-substrate.tfstate.gz" + echo "serial=$SER sha256=$(sha256sum < "$HOME/${DC}-substrate.tfstate.gz" | cut -d' ' -f1)" +} ) +``` + +**RUN -- vcloud** +```bash +# RUN: vcloud -- pull, verify against source, then drop the staging copy +( { + DC="${DC:?export DC=vr1-dc0|vr1-dc1}" + install -d -m 700 "$HOME/${DC}-creds" + scp -q "voffice1:~/${DC}-substrate.tfstate.gz" "$HOME/${DC}-creds/${DC}-substrate.tfstate.gz" + chmod 600 "$HOME/${DC}-creds/${DC}-substrate.tfstate.gz" + A=$(sha256sum < "$HOME/${DC}-creds/${DC}-substrate.tfstate.gz" | cut -d' ' -f1) + B=$(ssh voffice1 "sha256sum < ~/${DC}-substrate.tfstate.gz" | cut -d' ' -f1) + [ "$A" = "$B" ] || { echo "ABORT: sha256 MISMATCH -- do NOT delete the source"; exit 1; } + # RESTORE PROOF, not just a transfer check: the archive must decompress to valid JSON and + # report the serial the source reported. A truncated gzip passes a size check and fails here. + SER=$(gzip -dc "$HOME/${DC}-creds/${DC}-substrate.tfstate.gz" | python3 -c "import json,sys;print(json.load(sys.stdin)['serial'])") + echo "OK sha256 matches; restored serial=$SER" + ssh voffice1 "rm -f ~/${DC}-substrate.tfstate.gz" +} ) +``` + +Run both blocks once per DC, and **again after every inner apply**. The staging copy is removed +only after the checksum matches AND the archive decompresses to valid JSON reporting a serial -- +a truncated `gzip` would otherwise leave a plausible-looking backup that cannot be restored. + +**FORWARD ITEM:** this is the INTERIM home. When the secrets-storage solution is pinned it MUST +carry a certificate/credential backup process, and this step and the Octavia one +(`phase-01-bundle-deploy.md` 1.0-GEN.e) are both steps that must fold into it -- see +`creds-matrix-notes.md` `## n-pki-backup`. + +**KNOWN AND NOT FIXED HERE:** both live inner states are mode **664**, i.e. group-writable. They +are the authority `tofu` trusts, so a group member who rewrites one can make `tofu` destroy or +orphan real resources. Tightening them is a separate gated change against a file `tofu` rewrites +on every apply, so it needs its own verification that the provider preserves the mode. + ## DC standup definition-of-done (DOCFIX-198 -- as executed on dc0 + dc1; the dc2+/Roosevelt checklist) Every item below was established the hard way on the dc0 build (2026-07-19..21) diff --git a/runbooks/phase-01-bundle-deploy.md b/runbooks/phase-01-bundle-deploy.md index 5506595..0ad1d89 100644 --- a/runbooks/phase-01-bundle-deploy.md +++ b/runbooks/phase-01-bundle-deploy.md @@ -531,7 +531,10 @@ # these files at runtime (Octavia receives the material via the overlay), so 600 costs # nothing. Done here so the NEXT DC is correct by construction rather than by a # remembered follow-up chmod. - chmod 600 controller.cert.pem ../issuing-ca/issuing-ca.cert.pem ../controller-ca/controller-ca.cert.pem + # The SERIAL is included: it carries no secret, but a group member who rewrites it forces the + # next issuance to reuse a serial. Omitted on the first pass and caught by creds-matrix E2. + chmod 600 controller.cert.pem ../issuing-ca/issuing-ca.cert.pem \ + ../controller-ca/controller-ca.cert.pem ../controller-ca/controller-ca.cert.srl # E3 FIX AT SOURCE (2026-07-29). These two are BUILD INTERMEDIATES: the signing request # is spent the moment the certificate is issued, and the openssl config is fully derived # from $DC plus the VIP overlay, so the repo already determines it. Both were left behind @@ -660,5 +663,8 @@ $REPO/overlays/vr1-dc0-octavia-pki.yaml $REPO/overlays/vr1-dc1-octavia-pki.yaml -Both the workspace tree and the overlay MUST be in the per-DC backup set: losing the -controller-CA key makes that DC's controller-cert rotation impossible. +The workspace tree MUST be in the per-DC backup set (step 1.0-GEN.e): losing the controller-CA +key makes that DC's controller-cert rotation impossible. **The OVERLAY does not need separate +backup** -- corrected 2026-07-30, because this line previously said "both ... and the overlay" +and implied a gap that does not exist: 1.0-GEN.d regenerates the overlay ENTIRELY from the +archived workspace files, so backing up the workspace covers it. diff --git a/scripts/octavia-pki.sh b/scripts/octavia-pki.sh index 8fb59e1..4d08508 100755 --- a/scripts/octavia-pki.sh +++ b/scripts/octavia-pki.sh @@ -186,7 +186,14 @@ if [ "$m" = "600" ]; then ok "A3 private $rel is 0600" else nfail "A3 private $rel is $m -- private material must be 0600"; fi done -for rel in issuing-ca/issuing-ca.cert.pem controller-ca/controller-ca.cert.pem controller/controller.cert.pem; do +# The CA SERIAL is in this list, not the private list: it holds no secret, but rewriting it is +# an INTEGRITY attack -- forcing the next issuance to reuse a serial. It was missed on the first +# pass, and the miss was CAUGHT BY P5 rather than here: `verify` read 26/0 while +# `creds-matrix.py` E2 reported it 664 on both DCs. The two gates are complementary by design +# (this one checks whether a certificate is the RIGHT certificate; P5 checks the declared +# artifact set), but they must not DISAGREE about a file both can see. +for rel in issuing-ca/issuing-ca.cert.pem controller-ca/controller-ca.cert.pem \ + controller-ca/controller-ca.cert.srl controller/controller.cert.pem; do f="$W/$rel"; [ -f "$f" ] || continue m="$(mode_of "$f")" # group-writable = 2nd digit has the 2 bit; world-writable = 3rd digit has the 2 bit diff --git a/tests/octavia-pki/run-tests.sh b/tests/octavia-pki/run-tests.sh index 5a230ba..4c95d6e 100755 --- a/tests/octavia-pki/run-tests.sh +++ b/tests/octavia-pki/run-tests.sh @@ -88,7 +88,10 @@ openssl x509 -req -sha256 -in controller.csr -CA ../controller-ca/controller-ca.cert.pem \ -CAkey ../controller-ca/controller-ca.key.enc -CAcreateserial -days 730 \ -extfile controller.cnf -extensions v3_req -out controller.cert.pem >/dev/null 2>&1 - chmod 644 controller.cert.pem + # 600 on the certs AND the serial, matching the generator's E2 fix at source. The serial is + # created by -CAcreateserial at the inherited umask, so it must be tightened explicitly -- + # exactly the omission that let the live material sit 664 with `verify` still reading 26/0. + chmod 600 controller.cert.pem ../controller-ca/controller-ca.cert.srl cat controller.cert.pem controller.key > controller.bundle.pem; chmod 600 controller.bundle.pem ) # the deploy overlay: 5 keys, ASCII, 0600, and gitignored by the fixture .gitignore @@ -221,6 +224,17 @@ [ "$RC" = 0 ] && ok "T17 a workspace with the intermediates DELETED still PASSES (E3 fix at source)" \ || { no "T17 deleted intermediates must not fail the gate (rc=$RC)"; printf '%s\n' "$OUT" | sed 's/^/ /'; } +# T18 -- the CA SERIAL must be mode-checked too. This case exists because the first version of +# the script MISSED it: `verify` read 26/0 on live material while `creds-matrix.py` E2 reported +# the serial 664 on both DCs. Two gates that can both see a file must not disagree about it. +# The serial holds no secret, so the exposure is INTEGRITY -- rewriting it forces the next +# issuance to reuse a serial. +E=$(mkfix gwserial vr1-dc0 "VR1 DC0" full) +chmod 664 "$E/home/octavia-pki/vr1-dc0/controller-ca/controller-ca.cert.srl"; run "$E" vr1-dc0 +[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "controller-ca.cert.srl is 664" \ + && ok "T18 a group-writable CA serial FAILS -- duplicate-serial forcing (found by P5, not by this gate)" \ + || { no "T18 must catch a group-writable serial (rc=$RC)"; printf '%s\n' "$OUT" | sed 's/^/ /'; } + # T14 -- args. A bare `dcN` has been a live defect class; it must be REJECTED, not guessed. RC=0; bash "$CHECK" verify dc0 >/dev/null 2>&1 || RC=$? [ "$RC" = 2 ] && ok "T14 a bare 'dc0' is REJECTED (exit 2), never coerced" || no "T14 bare site must exit 2 (rc=$RC)"