Make S1 handoff read-only by default
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
parent
24b799ec59
commit
40e295e3bd
16 changed files with 637 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,3 +16,6 @@ terraform.tfstate*
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# Transient verification/rotation receipts; promote reviewed evidence under docs/evidence/
|
||||
/reports/
|
||||
|
|
|
|||
30
Makefile
30
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-<HOST>
|
||||
@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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
3
SCOPE.md
3
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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
15
ansible/playbooks/verify-refresh.yaml
Normal file
15
ansible/playbooks/verify-refresh.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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-<host>-<ts>.tap`
|
||||
4. **controller TAP** — `make verify` writes ignored
|
||||
`reports/goss-<host>-<ts>.tap`; reviewed durable evidence is promoted
|
||||
deliberately under `docs/evidence/`
|
||||
|
||||
`make goss-status` fails the play if any host still has the `FAILED` flag.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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-<host>-<timestamp>.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`.
|
||||
|
|
|
|||
196
scripts/handoff_contract.py
Normal file
196
scripts/handoff_contract.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
65
tests/test_handoff_contract.py
Normal file
65
tests/test_handoff_contract.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
178
workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md
Normal file
178
workplans/RAIL-HO-WP-0012-s1-backup-recovery-loop.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue