Implement reproducible S1 handoff contracts
Some checks failed
CI Smoke / source-contract (push) Failing after 2s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
codex 2026-08-23 12:02:23 +02:00
parent c8cb1c8edf
commit b93af8cc78
44 changed files with 2035 additions and 342 deletions

View file

@ -9,6 +9,22 @@ on:
workflow_dispatch:
jobs:
source-contract:
runs-on: self-hosted
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Validate S1 source contracts
run: |
set -eu
python3 -c 'import yaml'
python3 scripts/inventory_contract.py
python3 scripts/baseline_contract.py --check-repo
python3 scripts/sops_rotation.py --check
python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json
python3 -m unittest discover -s tests -v
make check-secrets
host-smoke:
runs-on: self-hosted
steps:

View file

@ -1,46 +1,4 @@
#!/usr/bin/env bash
# Block commits that add/modify plaintext files under secrets/
# Block commits that add/modify any declared plaintext secret-bearing path.
set -euo pipefail
# Find added/modified paths under secrets/ in the index
changed_files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^secrets/' || true)
[ -z "$changed_files" ] && exit 0 # nothing to check
fail=0
msg="❌ Commit blocked: Unencrypted file(s) detected under secrets/.
Each file in secrets/ must be SOPS-encrypted (contain a top-level 'sops:' block).
Use 'sops <file>' to edit or 'sops --encrypt --in-place <file>' to encrypt."
while IFS= read -r f; do
if ! git cat-file -e ":$f" 2>/dev/null; then
continue
fi
if [ -d "$f" ]; then
continue
fi
content="$(git show ":$f" || true)"
if [ -z "$content" ]; then
echo " - $f (empty)"; fail=1; continue
fi
if echo "$content" | grep -qE '^[[:space:]]*sops:[[:space:]]*$|"sops"[[:space:]]*:'; then
continue
fi
case "$f" in
*.age|*.gpg) continue ;;
esac
echo " - $f"
fail=1
done <<< "$changed_files"
if [ "$fail" -ne 0 ]; then
echo ""
echo "$msg"
echo ""
echo "Tips:"
echo " • Edit with SOPS: sops secrets/<file>.yaml"
echo " • Encrypt in place: sops --encrypt --in-place secrets/<file>.yaml"
exit 1
fi
exit 0
exec python3 scripts/check_secret_paths.py --staged

View file

@ -1,8 +1,7 @@
# SOPS encryption policy: encrypt files matching *.sops.yaml
creation_rules:
- path_regex: secrets/.*$
- path_regex: (secrets/.*|inventory/.*/secrets[^/]*\.(yaml|yml|json))$
key_groups:
- age:
- age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4

View file

@ -61,35 +61,49 @@ sops-decrypt: ## Print decrypted file to stdout (for inspection) FILE=secrets/fo
@[ -n "$(FILE)" ] || (echo "Usage: make sops-decrypt FILE=secrets/xxx.sops.yaml" && exit 1)
sops -d $(FILE)
sops-rotate: ## Rotate recipients on a SOPS file (after updating .sops.yaml)
@[ -n "$(FILE)" ] || (echo "Usage: make sops-rotate FILE=secrets/xxx.sops.yaml" && exit 1)
sops --rotate --in-place $(FILE)
sops-rotate: ## Check SOPS recipient drift; use the bounded tool for approved changes
python3 scripts/sops_rotation.py --check
check-secrets: ## Fail if any file in secrets/ is not SOPS-encrypted
@! (git ls-files secrets | xargs -r grep -L -E '(^sops:$$|\"sops\"[[:space:]]*:)' | tee /dev/stderr | read) \
|| (echo "❌ Unencrypted secrets detected above. Encrypt with: sops --encrypt --in-place <file>"; exit 1)
@echo "✔ All files in secrets/ appear SOPS-encrypted"
check-secrets: ## Fail if any declared secret-bearing path is not encrypted
python3 scripts/check_secret_paths.py --tracked
# ---- Terraform (Hetzner) ----
validate-inventory: ## Validate adopted/provider-managed host declarations without provider access
python3 scripts/inventory_contract.py inventory/servers.yaml
validate-baseline: ## Validate the executable baseline and its Ansible/Goss consumers
python3 scripts/baseline_contract.py --check-repo
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
python3 scripts/s1_handoff.py
s1-handoff-dry-run: ## Validate handoff inputs without host access; receipt is not-run
python3 scripts/s1_handoff.py --dry-run
tf-fmt: ## Terraform fmt
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); @terraform -chdir=terraform/hetzner fmt -recursive || true
terraform -chdir=terraform/hetzner fmt -recursive
tf-init: ## Terraform init
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner init
terraform -chdir=terraform/hetzner init
tf-plan: tf-init ## Terraform plan (requires decrypted HCLOUD_TOKEN)
@echo "🔍 Running terraform plan..."
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner plan -var="hcloud_token=$(HCLOUD_TOKEN)"
tf-apply: tf-init ## Terraform apply (provision)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
tf-apply: ## Terraform apply (provision; exact approval required before init)
@test "$(APPROVE_TF_APPLY)" = "YES" || (echo "Refusing apply: review make tf-plan, then set APPROVE_TF_APPLY=YES" && exit 1)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
@$(MAKE) tf-init
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner apply -auto-approve -var="hcloud_token=$(HCLOUD_TOKEN)"
tf-destroy: tf-init ## Terraform destroy (tear down)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
tf-destroy: ## Terraform destroy (exact approval required before init)
@test "$(APPROVE_TF_DESTROY)" = "DESTROY-MANAGED-HETZNER" || (echo "Refusing destroy: set APPROVE_TF_DESTROY=DESTROY-MANAGED-HETZNER after exact plan review" && exit 1)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
@$(MAKE) tf-init
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner destroy -auto-approve -var="hcloud_token=$(HCLOUD_TOKEN)"
# --- Terraform provider/lockfile helpers ---
@ -199,7 +213,7 @@ doctor: ## Check tools and basic repo setup
command -v age >/dev/null && ok "age: $$(age --version)"; \
command -v terraform >/dev/null && ok "terraform: $$(terraform -version | head -1)"; \
test -f keys/admin_ssh.pub && ok "keys/admin_ssh.pub present" || echo " add your SSH pubkey to keys/admin_ssh.pub"; \
test -f inventory/group_vars/secrets.sops.yaml && ok "secrets.sops.yaml present" || echo " create inventory/group_vars/secrets.sops.yaml"; \
python3 scripts/check_secret_paths.py --tracked >/dev/null && ok "declared secret paths encrypted" || fail "secret path check failed"; \
grep -q "age1" .sops.yaml && ok ".sops.yaml has an age recipient" || echo " add your age public key to .sops.yaml"; \
git config --get core.hooksPath >/dev/null && ok "git hooksPath: $$(git config --get core.hooksPath)" || echo " run: make hooks"; \
'

View file

@ -3,12 +3,12 @@
**Tagline:** Git-driven automation for secure, self-reliant servers.
`railiance-infra` is the canonical S1 ownership repo for the Railiance
infrastructure substrate. It provisions and manages servers on HostEurope and
Hetzner Cloud entirely from Git. It combines **Terraform** for lifecycle
management, **cloud-init** for first-boot configuration, and **Ansible** for
convergence. All secrets live in-repo encrypted with **SOPS** and are unlocked
with your single **age** master key (which you keep in your password manager).
The minimal server registry in `inventory/servers.yaml` is the source of truth.
infrastructure substrate. It manages two adopted Host Europe servers through
source-backed inventory, **Ansible** convergence, and recurring **Goss**
verification. A separate **Terraform** and cloud-init path provisions only
records explicitly declared as provider-managed Hetzner resources. Selected
provider material lives in-repo encrypted with **SOPS/age**; host convergence
does not distribute the private age key.
Future `reef-*` repos will model purpose-bound substrate boundaries such as
`reef-railiance` or `reef-ops-workstations`, but the source-backed S1
@ -20,7 +20,8 @@ inventory, hardening baseline, and OS convergence facts stay here.
1. **Prerequisites**: terraform >= 1.7, ansible >= 2.16, age, sops.
2. **Secrets Management**: Generate master key (age), provide it to sops and provide your SSH key.
3. **Setup Provider**: Create account, select payment option, establish API token.
4. **Provisioning**: Plan and apply `inventory/servers.yaml` to add hosts with terraform.
4. **Provisioning**: Validate inventory; plan/apply only provider-managed
Hetzner records. Adopted Host Europe records are never Terraform resources.
5. **Convergence**: Setup security and tooling with ansible.
@ -100,13 +101,16 @@ How to declare hosts and bring them up on Hetzner:
➡️ [Provisioning Servers](docs/provisioning.md)
TL;DR
- Define servers in inventory/servers.yaml (name, region, type, image, ssh_user, labels/role).
- Provision with make tf-apply (or make apply to also run Ansible).
- Run `make validate-inventory` after editing `inventory/servers.yaml`.
- Put Hetzner-only fields under `provisioning` on a
`lifecycle_mode: provider-managed` record.
- Review `make tf-plan`; an apply additionally requires
`APPROVE_TF_APPLY=YES`.
- One-shot helper: scripts/hcloud_new_server.sh <name> --type ... --region ....
## 💻 5. Convergence
After provisioning a server with Terraform, `railiance-infra` uses
For adopted or newly provisioned servers, `railiance-infra` uses
[Ansible](https://docs.ansible.com/) to **converge** hosts into a secure,
baseline state.
This includes admin user setup, SSH hardening, firewall rules, essential tooling, and secret handling.

View file

@ -110,12 +110,14 @@ maintenance, evidence collection, and drift checks are ongoing S1 work.
the Host Europe Nydus exception is declared
- `CoulombCore`: UFW is deliberately unmanaged because its live packet filter
has not been migrated safely to this repo's UFW model
- Verification: Goss can run on demand and hourly on-host. `CoulombCore` is a
documented expected failure for the uniform UFW-active assertion, so the
repository does not currently provide an all-host green handoff gate
- Provisioning: a Hetzner-only Terraform template and helper scripts exist.
They do not currently plan against the mixed/adopted inventory and do not
provision either live Host Europe server
- 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
is still pending
- Provisioning: adopted and provider-managed records now have a validated
schema. Terraform selects only provider-managed Hetzner records, with mock
plan tests proving the current Host Europe records select no resources. It
does not provision either live Host Europe server
- Secrets: `secrets/hetzner-token.yaml` is SOPS-encrypted. The Ansible
`sops_agent` role installs tools but intentionally does not place a private
age key on a host
@ -196,7 +198,7 @@ keywords: [ufw, firewall, k3s, tunnel, flannel, reef, exposure]
```capability
type: infrastructure
title: Recurring host baseline verification
description: Render inventory-aware Goss checks, run them on demand or hourly on-host, retain local failure state, and collect TAP evidence; the current all-host gate has a documented CoulombCore exception.
description: Resolve host-specific profiles into Goss checks, run them on demand or hourly on-host, retain local failure state, collect TAP evidence, and support a fail-closed handoff receipt.
keywords: [goss, verification, drift, systemd-timer, tap, evidence]
```

View file

@ -14,7 +14,7 @@
| workplan | RAIL-HO-WP-0008 | finished | — | workplans/RAIL-HO-WP-0008-railiance01-resource-and-commercial-evidence.md |
| 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 | ready | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| workplan | RAIL-HO-WP-0011 | active | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.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 |
@ -41,11 +41,11 @@
| task | RAIL-HO-WP-0009-T05 | done | — | workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md |
| task | RAIL-HO-WP-0009-T06 | done | — | workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md |
| task | RAIL-HO-WP-0010-T01 | done | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md |
| task | RAIL-HO-WP-0011-T01 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T02 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T03 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T04 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T05 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T06 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T07 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T08 | todo | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T01 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T02 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T03 | done | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T04 | progress | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| task | RAIL-HO-WP-0011-T05 | wait | — | workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md |
| 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 |

View file

@ -5,8 +5,6 @@
# DROP with a Plesk-era accept list (UFW status: inactive). Enabling UFW
# here would take down 80/443 and the rest of the accepted surface unless
# every live accept is declared first. RAIL-HO-WP-0009-T03.
ufw_manage: false
# Swapfile (T01)
swap_size_gb: 4
swap_swappiness: 10

View file

@ -1,16 +1,28 @@
#!/usr/bin/env python3
import json, yaml, subprocess, os, sys, pathlib, glob
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from baseline_contract import load_spec, profile_hostvars
from inventory_contract import load_inventory
def load_servers():
with open(os.path.join(os.path.dirname(__file__), '..', 'inventory', 'servers.yaml')) as f:
data = yaml.safe_load(f)
data = load_inventory(REPO_ROOT / 'inventory' / 'servers.yaml')
servers = data.get('servers', [])
return servers
def load_baseline():
return load_spec(REPO_ROOT / 'spec' / 'server-baseline.yaml')
def load_tf_outputs():
# Try to read terraform outputs to attach IPs, if available.
try:
out = subprocess.check_output(['terraform', '-chdir=../terraform/hetzner', 'output', '-json'], stderr=subprocess.DEVNULL, text=True)
out = subprocess.check_output(
['terraform', f'-chdir={REPO_ROOT / "terraform" / "hetzner"}', 'output', '-json'],
stderr=subprocess.DEVNULL,
text=True,
)
j = json.loads(out)
servers = j.get('servers', {}).get('value', {})
return servers # {name: ip}
@ -40,6 +52,7 @@ def load_host_vars(name):
def main():
server_list = load_servers()
baseline = load_baseline()
tf = load_tf_outputs()
host_names = []
hostvars = {}
@ -50,6 +63,7 @@ def main():
"ansible_host": tf.get(name) or s.get('ip'),
"ansible_user": s.get('ssh_user', 'admin'),
}
hvars.update(profile_hostvars(baseline, s['baseline_profile']))
if s.get('ssh_key'):
hvars["ansible_ssh_private_key_file"] = s['ssh_key']
hvars.update(load_host_vars(name))

