diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml index bd44c56..26b19e3 100644 --- a/.forgejo/workflows/ci-smoke.yaml +++ b/.forgejo/workflows/ci-smoke.yaml @@ -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: @@ -26,4 +42,4 @@ jobs: - name: Routing probe (container label) run: | set -eu - echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}" \ No newline at end of file + echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}" diff --git a/.githooks/pre-commit b/.githooks/pre-commit index eacc6bd..b3510be 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 ' to edit or 'sops --encrypt --in-place ' 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/.yaml" - echo " • Encrypt in place: sops --encrypt --in-place secrets/.yaml" - exit 1 -fi - -exit 0 +exec python3 scripts/check_secret_paths.py --staged diff --git a/.sops.yaml b/.sops.yaml index 1df20ba..49cf108 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -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 - diff --git a/Makefile b/Makefile index c3e1770..833f2c1 100644 --- a/Makefile +++ b/Makefile @@ -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 "; 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"; \ ' diff --git a/README.md b/README.md index 991e18f..4d1acee 100644 --- a/README.md +++ b/README.md @@ -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 --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. diff --git a/SCOPE.md b/SCOPE.md index f6a7be5..292c19b 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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] ``` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 3a049b2..16a4dc0 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/ansible/inventory/host_vars/CoulombCore.yml b/ansible/inventory/host_vars/CoulombCore.yml index 2ed4c5e..c4186f3 100644 --- a/ansible/inventory/host_vars/CoulombCore.yml +++ b/ansible/inventory/host_vars/CoulombCore.yml @@ -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 diff --git a/ansible/inventory_from_yaml.py b/ansible/inventory_from_yaml.py index f43101c..8ff00ff 100755 --- a/ansible/inventory_from_yaml.py +++ b/ansible/inventory_from_yaml.py @@ -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)) diff --git a/ansible/playbooks/bootstrap.yaml b/ansible/playbooks/bootstrap.yaml index 8104bea..84d7611 100644 --- a/ansible/playbooks/bootstrap.yaml +++ b/ansible/playbooks/bootstrap.yaml @@ -2,7 +2,6 @@ become: true vars_files: - ../inventory/group_vars/all.yaml - - ../inventory/group_vars/secrets.sops.yaml roles: - role: base tags: [base] diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml index da7f01e..89b3a00 100644 --- a/ansible/roles/base/tasks/main.yml +++ b/ansible/roles/base/tasks/main.yml @@ -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] diff --git a/docs/conformance-loop.md b/docs/conformance-loop.md index 50f728f..260df49 100644 --- a/docs/conformance-loop.md +++ b/docs/conformance-loop.md @@ -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. diff --git a/docs/convergence.md b/docs/convergence.md index 41c11fc..ffa7f4a 100644 --- a/docs/convergence.md +++ b/docs/convergence.md @@ -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. diff --git a/docs/evidence/s1-receipts/synthetic-provisioning-chain.json b/docs/evidence/s1-receipts/synthetic-provisioning-chain.json new file mode 100644 index 0000000..f385fdc --- /dev/null +++ b/docs/evidence/s1-receipts/synthetic-provisioning-chain.json @@ -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 +} diff --git a/docs/provisioning.md b/docs/provisioning.md index 892714d..5765864 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -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@ ## 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 ~30–60s after creation for first-boot tasks before first SSH. - **Labels & role:** `labels` are freeform tags; `role` can drive Ansible plays as you grow. diff --git a/docs/s1-handoff.md b/docs/s1-handoff.md new file mode 100644 index 0000000..a9d96ef --- /dev/null +++ b/docs/s1-handoff.md @@ -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. diff --git a/docs/sops-rotation-approval.example.yaml b/docs/sops-rotation-approval.example.yaml new file mode 100644 index 0000000..806236d --- /dev/null +++ b/docs/sops-rotation-approval.example.yaml @@ -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 diff --git a/docs/sops-rotation.md b/docs/sops-rotation.md new file mode 100644 index 0000000..3c58af2 --- /dev/null +++ b/docs/sops-rotation.md @@ -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. diff --git a/docs/verification.md b/docs/verification.md index c9ec24d..f846206 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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`. diff --git a/goss/baseline.yaml.j2 b/goss/baseline.yaml.j2 index c9e4328..1563f84 100644 --- a/goss/baseline.yaml.j2 +++ b/goss/baseline.yaml.j2 @@ -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": diff --git a/inventory/group_vars/secrets.sops.yaml b/inventory/group_vars/secrets.sops.yaml deleted file mode 100644 index 690d512..0000000 --- a/inventory/group_vars/secrets.sops.yaml +++ /dev/null @@ -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 diff --git a/inventory/servers.yaml b/inventory/servers.yaml index 732e535..53f77fa 100644 --- a/inventory/servers.yaml +++ b/inventory/servers.yaml @@ -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 diff --git a/schemas/s1-receipt.schema.json b/schemas/s1-receipt.schema.json new file mode 100644 index 0000000..24ee9c1 --- /dev/null +++ b/schemas/s1-receipt.schema.json @@ -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 +} diff --git a/scripts/baseline_contract.py b/scripts/baseline_contract.py new file mode 100644 index 0000000..ec8311d --- /dev/null +++ b/scripts/baseline_contract.py @@ -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()) diff --git a/scripts/check_secret_paths.py b/scripts/check_secret_paths.py new file mode 100644 index 0000000..559c459 --- /dev/null +++ b/scripts/check_secret_paths.py @@ -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()) diff --git a/scripts/hcloud_new_server.sh b/scripts/hcloud_new_server.sh index 29cf793..8d27564 100755 --- a/scripts/hcloud_new_server.sh +++ b/scripts/hcloud_new_server.sh @@ -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 [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin] +# scripts/hcloud_new_server.sh [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 [--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 diff --git a/scripts/inventory_contract.py b/scripts/inventory_contract.py new file mode 100644 index 0000000..d1014a3 --- /dev/null +++ b/scripts/inventory_contract.py @@ -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()) diff --git a/scripts/new-server.sh b/scripts/new-server.sh index c17bb49..04bac60 100644 --- a/scripts/new-server.sh +++ b/scripts/new-server.sh @@ -5,7 +5,8 @@ if [[ -z "$NAME" ]]; then echo "Usage: scripts/new-server.sh " 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" diff --git a/scripts/new_host.py b/scripts/new_host.py index f0a7bfd..ba52ce7 100644 --- a/scripts/new_host.py +++ b/scripts/new_host.py @@ -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) diff --git a/scripts/s1_handoff.py b/scripts/s1_handoff.py new file mode 100644 index 0000000..69a75b7 --- /dev/null +++ b/scripts/s1_handoff.py @@ -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()) diff --git a/scripts/s1_receipt.py b/scripts/s1_receipt.py new file mode 100644 index 0000000..f790076 --- /dev/null +++ b/scripts/s1_receipt.py @@ -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()) diff --git a/scripts/sops.sh b/scripts/sops.sh index 23bbc5d..7016cd5 100644 --- a/scripts/sops.sh +++ b/scripts/sops.sh @@ -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]" ;; diff --git a/scripts/sops_rotation.py b/scripts/sops_rotation.py new file mode 100644 index 0000000..f8ad895 --- /dev/null +++ b/scripts/sops_rotation.py @@ -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()) diff --git a/spec/server-baseline.yaml b/spec/server-baseline.yaml index 9a45d03..f4c1d0c 100644 --- a/spec/server-baseline.yaml +++ b/spec/server-baseline.yaml @@ -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$" diff --git a/terraform/hetzner/inventory_selection.tftest.hcl b/terraform/hetzner/inventory_selection.tftest.hcl new file mode 100644 index 0000000..4756c2f --- /dev/null +++ b/terraform/hetzner/inventory_selection.tftest.hcl @@ -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" + } +} diff --git a/terraform/hetzner/main.tf b/terraform/hetzner/main.tf index aaaaaca..cc89422 100644 --- a/terraform/hetzner/main.tf +++ b/terraform/hetzner/main.tf @@ -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)) +} diff --git a/terraform/hetzner/variables.tf b/terraform/hetzner/variables.tf index f248140..b0b476d 100644 --- a/terraform/hetzner/variables.tf +++ b/terraform/hetzner/variables.tf @@ -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 +} diff --git a/tests/fixtures/inventory/invalid-mixed.yaml b/tests/fixtures/inventory/invalid-mixed.yaml new file mode 100644 index 0000000..1511ad0 --- /dev/null +++ b/tests/fixtures/inventory/invalid-mixed.yaml @@ -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 diff --git a/tests/fixtures/inventory/valid-hetzner.yaml b/tests/fixtures/inventory/valid-hetzner.yaml new file mode 100644 index 0000000..3d00eda --- /dev/null +++ b/tests/fixtures/inventory/valid-hetzner.yaml @@ -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] diff --git a/tests/test_baseline_contract.py b/tests/test_baseline_contract.py new file mode 100644 index 0000000..93e8bdc --- /dev/null +++ b/tests/test_baseline_contract.py @@ -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() diff --git a/tests/test_handoff.py b/tests/test_handoff.py new file mode 100644 index 0000000..99b1d1b --- /dev/null +++ b/tests/test_handoff.py @@ -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() diff --git a/tests/test_inventory_contract.py b/tests/test_inventory_contract.py new file mode 100644 index 0000000..2090dfe --- /dev/null +++ b/tests/test_inventory_contract.py @@ -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() diff --git a/tests/test_secret_and_receipt_contracts.py b/tests/test_secret_and_receipt_contracts.py new file mode 100644 index 0000000..7b9b0ad --- /dev/null +++ b/tests/test_secret_and_receipt_contracts.py @@ -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() diff --git a/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md b/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md index b07e131..bc68caf 100644 --- a/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md +++ b/workplans/RAIL-HO-WP-0011-reproducible-s1-declaration-and-handoff.md @@ -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.