diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml index 1780ae4..ebaccc9 100644 --- a/.forgejo/workflows/ci-smoke.yaml +++ b/.forgejo/workflows/ci-smoke.yaml @@ -28,6 +28,7 @@ jobs: python3 -c 'import yaml' python3 scripts/inventory_contract.py python3 scripts/baseline_contract.py --check-repo + python3 scripts/handoff_contract.py python3 scripts/sops_rotation.py --check python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json python3 -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore index b56d435..826965b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ terraform.tfstate* __pycache__/ *.pyc .venv/ + +# Transient verification/rotation receipts; promote reviewed evidence under docs/evidence/ +/reports/ diff --git a/Makefile b/Makefile index 833f2c1..681aebe 100644 --- a/Makefile +++ b/Makefile @@ -74,10 +74,13 @@ validate-inventory: ## Validate adopted/provider-managed host declarations witho validate-baseline: ## Validate the executable baseline and its Ansible/Goss consumers python3 scripts/baseline_contract.py --check-repo +validate-handoff-readonly: ## Prove the live S1 handoff playbook has no remote mutation surface + python3 scripts/handoff_contract.py + validate-receipts: ## Validate committed metadata-only S1 receipt examples python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json -s1-handoff: ## Run the live fail-closed S1 verification gate and emit a receipt +s1-handoff: ## Run the read-only live S1 verification gate and emit a receipt python3 scripts/s1_handoff.py s1-handoff-dry-run: ## Validate handoff inputs without host access; receipt is not-run @@ -254,7 +257,8 @@ ansible-help: ## Show common Ansible commands @echo " make converge-firewall HOST=Railiance01 # UFW only (RAIL-HO-WP-0009)" @echo " make converge-check # dry-run (check mode)" @echo " make converge-diff # show config diffs" - @echo " make verify-host HOST=Railiance01" + @echo " make verify-host HOST=Railiance01 # read-only host verification" + @echo " make verify-refresh-host HOST=Railiance01 APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01" @echo " make goss-status # last on-host timer result" ansible-inventory: ## Print the dynamic inventory Ansible will use @@ -275,14 +279,11 @@ status: ## Show live security state of all hosts (UFW, fail2ban, SSH hardening) @echo "" @echo "--- Hint: run 'make verify' for a structured pass/fail report ---" -verify: ## Run Goss test suite against all hosts, commit TAP reports — exits non-zero on failure - @echo "Running Goss baseline assertions..." +verify: validate-handoff-readonly ## Read-only Goss verification of all hosts; writes TAP only on controller + @echo "Running read-only Goss baseline assertions..." @cd $(ANS_DIR) && ansible-playbook playbooks/verify.yaml $(ANSIBLE_USER_FLAG) || \ (echo "One or more assertions FAILED — see reports/ for TAP output." && exit 1) @echo "All assertions passed." - @git add reports/ && \ - git diff --cached --quiet && echo "No new reports to commit." || \ - git commit -m "chore: Goss verification reports $$(date -u +%Y-%m-%dT%H%M%SZ)" observe-railiance01: ## Timestamped host capacity observation for resource-control @mkdir -p docs/evidence/resource-hosteurope-railiance01/observations @@ -294,12 +295,23 @@ observe-railiance01: ## Timestamped host capacity observation for resource-contr ln -sfn $$stamp.json docs/evidence/resource-hosteurope-railiance01/observations/latest.json; \ echo "wrote $$dest" -verify-host: ## Run Goss against one host: make verify-host HOST=Railiance01 +verify-host: validate-handoff-readonly ## Read-only Goss verification: make verify-host HOST=Railiance01 @test -n "$(HOST)" || (echo "Usage: make verify-host HOST=Railiance01"; exit 1) - @echo "Running Goss baseline assertions on $(HOST)..." + @echo "Running read-only Goss baseline assertions on $(HOST)..." @cd $(ANS_DIR) && ansible-playbook playbooks/verify.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) || \ (echo "One or more assertions FAILED — see reports/ for TAP output." && exit 1) +verify-refresh: ## Refresh Goss on all hosts after review (exact approval required) + @test "$(APPROVE_VERIFY_REFRESH)" = "REFRESH-GOSS-ALL" || \ + (echo "Refusing host mutation: set APPROVE_VERIFY_REFRESH=REFRESH-GOSS-ALL after review"; exit 1) + cd $(ANS_DIR) && ansible-playbook playbooks/verify-refresh.yaml $(ANSIBLE_USER_FLAG) + +verify-refresh-host: ## Refresh one host: HOST=... APPROVE_VERIFY_REFRESH=REFRESH-GOSS- + @test -n "$(HOST)" || (echo "Usage: make verify-refresh-host HOST=Railiance01 APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01"; exit 1) + @test "$(APPROVE_VERIFY_REFRESH)" = "REFRESH-GOSS-$(HOST)" || \ + (echo "Refusing host mutation: set APPROVE_VERIFY_REFRESH=REFRESH-GOSS-$(HOST) after review"; exit 1) + cd $(ANS_DIR) && ansible-playbook playbooks/verify-refresh.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) + goss-status: ## Fetch last on-host Goss timer result (fails if FAILED flag present) cd $(ANS_DIR) && ansible-playbook playbooks/goss-status.yaml $(ANSIBLE_USER_FLAG) diff --git a/README.md b/README.md index 4d1acee..e0b295a 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,8 @@ baseline state. This includes admin user setup, SSH hardening, firewall rules, essential tooling, and secret handling. 📖 See the full guide here: [Convergence Documentation](docs/convergence.md) + +Routine `make verify` and `make s1-handoff` runs are read-only on managed +hosts. Updating the installed Goss surface is deliberately separate and +requires an exact `APPROVE_VERIFY_REFRESH` value; see +[Server Verification](docs/verification.md). diff --git a/SCOPE.md b/SCOPE.md index 292c19b..4ece2e4 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -112,7 +112,8 @@ maintenance, evidence collection, and drift checks are ongoing S1 work. has not been migrated safely to this repo's UFW model - Verification: the executable baseline now resolves `ufw-managed` and `external-firewall` profiles into both Ansible and Goss. The fail-closed - handoff command and receipt format exist; a fresh attended all-host receipt + handoff command is remotely read-only, refuses a stale installed Goss + surface, and emits metadata-only receipts; a fresh attended all-host receipt is still pending - Provisioning: adopted and provider-managed records now have a validated schema. Terraform selects only provider-managed Hetzner records, with mock diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 73c076b..f4526cb 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -15,6 +15,7 @@ | workplan | RAIL-HO-WP-0009 | finished | — | workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md | | workplan | RAIL-HO-WP-0010 | finished | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md | | workplan | RAIL-HO-WP-0011 | active | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md | +| workplan | RAIL-HO-WP-0012 | proposed | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | | task | ADHOC-2026-08-22-T01 | done | — | workplans/ADHOC-2026-08-22.md | | task | ADHOC-2026-08-22-T02 | done | — | workplans/ADHOC-2026-08-22.md | | task | ADHOC-2026-08-22-T03 | done | — | workplans/ADHOC-2026-08-22.md | @@ -49,3 +50,9 @@ | task | RAIL-HO-WP-0011-T06 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md | | task | RAIL-HO-WP-0011-T07 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md | | task | RAIL-HO-WP-0011-T08 | wait | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md | +| task | RAIL-HO-WP-0012-T01 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | +| task | RAIL-HO-WP-0012-T02 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | +| task | RAIL-HO-WP-0012-T03 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | +| task | RAIL-HO-WP-0012-T04 | todo | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | +| task | RAIL-HO-WP-0012-T05 | wait | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | +| task | RAIL-HO-WP-0012-T06 | wait | — | workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md | diff --git a/ansible/playbooks/verify-refresh.yaml b/ansible/playbooks/verify-refresh.yaml new file mode 100644 index 0000000..10feb18 --- /dev/null +++ b/ansible/playbooks/verify-refresh.yaml @@ -0,0 +1,15 @@ +--- +# verify-refresh.yaml — Install/refresh Goss, run assertions, fetch TAP. +# +# This is a host-mutating operation. Review the rendered baseline and use it +# only when updating the verification surface. Routine verification and the S1 +# handoff use the read-only verify.yaml playbook. + +- name: Refresh and verify the managed Goss surface + hosts: all + become: true + gather_facts: true + vars_files: + - ../inventory/group_vars/all.yaml + roles: + - role: goss diff --git a/ansible/playbooks/verify.yaml b/ansible/playbooks/verify.yaml index f0a9879..521c3b0 100644 --- a/ansible/playbooks/verify.yaml +++ b/ansible/playbooks/verify.yaml @@ -1,17 +1,84 @@ --- -# verify.yaml — Deploy Goss, run baseline assertions, fetch TAP results. -# Exit code mirrors Goss: 0 = all pass, non-zero = failures. +# verify.yaml — Read-only S1 baseline verification and local TAP collection. # -# Usage: -# ansible-playbook ansible/playbooks/verify.yaml -u admin -# make verify +# This playbook must not install, template, enable, restart, or otherwise +# mutate a managed host. scripts/handoff_contract.py enforces the allowed +# module and command surface before scripts/s1_handoff.py contacts a host. +# Use verify-refresh.yaml only after reviewing and approving host changes. -- hosts: all +- name: Verify the installed S1 baseline without changing the host + hosts: all become: true - gather_facts: true - # Same declaration the bootstrap play converges from. Without this the - # firewall assertions render empty and silently assert nothing. + gather_facts: false vars_files: - ../inventory/group_vars/all.yaml - roles: - - role: goss + vars: + goss_bin: /usr/local/bin/goss + goss_config: /etc/goss/baseline.yaml + expected_goss_baseline: >- + {{ lookup('ansible.builtin.template', + playbook_dir ~ '/../../goss/baseline.yaml.j2', + keep_trailing_newline=true) }} + expected_goss_sha256: "{{ expected_goss_baseline | hash('sha256') }}" + report_stamp: "{{ now(utc=true, fmt='%Y%m%dT%H%M%SZ') }}" + + tasks: + - name: Inspect the installed Goss executable + ansible.builtin.stat: + path: "{{ goss_bin }}" + register: installed_goss + + - name: Inspect the installed baseline digest + ansible.builtin.stat: + path: "{{ goss_config }}" + checksum_algorithm: sha256 + register: installed_baseline + + - name: Require the exact source-rendered verification surface + ansible.builtin.assert: + that: + - installed_goss.stat.exists | default(false) + - installed_goss.stat.executable | default(false) + - installed_baseline.stat.exists | default(false) + - installed_baseline.stat.checksum | default('') == expected_goss_sha256 + fail_msg: >- + {{ inventory_hostname }} has no usable Goss surface or its baseline is + stale. Review changes, then run verify-refresh for this host before + retrying the read-only handoff gate. + + - name: Run the installed baseline assertions + ansible.builtin.command: + argv: + - "{{ goss_bin }}" + - -g + - "{{ goss_config }}" + - validate + - --format + - tap + register: goss_result + changed_when: false + failed_when: false + + - name: Ensure the controller reports directory exists + ansible.builtin.file: + path: "{{ playbook_dir }}/../../reports" + state: directory + mode: "0755" + delegate_to: localhost + become: false + + - name: Record TAP evidence on the controller + ansible.builtin.copy: + content: "{{ goss_result.stdout }}\n" + dest: >- + {{ playbook_dir }}/../../reports/goss-{{ inventory_hostname }}-{{ report_stamp }}.tap + mode: "0644" + delegate_to: localhost + become: false + changed_when: false + + - name: Fail closed on a baseline assertion failure + ansible.builtin.assert: + that: + - goss_result.rc == 0 + fail_msg: "Goss baseline failed on {{ inventory_hostname }}" diff --git a/docs/conformance-loop.md b/docs/conformance-loop.md index 260df49..5e1f423 100644 --- a/docs/conformance-loop.md +++ b/docs/conformance-loop.md @@ -10,11 +10,11 @@ conformance (that is 10c, blocked on the family declaration validator). | Piece | Where | Cadence | | --- | --- | --- | -| Rendered Goss baseline | `/etc/goss/baseline.yaml` from `goss/baseline.yaml.j2` | every `make verify` | +| Rendered Goss baseline | `/etc/goss/baseline.yaml` from `goss/baseline.yaml.j2` | reviewed converge or `make verify-refresh*` | | On-host timer | `railiance-goss-baseline.timer` | hourly, plus 5 minutes after boot | | Wrapper | `/usr/local/sbin/goss-baseline-check` | writes `/var/lib/railiance/goss/` | | Operator collect | `make goss-status` | on demand | -| Operator run | `make verify` / `make verify-host HOST=…` | on demand; commits TAP to `reports/` | +| Operator run | `make verify` / `make verify-host HOST=…` | on demand; read-only remotely, writes transient TAP locally | The firewall assertions are generated from the same inventory lists that converge UFW (`k3s_api_allowed_sources`, `k3s_api_revoked_sources`, @@ -37,7 +37,9 @@ Railiance01 after T01. to a State Hub `/progress/` endpoint. The wrapper notifies only on a pass↔fail transition, so a known-fail host (CoulombCore, UFW inactive) does not spam. -4. **git TAP** — `make verify` still commits `reports/goss--.tap` +4. **controller TAP** — `make verify` writes ignored + `reports/goss--.tap`; reviewed durable evidence is promoted + deliberately under `docs/evidence/` `make goss-status` fails the play if any host still has the `FAILED` flag. diff --git a/docs/s1-handoff.md b/docs/s1-handoff.md index a9d96ef..29f20cd 100644 --- a/docs/s1-handoff.md +++ b/docs/s1-handoff.md @@ -23,11 +23,19 @@ The current profiles are: - `CoulombCore`: `external-firewall`, which verifies the declared iptables INPUT default-drop replacement control -The verification play installs or refreshes the Goss check surface before it -runs. Treat the first run after source changes as an attended host change: -review the rendered contract and access path first. The command does not run -Terraform, change provider resources, rotate credentials, or converge the base -role. +The gate is read-only on managed hosts. Before connecting, +`scripts/handoff_contract.py` proves that its playbook contains only remote +`stat`, `assert`, and the fixed Goss validation command, and pins the Goss +assertion commands themselves to a reviewed read-only set. It renders each +selected profile locally and fails if the installed +`/etc/goss/baseline.yaml` digest differs, instead of refreshing it implicitly. +TAP and the aggregate receipt are written only beneath the controller's +ignored `reports/` path. + +If a surface is stale, the operator must review it and separately approve +`make verify-refresh-host`; that change cannot be smuggled through a handoff +run. The handoff command does not run Terraform, change provider resources, +rotate credentials, converge a role, write remote files, or change services. Receipts validate with: diff --git a/docs/verification.md b/docs/verification.md index f846206..5c1ab32 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -37,12 +37,19 @@ make verify This runs `ansible/playbooks/verify.yaml` against all hosts. The playbook: -1. Downloads the Goss binary (pinned version) to `/usr/local/bin/goss` -2. Copies `goss/baseline.yaml` to `/etc/goss/baseline.yaml` on each host -3. Runs `goss validate --format tap` -4. Fails the play (non-zero exit) if any assertion fails -5. Fetches the TAP report to `reports/goss--.tap` -6. Auto-commits the report to git +1. Reads the installed Goss binary and `/etc/goss/baseline.yaml` metadata. +2. Renders the selected profile on the controller and requires its SHA-256 + digest to match the installed baseline exactly. +3. Runs the fixed `goss validate --format tap` argument vector. +4. Fails the play if the surface is missing/stale or any assertion fails. +5. Writes TAP evidence only on the controller under `reports/`. + +`scripts/handoff_contract.py` statically rejects remote modules other than +`stat`, `assert`, and the exact Goss `command`. Controller writes must be +delegated to `localhost` and remain under `reports/`. It also pins every Goss +command assertion and both profile firewall probes to their reviewed read-only +set. `make verify` therefore does not install packages, rewrite files, reload +systemd, or change services on a managed host. **All assertions passed** → exit 0 **One or more assertions FAILED** → exit non-zero, TAP report in `reports/` @@ -59,6 +66,20 @@ make verify # assert it got there Run `make status` for a quick human-readable summary; run `make verify` when you need a structured, automatable check. +If verification fails because the installed surface is stale, review the +rendered changes and use the explicit mutating interface for only the intended +host: + +```bash +make verify-refresh-host \ + HOST=Railiance01 \ + APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01 +``` + +Refreshing every host requires the distinct approval value +`APPROVE_VERIFY_REFRESH=REFRESH-GOSS-ALL`. Both refresh targets install or +update Goss, its baseline, wrapper, service, and timer before running checks. + ## Goss test file `goss/baseline.yaml.j2` is rendered per host from the same inventory lists @@ -80,14 +101,15 @@ that converge UFW. The mapping is: 1. Add the desired state to `spec/server-baseline.yaml`. 2. If it introduces a new control kind, teach both consumers that kind. 3. Run `make validate-baseline` and the unit tests. -4. Run `make converge-firewall` and `make verify-host HOST=…` only in the - appropriate reviewed live-change sequence. +4. Run the applicable reviewed convergence/refresh command, then use + `make verify-host HOST=…` as the read-only acceptance check. An hourly on-host timer (`railiance-goss-baseline.timer`) reruns the last rendered baseline. See `docs/conformance-loop.md`. ## Reports -TAP reports are committed to `reports/` after each `make verify` run. -They are machine-readable and suitable for CI pipelines. A cleanup policy -for old reports is tracked as extension point EP `78ef4879`. +TAP reports are transient under ignored `reports/`. They are machine-readable +and suitable for CI pipelines. Promote deliberately retained evidence under +`docs/evidence/` after reviewing it for safe metadata. A cleanup policy for old +reports is tracked as extension point EP `78ef4879`. diff --git a/scripts/handoff_contract.py b/scripts/handoff_contract.py new file mode 100644 index 0000000..25fa169 --- /dev/null +++ b/scripts/handoff_contract.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Enforce the remote read-only contract of the S1 handoff playbook.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +import yaml + + +REMOTE_MODULES = { + "ansible.builtin.assert", + "ansible.builtin.command", + "ansible.builtin.stat", +} +CONTROLLER_MODULES = { + "ansible.builtin.copy", + "ansible.builtin.file", +} +TASK_CONTROL_KEYS = { + "become", + "changed_when", + "delegate_to", + "failed_when", + "name", + "register", + "tags", + "when", +} +EXPECTED_GOSS_ARGV = [ + "{{ goss_bin }}", + "-g", + "{{ goss_config }}", + "validate", + "--format", + "tap", +] +EXPECTED_GOSS_COMMANDS = { + "{{ baseline_firewall.verification.command }}", + "ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+Anywhere' || true", + "ufw status | grep -E '6443/tcp[[:space:]]+ALLOW' | grep -vc 'Anywhere' || true", + "ufw status | grep -Ec '8472/udp[[:space:]]+ALLOW[[:space:]]+Anywhere' || true", + "ufw status | grep -E '8472/udp[[:space:]]+ALLOW' | grep -vc 'Anywhere' || true", + "ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+{{ src.address }}' || true", + "grep NOPASSWD /etc/sudoers.d/{{ baseline_user.name }}", + "grep -r HISTCONTROL /etc/profile.d/", + "fail2ban-client status {{ jail }}", + "test -x /usr/local/bin/age", + "test -x /usr/local/bin/sops", +} +EXPECTED_PROFILE_COMMANDS = {"iptables -S INPUT", "ufw status"} +COMMAND_KEY_RE = re.compile(r'^ "(.+)":$', re.MULTILINE) + + +class HandoffContractError(ValueError): + """The handoff playbook could change a managed host or execute arbitrary code.""" + + +def _module(task: dict[str, Any], label: str) -> tuple[str, Any]: + modules = [key for key in task if key.startswith("ansible.")] + if len(modules) != 1: + raise HandoffContractError(f"{label} must contain exactly one fully-qualified module") + unknown = set(task) - TASK_CONTROL_KEYS - set(modules) + if unknown: + raise HandoffContractError(f"{label} has unsupported task keys: {', '.join(sorted(unknown))}") + return modules[0], task[modules[0]] + + +def validate_payload(payload: Any) -> None: + if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict): + raise HandoffContractError("handoff playbook must contain exactly one play") + play = payload[0] + if play.get("gather_facts") is not False: + raise HandoffContractError("handoff playbook must set gather_facts: false") + for forbidden in ("force_handlers", "handlers", "post_tasks", "pre_tasks", "roles"): + if forbidden in play: + raise HandoffContractError(f"handoff playbook must not declare {forbidden}") + tasks = play.get("tasks") + if not isinstance(tasks, list) or not tasks: + raise HandoffContractError("handoff playbook must contain tasks") + + command_count = 0 + for index, task in enumerate(tasks, start=1): + label = f"task {index}" + if not isinstance(task, dict): + raise HandoffContractError(f"{label} must be an object") + module, arguments = _module(task, label) + delegated = task.get("delegate_to") == "localhost" + + if delegated: + if module not in CONTROLLER_MODULES: + raise HandoffContractError(f"{label} uses unsupported controller module {module}") + if task.get("become") is not False: + raise HandoffContractError(f"{label} controller output must set become: false") + if not isinstance(arguments, dict): + raise HandoffContractError(f"{label} module arguments must be an object") + destination = str(arguments.get("path", arguments.get("dest", ""))) + if "/reports" not in destination: + raise HandoffContractError(f"{label} may write only beneath the controller reports path") + if module == "ansible.builtin.file": + if arguments.get("state") != "directory" or destination != "{{ playbook_dir }}/../../reports": + raise HandoffContractError(f"{label} may only ensure the reports directory") + elif "src" in arguments or not destination.endswith(".tap"): + raise HandoffContractError(f"{label} may record inline TAP evidence only") + continue + + if module not in REMOTE_MODULES: + raise HandoffContractError(f"{label} uses remote module {module}, which is not read-only") + if module == "ansible.builtin.command": + command_count += 1 + if not isinstance(arguments, dict) or arguments.get("argv") != EXPECTED_GOSS_ARGV: + raise HandoffContractError(f"{label} command is not the fixed Goss validation argv") + if task.get("changed_when") is not False or task.get("failed_when") is not False: + raise HandoffContractError( + f"{label} command must set changed_when: false and failed_when: false" + ) + + if command_count != 1: + raise HandoffContractError("handoff playbook must execute exactly one fixed Goss command") + + +def validate_playbook(path: Path) -> None: + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise HandoffContractError(f"cannot read {path}: {exc}") from exc + validate_payload(payload) + + +def validate_goss_commands(template_path: Path, baseline_path: Path) -> None: + try: + template = template_path.read_text(encoding="utf-8") + baseline = yaml.safe_load(baseline_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError) as exc: + raise HandoffContractError(f"cannot read handoff command inputs: {exc}") from exc + commands = set(COMMAND_KEY_RE.findall(template)) + if commands != EXPECTED_GOSS_COMMANDS: + unexpected = sorted(commands - EXPECTED_GOSS_COMMANDS) + missing = sorted(EXPECTED_GOSS_COMMANDS - commands) + detail = [] + if unexpected: + detail.append(f"unexpected: {unexpected}") + if missing: + detail.append(f"missing: {missing}") + raise HandoffContractError("Goss command surface changed; " + "; ".join(detail)) + profiles = baseline.get("profiles", {}) if isinstance(baseline, dict) else {} + profile_commands = { + profile.get("firewall", {}).get("verification", {}).get("command") + for profile in profiles.values() + if isinstance(profile, dict) + } + if profile_commands != EXPECTED_PROFILE_COMMANDS: + raise HandoffContractError( + f"baseline profile command surface must be exactly {sorted(EXPECTED_PROFILE_COMMANDS)}" + ) + + +def validate_repository_surface(root: Path) -> None: + validate_playbook(root / "ansible" / "playbooks" / "verify.yaml") + validate_goss_commands( + root / "goss" / "baseline.yaml.j2", + root / "spec" / "server-baseline.yaml", + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "playbook", + nargs="?", + type=Path, + default=Path("ansible/playbooks/verify.yaml"), + ) + args = parser.parse_args() + playbook = args.playbook.resolve() + root = playbook.parents[2] + validate_repository_surface(root) + print( + json.dumps( + { + "goss_commands": len(EXPECTED_GOSS_COMMANDS) + len(EXPECTED_PROFILE_COMMANDS), + "ok": True, + "playbook": str(args.playbook), + "remote_mutations": 0, + } + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/s1_handoff.py b/scripts/s1_handoff.py index 69a75b7..fc9fa27 100644 --- a/scripts/s1_handoff.py +++ b/scripts/s1_handoff.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Any from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers +from handoff_contract import validate_repository_surface from inventory_contract import load_inventory from s1_receipt import validate_receipt @@ -110,6 +111,7 @@ def main() -> int: inventory = load_inventory(inventory_path) baseline = load_spec(spec_path) validate_repo_consumers(ROOT) + validate_repository_surface(ROOT) hosts = _selected_hosts(inventory, args.host) for host in hosts: profile_hostvars(baseline, host["baseline_profile"]) diff --git a/tests/test_handoff_contract.py b/tests/test_handoff_contract.py new file mode 100644 index 0000000..6e1cba5 --- /dev/null +++ b/tests/test_handoff_contract.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import copy +import sys +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from handoff_contract import ( # noqa: E402 + HandoffContractError, + validate_goss_commands, + validate_payload, + validate_playbook, +) + + +class ReadOnlyHandoffContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.path = ROOT / "ansible" / "playbooks" / "verify.yaml" + cls.payload = yaml.safe_load(cls.path.read_text(encoding="utf-8")) + + def test_repository_playbook_is_read_only(self) -> None: + validate_playbook(self.path) + + def test_remote_template_module_is_rejected(self) -> None: + payload = copy.deepcopy(self.payload) + payload[0]["tasks"].append( + { + "name": "Mutate remote config", + "ansible.builtin.template": {"src": "x", "dest": "/etc/goss/baseline.yaml"}, + } + ) + with self.assertRaisesRegex(HandoffContractError, "not read-only"): + validate_payload(payload) + + def test_arbitrary_remote_command_is_rejected(self) -> None: + payload = copy.deepcopy(self.payload) + command = next( + task for task in payload[0]["tasks"] if "ansible.builtin.command" in task + ) + command["ansible.builtin.command"]["argv"] = ["systemctl", "restart", "ssh"] + with self.assertRaisesRegex(HandoffContractError, "fixed Goss"): + validate_payload(payload) + + def test_controller_write_requires_local_delegation(self) -> None: + payload = copy.deepcopy(self.payload) + output = next(task for task in payload[0]["tasks"] if "ansible.builtin.copy" in task) + del output["delegate_to"] + with self.assertRaisesRegex(HandoffContractError, "not read-only"): + validate_payload(payload) + + def test_goss_command_surface_is_pinned(self) -> None: + validate_goss_commands( + ROOT / "goss" / "baseline.yaml.j2", + ROOT / "spec" / "server-baseline.yaml", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md b/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md index ba76c73..0851e96 100644 --- a/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md +++ b/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md @@ -179,9 +179,12 @@ interpreting an expected-red exception. `make s1-handoff` validate source contracts, require a clean revision, run each host separately, fail the aggregate on any non-zero result, require TAP hashes for a pass, and record a freshness boundary. Dry-run output is forcibly -`not-run`. The attended all-host run waits for an environment with Ansible and -reviewed permission to refresh the on-host Goss surface; this workstation has -no `ansible-playbook`. No host was contacted. +`not-run`. The live playbook is now remotely read-only: a static contract +rejects mutating modules and arbitrary commands, it requires the installed +Goss surface to match the locally rendered profile digest, and it writes only +controller-side evidence. Refresh is a separate exact-approval interface. The +attended all-host run now waits only for Ansible plus short-lived SSH access; +no host was contacted. ## T06 — Repair and enforce the secret-source contract @@ -286,10 +289,14 @@ decryption receipt or recipient change was attempted. Current evidence (2026-08-23): -- Python unit suite: 25 tests pass. +- Python unit suite: 30 tests pass, including rejection of a remote template, + arbitrary remote command, non-delegated controller write, and unreviewed + Goss command surface in the handoff path. - Terraform 1.9.8 mock-provider tests: 2 pass. -- Ansible 2.17.13 syntax checks: bootstrap, verify, and firewall pass in a - disposable environment. +- Ansible 2.17.13 syntax checks: bootstrap, read-only verify, explicit + verify-refresh, and firewall pass in a disposable environment. A disposable + render comparison also proves the template lookup digest matches the bytes + produced by Ansible's deployment template action. - Both profile-specific Goss templates render and parse as YAML. - Inventory, baseline, secret metadata, receipt, shell syntax, Python compile, and whitespace checks pass. diff --git a/workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md b/workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md new file mode 100644 index 0000000..d14fdf2 --- /dev/null +++ b/workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md @@ -0,0 +1,178 @@ +--- +id: RAIL-HO-WP-0012 +type: workplan +title: "Close the encrypted S1 backup and recovery loop" +domain: financials +repo: railiance-infra +status: proposed +owner: codex +topic_slug: railiance +created: "2026-08-23" +updated: "2026-08-23" +related: + - RAIL-HO-WP-0011 +--- + +# RAIL-HO-WP-0012 — encrypted S1 backup and recovery loop + +## Goal + +Turn the existing local `railiance-backup-s1` helper into a fail-closed, +scheduled, off-host, and restore-tested S1 recovery capability. Preserve the +repository boundary: this covers host operating-system configuration and +package-selection evidence only, not Kubernetes, database, Forgejo, or +application data. + +This workplan does **not** authorize deployment of a systemd timer, access to an +age private key, an off-host upload, a live `/etc` restore, or deletion of a +retained artifact. Each live action retains its normal owner review and exact +operator approval. + +## Current State and Risks + +`tools/cmd/railiance-backup-s1` currently creates age-encrypted OS-configuration +and package-selection files under `/opt/backup/railiance/infra` and retains the +latest seven of each. It is not scheduled, has no off-host transfer, produces +no manifest or receipt, has no restoration tool or drill, duplicates an age +recipient in executable source, suppresses individual copy failures, and can +leave ambiguous partial output. The output is therefore a useful local helper, +not current proof of recoverability. + +## Delivery Order + +T01 → T02 → T03 establish a locally testable contract. T04 and T05 may proceed +after T02 but need separate deployment/custody review. T06 depends on all prior +tasks and an attended isolated drill. + +## T01 — Define the S1 backup declaration and safety contract + +```task +id: RAIL-HO-WP-0012-T01 +status: todo +priority: high +``` + +Create a machine-readable declaration for included paths, optional paths, +artifact classes, public encryption recipients, retention, and excluded +higher-layer data. Validate that every selected path is absolute and bounded, +that no private-key or credential path can be selected, and that the recipient +contract cannot drift silently from the repository's approved public metadata. + +**Done when:** the current intended S1 files resolve deterministically, unsafe +or relative paths fail closed, optional absence is explicit, and validation +requires neither root nor a decryption key. + +## T02 — Produce atomic encrypted artifacts, manifests, and receipts + +```task +id: RAIL-HO-WP-0012-T02 +status: todo +priority: high +``` + +Refactor the backup helper around the T01 declaration. Check privileges before +creating the destination, stage output on the same filesystem, fail on an +unreadable required input, encrypt before publication, and atomically publish +only complete artifacts. Emit a metadata-only manifest and receipt containing +host identity, source revision, declaration digest, artifact class, encrypted +size/hash, completion time, and retention decision. + +Exercise it against disposable fixture roots and output directories. Tests +must prove a failed collection or encryption cannot produce a passing receipt +or a final-named partial artifact. + +**Done when:** a fixture backup is deterministic in membership, encrypted +before publication, fully described by redacted metadata, and failure-injection +tests leave no ambiguous success state. + +## T03 — Add non-destructive inspection and isolated restore validation + +```task +id: RAIL-HO-WP-0012-T03 +status: todo +priority: high +``` + +Provide a restore command that defaults to inspection or extraction beneath an +explicit empty staging directory. It must verify receipt/manifest hashes before +decryption, suppress decrypted content from logs, reject path traversal and +links escaping the staging root, and compare restored membership and critical +file modes with the declaration. + +Restoring over live `/etc` is outside this task and must remain impossible +without a separately designed exact-approval interface. + +**Done when:** a fixture artifact restores into disposable storage, tampering +and unsafe members fail closed, no plaintext enters repository paths or logs, +and no default invocation can overwrite a host file. + +## T04 — Define scheduling, freshness, and local retention + +```task +id: RAIL-HO-WP-0012-T04 +status: todo +priority: medium +``` + +Add source-controlled systemd service/timer units and Ansible deployment for a +bounded backup cadence. Define maximum acceptable age, failure state, disk +budget, atomic pruning, and an operator status interface. Keep timer deployment +separate from source validation and require explicit host-change approval. + +**Done when:** unit rendering and calendar behavior test locally, stale/missing +backup status fails, pruning never crosses the declared backup directory, and +no timer is installed merely by running a verification command. + +## T05 — Integrate the governed write-only off-host lane + +```task +id: RAIL-HO-WP-0012-T05 +status: wait +priority: high +``` + +Coordinate with `railiance-platform` through the existing +`railiance-backup-offsite-lane` route. Upload only completed encrypted +artifacts plus metadata manifests, use a host-local or workload-owned +credential projection, and record a non-secret remote object identity and +encrypted hash. Do not place WebDAV credentials or an age private key in this +repository, State Hub, command output, or agent prompts. + +**Done when:** the owner accepts the exact projection and write contract, a +dry-run proves object naming and collision behavior, a controlled upload is +visible through owner-provided metadata, and local/off-host retention cannot +delete the only recoverable copy. + +## T06 — Perform an attended isolated restore drill + +```task +id: RAIL-HO-WP-0012-T06 +status: wait +priority: high +``` + +Select a fresh off-host artifact by metadata, retrieve it through the governed +lane, and restore it into an isolated disposable filesystem. Prove manifest +integrity, expected membership and modes, usable SSH/fail2ban/UFW configuration +syntax, package-selection readability, and cleanup of all decrypted temporary +material. Do not restore over a live host in this drill. + +**Done when:** a metadata-only drill receipt records artifact and source +identity, checks, duration, cleanup, and result; an independent observer can +distinguish a successful recovery from a backup-only claim without seeing +decrypted configuration. + +## Acceptance + +- [ ] S1 backup membership, exclusions, recipients, and retention are declared + and validated from source. +- [ ] Backup publication is atomic, encrypted, and accompanied by a safe + manifest and receipt. +- [ ] Restore defaults to isolated inspection and rejects tampering, traversal, + and accidental live overwrite. +- [ ] Scheduling and freshness checks are source-controlled but deployed only + through an explicit host-change approval. +- [ ] A governed off-host copy and an attended isolated restore drill prove the + selected S1 state is recoverable. +- [ ] No cluster, platform, tenant, secret-issuance, or private-key custody + responsibility is absorbed into this repository.