View file

@ -2,7 +2,6 @@
become: true
vars_files:
- ../inventory/group_vars/all.yaml
- ../inventory/group_vars/secrets.sops.yaml
roles:
- role: base
tags: [base]

View file

@ -1,17 +1,22 @@
---
- name: Require the executable baseline contract
tags: [base, baseline]
ansible.builtin.assert:
that:
- baseline_required_packages is defined
- baseline_ssh_directives is defined
- baseline_user is defined
- baseline_security is defined
- baseline_firewall is defined
- ufw_manage == baseline_firewall.managed
fail_msg: >-
Resolve a baseline_profile from spec/server-baseline.yaml through the
dynamic inventory before running this role.
- name: Ensure base packages
tags: [base, packages]
ansible.builtin.package:
name:
- apt-transport-https
- ca-certificates
- curl
- git
- vim
- ufw
- fail2ban
- python3
- python3-venv
name: "{{ baseline_required_packages }}"
state: present
update_cache: true
@ -23,11 +28,29 @@
group: root
mode: '0644'
content: |
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
{% for directive in baseline_ssh_directives | dict2items %}
{{ directive.key }} {{ directive.value }}
{% endfor %}
notify: Restart sshd
- name: Ensure baseline operator user exists
tags: [base, user]
ansible.builtin.user:
name: "{{ baseline_user.name }}"
state: present
shell: "{{ baseline_user.shell }}"
create_home: true
- name: Ensure declared passwordless sudo posture
tags: [base, user, sudo]
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ baseline_user.name }}"
owner: root
group: root
mode: '0440'
content: "{{ baseline_user.name }} ALL=(ALL) {{ baseline_user.sudo }}:ALL\n"
validate: /usr/sbin/visudo -cf %s
- name: Ensure .ssh directory exists for ops_bridge_user
tags: [base, ssh]
ansible.builtin.file:
@ -194,24 +217,25 @@
state: started
enabled: true
- name: Configure fail2ban SSH jail
- name: Configure declared fail2ban jails
tags: [base, fail2ban]
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/sshd.conf
dest: "/etc/fail2ban/jail.d/{{ item }}.conf"
owner: root
group: root
mode: '0644'
content: |
[sshd]
[{{ item }}]
enabled = true
port = ssh
filter = sshd
port = {{ 'ssh' if item == 'sshd' else item }}
filter = {{ item }}
maxretry = 5
bantime = 3600
findtime = 600
loop: "{{ baseline_security.fail2ban_jails }}"
notify: Restart fail2ban
- name: Set HISTCONTROL to ignorespace
- name: Set declared HISTCONTROL
tags: [base, histcontrol]
ansible.builtin.copy:
dest: /etc/profile.d/histcontrol.sh
@ -219,7 +243,7 @@
group: root
mode: '0644'
content: |
export HISTCONTROL=ignorespace
export HISTCONTROL={{ baseline_security.histcontrol }}
- name: Set timezone
tags: [base, timezone]

View file

@ -54,8 +54,9 @@ handoff:
Do not build a second alert path in `railiance-telemetry` for the same
"check failed → someone sees it" plumbing. Item 9b should reuse this.
## Known expected fail
## Host profiles
`CoulombCore` has UFW inactive. The baseline asserts `Status: active`. The
timer will fail there until an explicit decision enables UFW or the host is
removed from the verify inventory. That failure is evidence, not noise.
`Railiance01` selects `ufw-managed`; `CoulombCore` selects
`external-firewall`. The latter does not turn an unmanaged control into a
pass: it asserts the documented replacement control, an iptables INPUT
default-drop policy. An absent replacement control is a failure.

View file

@ -23,8 +23,8 @@ make converge
```
This will:
1. Decrypt secrets locally (with your age key)
2. Run the Ansible playbooks against all hosts in your `inventory/servers.yaml`
1. Validate and resolve each host's baseline profile from source
2. Run the Ansible playbooks against all hosts in `inventory/servers.yaml`
3. Apply the baseline security and tooling configuration
## Verifying
@ -48,5 +48,6 @@ make status
## Notes
- Convergence is **idempotent**: re-running it will not break your server.
- Only your workstation (control node) needs the age private key; hosts never see it.
- Convergence does not load provider credentials. The `sops_agent` role installs
SOPS/age clients but does not place an age private key on a host.
- Additional roles (e.g. WireGuard, Kubernetes, apps) can be layered later.

View file

@ -0,0 +1,41 @@
{
"created_at": "2026-08-23T09:30:00Z",
"event_type": "provisioning-chain",
"inventory_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"phases": [
{
"evidence_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"phase": "plan",
"status": "pass"
},
{
"evidence_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"phase": "apply",
"provider_resource_ids": ["synthetic-provider-resource"],
"status": "pass"
},
{
"evidence_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"phase": "cloud-init",
"status": "pass"
},
{
"evidence_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"hosts": ["synthetic-host"],
"phase": "convergence",
"status": "pass"
},
{
"evidence_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"fresh_until": "2026-08-24T09:30:00Z",
"hosts": ["synthetic-host"],
"phase": "verification",
"status": "pass"
}
],
"receipt_id": "6f974742-7a0f-4fc4-a931-7ff68dc8eb84",
"schema_version": "1.0",
"source_revision": "3734a1c",
"status": "pass",
"synthetic": true
}

View file

@ -1,6 +1,7 @@
# 🚀 Provisioning Servers with railiance-infra
This guide explains **where you declare servers**, **how Terraform uses that declaration**, and **how to provision** (and later destroy) machines on Hetzner.
This guide explains how adopted hosts and provider-managed Hetzner hosts share
an inventory without sharing lifecycle behavior.
---
@ -11,13 +12,16 @@ This script will:
1. Add the new host entry to `inventory/servers.yaml`
2. Decrypt your Hetzner API token with SOPS
3. Run Terraform (`init/plan/apply`) to provision the server
4. Print the IPv4 address and a ready-to-use SSH command
3. Run Terraform init and plan
4. Apply only when the operator supplies the explicit `--apply` flag after
reviewing the plan
**Example:**
```bash
scripts/hcloud_new_server.sh core-01 --type cpx11 --region nbg1 --role core
# after review and the required approval:
scripts/hcloud_new_server.sh core-01 --type cpx11 --region nbg1 --role core --apply
```
This will create a small cpx11 instance in the Nuremberg (nbg1) region, tagged with the role core.
@ -33,17 +37,24 @@ ssh admin@<printed-ip>
## 1) Where you define servers
All desired hosts live in **`inventory/servers.yaml`**. Each entry is a simple YAML object with the required attributes:
All host identities live in **`inventory/servers.yaml`**. Run
`make validate-inventory` before Terraform or Ansible. Adopted hosts have a
provider address but no `provisioning` block. Provider-managed Hetzner hosts
have no committed IP and use this shape:
```yaml
servers:
- name: core-01
labels: [core, wireguard, git]
role: "core"
region: "nbg1" # Hetzner location (e.g., nbg1, fsn1, hel1)
type: "cpx21" # Hetzner server type/flavor
image: "ubuntu-24.04" # OS image slug
ssh_user: "admin" # bootstrap user (cloud-init creates this)
provider: hetzner
lifecycle_mode: provider-managed
ssh_user: admin
baseline_profile: ufw-managed
provisioning:
server_type: cpx21
region: nbg1
image: ubuntu-24.04
role: core
labels: [core, wireguard, git]
```
> Tip: Keep **names stable**. Renaming a server in this file makes Terraform think the old one was destroyed and a new one should be created.
@ -73,7 +84,8 @@ scripts/hcloud_new_server.sh web-01 --type cpx21 --region nbg1 --role web
## 3) How Terraform uses your declaration
The module at `terraform/hetzner/`:
- Reads `inventory/servers.yaml` (`for_each` over `servers`)
- Selects only records with `provider: hetzner` and
`lifecycle_mode: provider-managed`
- Registers your SSH key from `keys/admin_ssh.pub`
- Injects **cloud-init** that sets up the `admin` user and basic hardening
- Creates/updates/destroys servers to match the YAML
@ -84,17 +96,19 @@ Outputs include a map of server names → IPv4 addresses.
## 4) Provision (create/update)
Make sure your Hetzner API token is present and **SOPS-decryptable** in `inventory/group_vars/secrets.sops.yaml` under `ops.hcloud_token`.
The Hetzner API token is **SOPS-decryptable** at
`secrets/hetzner-token.yaml`, field `hetzner.token`. It is decrypted only into
the invoking process environment.
Then run either:
```bash
# plan and apply in separate steps
make tf-plan
make tf-apply
APPROVE_TF_APPLY=YES make tf-apply
```
or the end-to-end convenience:
```bash
make apply # terraform apply + ansible bootstrap
APPROVE_TF_APPLY=YES make apply # terraform apply + Ansible bootstrap
```
If you used the one-shot script:
@ -124,14 +138,16 @@ make ansible-bootstrap
To remove all servers managed by this repo:
```bash
make tf-destroy
APPROVE_TF_DESTROY=DESTROY-MANAGED-HETZNER make tf-destroy
```
To remove just one server, delete its entry from `inventory/servers.yaml`, commit, then:
To remove one **provider-managed Hetzner** server, delete its entry from
`inventory/servers.yaml`, review the plan, obtain the required approval, then:
```bash
make tf-apply
APPROVE_TF_APPLY=YES make tf-apply
```
Terraform will destroy the missing server and leave others intact.
Terraform will destroy the missing managed Hetzner server and leave others
intact. Adopted Host Europe records are never Terraform resources.
---
@ -139,6 +155,7 @@ Terraform will destroy the missing server and leave others intact.
- **Idempotent:** You can run `make apply` repeatedly; Terraform converges infra, Ansible converges config.
- **SSH keys:** Ensure `keys/admin_ssh.pub` exists before provisioning.
- **Secret token:** The Hetzner API token must be in `inventory/group_vars/secrets.sops.yaml` (encrypted with SOPS).
- **Secret token:** The Hetzner API token is `secrets/hetzner-token.yaml`, field
`hetzner.token`, and must remain SOPS-encrypted.
- **Cloud-init delay:** Allow ~3060s after creation for first-boot tasks before first SSH.
- **Labels & role:** `labels` are freeform tags; `role` can drive Ansible plays as you grow.

39
docs/s1-handoff.md Normal file
View file

@ -0,0 +1,39 @@
# S1 Handoff Gate
`make s1-handoff` is the operator-facing gate from host substrate (S1) to the
cluster layer (S2). It validates inventory and baseline contracts, requires a
clean checkout, runs the applicable Goss profile for every selected host, and
emits a metadata-only JSON receipt under `reports/`.
A passing receipt pins:
- source revision and inventory digest
- every selected host and its baseline profile
- observed time and 24-hour default freshness boundary
- per-host exit status
- SHA-256 digests of the resulting TAP evidence
Any failed host fails the aggregate. A passing receipt without host evidence is
invalid. `make s1-handoff-dry-run` validates local inputs but records
`status: not-run`; it cannot authorize S2 handoff.
The current profiles are:
- `Railiance01`: `ufw-managed`
- `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.
Receipts validate with:
```bash
python3 scripts/s1_receipt.py reports/s1-handoff-*.json
```
S2 should accept only `status: pass` receipts whose source and inventory match
the intended handoff and whose `fresh_until` has not elapsed.

View file

@ -0,0 +1,10 @@
# Metadata-only example. Copy outside Git for an attended approved rotation.
approved: false
approved_by: "operator-name"
approved_at: "2026-08-23T00:00:00Z"
changes:
- path: secrets/hetzner-token.yaml
before_recipients:
- age1old-example-not-valid
after_recipients:
- age1new-example-not-valid

31
docs/sops-rotation.md Normal file
View file

@ -0,0 +1,31 @@
# Bounded SOPS Recipient Rotation
The default operation is metadata-only and does not decrypt values:
```bash
python3 scripts/sops_rotation.py --check
```
It compares each protected file's public age-recipient metadata with the first
matching rule in `.sops.yaml`. CI runs this check to detect recipient drift.
An attended non-printing decryption check may emit a receipt:
```bash
python3 scripts/sops_rotation.py --check --verify-decryption \
--receipt reports/sops-rotation-check.json
```
Decrypted bytes go directly to the null device. They are not retained in the
receipt or command output.
Actual key updates require `--apply` and an approval YAML containing
`approved: true`, `approved_by`, `approved_at`, and an exact `changes` list from
the current plan. The command fails if that list differs from current metadata.
Review and preserve recovery-key custody before approving recipient removal.
Start from `docs/sops-rotation-approval.example.yaml`; the committed example is
deliberately unapproved and contains no usable recipient.
Rollback is a reviewed restoration of the prior `.sops.yaml` recipient set
followed by the same exact-plan approval, `sops updatekeys`, and non-printing
decryption verification. Git history alone is not recovery-key custody.

View file

@ -6,8 +6,9 @@ a reproducible, CI-friendly pass/fail verdict.
## The spec
`spec/server-baseline.yaml` is the single source of truth for the target state
of every managed node. It covers:
`spec/server-baseline.yaml` is executable through
`scripts/baseline_contract.py`. The dynamic inventory resolves its selected
profile into the variables consumed by both Ansible and Goss. It covers:
- **Firewall** — UFW active, default deny inbound, required ports allowed
(SSH 22/tcp; HostEurope Nydus 2224/tcp). The k3s API (6443/tcp) is
@ -15,16 +16,18 @@ of every managed node. It covers:
`k3s_api_revoked_sources` is pruned on a firewall-tagged converge. Flannel
VXLAN (8472/udp) is omitted while the cluster is single-node; peer
addresses go in `flannel_vxlan_allowed_sources` when a second node appears.
Hosts with `ufw_manage: false` (CoulombCore) are not rewritten. A host with
an empty 6443 allowlist is recoverable over SSH.
`CoulombCore` selects an explicit external-firewall profile and verifies its
replacement INPUT default-drop control without rewriting it. A host with an
empty 6443 allowlist is recoverable over SSH.
- **SSH daemon** — root login disabled, password auth disabled, pubkey auth enabled
- **Services** — ufw, fail2ban, ssh.socket enabled and running
- **Packages** — ufw, fail2ban, git, curl, vim, htop (age and sops installed as binaries)
- **Users** — admin user with bash shell and passwordless sudo
- **Security** — fail2ban sshd jail active, HISTCONTROL=ignorespace in /etc/profile.d/
When you change the desired state of a node, update this file first. Then
update the Ansible role **and** the Goss tests to match.
When you change governed desired state, update this file. The consumers use the
same resolved variables; `make validate-baseline` fails if either consumer is
disconnected from the contract.
## Running verification
@ -74,10 +77,11 @@ that converge UFW. The mapping is:
## Adding new assertions
1. Add the desired state to `spec/server-baseline.yaml`
2. Add the Ansible task to `ansible/roles/base/tasks/main.yml`
3. Add the Goss assertion to `goss/baseline.yaml.j2`
4. Run `make converge-firewall` and `make verify-host HOST=…` to confirm
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.
An hourly on-host timer (`railiance-goss-baseline.timer`) reruns the last
rendered baseline. See `docs/conformance-loop.md`.

View file

@ -1,63 +1,42 @@
# Goss baseline assertions for railiance managed nodes.
# Derived from spec/server-baseline.yaml — keep in sync.
# Run: goss -g /etc/goss/baseline.yaml validate
#
# THIS IS A TEMPLATE. It is rendered per host from inventory variables, so the
# firewall assertions below are generated from the SAME declaration that
# converges the host (k3s_api_allowed_sources, flannel_vxlan_allowed_sources,
# ufw_extra_allowed in group_vars). That is deliberate: a hand-written
# assertion drifts from the declaration it is meant to check, which is exactly
# how RAIL-HO-WP-0009 happened.
# Rendered from the executable spec/server-baseline.yaml profile selected by
# inventory/servers.yaml. Package, service, SSH, user, security, and firewall
# controls below consume baseline_* variables resolved by inventory_from_yaml.py.
package:
ufw:
{% for package_name in baseline_required_packages %}
{{ package_name }}:
installed: true
fail2ban:
installed: true
git:
installed: true
curl:
installed: true
vim:
installed: true
htop:
installed: true
# age and sops are binary installs, not apt packages — checked via command below
{% endfor %}
service:
ufw:
enabled: true
running: true
fail2ban:
enabled: true
running: true
# Ubuntu 24.04 uses socket activation: ssh.service is disabled by design,
# ssh.socket keeps it running. Assert the socket is enabled.
ssh.socket:
{% for service_name in baseline_required_services %}
{{ service_name }}:
enabled: true
running: true
{% endfor %}
file:
/etc/ssh/sshd_config.d/10-hardening.conf:
exists: true
contents:
- "PermitRootLogin no"
- "PasswordAuthentication no"
- "PubkeyAuthentication yes"
{% for directive in baseline_ssh_directives | dict2items %}
- "{{ directive.key }} {{ directive.value }}"
{% endfor %}
user:
tegwick:
{{ baseline_user.name }}:
exists: true
# sudo access is via /etc/sudoers.d/tegwick (NOPASSWD), not group membership
shell: /bin/bash
shell: {{ baseline_user.shell }}
command:
"ufw status":
"{{ baseline_firewall.verification.command }}":
exit-status: 0
stdout:
- "Status: active"
- /OpenSSH.*ALLOW/
{% for pattern in baseline_firewall.verification.stdout %}
- '/{{ pattern }}/'
{% endfor %}
{% if baseline_firewall.mode == 'ufw' %}
{% for src in k3s_api_allowed_sources | default([]) %}
- '/6443\/tcp\s+ALLOW\s+{{ src.address | regex_escape }}/'
{% endfor %}
@ -71,10 +50,6 @@ command:
- '/{{ port }}\/tcp\s+ALLOW\s+Anywhere/'
{% endfor %}
# Exact allowlist size: extra hand grants must fail, not only missing ones.
# The previous assertion matched /6443\/tcp.*ALLOW/, which passes identically
# whether the API is restricted to one operator address or open to the entire
# internet — it asserted that the port was allowed, not from whom.
"ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+Anywhere' || true":
exit-status: 0
stdout:
@ -93,24 +68,27 @@ command:
- "{{ flannel_vxlan_allowed_sources | default([]) | length }}"
{% for src in k3s_api_revoked_sources | default([]) %}
# Revoked operator source must not retain access: {{ src.comment | default('') }}
"ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+{{ src.address }}' || true":
exit-status: 0
stdout:
- "0"
{% endfor %}
"grep NOPASSWD /etc/sudoers.d/tegwick":
{% endif %}
"grep NOPASSWD /etc/sudoers.d/{{ baseline_user.name }}":
exit-status: 0
stdout:
- "NOPASSWD"
"grep -r HISTCONTROL /etc/profile.d/":
exit-status: 0
stdout:
- "ignorespace"
"fail2ban-client status sshd":
- "{{ baseline_security.histcontrol }}"
{% for jail in baseline_security.fail2ban_jails %}
"fail2ban-client status {{ jail }}":
exit-status: 0
stdout:
- "Status for the jail: sshd"
- "Status for the jail: {{ jail }}"
{% endfor %}
"test -x /usr/local/bin/age":
exit-status: 0
"test -x /usr/local/bin/sops":

View file

@ -1,3 +0,0 @@
# Encrypt this file with SOPS before committing!
ops:
hcloud_token: "hc_XXXXXXXXXXXXXXXXXXXXXXXXXXXX" # replace; then run: sops --encrypt --in-place inventory/group_vars/secrets.sops.yaml

View file

@ -1,8 +1,15 @@
schema_version: "1.0"
servers:
- name: CoulombCore
provider: hosteurope
lifecycle_mode: adopted
ip: 92.205.130.254
ssh_user: tegwick
ssh_key: ~/.ssh/id_ops
baseline_profile: external-firewall
- name: Railiance01
provider: hosteurope
lifecycle_mode: adopted
ip: 92.205.62.239
ssh_user: tegwick
baseline_profile: ufw-managed

View file

@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://coulomb.social/schemas/railiance-infra/s1-receipt-v1.json",
"title": "Railiance S1 metadata-only receipt",
"type": "object",
"required": [
"schema_version",
"receipt_id",
"event_type",
"synthetic",
"created_at",
"source_revision",
"inventory_sha256",
"status"
],
"properties": {
"schema_version": {"const": "1.0"},
"receipt_id": {"type": "string", "format": "uuid"},
"event_type": {
"enum": ["plan", "apply", "convergence", "verification", "rotation", "provisioning-chain"]
},
"synthetic": {"type": "boolean"},
"created_at": {"type": "string", "format": "date-time"},
"source_revision": {"type": "string", "pattern": "^[0-9a-f]{7,40}$"},
"inventory_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
"status": {"enum": ["pass", "fail", "not-run"]},
"hosts": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
"profiles": {"type": "object", "additionalProperties": {"type": "string"}},
"evidence": {"type": "array"},
"links": {"type": "array"},
"phases": {"type": "array"}
},
"additionalProperties": true
}

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Validate and resolve the executable S1 host-baseline contract."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import yaml
SCHEMA_VERSION = "2.0"
PROFILE_MODES = {"ufw", "external"}
REQUIRED_PACKAGES = {"curl", "git", "vim", "htop", "ufw", "fail2ban"}
REQUIRED_SERVICES = {"fail2ban", "ssh.socket"}
REQUIRED_SSH = {
"PasswordAuthentication": "no",
"PermitRootLogin": "no",
"PubkeyAuthentication": "yes",
"ChallengeResponseAuthentication": "no",
}
CONSUMER_MARKERS = {
"ansible/roles/base/tasks/main.yml": [
"baseline_required_packages",
"baseline_ssh_directives",
"baseline_user",
"baseline_security",
"baseline_firewall",
],
"goss/baseline.yaml.j2": [
"baseline_required_packages",
"baseline_required_services",
"baseline_ssh_directives",
"baseline_user",
"baseline_security",
"baseline_firewall",
],
}
class BaselineError(ValueError):
"""The baseline model or its repository consumers are inconsistent."""
def _strings(
value: Any, label: str, errors: list[str], *, allow_empty: bool = False
) -> list[str]:
if not isinstance(value, list) or (not value and not allow_empty) or not all(
isinstance(item, str) and item.strip() for item in value
):
errors.append(f"{label} must be a non-empty string list")
return []
if len(set(value)) != len(value):
errors.append(f"{label} contains duplicates")
return value
def validate_spec(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise BaselineError("baseline must be a YAML object")
errors: list[str] = []
if str(payload.get("version")) != SCHEMA_VERSION:
errors.append(f"version must be {SCHEMA_VERSION!r}")
defaults = payload.get("defaults")
profiles = payload.get("profiles")
if not isinstance(defaults, dict):
errors.append("defaults must be an object")
defaults = {}
if not isinstance(profiles, dict) or not profiles:
errors.append("profiles must be a non-empty object")
profiles = {}
packages = _strings(defaults.get("packages"), "defaults.packages", errors)
services = _strings(defaults.get("services"), "defaults.services", errors)
missing_packages = sorted(REQUIRED_PACKAGES - set(packages))
missing_services = sorted(REQUIRED_SERVICES - set(services))
if missing_packages:
errors.append(f"defaults.packages omits governed packages {', '.join(missing_packages)}")
if missing_services:
errors.append(f"defaults.services omits governed services {', '.join(missing_services)}")
ssh = defaults.get("ssh_directives")
if not isinstance(ssh, dict) or not ssh or not all(
isinstance(key, str) and isinstance(value, str) for key, value in (ssh or {}).items()
):
errors.append("defaults.ssh_directives must be a non-empty string map")
elif {key: ssh.get(key) for key in REQUIRED_SSH} != REQUIRED_SSH:
errors.append("defaults.ssh_directives weakens a governed SSH directive")
user = defaults.get("user")
if not isinstance(user, dict):
errors.append("defaults.user must be an object")
else:
for key in ("name", "shell", "sudo"):
if not isinstance(user.get(key), str) or not user[key]:
errors.append(f"defaults.user.{key} must be a non-empty string")
if user.get("sudo") != "NOPASSWD":
errors.append("defaults.user.sudo must be NOPASSWD")
security = defaults.get("security")
if not isinstance(security, dict):
errors.append("defaults.security must be an object")
else:
if not isinstance(security.get("histcontrol"), str):
errors.append("defaults.security.histcontrol must be a string")
_strings(
security.get("fail2ban_jails"),
"defaults.security.fail2ban_jails",
errors,
)
for name, profile in profiles.items():
label = f"profiles.{name}"
if not isinstance(profile, dict):
errors.append(f"{label} must be an object")
continue
_strings(
profile.get("services", []),
f"{label}.services",
errors,
allow_empty=True,
)
firewall = profile.get("firewall")
if not isinstance(firewall, dict):
errors.append(f"{label}.firewall must be an object")
continue
mode = firewall.get("mode")
if mode not in PROFILE_MODES:
errors.append(f"{label}.firewall.mode must be one of {sorted(PROFILE_MODES)}")
expected_managed = mode == "ufw"
if firewall.get("managed") is not expected_managed:
errors.append(f"{label}.firewall.managed must be {expected_managed}")
verification = firewall.get("verification")
if not isinstance(verification, dict):
errors.append(f"{label}.firewall.verification must be an object")
else:
if not isinstance(verification.get("command"), str):
errors.append(f"{label}.firewall.verification.command must be a string")
_strings(
verification.get("stdout"),
f"{label}.firewall.verification.stdout",
errors,
)
if mode == "external":
replacement = firewall.get("replacement_control")
if not isinstance(replacement, dict):
errors.append(f"{label}.firewall.replacement_control must be an object")
else:
for key in ("description", "owner", "removal_condition"):
if not isinstance(replacement.get(key), str) or not replacement[key]:
errors.append(
f"{label}.firewall.replacement_control.{key} must be a string"
)
for required_profile, required_mode in {
"ufw-managed": "ufw",
"external-firewall": "external",
}.items():
if profiles.get(required_profile, {}).get("firewall", {}).get("mode") != required_mode:
errors.append(
f"profiles.{required_profile} must declare firewall mode {required_mode}"
)
if errors:
raise BaselineError("baseline contract failed:\n- " + "\n- ".join(errors))
return payload
def load_spec(path: Path) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise BaselineError(f"cannot read {path}: {exc}") from exc
return validate_spec(payload)
def profile_hostvars(payload: dict[str, Any], profile_name: str) -> dict[str, Any]:
validate_spec(payload)
try:
profile = payload["profiles"][profile_name]
except KeyError as exc:
raise BaselineError(f"unknown baseline profile {profile_name!r}") from exc
defaults = payload["defaults"]
services = list(dict.fromkeys(defaults["services"] + profile["services"]))
return {
"baseline_profile": profile_name,
"baseline_required_packages": defaults["packages"],
"baseline_required_services": services,
"baseline_ssh_directives": defaults["ssh_directives"],
"baseline_user": defaults["user"],
"baseline_security": defaults["security"],
"baseline_firewall": profile["firewall"],
"ufw_manage": profile["firewall"]["managed"],
}
def validate_repo_consumers(root: Path) -> None:
errors: list[str] = []
for relative, markers in CONSUMER_MARKERS.items():
path = root / relative
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
errors.append(f"cannot read {relative}: {exc}")
continue
missing = [marker for marker in markers if marker not in text]
if missing:
errors.append(f"{relative} does not consume {', '.join(missing)}")
if errors:
raise BaselineError("baseline consumer parity failed:\n- " + "\n- ".join(errors))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"spec", nargs="?", type=Path, default=Path("spec/server-baseline.yaml")
)
parser.add_argument("--profile", help="resolve one profile as Ansible hostvars")
parser.add_argument("--check-repo", action="store_true")
args = parser.parse_args()
try:
payload = load_spec(args.spec)
if args.check_repo:
validate_repo_consumers(Path(__file__).resolve().parents[1])
result: dict[str, Any] = {
"ok": True,
"version": payload["version"],
"profiles": sorted(payload["profiles"]),
}
if args.profile:
result["hostvars"] = profile_hostvars(payload, args.profile)
except BaselineError as exc:
print(exc, file=sys.stderr)
return 1
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Fail when a declared secret-bearing Git path is not SOPS/age encrypted."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def is_protected_path(path: str) -> bool:
normalized = path.strip("/")
name = Path(normalized).name.lower()
return normalized.startswith("secrets/") or (
normalized.startswith("inventory/")
and name.startswith("secrets")
and name.endswith((".yaml", ".yml", ".json"))
)
def is_encrypted_content(path: str, content: str) -> bool:
if path.endswith((".age", ".gpg")):
return bool(content)
return any(
line.strip() == "sops:" or line.lstrip().startswith('"sops"')
for line in content.splitlines()
)
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True)
def staged_files() -> list[str]:
return [
path
for path in _git("diff", "--cached", "--name-only", "--diff-filter=ACMR").splitlines()
if is_protected_path(path)
]
def tracked_files() -> list[str]:
return [
path
for path in _git("ls-files").splitlines()
if is_protected_path(path) and (ROOT / path).is_file()
]
def validate_paths(paths: list[str], *, staged: bool) -> list[str]:
failures = []
for relative in sorted(set(paths)):
try:
content = (
_git("show", f":{relative}")
if staged
else (ROOT / relative).read_text(encoding="utf-8")
)
except (OSError, subprocess.CalledProcessError):
failures.append(f"{relative}: cannot read protected content")
continue
if not is_encrypted_content(relative, content):
failures.append(f"{relative}: plaintext or missing SOPS metadata")
return failures
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--staged", action="store_true")
mode.add_argument("--tracked", action="store_true")
args = parser.parse_args()
paths = staged_files() if args.staged else tracked_files()
failures = validate_paths(paths, staged=args.staged)
if failures:
print("Unencrypted secret-bearing paths:\n- " + "\n- ".join(failures), file=sys.stderr)
return 1
print(f"secret path check passed ({len(paths)} protected file(s))")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# hcloud_new_server.sh — Add a host to inventory and provision it on Hetzner
# Usage:
# scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin]
# scripts/hcloud_new_server.sh <NAME> [options] [--apply]
#
# Prereqs:
# - age + SOPS installed, with access to decrypt your Hetzner token
@ -23,6 +23,7 @@ REGION="nbg1"
ROLE="generic"
IMAGE="ubuntu-24.04"
USER="admin"
APPLY=false
while [[ $# -gt 0 ]]; do
case "$1" in
@ -31,6 +32,7 @@ while [[ $# -gt 0 ]]; do
--role) ROLE="$2"; shift 2;;
--image) IMAGE="$2"; shift 2;;
--user) USER="$2"; shift 2;;
--apply) APPLY=true; shift;;
*) fail "Unknown arg: $1 (usage: scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin])";;
esac
done
@ -58,18 +60,27 @@ python3 scripts/new_host.py \
--region "$REGION" \
--role "$ROLE" \
--image "$IMAGE" \
--user "$USER"
--user "$USER" \
--reuse-existing
ok "Inventory updated: $NAME → inventory/servers.yaml"
# --- Decrypt Hetzner token and apply Terraform ---
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.sops.yaml 2>/dev/null)"
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt ops.hcloud_token from secrets/hetzner-token.sops.yaml. Ensure SOPS_AGE_KEY or keys.txt is set and token exists."
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.yaml 2>/dev/null)"
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt hetzner.token from secrets/hetzner-token.yaml. Ensure SOPS_AGE_KEY or keys.txt is set."
pushd terraform/hetzner >/dev/null
terraform init -upgrade
export HCLOUD_TOKEN
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
terraform plan
if [[ "$APPLY" != true ]]; then
info "Plan complete; no provider mutation performed. Re-run with --apply only after review and approval."
popd >/dev/null
exit 0
fi
terraform apply -auto-approve
# Try to show IP of the created host

View file

@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Validate the S1 host inventory without contacting a provider or host."""
from __future__ import annotations
import argparse
import ipaddress
import json
import re
import sys
from pathlib import Path
from typing import Any
import yaml
SCHEMA_VERSION = "1.0"
PROVIDERS = {"hosteurope", "hetzner"}
LIFECYCLE_MODES = {"adopted", "provider-managed"}
BASELINE_PROFILES = {"ufw-managed", "external-firewall"}
HETZNER_REQUIRED = {"server_type", "region", "image", "role"}
HETZNER_OPTIONAL = {"labels"}
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
class InventoryError(ValueError):
"""The inventory does not satisfy the S1 contract."""
def _nonempty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def validate_inventory(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise InventoryError("inventory must be a YAML object")
if str(payload.get("schema_version")) != SCHEMA_VERSION:
raise InventoryError(f"schema_version must be {SCHEMA_VERSION!r}")
servers = payload.get("servers")
if not isinstance(servers, list) or not servers:
raise InventoryError("servers must be a non-empty list")
errors: list[str] = []
names: set[str] = set()
for index, server in enumerate(servers):
label = f"servers[{index}]"
if not isinstance(server, dict):
errors.append(f"{label} must be an object")
continue
name = server.get("name")
if not _nonempty_string(name) or not NAME_RE.fullmatch(name):
errors.append(f"{label}.name must be a stable host identifier")
name = label
elif name in names:
errors.append(f"{label}.name duplicates {name!r}")
else:
names.add(name)
label = str(name)
provider = server.get("provider")
lifecycle = server.get("lifecycle_mode")
profile = server.get("baseline_profile")
ssh_user = server.get("ssh_user")
if provider not in PROVIDERS:
errors.append(f"{label}: provider must be one of {sorted(PROVIDERS)}")
if lifecycle not in LIFECYCLE_MODES:
errors.append(
f"{label}: lifecycle_mode must be one of {sorted(LIFECYCLE_MODES)}"
)
if profile not in BASELINE_PROFILES:
errors.append(
f"{label}: baseline_profile must be one of {sorted(BASELINE_PROFILES)}"
)
if not _nonempty_string(ssh_user) or not USER_RE.fullmatch(ssh_user):
errors.append(f"{label}: ssh_user must be a valid Unix user name")
if lifecycle == "adopted":
address = server.get("ip")
try:
ipaddress.ip_address(address)
except (TypeError, ValueError):
errors.append(f"{label}: adopted hosts require a literal ip address")
if "provisioning" in server:
errors.append(
f"{label}: adopted hosts must not carry provider provisioning fields"
)
elif lifecycle == "provider-managed":
if provider != "hetzner":
errors.append(
f"{label}: provider-managed is currently implemented only for hetzner"
)
if "ip" in server:
errors.append(
f"{label}: provider-managed addresses come from provider output; remove ip"
)
provisioning = server.get("provisioning")
if not isinstance(provisioning, dict):
errors.append(f"{label}: provider-managed hosts require provisioning")
else:
missing = sorted(
key
for key in HETZNER_REQUIRED
if not _nonempty_string(provisioning.get(key))
)
unknown = sorted(
set(provisioning) - HETZNER_REQUIRED - HETZNER_OPTIONAL
)
if missing:
errors.append(
f"{label}: provisioning missing {', '.join(missing)}"
)
if unknown:
errors.append(
f"{label}: provisioning has unknown fields {', '.join(unknown)}"
)
labels = provisioning.get("labels", [])
if not isinstance(labels, list) or not all(
_nonempty_string(item) for item in labels
):
errors.append(
f"{label}: provisioning.labels must be a list of strings"
)
if errors:
raise InventoryError("inventory contract failed:\n- " + "\n- ".join(errors))
return payload
def load_inventory(path: Path) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise InventoryError(f"cannot read {path}: {exc}") from exc
return validate_inventory(payload)
def managed_hetzner_servers(payload: dict[str, Any]) -> list[dict[str, Any]]:
validate_inventory(payload)
return [
server
for server in payload["servers"]
if server["provider"] == "hetzner"
and server["lifecycle_mode"] == "provider-managed"
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"inventory", nargs="?", type=Path, default=Path("inventory/servers.yaml")
)
parser.add_argument(
"--managed-hetzner", action="store_true", help="print selected names"
)
args = parser.parse_args()
try:
payload = load_inventory(args.inventory)
except InventoryError as exc:
print(exc, file=sys.stderr)
return 1
selected = managed_hetzner_servers(payload)
result = {
"ok": True,
"schema_version": SCHEMA_VERSION,
"server_count": len(payload["servers"]),
"managed_hetzner": [server["name"] for server in selected],
}
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,7 +5,8 @@ if [[ -z "$NAME" ]]; then
echo "Usage: scripts/new-server.sh <name>"
exit 1
fi
yq -i '.servers += [{ "name": "'$NAME'", "labels": [], "role": "generic", "region": "nbg1", "type": "cpx21", "image": "ubuntu-24.04", "ssh_user": "admin"}]' inventory/servers.yaml
python3 scripts/new_host.py --name "$NAME" --type cpx21 --region nbg1 \
--role generic --image ubuntu-24.04 --user admin
git add inventory/servers.yaml
git commit -m "Add server ${NAME}"
echo "Added ${NAME}. Run: make apply"

View file

@ -14,6 +14,11 @@ def main():
p.add_argument("--role", default="test")
p.add_argument("--image", default="ubuntu-24.04")
p.add_argument("--user", default="admin")
p.add_argument(
"--reuse-existing",
action="store_true",
help="succeed only when an existing record exactly matches the request",
)
args = p.parse_args()
inv_path = os.path.join("inventory", "servers.yaml")
@ -23,20 +28,33 @@ def main():
data = yaml.safe_load(f) or {}
servers = data.setdefault("servers", [])
# Prevent duplicates
if any(s.get("name") == args.name for s in servers):
print(f"ERROR: host '{args.name}' already exists in {inv_path}", file=sys.stderr)
candidate = {
"name": args.name,
"provider": "hetzner",
"lifecycle_mode": "provider-managed",
"ssh_user": args.user,
"baseline_profile": "ufw-managed",
"provisioning": {
"server_type": args.type,
"region": args.region,
"role": args.role,
"image": args.image,
"labels": [],
},
}
existing = next((s for s in servers if s.get("name") == args.name), None)
if existing is not None:
if args.reuse_existing and existing == candidate:
print(f"Reusing matching host '{args.name}' from {inv_path}")
return
print(
f"ERROR: host '{args.name}' already exists with a different declaration",
file=sys.stderr,
)
sys.exit(1)
servers.append({
"name": args.name,
"labels": [],
"role": args.role,
"region": args.region,
"type": args.type,
"image": args.image,
"ssh_user": args.user,
})
data.setdefault("schema_version", "1.0")
servers.append(candidate)
with open(inv_path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=False)

163
scripts/s1_handoff.py Normal file
View file

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Run the fail-closed S1 verification gate and emit a metadata-only receipt."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers
from inventory_contract import load_inventory
from s1_receipt import validate_receipt
ROOT = Path(__file__).resolve().parents[1]
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
def _timestamp(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _selected_hosts(inventory: dict[str, Any], requested: list[str]) -> list[dict[str, Any]]:
hosts = inventory["servers"]
if not requested:
return hosts
known = {host["name"]: host for host in hosts}
missing = sorted(set(requested) - set(known))
if missing:
raise ValueError(f"unknown hosts: {', '.join(missing)}")
return [known[name] for name in requested]
def build_receipt(
*,
revision: str,
inventory_digest: str,
hosts: list[dict[str, Any]],
results: dict[str, int] | None,
observed_at: datetime,
freshness_hours: int,
evidence: list[dict[str, str]],
) -> dict[str, Any]:
ran = results is not None
passed = ran and all(results[host["name"]] == 0 for host in hosts)
receipt: dict[str, Any] = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "verification",
"synthetic": False,
"created_at": _timestamp(datetime.now(timezone.utc)),
"source_revision": revision,
"inventory_sha256": inventory_digest,
"status": "pass" if passed else ("fail" if ran else "not-run"),
"hosts": [host["name"] for host in hosts],
"profiles": {host["name"]: host["baseline_profile"] for host in hosts},
"observed_at": _timestamp(observed_at),
"fresh_until": _timestamp(observed_at + timedelta(hours=freshness_hours)),
"evidence": evidence,
}
if ran:
receipt["host_exit_status"] = results
validate_receipt(receipt)
return receipt
def _new_reports(started: float, host_names: list[str]) -> list[dict[str, str]]:
evidence = []
for path in sorted((ROOT / "reports").glob("goss-*.tap")):
if path.stat().st_mtime < started:
continue
if not any(f"goss-{name}-" in path.name for name in host_names):
continue
evidence.append(
{
"kind": "goss-tap",
"path": str(path.relative_to(ROOT)),
"sha256": _sha256(path),
}
)
return evidence
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", action="append", default=[])
parser.add_argument("--freshness-hours", type=int, default=24)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
if not 1 <= args.freshness_hours <= 168:
parser.error("--freshness-hours must be between 1 and 168")
inventory_path = ROOT / "inventory" / "servers.yaml"
spec_path = ROOT / "spec" / "server-baseline.yaml"
inventory = load_inventory(inventory_path)
baseline = load_spec(spec_path)
validate_repo_consumers(ROOT)
hosts = _selected_hosts(inventory, args.host)
for host in hosts:
profile_hostvars(baseline, host["baseline_profile"])
revision = _git("rev-parse", "HEAD")
dirty = _git("status", "--porcelain")
if dirty and not args.dry_run:
print("handoff gate requires a clean checkout so the receipt pins all inputs", file=sys.stderr)
return 2
observed = datetime.now(timezone.utc)
results: dict[str, int] | None = None
evidence: list[dict[str, str]] = []
if not args.dry_run:
if shutil.which("ansible-playbook") is None:
print("handoff gate requires ansible-playbook", file=sys.stderr)
return 2
started = datetime.now(timezone.utc).timestamp()
results = {}
for host in hosts:
completed = subprocess.run(
["ansible-playbook", "playbooks/verify.yaml", "--limit", host["name"]],
cwd=ROOT / "ansible",
check=False,
)
results[host["name"]] = completed.returncode
evidence = _new_reports(started, [host["name"] for host in hosts])
receipt = build_receipt(
revision=revision,
inventory_digest=_sha256(inventory_path),
hosts=hosts,
results=results,
observed_at=observed,
freshness_hours=args.freshness_hours,
evidence=evidence,
)
output = args.output
if output is None:
stamp = observed.strftime("%Y%m%dT%H%M%SZ")
output = ROOT / "reports" / f"s1-handoff-{stamp}.json"
elif not output.is_absolute():
output = ROOT / output
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({"receipt": str(output), "status": receipt["status"]}, sort_keys=True))
return 0 if receipt["status"] in {"pass", "not-run"} else 1
if __name__ == "__main__":
raise SystemExit(main())

190
scripts/s1_receipt.py Normal file
View file

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Validate Railiance S1 receipts and reject secret-shaped content."""
from __future__ import annotations
import argparse
import json
import re
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA_VERSION = "1.0"
EVENT_TYPES = {
"plan",
"apply",
"convergence",
"verification",
"rotation",
"provisioning-chain",
}
STATUSES = {"pass", "fail", "not-run"}
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
REVISION_RE = re.compile(r"^[0-9a-f]{7,40}$")
FORBIDDEN_KEYS = {
"api_key",
"credential",
"credentials",
"password",
"private_key",
"secret",
"secret_value",
"token",
"tokens",
}
FORBIDDEN_VALUE_PATTERNS = (
re.compile(r"-----BEGIN (?:OPENSSH|RSA|EC|AGE) PRIVATE KEY-----"),
re.compile(r"\bhc_[A-Za-z0-9_-]{16,}\b"),
)
class ReceiptError(ValueError):
"""A receipt is incomplete, ambiguous, or unsafe to retain."""
def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_time(value: Any, label: str) -> datetime:
if not isinstance(value, str):
raise ReceiptError(f"{label} must be an RFC3339 string")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ReceiptError(f"{label} must be RFC3339") from exc
if parsed.tzinfo is None:
raise ReceiptError(f"{label} must include a timezone")
return parsed
def _scan_safe(value: Any, path: str = "$") -> None:
if isinstance(value, dict):
for key, child in value.items():
normalized = str(key).lower().replace("-", "_")
if normalized in FORBIDDEN_KEYS or normalized.endswith(
("_api_key", "_credential", "_password", "_private_key", "_secret", "_token")
):
raise ReceiptError(f"forbidden secret-shaped key at {path}.{key}")
_scan_safe(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
_scan_safe(child, f"{path}[{index}]")
elif isinstance(value, str):
for pattern in FORBIDDEN_VALUE_PATTERNS:
if pattern.search(value):
raise ReceiptError(f"forbidden credential-shaped value at {path}")
def _require_digest(payload: dict[str, Any], key: str) -> None:
if not DIGEST_RE.fullmatch(str(payload.get(key, ""))):
raise ReceiptError(f"{key} must be a lowercase sha256 digest")
def _validate_hosts(payload: dict[str, Any]) -> None:
hosts = payload.get("hosts")
profiles = payload.get("profiles")
if not isinstance(hosts, list) or not hosts or not all(
isinstance(host, str) and host for host in hosts
):
raise ReceiptError("passing host receipts require hosts")
if len(set(hosts)) != len(hosts):
raise ReceiptError("hosts must be unique")
if not isinstance(profiles, dict) or set(profiles) != set(hosts):
raise ReceiptError("profiles must map every and only listed host")
def validate_receipt(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ReceiptError("receipt must be a JSON object")
_scan_safe(payload)
if payload.get("schema_version") != SCHEMA_VERSION:
raise ReceiptError(f"schema_version must be {SCHEMA_VERSION}")
try:
uuid.UUID(str(payload.get("receipt_id")))
except (ValueError, TypeError, AttributeError) as exc:
raise ReceiptError("receipt_id must be a UUID") from exc
event_type = payload.get("event_type")
status = payload.get("status")
if event_type not in EVENT_TYPES:
raise ReceiptError(f"event_type must be one of {sorted(EVENT_TYPES)}")
if status not in STATUSES:
raise ReceiptError(f"status must be one of {sorted(STATUSES)}")
if not isinstance(payload.get("synthetic"), bool):
raise ReceiptError("synthetic must be boolean")
_parse_time(payload.get("created_at"), "created_at")
if not REVISION_RE.fullmatch(str(payload.get("source_revision", ""))):
raise ReceiptError("source_revision must be a 7-40 character git revision")
_require_digest(payload, "inventory_sha256")
if status == "pass" and event_type == "plan":
_require_digest(payload, "plan_summary_sha256")
elif status == "pass" and event_type == "apply":
resource_ids = payload.get("provider_resource_ids")
if not isinstance(resource_ids, list) or not resource_ids:
raise ReceiptError("passing apply receipts require provider_resource_ids")
elif status == "pass" and event_type in {"convergence", "verification"}:
_validate_hosts(payload)
if event_type == "verification":
observed = _parse_time(payload.get("observed_at"), "observed_at")
fresh = _parse_time(payload.get("fresh_until"), "fresh_until")
if fresh <= observed:
raise ReceiptError("fresh_until must be after observed_at")
evidence = payload.get("evidence")
if not isinstance(evidence, list) or len(evidence) < len(payload["hosts"]):
raise ReceiptError("passing verification receipts require host evidence")
for item in evidence:
if not isinstance(item, dict) or not DIGEST_RE.fullmatch(
str(item.get("sha256", ""))
):
raise ReceiptError("verification evidence requires sha256 digests")
elif status == "pass" and event_type == "rotation":
if payload.get("decryption_verified") is not True:
raise ReceiptError("passing rotation receipts require decryption_verified=true")
for key in ("files", "before_recipients", "after_recipients"):
if not isinstance(payload.get(key), list) or not payload[key]:
raise ReceiptError(f"passing rotation receipts require {key}")
elif status == "pass" and event_type == "provisioning-chain":
if payload.get("synthetic") is not True:
raise ReceiptError("combined provisioning-chain receipts are synthetic only")
phases = payload.get("phases")
expected = ["plan", "apply", "cloud-init", "convergence", "verification"]
if not isinstance(phases, list) or [p.get("phase") for p in phases] != expected:
raise ReceiptError(f"provisioning-chain phases must be {expected}")
if any(phase.get("status") != "pass" for phase in phases):
raise ReceiptError("passing provisioning-chain receipts require every phase to pass")
return payload
def load_receipt(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ReceiptError(f"cannot read {path}: {exc}") from exc
return validate_receipt(payload)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("receipts", nargs="+", type=Path)
args = parser.parse_args()
failures = []
for path in args.receipts:
try:
load_receipt(path)
except ReceiptError as exc:
failures.append(f"{path}: {exc}")
if failures:
print("receipt validation failed:\n- " + "\n- ".join(failures), file=sys.stderr)
return 1
print(json.dumps({"ok": True, "validated": len(args.receipts)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,8 +2,8 @@
set -euo pipefail
case "${1:-}" in
edit) sops inventory/group_vars/secrets.sops.yaml ;;
rotate) sops --rotate --in-place inventory/group_vars/secrets.sops.yaml ;;
edit) sops secrets/hetzner-token.yaml ;;
rotate) python3 scripts/sops_rotation.py --check ;;
*)
echo "Usage: scripts/sops.sh [edit|rotate]"
;;

233
scripts/sops_rotation.py Normal file
View file

@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Check or execute a bounded, metadata-only SOPS recipient rotation."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from check_secret_paths import is_protected_path
from s1_receipt import validate_receipt
ROOT = Path(__file__).resolve().parents[1]
class RotationError(ValueError):
"""Rotation inputs, metadata, or approval are unsafe or incomplete."""
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
def protected_files(root: Path = ROOT) -> list[Path]:
candidates = list((root / "secrets").rglob("*")) if (root / "secrets").exists() else []
inventory = list((root / "inventory").rglob("secrets*"))
return sorted(
path
for path in candidates + inventory
if path.is_file() and is_protected_path(str(path.relative_to(root)))
)
def load_policy(path: Path) -> list[dict[str, Any]]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
rules = payload["creation_rules"]
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
raise RotationError(f"cannot read SOPS policy {path}: {exc}") from exc
if not isinstance(rules, list) or not rules:
raise RotationError("SOPS policy has no creation_rules")
return rules
def expected_recipients(rules: list[dict[str, Any]], relative: str) -> list[str]:
for rule in rules:
pattern = rule.get("path_regex")
if not isinstance(pattern, str) or re.fullmatch(pattern, relative) is None:
continue
recipients = []
for group in rule.get("key_groups", []):
recipients.extend(group.get("age", []))
recipients = sorted(set(recipients))
if not recipients:
raise RotationError(f"{relative}: matching policy has no age recipients")
return recipients
raise RotationError(f"{relative}: no .sops.yaml creation rule matches")
def actual_recipients(path: Path) -> list[str]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
age_entries = payload["sops"]["age"]
recipients = sorted({entry["recipient"] for entry in age_entries})
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
raise RotationError(f"{path}: missing readable SOPS age metadata") from exc
if not recipients:
raise RotationError(f"{path}: SOPS metadata has no age recipients")
return recipients
def rotation_plan(root: Path = ROOT) -> list[dict[str, Any]]:
rules = load_policy(root / ".sops.yaml")
plan = []
for path in protected_files(root):
relative = str(path.relative_to(root))
before = actual_recipients(path)
after = expected_recipients(rules, relative)
plan.append(
{
"path": relative,
"sha256": _sha256(path),
"before_recipients": before,
"after_recipients": after,
"changed": before != after,
}
)
if not plan:
raise RotationError("no protected SOPS files found")
return plan
def _load_approval(path: Path, plan: list[dict[str, Any]]) -> None:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise RotationError(f"cannot read approval file: {exc}") from exc
if not isinstance(payload, dict) or payload.get("approved") is not True:
raise RotationError("approval file must contain approved: true")
if not payload.get("approved_by") or not payload.get("approved_at"):
raise RotationError("approval file requires approved_by and approved_at")
expected = [
{
"path": item["path"],
"before_recipients": item["before_recipients"],
"after_recipients": item["after_recipients"],
}
for item in plan
if item["changed"]
]
if payload.get("changes") != expected:
raise RotationError("approval changes do not exactly match the current rotation plan")
def _verify_decryption(paths: list[Path]) -> bool:
if shutil.which("sops") is None:
raise RotationError("sops is required for non-printing decryption verification")
for path in paths:
completed = subprocess.run(
["sops", "--decrypt", str(path)],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if completed.returncode != 0:
raise RotationError(f"decryption verification failed for {path.relative_to(ROOT)}")
return True
def _apply(plan: list[dict[str, Any]]) -> None:
if shutil.which("sops") is None:
raise RotationError("sops is required for rotation")
for item in plan:
if not item["changed"]:
continue
completed = subprocess.run(
["sops", "updatekeys", "--yes", item["path"]],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if completed.returncode != 0:
raise RotationError(f"sops updatekeys failed for {item['path']}")
def build_receipt(plan: list[dict[str, Any]], verified: bool, applied: bool) -> dict[str, Any]:
all_before = sorted({r for item in plan for r in item["before_recipients"]})
all_after = sorted({r for item in plan for r in item["after_recipients"]})
receipt = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "rotation",
"synthetic": False,
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"source_revision": _git("rev-parse", "HEAD"),
"inventory_sha256": _sha256(ROOT / "inventory" / "servers.yaml"),
"status": "pass" if verified else "not-run",
"applied": applied,
"decryption_verified": verified,
"files": [item["path"] for item in plan],
"before_recipients": all_before,
"after_recipients": all_after,
"file_metadata": plan,
}
validate_receipt(receipt)
return receipt
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail on recipient drift")
parser.add_argument("--verify-decryption", action="store_true")
parser.add_argument("--apply", action="store_true")
parser.add_argument("--approval-file", type=Path)
parser.add_argument("--receipt", type=Path)
args = parser.parse_args()
try:
plan = rotation_plan()
if args.check and any(item["changed"] for item in plan):
raise RotationError("recipient drift detected")
if args.apply:
if args.approval_file is None:
raise RotationError("--apply requires --approval-file")
if not any(item["changed"] for item in plan):
raise RotationError("--apply requires at least one recipient change")
_load_approval(args.approval_file, plan)
_apply(plan)
plan = rotation_plan()
if any(item["changed"] for item in plan):
raise RotationError("recipient drift remains after rotation")
verified = _verify_decryption(protected_files()) if args.verify_decryption or args.apply else False
receipt = build_receipt(plan, verified, args.apply)
if args.receipt:
destination = args.receipt if args.receipt.is_absolute() else ROOT / args.receipt
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(
json.dumps(
{
"ok": True,
"files": len(plan),
"changes": sum(1 for item in plan if item["changed"]),
"decryption_verified": verified,
"applied": args.apply,
},
sort_keys=True,
)
)
return 0
except RotationError as exc:
print(f"rotation failed closed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,99 +1,63 @@
# Railiance Managed Node — Baseline Server Specification
# This file is the authoritative source of truth for the target state of every
# server managed by railiance-infra. All convergence roles and test assertions
# MUST be derivable from this document.
#
# When you change something here, update the Ansible roles AND the Goss tests.
# Format: human-readable YAML, kept technology-neutral.
# Executable S1 host baseline. scripts/baseline_contract.py resolves this model
# into the Ansible hostvars consumed by convergence and Goss verification.
version: "2.0"
version: "1.1"
applies_to: all # override per node group if needed
# ---------------------------------------------------------------------------
# Firewall
# ---------------------------------------------------------------------------
firewall:
engine: ufw
status: active
default_incoming: deny
default_outgoing: allow
# Hosts with ufw_manage: false (CoulombCore) keep their existing packet
# filter. Do not enable UFW there as a side effect of an unrelated converge.
rules:
- name: OpenSSH # UFW app name; resolves to 22/tcp
action: allow
- name: k3s-api
port: 6443
proto: tcp
action: allow
sources: [] # tunnel-only (ADR-005); public allowlist stays empty
- name: flannel-vxlan
port: 8472
proto: udp
action: allow
sources: [] # omit while single-node; peer addresses only when multi-node
- name: nydus-ex-api
port: 2224
proto: tcp
action: allow
sources: anywhere # HostEurope provider agent; required by the VPS platform
# ---------------------------------------------------------------------------
# SSH daemon
# ---------------------------------------------------------------------------
ssh:
permit_root_login: "no"
password_authentication: "no"
pubkey_authentication: "yes"
challenge_response_authentication: "no"
# Hardening is applied via drop-in: /etc/ssh/sshd_config.d/10-hardening.conf
# The cloud image default sshd_config is left in place; the drop-in overrides it.
# ---------------------------------------------------------------------------
# Services
# ---------------------------------------------------------------------------
services:
- name: ufw
enabled: true
running: true
- name: fail2ban
enabled: true
running: true
- name: ssh.socket
enabled: true
running: true
# Ubuntu 24.04 uses socket activation: ssh.service is disabled by design,
# triggered on demand by ssh.socket.
# ---------------------------------------------------------------------------
# Packages
# ---------------------------------------------------------------------------
packages:
installed:
- ufw
- fail2ban
- git
defaults:
packages:
- apt-transport-https
- ca-certificates
- curl
- git
- vim
- htop
binaries:
# Installed to /usr/local/bin/ by the sops_agent role, not via apt
- age
- sops
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
users:
- name: tegwick
- ufw
- fail2ban
- python3
- python3-venv
services:
- fail2ban
- ssh.socket
ssh_directives:
PasswordAuthentication: "no"
PermitRootLogin: "no"
PubkeyAuthentication: "yes"
ChallengeResponseAuthentication: "no"
user:
name: tegwick
shell: /bin/bash
sudo: passwordless # NOPASSWD:ALL via /etc/sudoers.d/tegwick — NOT via sudo group
ssh_key_auth: true
sudo: NOPASSWD
security:
histcontrol: ignorespace
fail2ban_jails:
- sshd
# ---------------------------------------------------------------------------
# Security baseline
# ---------------------------------------------------------------------------
security:
histcontrol: ignorespace # set in /etc/profile.d/
fail2ban_jails:
- sshd
profiles:
ufw-managed:
services:
- ufw
firewall:
mode: ufw
managed: true
verification:
command: ufw status
stdout:
- "Status: active"
- "OpenSSH.*ALLOW"
external-firewall:
services: []
firewall:
mode: external
managed: false
replacement_control:
description: >-
CoulombCore retains its iptables INPUT default-drop policy and
Plesk-era accept list until that surface is fully declared for UFW.
owner: railiance-infra
removal_condition: >-
Replace this exception after every required listener is declared and
an attended UFW migration plan proves no availability regression.
verification:
command: iptables -S INPUT
stdout:
- "^-P INPUT DROP$"

View file

@ -0,0 +1,53 @@
mock_provider "hcloud" {}
mock_provider "template" {}
run "adopted_host_is_not_managed" {
command = plan
variables {
hcloud_token = "fixture-not-a-credential"
inventory_yaml = <<-YAML
schema_version: "1.0"
servers:
- name: adopted-host
provider: hosteurope
lifecycle_mode: adopted
ip: 192.0.2.10
ssh_user: admin
baseline_profile: ufw-managed
YAML
}
assert {
condition = length(output.managed_server_names) == 0
error_message = "adopted Host Europe records must not select Hetzner resources"
}
}
run "managed_hetzner_host_is_selected" {
command = plan
variables {
hcloud_token = "fixture-not-a-credential"
inventory_yaml = <<-YAML
schema_version: "1.0"
servers:
- name: fixture-hetzner-01
provider: hetzner
lifecycle_mode: provider-managed
ssh_user: admin
baseline_profile: ufw-managed
provisioning:
server_type: cpx21
region: nbg1
image: ubuntu-24.04
role: fixture
labels: [test]
YAML
}
assert {
condition = length(output.managed_server_names) == 1 && output.managed_server_names[0] == "fixture-hetzner-01"
error_message = "provider-managed Hetzner records must be selected"
}
}

View file

@ -17,27 +17,32 @@ provider "hcloud" {
}
locals {
servers = yamldecode(file("${path.module}/../../inventory/servers.yaml")).servers
inventory = yamldecode(var.inventory_yaml != null ? var.inventory_yaml : file("${path.module}/../../inventory/servers.yaml"))
servers = {
for server in local.inventory.servers : server.name => server
if server.provider == "hetzner" && server.lifecycle_mode == "provider-managed"
}
cloud_init = file("${path.module}/cloud_init.yaml")
}
resource "hcloud_ssh_key" "admin" {
count = length(local.servers) > 0 ? 1 : 0
name = "railliance-admin"
public_key = file("${path.module}/../../keys/admin_ssh.pub")
}
resource "hcloud_server" "srv" {
for_each = { for s in local.servers : s.name => s }
for_each = local.servers
name = each.value.name
image = coalesce(each.value.image, "ubuntu-24.04")
server_type = each.value.type
location = each.value.region
ssh_keys = [hcloud_ssh_key.admin.id]
image = each.value.provisioning.image
server_type = each.value.provisioning.server_type
location = each.value.provisioning.region
ssh_keys = [hcloud_ssh_key.admin[0].id]
user_data = local.cloud_init
labels = {
role = each.value.role
labels = join(",", try(each.value.labels, []))
role = each.value.provisioning.role
labels = join(",", try(each.value.provisioning.labels, []))
env = "default"
}
}
@ -45,3 +50,7 @@ resource "hcloud_server" "srv" {
output "servers" {
value = { for k, v in hcloud_server.srv : k => v.ipv4_address }
}
output "managed_server_names" {
value = sort(keys(local.servers))
}

View file

@ -3,3 +3,9 @@ variable "hcloud_token" {
type = string
sensitive = true
}
variable "inventory_yaml" {
description = "Optional inventory YAML for isolated tests; normal runs use inventory/servers.yaml"
type = string
default = null
}

View file

@ -0,0 +1,13 @@
schema_version: "1.0"
servers:
- name: unsafe-mixed-host
provider: hosteurope
lifecycle_mode: adopted
ip: 192.0.2.10
ssh_user: admin
baseline_profile: ufw-managed
provisioning:
server_type: cpx21
region: nbg1
image: ubuntu-24.04
role: test

View file

@ -0,0 +1,13 @@
schema_version: "1.0"
servers:
- name: fixture-hetzner-01
provider: hetzner
lifecycle_mode: provider-managed
ssh_user: admin
baseline_profile: ufw-managed
provisioning:
server_type: cpx21
region: nbg1
image: ubuntu-24.04
role: test
labels: [fixture]

View file

@ -0,0 +1,62 @@
from __future__ import annotations
import copy
import json
import subprocess
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from baseline_contract import ( # noqa: E402
BaselineError,
load_spec,
profile_hostvars,
validate_repo_consumers,
validate_spec,
)
class BaselineContractTests(unittest.TestCase):
def setUp(self) -> None:
self.payload = load_spec(ROOT / "spec" / "server-baseline.yaml")
def test_profiles_resolve_distinct_firewall_controls(self) -> None:
managed = profile_hostvars(self.payload, "ufw-managed")
external = profile_hostvars(self.payload, "external-firewall")
self.assertTrue(managed["ufw_manage"])
self.assertIn("ufw", managed["baseline_required_services"])
self.assertFalse(external["ufw_manage"])
self.assertNotIn("ufw", external["baseline_required_services"])
self.assertIn("replacement_control", external["baseline_firewall"])
def test_governed_package_mismatch_fails(self) -> None:
broken = copy.deepcopy(self.payload)
broken["defaults"]["packages"].remove("htop")
with self.assertRaisesRegex(BaselineError, "htop"):
validate_spec(broken)
def test_governed_ssh_weakening_fails(self) -> None:
broken = copy.deepcopy(self.payload)
broken["defaults"]["ssh_directives"]["PermitRootLogin"] = "yes"
with self.assertRaisesRegex(BaselineError, "SSH"):
validate_spec(broken)
def test_ansible_and_goss_consume_shared_variables(self) -> None:
validate_repo_consumers(ROOT)
def test_dynamic_inventory_resolves_each_host_profile(self) -> None:
raw = subprocess.check_output(
[sys.executable, str(ROOT / "ansible" / "inventory_from_yaml.py")],
text=True,
cwd=ROOT,
)
hostvars = json.loads(raw)["_meta"]["hostvars"]
self.assertEqual("external-firewall", hostvars["CoulombCore"]["baseline_profile"])
self.assertEqual("ufw-managed", hostvars["Railiance01"]["baseline_profile"])
if __name__ == "__main__":
unittest.main()

64
tests/test_handoff.py Normal file
View file

@ -0,0 +1,64 @@
from __future__ import annotations
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from s1_handoff import build_receipt # noqa: E402
from s1_receipt import validate_receipt # noqa: E402
HOSTS = [
{"name": "Railiance01", "baseline_profile": "ufw-managed"},
{"name": "CoulombCore", "baseline_profile": "external-firewall"},
]
class HandoffTests(unittest.TestCase):
def test_all_hosts_and_evidence_produce_pass(self) -> None:
receipt = build_receipt(
revision="3734a1c",
inventory_digest="a" * 64,
hosts=HOSTS,
results={"Railiance01": 0, "CoulombCore": 0},
observed_at=datetime(2026, 8, 23, tzinfo=timezone.utc),
freshness_hours=24,
evidence=[
{"kind": "goss-tap", "sha256": "b" * 64},
{"kind": "goss-tap", "sha256": "c" * 64},
],
)
self.assertEqual("pass", receipt["status"])
validate_receipt(receipt)
def test_one_failed_host_fails_aggregate(self) -> None:
receipt = build_receipt(
revision="3734a1c",
inventory_digest="a" * 64,
hosts=HOSTS,
results={"Railiance01": 0, "CoulombCore": 1},
observed_at=datetime(2026, 8, 23, tzinfo=timezone.utc),
freshness_hours=24,
evidence=[],
)
self.assertEqual("fail", receipt["status"])
def test_dry_run_cannot_claim_pass(self) -> None:
receipt = build_receipt(
revision="3734a1c",
inventory_digest="a" * 64,
hosts=HOSTS,
results=None,
observed_at=datetime(2026, 8, 23, tzinfo=timezone.utc),
freshness_hours=24,
evidence=[],
)
self.assertEqual("not-run", receipt["status"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,64 @@
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 inventory_contract import ( # noqa: E402
InventoryError,
load_inventory,
managed_hetzner_servers,
validate_inventory,
)
class InventoryContractTests(unittest.TestCase):
def test_current_adopted_inventory_has_no_managed_hetzner_hosts(self) -> None:
payload = load_inventory(ROOT / "inventory" / "servers.yaml")
self.assertEqual([], managed_hetzner_servers(payload))
self.assertEqual(
{"CoulombCore", "Railiance01"},
{server["name"] for server in payload["servers"]},
)
def test_valid_hetzner_fixture_is_selected(self) -> None:
payload = load_inventory(
ROOT / "tests" / "fixtures" / "inventory" / "valid-hetzner.yaml"
)
self.assertEqual(
["fixture-hetzner-01"],
[server["name"] for server in managed_hetzner_servers(payload)],
)
def test_adopted_host_cannot_carry_provisioning_fields(self) -> None:
with self.assertRaisesRegex(InventoryError, "must not carry"):
load_inventory(
ROOT / "tests" / "fixtures" / "inventory" / "invalid-mixed.yaml"
)
def test_managed_host_requires_complete_provisioning(self) -> None:
payload = yaml.safe_load(
(ROOT / "tests" / "fixtures" / "inventory" / "valid-hetzner.yaml").read_text()
)
broken = copy.deepcopy(payload)
del broken["servers"][0]["provisioning"]["server_type"]
with self.assertRaisesRegex(InventoryError, "server_type"):
validate_inventory(broken)
def test_managed_host_cannot_have_static_ip(self) -> None:
payload = yaml.safe_load(
(ROOT / "tests" / "fixtures" / "inventory" / "valid-hetzner.yaml").read_text()
)
payload["servers"][0]["ip"] = "192.0.2.20"
with self.assertRaisesRegex(InventoryError, "addresses come from provider"):
validate_inventory(payload)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,79 @@
from __future__ import annotations
import copy
import json
import sys
import unittest
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from check_secret_paths import is_encrypted_content, is_protected_path # noqa: E402
from s1_receipt import ReceiptError, load_receipt, validate_receipt # noqa: E402
from sops_rotation import rotation_plan # noqa: E402
class SecretAndReceiptContractTests(unittest.TestCase):
def test_inventory_secret_paths_are_protected(self) -> None:
self.assertTrue(is_protected_path("secrets/provider.yaml"))
self.assertTrue(is_protected_path("inventory/group_vars/secrets.sops.yaml"))
self.assertFalse(is_protected_path("inventory/group_vars/all.yaml"))
def test_plaintext_and_empty_files_fail(self) -> None:
self.assertFalse(is_encrypted_content("secrets/example.yaml", "value: clear"))
self.assertFalse(is_encrypted_content("secrets/example.age", ""))
self.assertTrue(is_encrypted_content("secrets/example.yaml", "sops:\n age: []\n"))
def test_synthetic_chain_validates(self) -> None:
load_receipt(ROOT / "docs" / "evidence" / "s1-receipts" / "synthetic-provisioning-chain.json")
def test_incomplete_passing_chain_fails(self) -> None:
payload = json.loads(
(ROOT / "docs" / "evidence" / "s1-receipts" / "synthetic-provisioning-chain.json").read_text()
)
payload["phases"][2]["status"] = "not-run"
with self.assertRaisesRegex(ReceiptError, "every phase"):
validate_receipt(payload)
def test_secret_shaped_receipt_content_fails(self) -> None:
payload = json.loads(
(ROOT / "docs" / "evidence" / "s1-receipts" / "synthetic-provisioning-chain.json").read_text()
)
payload["token"] = "not-even-a-real-value"
with self.assertRaisesRegex(ReceiptError, "secret-shaped key"):
validate_receipt(payload)
payload.pop("token")
payload["provider_access_token"] = "redacted-is-still-not-allowed"
with self.assertRaisesRegex(ReceiptError, "secret-shaped key"):
validate_receipt(payload)
def test_passing_verification_without_evidence_fails(self) -> None:
payload = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "verification",
"synthetic": False,
"created_at": "2026-08-23T09:30:00Z",
"source_revision": "3734a1c",
"inventory_sha256": "a" * 64,
"status": "pass",
"hosts": ["Railiance01"],
"profiles": {"Railiance01": "ufw-managed"},
"observed_at": "2026-08-23T09:30:00Z",
"fresh_until": "2026-08-24T09:30:00Z",
"evidence": [],
}
with self.assertRaisesRegex(ReceiptError, "host evidence"):
validate_receipt(payload)
def test_current_sops_recipient_metadata_matches_policy(self) -> None:
plan = rotation_plan(ROOT)
self.assertTrue(plan)
self.assertFalse(any(item["changed"] for item in plan))
if __name__ == "__main__":
unittest.main()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Make the S1 declaration reproducible and the handoff verifiably green"
domain: financials
repo: railiance-infra
status: ready
status: active
owner: codex
topic_slug: railiance
created: "2026-08-23"
@ -39,7 +39,7 @@ independently. T07 depends on T02 and T05. T08 depends on T06.
```task
id: RAIL-HO-WP-0011-T01
status: todo
status: done
priority: high
```
@ -60,11 +60,17 @@ resources, a complete Hetzner fixture validates, incomplete or contradictory
records fail with actionable errors, and no provider command is needed to run
the validation.
**Done 2026-08-23.** `scripts/inventory_contract.py` validates schema version,
provider, lifecycle mode, connection identity, baseline profile, and nested
Hetzner provisioning fields. Both current hosts are explicit adopted Host
Europe records; valid Hetzner and invalid mixed fixtures have negative/positive
unit coverage.
## T02 — Make Hetzner planning select only managed Hetzner resources
```task
id: RAIL-HO-WP-0011-T02
status: todo
status: done
priority: high
```
@ -81,11 +87,19 @@ boundary without missing-field errors or managed resources, a provisionable
Hetzner fixture produces the expected resource shape, and a reviewed plan shows
no unintended create, replace, or destroy. Do not apply the plan in this task.
**Done 2026-08-23.** Terraform filters on `provider: hetzner` plus
`lifecycle_mode: provider-managed`; the shared SSH-key resource is also absent
when selection is empty. No local Terraform state exists in the module. In an
isolated Terraform 1.9.8 container with mocked providers, the adopted-only plan
selected zero resources and the managed fixture selected exactly its named
host (2/2 native Terraform tests passed). Apply/destroy Make targets now refuse
before init without exact approval variables; no provider mutation occurred.
## T03 — Model host-specific baseline profiles and reconcile declared state
```task
id: RAIL-HO-WP-0011-T03
status: todo
status: done
priority: high
```
@ -103,11 +117,18 @@ UFW-managed and externally-filtered postures have explicit assertions, and the
spec no longer claims properties that convergence neither establishes nor
deliberately delegates.
**Done 2026-08-23.** `spec/server-baseline.yaml` v2 defines shared defaults and
the `ufw-managed` / `external-firewall` profiles. Dynamic inventory resolves
the model into both consumers. The external profile carries its replacement
control, owner, removal condition, and an iptables INPUT default-drop check.
Both rendered Goss files parse as YAML and contain their distinct firewall
assertions. No live firewall change occurred.
## T04 — Add automated baseline contract-parity tests
```task
id: RAIL-HO-WP-0011-T04
status: todo
status: progress
priority: high
```
@ -124,11 +145,17 @@ requiring host access or secrets.
firewall rule, service, or user property fails locally and in CI, while all
declared profiles pass from a clean checkout.
**Implemented locally 2026-08-23.** The baseline validator enforces governed
minimum packages, services, SSH directives, user/sudo posture, both required
profiles, and consumer markers. Unit tests prove deliberate `htop` removal and
SSH weakening fail. Forgejo workflow coverage is committed but still needs its
post-push green run before this task is done.
## T05 — Provide a fresh green S1 handoff gate
```task
id: RAIL-HO-WP-0011-T05
status: todo
status: wait
priority: high
```
@ -146,11 +173,19 @@ profiles in an attended verification run, a failing or stale host makes the
aggregate gate fail, and the evidence is sufficient for an S2 handoff without
interpreting an expected-red exception.
**Implemented; live gate pending 2026-08-23.** `scripts/s1_handoff.py` and
`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.
## T06 — Repair and enforce the secret-source contract
```task
id: RAIL-HO-WP-0011-T06
status: todo
status: done
priority: high
```
@ -168,11 +203,17 @@ but does not distribute an age private key.
the committed tree contains no file falsely presented as encrypted input, and
the documented controller and host secret flows match the executable paths.
**Done 2026-08-23.** The plaintext placeholder was removed and bootstrap no
longer loads it. `scripts/check_secret_paths.py` protects both `secrets/` and
inventory `secrets*` paths in the pre-commit hook, Make target, and CI. Docs,
helpers, and Make consistently use `secrets/hetzner-token.yaml` field
`hetzner.token`; tests reject plaintext and empty encrypted-file fixtures.
## T07 — Emit non-secret provisioning and handoff receipts
```task
id: RAIL-HO-WP-0011-T07
status: todo
status: done
priority: medium
```
@ -189,11 +230,17 @@ and redaction behavior.
tokens cannot be serialized, incomplete phases fail closed, and a synthetic
end-to-end example is consumable without prose interpretation.
**Done 2026-08-23.** `schemas/s1-receipt.schema.json` documents the v1 shape
and `scripts/s1_receipt.py` enforces phase-specific completeness plus recursive
secret-shaped key/value rejection. The committed synthetic chain separates all
five phases and validates; tests reject an incomplete passing chain, a passing
verification without host evidence, and a token-shaped field.
## T08 — Automate bounded SOPS recipient rotation
```task
id: RAIL-HO-WP-0011-T08
status: todo
status: wait
priority: medium
```
@ -211,15 +258,22 @@ recovery-key custody are documented.
run identifies the exact files and before/after recipient set, rollback is
documented, and a sample receipt proves verification without exposing values.
**Implemented; attended verification pending 2026-08-23.**
`scripts/sops_rotation.py` reports zero metadata drift for the current file,
supports null-output decryption verification, exact approval-file binding, and
suppressed-output `sops updatekeys`; CI runs metadata-only `--check`. The local
workstation has no `sops` executable or approved age-key session, so no passing
decryption receipt or recipient change was attempted.
## Acceptance
- [ ] Adopted Host Europe records and provider-managed Hetzner records cannot
- [x] Adopted Host Europe records and provider-managed Hetzner records cannot
be confused by validation or Terraform selection.
- [ ] The declared baseline, Ansible convergence, and Goss verification have an
- [x] The declared baseline, Ansible convergence, and Goss verification have an
automated parity contract.
- [ ] Every current host can produce a fresh green result against an explicit
profile, and the aggregate handoff fails closed.
- [ ] Every documented secret-bearing path is encrypted or deliberately absent
- [x] Every documented secret-bearing path is encrypted or deliberately absent
and protected by local and CI checks.
- [ ] Provisioning/handoff and rotation paths emit metadata-only receipts with
reviewed redaction behavior.
@ -228,7 +282,14 @@ documented, and a sample receipt proves verification without exposing values.
## Completion Evidence
Record the final inventory schema and examples, Terraform plan safety result,
baseline parity tests, CI run, attended all-host gate receipt, secret-path
negative tests, provisioning receipt fixture, and SOPS rotation dry-run receipt
here before marking the workplan finished.
Current evidence (2026-08-23):
- Python unit suite: 25 tests pass.
- Terraform 1.9.8 mock-provider tests: 2 pass.
- Ansible 2.17.13 syntax checks: bootstrap, verify, and firewall pass in a
disposable environment.
- Both profile-specific Goss templates render and parse as YAML.
- Inventory, baseline, secret metadata, receipt, shell syntax, Python compile,
and whitespace checks pass.
- Pending before finish: green Forgejo CI, an attended fresh all-host handoff
receipt, and an attended non-printing SOPS decryption receipt.