Implement reproducible S1 handoff contracts
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
parent
c8cb1c8edf
commit
b93af8cc78
44 changed files with 2035 additions and 342 deletions
|
|
@ -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}"
|
||||
echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}"
|
||||
|
|
|
|||
|
|
@ -1,46 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
# Block commits that add/modify plaintext files under secrets/
|
||||
# Block commits that add/modify any declared plaintext secret-bearing path.
|
||||
set -euo pipefail
|
||||
|
||||
# Find added/modified paths under secrets/ in the index
|
||||
changed_files=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^secrets/' || true)
|
||||
|
||||
[ -z "$changed_files" ] && exit 0 # nothing to check
|
||||
|
||||
fail=0
|
||||
msg="❌ Commit blocked: Unencrypted file(s) detected under secrets/.
|
||||
Each file in secrets/ must be SOPS-encrypted (contain a top-level 'sops:' block).
|
||||
Use 'sops <file>' to edit or 'sops --encrypt --in-place <file>' to encrypt."
|
||||
|
||||
while IFS= read -r f; do
|
||||
if ! git cat-file -e ":$f" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
if [ -d "$f" ]; then
|
||||
continue
|
||||
fi
|
||||
content="$(git show ":$f" || true)"
|
||||
if [ -z "$content" ]; then
|
||||
echo " - $f (empty)"; fail=1; continue
|
||||
fi
|
||||
if echo "$content" | grep -qE '^[[:space:]]*sops:[[:space:]]*$|"sops"[[:space:]]*:'; then
|
||||
continue
|
||||
fi
|
||||
case "$f" in
|
||||
*.age|*.gpg) continue ;;
|
||||
esac
|
||||
echo " - $f"
|
||||
fail=1
|
||||
done <<< "$changed_files"
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "$msg"
|
||||
echo ""
|
||||
echo "Tips:"
|
||||
echo " • Edit with SOPS: sops secrets/<file>.yaml"
|
||||
echo " • Encrypt in place: sops --encrypt --in-place secrets/<file>.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
exec python3 scripts/check_secret_paths.py --staged
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
48
Makefile
48
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 <file>"; exit 1)
|
||||
@echo "✔ All files in secrets/ appear SOPS-encrypted"
|
||||
check-secrets: ## Fail if any declared secret-bearing path is not encrypted
|
||||
python3 scripts/check_secret_paths.py --tracked
|
||||
|
||||
# ---- Terraform (Hetzner) ----
|
||||
validate-inventory: ## Validate adopted/provider-managed host declarations without provider access
|
||||
python3 scripts/inventory_contract.py inventory/servers.yaml
|
||||
|
||||
validate-baseline: ## Validate the executable baseline and its Ansible/Goss consumers
|
||||
python3 scripts/baseline_contract.py --check-repo
|
||||
|
||||
validate-receipts: ## Validate committed metadata-only S1 receipt examples
|
||||
python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json
|
||||
|
||||
s1-handoff: ## Run the live fail-closed S1 verification gate and emit a receipt
|
||||
python3 scripts/s1_handoff.py
|
||||
|
||||
s1-handoff-dry-run: ## Validate handoff inputs without host access; receipt is not-run
|
||||
python3 scripts/s1_handoff.py --dry-run
|
||||
|
||||
tf-fmt: ## Terraform fmt
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
|
||||
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); @terraform -chdir=terraform/hetzner fmt -recursive || true
|
||||
terraform -chdir=terraform/hetzner fmt -recursive
|
||||
|
||||
tf-init: ## Terraform init
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
|
||||
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner init
|
||||
terraform -chdir=terraform/hetzner init
|
||||
|
||||
tf-plan: tf-init ## Terraform plan (requires decrypted HCLOUD_TOKEN)
|
||||
@echo "🔍 Running terraform plan..."
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
|
||||
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner plan -var="hcloud_token=$(HCLOUD_TOKEN)"
|
||||
|
||||
tf-apply: tf-init ## Terraform apply (provision)
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
|
||||
tf-apply: ## Terraform apply (provision; exact approval required before init)
|
||||
@test "$(APPROVE_TF_APPLY)" = "YES" || (echo "Refusing apply: review make tf-plan, then set APPROVE_TF_APPLY=YES" && exit 1)
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
|
||||
@$(MAKE) tf-init
|
||||
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner apply -auto-approve -var="hcloud_token=$(HCLOUD_TOKEN)"
|
||||
|
||||
tf-destroy: tf-init ## Terraform destroy (tear down)
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; export SOPS_AGE_KEY or set keys.txt & fill secrets.sops.yaml" && exit 1)
|
||||
tf-destroy: ## Terraform destroy (exact approval required before init)
|
||||
@test "$(APPROVE_TF_DESTROY)" = "DESTROY-MANAGED-HETZNER" || (echo "Refusing destroy: set APPROVE_TF_DESTROY=DESTROY-MANAGED-HETZNER after exact plan review" && exit 1)
|
||||
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
|
||||
@$(MAKE) tf-init
|
||||
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner destroy -auto-approve -var="hcloud_token=$(HCLOUD_TOKEN)"
|
||||
|
||||
# --- Terraform provider/lockfile helpers ---
|
||||
|
|
@ -199,7 +213,7 @@ doctor: ## Check tools and basic repo setup
|
|||
command -v age >/dev/null && ok "age: $$(age --version)"; \
|
||||
command -v terraform >/dev/null && ok "terraform: $$(terraform -version | head -1)"; \
|
||||
test -f keys/admin_ssh.pub && ok "keys/admin_ssh.pub present" || echo "ℹ add your SSH pubkey to keys/admin_ssh.pub"; \
|
||||
test -f inventory/group_vars/secrets.sops.yaml && ok "secrets.sops.yaml present" || echo "ℹ create inventory/group_vars/secrets.sops.yaml"; \
|
||||
python3 scripts/check_secret_paths.py --tracked >/dev/null && ok "declared secret paths encrypted" || fail "secret path check failed"; \
|
||||
grep -q "age1" .sops.yaml && ok ".sops.yaml has an age recipient" || echo "ℹ add your age public key to .sops.yaml"; \
|
||||
git config --get core.hooksPath >/dev/null && ok "git hooksPath: $$(git config --get core.hooksPath)" || echo "ℹ run: make hooks"; \
|
||||
'
|
||||
|
|
|
|||
24
README.md
24
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 <name> --type ... --region ....
|
||||
|
||||
## 💻 5. Convergence
|
||||
|
||||
After provisioning a server with Terraform, `railiance-infra` uses
|
||||
For adopted or newly provisioned servers, `railiance-infra` uses
|
||||
[Ansible](https://docs.ansible.com/) to **converge** hosts into a secure,
|
||||
baseline state.
|
||||
This includes admin user setup, SSH hardening, firewall rules, essential tooling, and secret handling.
|
||||
|
|
|
|||
16
SCOPE.md
16
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]
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
become: true
|
||||
vars_files:
|
||||
- ../inventory/group_vars/all.yaml
|
||||
- ../inventory/group_vars/secrets.sops.yaml
|
||||
roles:
|
||||
- role: base
|
||||
tags: [base]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
41
docs/evidence/s1-receipts/synthetic-provisioning-chain.json
Normal file
41
docs/evidence/s1-receipts/synthetic-provisioning-chain.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"created_at": "2026-08-23T09:30:00Z",
|
||||
"event_type": "provisioning-chain",
|
||||
"inventory_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"phases": [
|
||||
{
|
||||
"evidence_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"phase": "plan",
|
||||
"status": "pass"
|
||||
},
|
||||
{
|
||||
"evidence_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"phase": "apply",
|
||||
"provider_resource_ids": ["synthetic-provider-resource"],
|
||||
"status": "pass"
|
||||
},
|
||||
{
|
||||
"evidence_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"phase": "cloud-init",
|
||||
"status": "pass"
|
||||
},
|
||||
{
|
||||
"evidence_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
"hosts": ["synthetic-host"],
|
||||
"phase": "convergence",
|
||||
"status": "pass"
|
||||
},
|
||||
{
|
||||
"evidence_sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
"fresh_until": "2026-08-24T09:30:00Z",
|
||||
"hosts": ["synthetic-host"],
|
||||
"phase": "verification",
|
||||
"status": "pass"
|
||||
}
|
||||
],
|
||||
"receipt_id": "6f974742-7a0f-4fc4-a931-7ff68dc8eb84",
|
||||
"schema_version": "1.0",
|
||||
"source_revision": "3734a1c",
|
||||
"status": "pass",
|
||||
"synthetic": true
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# 🚀 Provisioning Servers with railiance-infra
|
||||
|
||||
This guide explains **where you declare servers**, **how Terraform uses that declaration**, and **how to provision** (and later destroy) machines on Hetzner.
|
||||
This guide explains how adopted hosts and provider-managed Hetzner hosts share
|
||||
an inventory without sharing lifecycle behavior.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,13 +12,16 @@ This script will:
|
|||
|
||||
1. Add the new host entry to `inventory/servers.yaml`
|
||||
2. Decrypt your Hetzner API token with SOPS
|
||||
3. Run Terraform (`init/plan/apply`) to provision the server
|
||||
4. Print the IPv4 address and a ready-to-use SSH command
|
||||
3. Run Terraform init and plan
|
||||
4. Apply only when the operator supplies the explicit `--apply` flag after
|
||||
reviewing the plan
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
scripts/hcloud_new_server.sh core-01 --type cpx11 --region nbg1 --role core
|
||||
# after review and the required approval:
|
||||
scripts/hcloud_new_server.sh core-01 --type cpx11 --region nbg1 --role core --apply
|
||||
```
|
||||
|
||||
This will create a small cpx11 instance in the Nuremberg (nbg1) region, tagged with the role core.
|
||||
|
|
@ -33,17 +37,24 @@ ssh admin@<printed-ip>
|
|||
|
||||
## 1) Where you define servers
|
||||
|
||||
All desired hosts live in **`inventory/servers.yaml`**. Each entry is a simple YAML object with the required attributes:
|
||||
All host identities live in **`inventory/servers.yaml`**. Run
|
||||
`make validate-inventory` before Terraform or Ansible. Adopted hosts have a
|
||||
provider address but no `provisioning` block. Provider-managed Hetzner hosts
|
||||
have no committed IP and use this shape:
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- name: core-01
|
||||
labels: [core, wireguard, git]
|
||||
role: "core"
|
||||
region: "nbg1" # Hetzner location (e.g., nbg1, fsn1, hel1)
|
||||
type: "cpx21" # Hetzner server type/flavor
|
||||
image: "ubuntu-24.04" # OS image slug
|
||||
ssh_user: "admin" # bootstrap user (cloud-init creates this)
|
||||
provider: hetzner
|
||||
lifecycle_mode: provider-managed
|
||||
ssh_user: admin
|
||||
baseline_profile: ufw-managed
|
||||
provisioning:
|
||||
server_type: cpx21
|
||||
region: nbg1
|
||||
image: ubuntu-24.04
|
||||
role: core
|
||||
labels: [core, wireguard, git]
|
||||
```
|
||||
|
||||
> Tip: Keep **names stable**. Renaming a server in this file makes Terraform think the old one was destroyed and a new one should be created.
|
||||
|
|
@ -73,7 +84,8 @@ scripts/hcloud_new_server.sh web-01 --type cpx21 --region nbg1 --role web
|
|||
## 3) How Terraform uses your declaration
|
||||
|
||||
The module at `terraform/hetzner/`:
|
||||
- Reads `inventory/servers.yaml` (`for_each` over `servers`)
|
||||
- Selects only records with `provider: hetzner` and
|
||||
`lifecycle_mode: provider-managed`
|
||||
- Registers your SSH key from `keys/admin_ssh.pub`
|
||||
- Injects **cloud-init** that sets up the `admin` user and basic hardening
|
||||
- Creates/updates/destroys servers to match the YAML
|
||||
|
|
@ -84,17 +96,19 @@ Outputs include a map of server names → IPv4 addresses.
|
|||
|
||||
## 4) Provision (create/update)
|
||||
|
||||
Make sure your Hetzner API token is present and **SOPS-decryptable** in `inventory/group_vars/secrets.sops.yaml` under `ops.hcloud_token`.
|
||||
The Hetzner API token is **SOPS-decryptable** at
|
||||
`secrets/hetzner-token.yaml`, field `hetzner.token`. It is decrypted only into
|
||||
the invoking process environment.
|
||||
|
||||
Then run either:
|
||||
```bash
|
||||
# plan and apply in separate steps
|
||||
make tf-plan
|
||||
make tf-apply
|
||||
APPROVE_TF_APPLY=YES make tf-apply
|
||||
```
|
||||
or the end-to-end convenience:
|
||||
```bash
|
||||
make apply # terraform apply + ansible bootstrap
|
||||
APPROVE_TF_APPLY=YES make apply # terraform apply + Ansible bootstrap
|
||||
```
|
||||
|
||||
If you used the one-shot script:
|
||||
|
|
@ -124,14 +138,16 @@ make ansible-bootstrap
|
|||
|
||||
To remove all servers managed by this repo:
|
||||
```bash
|
||||
make tf-destroy
|
||||
APPROVE_TF_DESTROY=DESTROY-MANAGED-HETZNER make tf-destroy
|
||||
```
|
||||
|
||||
To remove just one server, delete its entry from `inventory/servers.yaml`, commit, then:
|
||||
To remove one **provider-managed Hetzner** server, delete its entry from
|
||||
`inventory/servers.yaml`, review the plan, obtain the required approval, then:
|
||||
```bash
|
||||
make tf-apply
|
||||
APPROVE_TF_APPLY=YES make tf-apply
|
||||
```
|
||||
Terraform will destroy the missing server and leave others intact.
|
||||
Terraform will destroy the missing managed Hetzner server and leave others
|
||||
intact. Adopted Host Europe records are never Terraform resources.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -139,6 +155,7 @@ Terraform will destroy the missing server and leave others intact.
|
|||
|
||||
- **Idempotent:** You can run `make apply` repeatedly; Terraform converges infra, Ansible converges config.
|
||||
- **SSH keys:** Ensure `keys/admin_ssh.pub` exists before provisioning.
|
||||
- **Secret token:** The Hetzner API token must be in `inventory/group_vars/secrets.sops.yaml` (encrypted with SOPS).
|
||||
- **Secret token:** The Hetzner API token is `secrets/hetzner-token.yaml`, field
|
||||
`hetzner.token`, and must remain SOPS-encrypted.
|
||||
- **Cloud-init delay:** Allow ~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.
|
||||
|
|
|
|||
39
docs/s1-handoff.md
Normal file
39
docs/s1-handoff.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# S1 Handoff Gate
|
||||
|
||||
`make s1-handoff` is the operator-facing gate from host substrate (S1) to the
|
||||
cluster layer (S2). It validates inventory and baseline contracts, requires a
|
||||
clean checkout, runs the applicable Goss profile for every selected host, and
|
||||
emits a metadata-only JSON receipt under `reports/`.
|
||||
|
||||
A passing receipt pins:
|
||||
|
||||
- source revision and inventory digest
|
||||
- every selected host and its baseline profile
|
||||
- observed time and 24-hour default freshness boundary
|
||||
- per-host exit status
|
||||
- SHA-256 digests of the resulting TAP evidence
|
||||
|
||||
Any failed host fails the aggregate. A passing receipt without host evidence is
|
||||
invalid. `make s1-handoff-dry-run` validates local inputs but records
|
||||
`status: not-run`; it cannot authorize S2 handoff.
|
||||
|
||||
The current profiles are:
|
||||
|
||||
- `Railiance01`: `ufw-managed`
|
||||
- `CoulombCore`: `external-firewall`, which verifies the declared iptables
|
||||
INPUT default-drop replacement control
|
||||
|
||||
The verification play installs or refreshes the Goss check surface before it
|
||||
runs. Treat the first run after source changes as an attended host change:
|
||||
review the rendered contract and access path first. The command does not run
|
||||
Terraform, change provider resources, rotate credentials, or converge the base
|
||||
role.
|
||||
|
||||
Receipts validate with:
|
||||
|
||||
```bash
|
||||
python3 scripts/s1_receipt.py reports/s1-handoff-*.json
|
||||
```
|
||||
|
||||
S2 should accept only `status: pass` receipts whose source and inventory match
|
||||
the intended handoff and whose `fresh_until` has not elapsed.
|
||||
10
docs/sops-rotation-approval.example.yaml
Normal file
10
docs/sops-rotation-approval.example.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Metadata-only example. Copy outside Git for an attended approved rotation.
|
||||
approved: false
|
||||
approved_by: "operator-name"
|
||||
approved_at: "2026-08-23T00:00:00Z"
|
||||
changes:
|
||||
- path: secrets/hetzner-token.yaml
|
||||
before_recipients:
|
||||
- age1old-example-not-valid
|
||||
after_recipients:
|
||||
- age1new-example-not-valid
|
||||
31
docs/sops-rotation.md
Normal file
31
docs/sops-rotation.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Bounded SOPS Recipient Rotation
|
||||
|
||||
The default operation is metadata-only and does not decrypt values:
|
||||
|
||||
```bash
|
||||
python3 scripts/sops_rotation.py --check
|
||||
```
|
||||
|
||||
It compares each protected file's public age-recipient metadata with the first
|
||||
matching rule in `.sops.yaml`. CI runs this check to detect recipient drift.
|
||||
|
||||
An attended non-printing decryption check may emit a receipt:
|
||||
|
||||
```bash
|
||||
python3 scripts/sops_rotation.py --check --verify-decryption \
|
||||
--receipt reports/sops-rotation-check.json
|
||||
```
|
||||
|
||||
Decrypted bytes go directly to the null device. They are not retained in the
|
||||
receipt or command output.
|
||||
|
||||
Actual key updates require `--apply` and an approval YAML containing
|
||||
`approved: true`, `approved_by`, `approved_at`, and an exact `changes` list from
|
||||
the current plan. The command fails if that list differs from current metadata.
|
||||
Review and preserve recovery-key custody before approving recipient removal.
|
||||
Start from `docs/sops-rotation-approval.example.yaml`; the committed example is
|
||||
deliberately unapproved and contains no usable recipient.
|
||||
|
||||
Rollback is a reviewed restoration of the prior `.sops.yaml` recipient set
|
||||
followed by the same exact-plan approval, `sops updatekeys`, and non-printing
|
||||
decryption verification. Git history alone is not recovery-key custody.
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
34
schemas/s1-receipt.schema.json
Normal file
34
schemas/s1-receipt.schema.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://coulomb.social/schemas/railiance-infra/s1-receipt-v1.json",
|
||||
"title": "Railiance S1 metadata-only receipt",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"receipt_id",
|
||||
"event_type",
|
||||
"synthetic",
|
||||
"created_at",
|
||||
"source_revision",
|
||||
"inventory_sha256",
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": "1.0"},
|
||||
"receipt_id": {"type": "string", "format": "uuid"},
|
||||
"event_type": {
|
||||
"enum": ["plan", "apply", "convergence", "verification", "rotation", "provisioning-chain"]
|
||||
},
|
||||
"synthetic": {"type": "boolean"},
|
||||
"created_at": {"type": "string", "format": "date-time"},
|
||||
"source_revision": {"type": "string", "pattern": "^[0-9a-f]{7,40}$"},
|
||||
"inventory_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
||||
"status": {"enum": ["pass", "fail", "not-run"]},
|
||||
"hosts": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
|
||||
"profiles": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||
"evidence": {"type": "array"},
|
||||
"links": {"type": "array"},
|
||||
"phases": {"type": "array"}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
240
scripts/baseline_contract.py
Normal file
240
scripts/baseline_contract.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and resolve the executable S1 host-baseline contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
SCHEMA_VERSION = "2.0"
|
||||
PROFILE_MODES = {"ufw", "external"}
|
||||
REQUIRED_PACKAGES = {"curl", "git", "vim", "htop", "ufw", "fail2ban"}
|
||||
REQUIRED_SERVICES = {"fail2ban", "ssh.socket"}
|
||||
REQUIRED_SSH = {
|
||||
"PasswordAuthentication": "no",
|
||||
"PermitRootLogin": "no",
|
||||
"PubkeyAuthentication": "yes",
|
||||
"ChallengeResponseAuthentication": "no",
|
||||
}
|
||||
CONSUMER_MARKERS = {
|
||||
"ansible/roles/base/tasks/main.yml": [
|
||||
"baseline_required_packages",
|
||||
"baseline_ssh_directives",
|
||||
"baseline_user",
|
||||
"baseline_security",
|
||||
"baseline_firewall",
|
||||
],
|
||||
"goss/baseline.yaml.j2": [
|
||||
"baseline_required_packages",
|
||||
"baseline_required_services",
|
||||
"baseline_ssh_directives",
|
||||
"baseline_user",
|
||||
"baseline_security",
|
||||
"baseline_firewall",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class BaselineError(ValueError):
|
||||
"""The baseline model or its repository consumers are inconsistent."""
|
||||
|
||||
|
||||
def _strings(
|
||||
value: Any, label: str, errors: list[str], *, allow_empty: bool = False
|
||||
) -> list[str]:
|
||||
if not isinstance(value, list) or (not value and not allow_empty) or not all(
|
||||
isinstance(item, str) and item.strip() for item in value
|
||||
):
|
||||
errors.append(f"{label} must be a non-empty string list")
|
||||
return []
|
||||
if len(set(value)) != len(value):
|
||||
errors.append(f"{label} contains duplicates")
|
||||
return value
|
||||
|
||||
|
||||
def validate_spec(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise BaselineError("baseline must be a YAML object")
|
||||
errors: list[str] = []
|
||||
if str(payload.get("version")) != SCHEMA_VERSION:
|
||||
errors.append(f"version must be {SCHEMA_VERSION!r}")
|
||||
defaults = payload.get("defaults")
|
||||
profiles = payload.get("profiles")
|
||||
if not isinstance(defaults, dict):
|
||||
errors.append("defaults must be an object")
|
||||
defaults = {}
|
||||
if not isinstance(profiles, dict) or not profiles:
|
||||
errors.append("profiles must be a non-empty object")
|
||||
profiles = {}
|
||||
|
||||
packages = _strings(defaults.get("packages"), "defaults.packages", errors)
|
||||
services = _strings(defaults.get("services"), "defaults.services", errors)
|
||||
missing_packages = sorted(REQUIRED_PACKAGES - set(packages))
|
||||
missing_services = sorted(REQUIRED_SERVICES - set(services))
|
||||
if missing_packages:
|
||||
errors.append(f"defaults.packages omits governed packages {', '.join(missing_packages)}")
|
||||
if missing_services:
|
||||
errors.append(f"defaults.services omits governed services {', '.join(missing_services)}")
|
||||
ssh = defaults.get("ssh_directives")
|
||||
if not isinstance(ssh, dict) or not ssh or not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in (ssh or {}).items()
|
||||
):
|
||||
errors.append("defaults.ssh_directives must be a non-empty string map")
|
||||
elif {key: ssh.get(key) for key in REQUIRED_SSH} != REQUIRED_SSH:
|
||||
errors.append("defaults.ssh_directives weakens a governed SSH directive")
|
||||
user = defaults.get("user")
|
||||
if not isinstance(user, dict):
|
||||
errors.append("defaults.user must be an object")
|
||||
else:
|
||||
for key in ("name", "shell", "sudo"):
|
||||
if not isinstance(user.get(key), str) or not user[key]:
|
||||
errors.append(f"defaults.user.{key} must be a non-empty string")
|
||||
if user.get("sudo") != "NOPASSWD":
|
||||
errors.append("defaults.user.sudo must be NOPASSWD")
|
||||
security = defaults.get("security")
|
||||
if not isinstance(security, dict):
|
||||
errors.append("defaults.security must be an object")
|
||||
else:
|
||||
if not isinstance(security.get("histcontrol"), str):
|
||||
errors.append("defaults.security.histcontrol must be a string")
|
||||
_strings(
|
||||
security.get("fail2ban_jails"),
|
||||
"defaults.security.fail2ban_jails",
|
||||
errors,
|
||||
)
|
||||
|
||||
for name, profile in profiles.items():
|
||||
label = f"profiles.{name}"
|
||||
if not isinstance(profile, dict):
|
||||
errors.append(f"{label} must be an object")
|
||||
continue
|
||||
_strings(
|
||||
profile.get("services", []),
|
||||
f"{label}.services",
|
||||
errors,
|
||||
allow_empty=True,
|
||||
)
|
||||
firewall = profile.get("firewall")
|
||||
if not isinstance(firewall, dict):
|
||||
errors.append(f"{label}.firewall must be an object")
|
||||
continue
|
||||
mode = firewall.get("mode")
|
||||
if mode not in PROFILE_MODES:
|
||||
errors.append(f"{label}.firewall.mode must be one of {sorted(PROFILE_MODES)}")
|
||||
expected_managed = mode == "ufw"
|
||||
if firewall.get("managed") is not expected_managed:
|
||||
errors.append(f"{label}.firewall.managed must be {expected_managed}")
|
||||
verification = firewall.get("verification")
|
||||
if not isinstance(verification, dict):
|
||||
errors.append(f"{label}.firewall.verification must be an object")
|
||||
else:
|
||||
if not isinstance(verification.get("command"), str):
|
||||
errors.append(f"{label}.firewall.verification.command must be a string")
|
||||
_strings(
|
||||
verification.get("stdout"),
|
||||
f"{label}.firewall.verification.stdout",
|
||||
errors,
|
||||
)
|
||||
if mode == "external":
|
||||
replacement = firewall.get("replacement_control")
|
||||
if not isinstance(replacement, dict):
|
||||
errors.append(f"{label}.firewall.replacement_control must be an object")
|
||||
else:
|
||||
for key in ("description", "owner", "removal_condition"):
|
||||
if not isinstance(replacement.get(key), str) or not replacement[key]:
|
||||
errors.append(
|
||||
f"{label}.firewall.replacement_control.{key} must be a string"
|
||||
)
|
||||
|
||||
for required_profile, required_mode in {
|
||||
"ufw-managed": "ufw",
|
||||
"external-firewall": "external",
|
||||
}.items():
|
||||
if profiles.get(required_profile, {}).get("firewall", {}).get("mode") != required_mode:
|
||||
errors.append(
|
||||
f"profiles.{required_profile} must declare firewall mode {required_mode}"
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise BaselineError("baseline contract failed:\n- " + "\n- ".join(errors))
|
||||
return payload
|
||||
|
||||
|
||||
def load_spec(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise BaselineError(f"cannot read {path}: {exc}") from exc
|
||||
return validate_spec(payload)
|
||||
|
||||
|
||||
def profile_hostvars(payload: dict[str, Any], profile_name: str) -> dict[str, Any]:
|
||||
validate_spec(payload)
|
||||
try:
|
||||
profile = payload["profiles"][profile_name]
|
||||
except KeyError as exc:
|
||||
raise BaselineError(f"unknown baseline profile {profile_name!r}") from exc
|
||||
defaults = payload["defaults"]
|
||||
services = list(dict.fromkeys(defaults["services"] + profile["services"]))
|
||||
return {
|
||||
"baseline_profile": profile_name,
|
||||
"baseline_required_packages": defaults["packages"],
|
||||
"baseline_required_services": services,
|
||||
"baseline_ssh_directives": defaults["ssh_directives"],
|
||||
"baseline_user": defaults["user"],
|
||||
"baseline_security": defaults["security"],
|
||||
"baseline_firewall": profile["firewall"],
|
||||
"ufw_manage": profile["firewall"]["managed"],
|
||||
}
|
||||
|
||||
|
||||
def validate_repo_consumers(root: Path) -> None:
|
||||
errors: list[str] = []
|
||||
for relative, markers in CONSUMER_MARKERS.items():
|
||||
path = root / relative
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
errors.append(f"cannot read {relative}: {exc}")
|
||||
continue
|
||||
missing = [marker for marker in markers if marker not in text]
|
||||
if missing:
|
||||
errors.append(f"{relative} does not consume {', '.join(missing)}")
|
||||
if errors:
|
||||
raise BaselineError("baseline consumer parity failed:\n- " + "\n- ".join(errors))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"spec", nargs="?", type=Path, default=Path("spec/server-baseline.yaml")
|
||||
)
|
||||
parser.add_argument("--profile", help="resolve one profile as Ansible hostvars")
|
||||
parser.add_argument("--check-repo", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
payload = load_spec(args.spec)
|
||||
if args.check_repo:
|
||||
validate_repo_consumers(Path(__file__).resolve().parents[1])
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"version": payload["version"],
|
||||
"profiles": sorted(payload["profiles"]),
|
||||
}
|
||||
if args.profile:
|
||||
result["hostvars"] = profile_hostvars(payload, args.profile)
|
||||
except BaselineError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
87
scripts/check_secret_paths.py
Normal file
87
scripts/check_secret_paths.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail when a declared secret-bearing Git path is not SOPS/age encrypted."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def is_protected_path(path: str) -> bool:
|
||||
normalized = path.strip("/")
|
||||
name = Path(normalized).name.lower()
|
||||
return normalized.startswith("secrets/") or (
|
||||
normalized.startswith("inventory/")
|
||||
and name.startswith("secrets")
|
||||
and name.endswith((".yaml", ".yml", ".json"))
|
||||
)
|
||||
|
||||
|
||||
def is_encrypted_content(path: str, content: str) -> bool:
|
||||
if path.endswith((".age", ".gpg")):
|
||||
return bool(content)
|
||||
return any(
|
||||
line.strip() == "sops:" or line.lstrip().startswith('"sops"')
|
||||
for line in content.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True)
|
||||
|
||||
|
||||
def staged_files() -> list[str]:
|
||||
return [
|
||||
path
|
||||
for path in _git("diff", "--cached", "--name-only", "--diff-filter=ACMR").splitlines()
|
||||
if is_protected_path(path)
|
||||
]
|
||||
|
||||
|
||||
def tracked_files() -> list[str]:
|
||||
return [
|
||||
path
|
||||
for path in _git("ls-files").splitlines()
|
||||
if is_protected_path(path) and (ROOT / path).is_file()
|
||||
]
|
||||
|
||||
|
||||
def validate_paths(paths: list[str], *, staged: bool) -> list[str]:
|
||||
failures = []
|
||||
for relative in sorted(set(paths)):
|
||||
try:
|
||||
content = (
|
||||
_git("show", f":{relative}")
|
||||
if staged
|
||||
else (ROOT / relative).read_text(encoding="utf-8")
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
failures.append(f"{relative}: cannot read protected content")
|
||||
continue
|
||||
if not is_encrypted_content(relative, content):
|
||||
failures.append(f"{relative}: plaintext or missing SOPS metadata")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
mode = parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--staged", action="store_true")
|
||||
mode.add_argument("--tracked", action="store_true")
|
||||
args = parser.parse_args()
|
||||
paths = staged_files() if args.staged else tracked_files()
|
||||
failures = validate_paths(paths, staged=args.staged)
|
||||
if failures:
|
||||
print("Unencrypted secret-bearing paths:\n- " + "\n- ".join(failures), file=sys.stderr)
|
||||
return 1
|
||||
print(f"secret path check passed ({len(paths)} protected file(s))")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# hcloud_new_server.sh — Add a host to inventory and provision it on Hetzner
|
||||
# Usage:
|
||||
# scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin]
|
||||
# scripts/hcloud_new_server.sh <NAME> [options] [--apply]
|
||||
#
|
||||
# Prereqs:
|
||||
# - age + SOPS installed, with access to decrypt your Hetzner token
|
||||
|
|
@ -23,6 +23,7 @@ REGION="nbg1"
|
|||
ROLE="generic"
|
||||
IMAGE="ubuntu-24.04"
|
||||
USER="admin"
|
||||
APPLY=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -31,6 +32,7 @@ while [[ $# -gt 0 ]]; do
|
|||
--role) ROLE="$2"; shift 2;;
|
||||
--image) IMAGE="$2"; shift 2;;
|
||||
--user) USER="$2"; shift 2;;
|
||||
--apply) APPLY=true; shift;;
|
||||
*) fail "Unknown arg: $1 (usage: scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin])";;
|
||||
esac
|
||||
done
|
||||
|
|
@ -58,18 +60,27 @@ python3 scripts/new_host.py \
|
|||
--region "$REGION" \
|
||||
--role "$ROLE" \
|
||||
--image "$IMAGE" \
|
||||
--user "$USER"
|
||||
--user "$USER" \
|
||||
--reuse-existing
|
||||
|
||||
ok "Inventory updated: $NAME → inventory/servers.yaml"
|
||||
|
||||
# --- Decrypt Hetzner token and apply Terraform ---
|
||||
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.sops.yaml 2>/dev/null)"
|
||||
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt ops.hcloud_token from secrets/hetzner-token.sops.yaml. Ensure SOPS_AGE_KEY or keys.txt is set and token exists."
|
||||
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.yaml 2>/dev/null)"
|
||||
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt hetzner.token from secrets/hetzner-token.yaml. Ensure SOPS_AGE_KEY or keys.txt is set."
|
||||
|
||||
pushd terraform/hetzner >/dev/null
|
||||
|
||||
terraform init -upgrade
|
||||
export HCLOUD_TOKEN
|
||||
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
|
||||
terraform plan
|
||||
|
||||
if [[ "$APPLY" != true ]]; then
|
||||
info "Plan complete; no provider mutation performed. Re-run with --apply only after review and approval."
|
||||
popd >/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
terraform apply -auto-approve
|
||||
|
||||
# Try to show IP of the created host
|
||||
|
|
|
|||
174
scripts/inventory_contract.py
Normal file
174
scripts/inventory_contract.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate the S1 host inventory without contacting a provider or host."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
PROVIDERS = {"hosteurope", "hetzner"}
|
||||
LIFECYCLE_MODES = {"adopted", "provider-managed"}
|
||||
BASELINE_PROFILES = {"ufw-managed", "external-firewall"}
|
||||
HETZNER_REQUIRED = {"server_type", "region", "image", "role"}
|
||||
HETZNER_OPTIONAL = {"labels"}
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
|
||||
USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
|
||||
|
||||
|
||||
class InventoryError(ValueError):
|
||||
"""The inventory does not satisfy the S1 contract."""
|
||||
|
||||
|
||||
def _nonempty_string(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def validate_inventory(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise InventoryError("inventory must be a YAML object")
|
||||
if str(payload.get("schema_version")) != SCHEMA_VERSION:
|
||||
raise InventoryError(f"schema_version must be {SCHEMA_VERSION!r}")
|
||||
servers = payload.get("servers")
|
||||
if not isinstance(servers, list) or not servers:
|
||||
raise InventoryError("servers must be a non-empty list")
|
||||
|
||||
errors: list[str] = []
|
||||
names: set[str] = set()
|
||||
for index, server in enumerate(servers):
|
||||
label = f"servers[{index}]"
|
||||
if not isinstance(server, dict):
|
||||
errors.append(f"{label} must be an object")
|
||||
continue
|
||||
name = server.get("name")
|
||||
if not _nonempty_string(name) or not NAME_RE.fullmatch(name):
|
||||
errors.append(f"{label}.name must be a stable host identifier")
|
||||
name = label
|
||||
elif name in names:
|
||||
errors.append(f"{label}.name duplicates {name!r}")
|
||||
else:
|
||||
names.add(name)
|
||||
label = str(name)
|
||||
|
||||
provider = server.get("provider")
|
||||
lifecycle = server.get("lifecycle_mode")
|
||||
profile = server.get("baseline_profile")
|
||||
ssh_user = server.get("ssh_user")
|
||||
if provider not in PROVIDERS:
|
||||
errors.append(f"{label}: provider must be one of {sorted(PROVIDERS)}")
|
||||
if lifecycle not in LIFECYCLE_MODES:
|
||||
errors.append(
|
||||
f"{label}: lifecycle_mode must be one of {sorted(LIFECYCLE_MODES)}"
|
||||
)
|
||||
if profile not in BASELINE_PROFILES:
|
||||
errors.append(
|
||||
f"{label}: baseline_profile must be one of {sorted(BASELINE_PROFILES)}"
|
||||
)
|
||||
if not _nonempty_string(ssh_user) or not USER_RE.fullmatch(ssh_user):
|
||||
errors.append(f"{label}: ssh_user must be a valid Unix user name")
|
||||
|
||||
if lifecycle == "adopted":
|
||||
address = server.get("ip")
|
||||
try:
|
||||
ipaddress.ip_address(address)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{label}: adopted hosts require a literal ip address")
|
||||
if "provisioning" in server:
|
||||
errors.append(
|
||||
f"{label}: adopted hosts must not carry provider provisioning fields"
|
||||
)
|
||||
elif lifecycle == "provider-managed":
|
||||
if provider != "hetzner":
|
||||
errors.append(
|
||||
f"{label}: provider-managed is currently implemented only for hetzner"
|
||||
)
|
||||
if "ip" in server:
|
||||
errors.append(
|
||||
f"{label}: provider-managed addresses come from provider output; remove ip"
|
||||
)
|
||||
provisioning = server.get("provisioning")
|
||||
if not isinstance(provisioning, dict):
|
||||
errors.append(f"{label}: provider-managed hosts require provisioning")
|
||||
else:
|
||||
missing = sorted(
|
||||
key
|
||||
for key in HETZNER_REQUIRED
|
||||
if not _nonempty_string(provisioning.get(key))
|
||||
)
|
||||
unknown = sorted(
|
||||
set(provisioning) - HETZNER_REQUIRED - HETZNER_OPTIONAL
|
||||
)
|
||||
if missing:
|
||||
errors.append(
|
||||
f"{label}: provisioning missing {', '.join(missing)}"
|
||||
)
|
||||
if unknown:
|
||||
errors.append(
|
||||
f"{label}: provisioning has unknown fields {', '.join(unknown)}"
|
||||
)
|
||||
labels = provisioning.get("labels", [])
|
||||
if not isinstance(labels, list) or not all(
|
||||
_nonempty_string(item) for item in labels
|
||||
):
|
||||
errors.append(
|
||||
f"{label}: provisioning.labels must be a list of strings"
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise InventoryError("inventory contract failed:\n- " + "\n- ".join(errors))
|
||||
return payload
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise InventoryError(f"cannot read {path}: {exc}") from exc
|
||||
return validate_inventory(payload)
|
||||
|
||||
|
||||
def managed_hetzner_servers(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
validate_inventory(payload)
|
||||
return [
|
||||
server
|
||||
for server in payload["servers"]
|
||||
if server["provider"] == "hetzner"
|
||||
and server["lifecycle_mode"] == "provider-managed"
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"inventory", nargs="?", type=Path, default=Path("inventory/servers.yaml")
|
||||
)
|
||||
parser.add_argument(
|
||||
"--managed-hetzner", action="store_true", help="print selected names"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
payload = load_inventory(args.inventory)
|
||||
except InventoryError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
selected = managed_hetzner_servers(payload)
|
||||
result = {
|
||||
"ok": True,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"server_count": len(payload["servers"]),
|
||||
"managed_hetzner": [server["name"] for server in selected],
|
||||
}
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -5,7 +5,8 @@ if [[ -z "$NAME" ]]; then
|
|||
echo "Usage: scripts/new-server.sh <name>"
|
||||
exit 1
|
||||
fi
|
||||
yq -i '.servers += [{ "name": "'$NAME'", "labels": [], "role": "generic", "region": "nbg1", "type": "cpx21", "image": "ubuntu-24.04", "ssh_user": "admin"}]' inventory/servers.yaml
|
||||
python3 scripts/new_host.py --name "$NAME" --type cpx21 --region nbg1 \
|
||||
--role generic --image ubuntu-24.04 --user admin
|
||||
git add inventory/servers.yaml
|
||||
git commit -m "Add server ${NAME}"
|
||||
echo "Added ${NAME}. Run: make apply"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ def main():
|
|||
p.add_argument("--role", default="test")
|
||||
p.add_argument("--image", default="ubuntu-24.04")
|
||||
p.add_argument("--user", default="admin")
|
||||
p.add_argument(
|
||||
"--reuse-existing",
|
||||
action="store_true",
|
||||
help="succeed only when an existing record exactly matches the request",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
inv_path = os.path.join("inventory", "servers.yaml")
|
||||
|
|
@ -23,20 +28,33 @@ def main():
|
|||
data = yaml.safe_load(f) or {}
|
||||
servers = data.setdefault("servers", [])
|
||||
|
||||
# Prevent duplicates
|
||||
if any(s.get("name") == args.name for s in servers):
|
||||
print(f"ERROR: host '{args.name}' already exists in {inv_path}", file=sys.stderr)
|
||||
candidate = {
|
||||
"name": args.name,
|
||||
"provider": "hetzner",
|
||||
"lifecycle_mode": "provider-managed",
|
||||
"ssh_user": args.user,
|
||||
"baseline_profile": "ufw-managed",
|
||||
"provisioning": {
|
||||
"server_type": args.type,
|
||||
"region": args.region,
|
||||
"role": args.role,
|
||||
"image": args.image,
|
||||
"labels": [],
|
||||
},
|
||||
}
|
||||
existing = next((s for s in servers if s.get("name") == args.name), None)
|
||||
if existing is not None:
|
||||
if args.reuse_existing and existing == candidate:
|
||||
print(f"Reusing matching host '{args.name}' from {inv_path}")
|
||||
return
|
||||
print(
|
||||
f"ERROR: host '{args.name}' already exists with a different declaration",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
servers.append({
|
||||
"name": args.name,
|
||||
"labels": [],
|
||||
"role": args.role,
|
||||
"region": args.region,
|
||||
"type": args.type,
|
||||
"image": args.image,
|
||||
"ssh_user": args.user,
|
||||
})
|
||||
data.setdefault("schema_version", "1.0")
|
||||
servers.append(candidate)
|
||||
|
||||
with open(inv_path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False)
|
||||
|
|
|
|||
163
scripts/s1_handoff.py
Normal file
163
scripts/s1_handoff.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run the fail-closed S1 verification gate and emit a metadata-only receipt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers
|
||||
from inventory_contract import load_inventory
|
||||
from s1_receipt import validate_receipt
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _selected_hosts(inventory: dict[str, Any], requested: list[str]) -> list[dict[str, Any]]:
|
||||
hosts = inventory["servers"]
|
||||
if not requested:
|
||||
return hosts
|
||||
known = {host["name"]: host for host in hosts}
|
||||
missing = sorted(set(requested) - set(known))
|
||||
if missing:
|
||||
raise ValueError(f"unknown hosts: {', '.join(missing)}")
|
||||
return [known[name] for name in requested]
|
||||
|
||||
|
||||
def build_receipt(
|
||||
*,
|
||||
revision: str,
|
||||
inventory_digest: str,
|
||||
hosts: list[dict[str, Any]],
|
||||
results: dict[str, int] | None,
|
||||
observed_at: datetime,
|
||||
freshness_hours: int,
|
||||
evidence: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
ran = results is not None
|
||||
passed = ran and all(results[host["name"]] == 0 for host in hosts)
|
||||
receipt: dict[str, Any] = {
|
||||
"schema_version": "1.0",
|
||||
"receipt_id": str(uuid.uuid4()),
|
||||
"event_type": "verification",
|
||||
"synthetic": False,
|
||||
"created_at": _timestamp(datetime.now(timezone.utc)),
|
||||
"source_revision": revision,
|
||||
"inventory_sha256": inventory_digest,
|
||||
"status": "pass" if passed else ("fail" if ran else "not-run"),
|
||||
"hosts": [host["name"] for host in hosts],
|
||||
"profiles": {host["name"]: host["baseline_profile"] for host in hosts},
|
||||
"observed_at": _timestamp(observed_at),
|
||||
"fresh_until": _timestamp(observed_at + timedelta(hours=freshness_hours)),
|
||||
"evidence": evidence,
|
||||
}
|
||||
if ran:
|
||||
receipt["host_exit_status"] = results
|
||||
validate_receipt(receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def _new_reports(started: float, host_names: list[str]) -> list[dict[str, str]]:
|
||||
evidence = []
|
||||
for path in sorted((ROOT / "reports").glob("goss-*.tap")):
|
||||
if path.stat().st_mtime < started:
|
||||
continue
|
||||
if not any(f"goss-{name}-" in path.name for name in host_names):
|
||||
continue
|
||||
evidence.append(
|
||||
{
|
||||
"kind": "goss-tap",
|
||||
"path": str(path.relative_to(ROOT)),
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", action="append", default=[])
|
||||
parser.add_argument("--freshness-hours", type=int, default=24)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.freshness_hours <= 168:
|
||||
parser.error("--freshness-hours must be between 1 and 168")
|
||||
|
||||
inventory_path = ROOT / "inventory" / "servers.yaml"
|
||||
spec_path = ROOT / "spec" / "server-baseline.yaml"
|
||||
inventory = load_inventory(inventory_path)
|
||||
baseline = load_spec(spec_path)
|
||||
validate_repo_consumers(ROOT)
|
||||
hosts = _selected_hosts(inventory, args.host)
|
||||
for host in hosts:
|
||||
profile_hostvars(baseline, host["baseline_profile"])
|
||||
|
||||
revision = _git("rev-parse", "HEAD")
|
||||
dirty = _git("status", "--porcelain")
|
||||
if dirty and not args.dry_run:
|
||||
print("handoff gate requires a clean checkout so the receipt pins all inputs", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
observed = datetime.now(timezone.utc)
|
||||
results: dict[str, int] | None = None
|
||||
evidence: list[dict[str, str]] = []
|
||||
if not args.dry_run:
|
||||
if shutil.which("ansible-playbook") is None:
|
||||
print("handoff gate requires ansible-playbook", file=sys.stderr)
|
||||
return 2
|
||||
started = datetime.now(timezone.utc).timestamp()
|
||||
results = {}
|
||||
for host in hosts:
|
||||
completed = subprocess.run(
|
||||
["ansible-playbook", "playbooks/verify.yaml", "--limit", host["name"]],
|
||||
cwd=ROOT / "ansible",
|
||||
check=False,
|
||||
)
|
||||
results[host["name"]] = completed.returncode
|
||||
evidence = _new_reports(started, [host["name"] for host in hosts])
|
||||
|
||||
receipt = build_receipt(
|
||||
revision=revision,
|
||||
inventory_digest=_sha256(inventory_path),
|
||||
hosts=hosts,
|
||||
results=results,
|
||||
observed_at=observed,
|
||||
freshness_hours=args.freshness_hours,
|
||||
evidence=evidence,
|
||||
)
|
||||
output = args.output
|
||||
if output is None:
|
||||
stamp = observed.strftime("%Y%m%dT%H%M%SZ")
|
||||
output = ROOT / "reports" / f"s1-handoff-{stamp}.json"
|
||||
elif not output.is_absolute():
|
||||
output = ROOT / output
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"receipt": str(output), "status": receipt["status"]}, sort_keys=True))
|
||||
return 0 if receipt["status"] in {"pass", "not-run"} else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
190
scripts/s1_receipt.py
Normal file
190
scripts/s1_receipt.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate Railiance S1 receipts and reject secret-shaped content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
EVENT_TYPES = {
|
||||
"plan",
|
||||
"apply",
|
||||
"convergence",
|
||||
"verification",
|
||||
"rotation",
|
||||
"provisioning-chain",
|
||||
}
|
||||
STATUSES = {"pass", "fail", "not-run"}
|
||||
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
REVISION_RE = re.compile(r"^[0-9a-f]{7,40}$")
|
||||
FORBIDDEN_KEYS = {
|
||||
"api_key",
|
||||
"credential",
|
||||
"credentials",
|
||||
"password",
|
||||
"private_key",
|
||||
"secret",
|
||||
"secret_value",
|
||||
"token",
|
||||
"tokens",
|
||||
}
|
||||
FORBIDDEN_VALUE_PATTERNS = (
|
||||
re.compile(r"-----BEGIN (?:OPENSSH|RSA|EC|AGE) PRIVATE KEY-----"),
|
||||
re.compile(r"\bhc_[A-Za-z0-9_-]{16,}\b"),
|
||||
)
|
||||
|
||||
|
||||
class ReceiptError(ValueError):
|
||||
"""A receipt is incomplete, ambiguous, or unsafe to retain."""
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _parse_time(value: Any, label: str) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
raise ReceiptError(f"{label} must be an RFC3339 string")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ReceiptError(f"{label} must be RFC3339") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ReceiptError(f"{label} must include a timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def _scan_safe(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
normalized = str(key).lower().replace("-", "_")
|
||||
if normalized in FORBIDDEN_KEYS or normalized.endswith(
|
||||
("_api_key", "_credential", "_password", "_private_key", "_secret", "_token")
|
||||
):
|
||||
raise ReceiptError(f"forbidden secret-shaped key at {path}.{key}")
|
||||
_scan_safe(child, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
_scan_safe(child, f"{path}[{index}]")
|
||||
elif isinstance(value, str):
|
||||
for pattern in FORBIDDEN_VALUE_PATTERNS:
|
||||
if pattern.search(value):
|
||||
raise ReceiptError(f"forbidden credential-shaped value at {path}")
|
||||
|
||||
|
||||
def _require_digest(payload: dict[str, Any], key: str) -> None:
|
||||
if not DIGEST_RE.fullmatch(str(payload.get(key, ""))):
|
||||
raise ReceiptError(f"{key} must be a lowercase sha256 digest")
|
||||
|
||||
|
||||
def _validate_hosts(payload: dict[str, Any]) -> None:
|
||||
hosts = payload.get("hosts")
|
||||
profiles = payload.get("profiles")
|
||||
if not isinstance(hosts, list) or not hosts or not all(
|
||||
isinstance(host, str) and host for host in hosts
|
||||
):
|
||||
raise ReceiptError("passing host receipts require hosts")
|
||||
if len(set(hosts)) != len(hosts):
|
||||
raise ReceiptError("hosts must be unique")
|
||||
if not isinstance(profiles, dict) or set(profiles) != set(hosts):
|
||||
raise ReceiptError("profiles must map every and only listed host")
|
||||
|
||||
|
||||
def validate_receipt(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ReceiptError("receipt must be a JSON object")
|
||||
_scan_safe(payload)
|
||||
if payload.get("schema_version") != SCHEMA_VERSION:
|
||||
raise ReceiptError(f"schema_version must be {SCHEMA_VERSION}")
|
||||
try:
|
||||
uuid.UUID(str(payload.get("receipt_id")))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise ReceiptError("receipt_id must be a UUID") from exc
|
||||
event_type = payload.get("event_type")
|
||||
status = payload.get("status")
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ReceiptError(f"event_type must be one of {sorted(EVENT_TYPES)}")
|
||||
if status not in STATUSES:
|
||||
raise ReceiptError(f"status must be one of {sorted(STATUSES)}")
|
||||
if not isinstance(payload.get("synthetic"), bool):
|
||||
raise ReceiptError("synthetic must be boolean")
|
||||
_parse_time(payload.get("created_at"), "created_at")
|
||||
if not REVISION_RE.fullmatch(str(payload.get("source_revision", ""))):
|
||||
raise ReceiptError("source_revision must be a 7-40 character git revision")
|
||||
_require_digest(payload, "inventory_sha256")
|
||||
|
||||
if status == "pass" and event_type == "plan":
|
||||
_require_digest(payload, "plan_summary_sha256")
|
||||
elif status == "pass" and event_type == "apply":
|
||||
resource_ids = payload.get("provider_resource_ids")
|
||||
if not isinstance(resource_ids, list) or not resource_ids:
|
||||
raise ReceiptError("passing apply receipts require provider_resource_ids")
|
||||
elif status == "pass" and event_type in {"convergence", "verification"}:
|
||||
_validate_hosts(payload)
|
||||
if event_type == "verification":
|
||||
observed = _parse_time(payload.get("observed_at"), "observed_at")
|
||||
fresh = _parse_time(payload.get("fresh_until"), "fresh_until")
|
||||
if fresh <= observed:
|
||||
raise ReceiptError("fresh_until must be after observed_at")
|
||||
evidence = payload.get("evidence")
|
||||
if not isinstance(evidence, list) or len(evidence) < len(payload["hosts"]):
|
||||
raise ReceiptError("passing verification receipts require host evidence")
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or not DIGEST_RE.fullmatch(
|
||||
str(item.get("sha256", ""))
|
||||
):
|
||||
raise ReceiptError("verification evidence requires sha256 digests")
|
||||
elif status == "pass" and event_type == "rotation":
|
||||
if payload.get("decryption_verified") is not True:
|
||||
raise ReceiptError("passing rotation receipts require decryption_verified=true")
|
||||
for key in ("files", "before_recipients", "after_recipients"):
|
||||
if not isinstance(payload.get(key), list) or not payload[key]:
|
||||
raise ReceiptError(f"passing rotation receipts require {key}")
|
||||
elif status == "pass" and event_type == "provisioning-chain":
|
||||
if payload.get("synthetic") is not True:
|
||||
raise ReceiptError("combined provisioning-chain receipts are synthetic only")
|
||||
phases = payload.get("phases")
|
||||
expected = ["plan", "apply", "cloud-init", "convergence", "verification"]
|
||||
if not isinstance(phases, list) or [p.get("phase") for p in phases] != expected:
|
||||
raise ReceiptError(f"provisioning-chain phases must be {expected}")
|
||||
if any(phase.get("status") != "pass" for phase in phases):
|
||||
raise ReceiptError("passing provisioning-chain receipts require every phase to pass")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def load_receipt(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ReceiptError(f"cannot read {path}: {exc}") from exc
|
||||
return validate_receipt(payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("receipts", nargs="+", type=Path)
|
||||
args = parser.parse_args()
|
||||
failures = []
|
||||
for path in args.receipts:
|
||||
try:
|
||||
load_receipt(path)
|
||||
except ReceiptError as exc:
|
||||
failures.append(f"{path}: {exc}")
|
||||
if failures:
|
||||
print("receipt validation failed:\n- " + "\n- ".join(failures), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps({"ok": True, "validated": len(args.receipts)}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
edit) sops inventory/group_vars/secrets.sops.yaml ;;
|
||||
rotate) sops --rotate --in-place inventory/group_vars/secrets.sops.yaml ;;
|
||||
edit) sops secrets/hetzner-token.yaml ;;
|
||||
rotate) python3 scripts/sops_rotation.py --check ;;
|
||||
*)
|
||||
echo "Usage: scripts/sops.sh [edit|rotate]"
|
||||
;;
|
||||
|
|
|
|||
233
scripts/sops_rotation.py
Normal file
233
scripts/sops_rotation.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check or execute a bounded, metadata-only SOPS recipient rotation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from check_secret_paths import is_protected_path
|
||||
from s1_receipt import validate_receipt
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RotationError(ValueError):
|
||||
"""Rotation inputs, metadata, or approval are unsafe or incomplete."""
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _git(*args: str) -> str:
|
||||
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
|
||||
|
||||
|
||||
def protected_files(root: Path = ROOT) -> list[Path]:
|
||||
candidates = list((root / "secrets").rglob("*")) if (root / "secrets").exists() else []
|
||||
inventory = list((root / "inventory").rglob("secrets*"))
|
||||
return sorted(
|
||||
path
|
||||
for path in candidates + inventory
|
||||
if path.is_file() and is_protected_path(str(path.relative_to(root)))
|
||||
)
|
||||
|
||||
|
||||
def load_policy(path: Path) -> list[dict[str, Any]]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
rules = payload["creation_rules"]
|
||||
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
|
||||
raise RotationError(f"cannot read SOPS policy {path}: {exc}") from exc
|
||||
if not isinstance(rules, list) or not rules:
|
||||
raise RotationError("SOPS policy has no creation_rules")
|
||||
return rules
|
||||
|
||||
|
||||
def expected_recipients(rules: list[dict[str, Any]], relative: str) -> list[str]:
|
||||
for rule in rules:
|
||||
pattern = rule.get("path_regex")
|
||||
if not isinstance(pattern, str) or re.fullmatch(pattern, relative) is None:
|
||||
continue
|
||||
recipients = []
|
||||
for group in rule.get("key_groups", []):
|
||||
recipients.extend(group.get("age", []))
|
||||
recipients = sorted(set(recipients))
|
||||
if not recipients:
|
||||
raise RotationError(f"{relative}: matching policy has no age recipients")
|
||||
return recipients
|
||||
raise RotationError(f"{relative}: no .sops.yaml creation rule matches")
|
||||
|
||||
|
||||
def actual_recipients(path: Path) -> list[str]:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
age_entries = payload["sops"]["age"]
|
||||
recipients = sorted({entry["recipient"] for entry in age_entries})
|
||||
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
|
||||
raise RotationError(f"{path}: missing readable SOPS age metadata") from exc
|
||||
if not recipients:
|
||||
raise RotationError(f"{path}: SOPS metadata has no age recipients")
|
||||
return recipients
|
||||
|
||||
|
||||
def rotation_plan(root: Path = ROOT) -> list[dict[str, Any]]:
|
||||
rules = load_policy(root / ".sops.yaml")
|
||||
plan = []
|
||||
for path in protected_files(root):
|
||||
relative = str(path.relative_to(root))
|
||||
before = actual_recipients(path)
|
||||
after = expected_recipients(rules, relative)
|
||||
plan.append(
|
||||
{
|
||||
"path": relative,
|
||||
"sha256": _sha256(path),
|
||||
"before_recipients": before,
|
||||
"after_recipients": after,
|
||||
"changed": before != after,
|
||||
}
|
||||
)
|
||||
if not plan:
|
||||
raise RotationError("no protected SOPS files found")
|
||||
return plan
|
||||
|
||||
|
||||
def _load_approval(path: Path, plan: list[dict[str, Any]]) -> None:
|
||||
try:
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise RotationError(f"cannot read approval file: {exc}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("approved") is not True:
|
||||
raise RotationError("approval file must contain approved: true")
|
||||
if not payload.get("approved_by") or not payload.get("approved_at"):
|
||||
raise RotationError("approval file requires approved_by and approved_at")
|
||||
expected = [
|
||||
{
|
||||
"path": item["path"],
|
||||
"before_recipients": item["before_recipients"],
|
||||
"after_recipients": item["after_recipients"],
|
||||
}
|
||||
for item in plan
|
||||
if item["changed"]
|
||||
]
|
||||
if payload.get("changes") != expected:
|
||||
raise RotationError("approval changes do not exactly match the current rotation plan")
|
||||
|
||||
|
||||
def _verify_decryption(paths: list[Path]) -> bool:
|
||||
if shutil.which("sops") is None:
|
||||
raise RotationError("sops is required for non-printing decryption verification")
|
||||
for path in paths:
|
||||
completed = subprocess.run(
|
||||
["sops", "--decrypt", str(path)],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RotationError(f"decryption verification failed for {path.relative_to(ROOT)}")
|
||||
return True
|
||||
|
||||
|
||||
def _apply(plan: list[dict[str, Any]]) -> None:
|
||||
if shutil.which("sops") is None:
|
||||
raise RotationError("sops is required for rotation")
|
||||
for item in plan:
|
||||
if not item["changed"]:
|
||||
continue
|
||||
completed = subprocess.run(
|
||||
["sops", "updatekeys", "--yes", item["path"]],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RotationError(f"sops updatekeys failed for {item['path']}")
|
||||
|
||||
|
||||
def build_receipt(plan: list[dict[str, Any]], verified: bool, applied: bool) -> dict[str, Any]:
|
||||
all_before = sorted({r for item in plan for r in item["before_recipients"]})
|
||||
all_after = sorted({r for item in plan for r in item["after_recipients"]})
|
||||
receipt = {
|
||||
"schema_version": "1.0",
|
||||
"receipt_id": str(uuid.uuid4()),
|
||||
"event_type": "rotation",
|
||||
"synthetic": False,
|
||||
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"source_revision": _git("rev-parse", "HEAD"),
|
||||
"inventory_sha256": _sha256(ROOT / "inventory" / "servers.yaml"),
|
||||
"status": "pass" if verified else "not-run",
|
||||
"applied": applied,
|
||||
"decryption_verified": verified,
|
||||
"files": [item["path"] for item in plan],
|
||||
"before_recipients": all_before,
|
||||
"after_recipients": all_after,
|
||||
"file_metadata": plan,
|
||||
}
|
||||
validate_receipt(receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail on recipient drift")
|
||||
parser.add_argument("--verify-decryption", action="store_true")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--approval-file", type=Path)
|
||||
parser.add_argument("--receipt", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
plan = rotation_plan()
|
||||
if args.check and any(item["changed"] for item in plan):
|
||||
raise RotationError("recipient drift detected")
|
||||
if args.apply:
|
||||
if args.approval_file is None:
|
||||
raise RotationError("--apply requires --approval-file")
|
||||
if not any(item["changed"] for item in plan):
|
||||
raise RotationError("--apply requires at least one recipient change")
|
||||
_load_approval(args.approval_file, plan)
|
||||
_apply(plan)
|
||||
plan = rotation_plan()
|
||||
if any(item["changed"] for item in plan):
|
||||
raise RotationError("recipient drift remains after rotation")
|
||||
verified = _verify_decryption(protected_files()) if args.verify_decryption or args.apply else False
|
||||
receipt = build_receipt(plan, verified, args.apply)
|
||||
if args.receipt:
|
||||
destination = args.receipt if args.receipt.is_absolute() else ROOT / args.receipt
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"files": len(plan),
|
||||
"changes": sum(1 for item in plan if item["changed"]),
|
||||
"decryption_verified": verified,
|
||||
"applied": args.apply,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
except RotationError as exc:
|
||||
print(f"rotation failed closed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -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$"
|
||||
|
|
|
|||
53
terraform/hetzner/inventory_selection.tftest.hcl
Normal file
53
terraform/hetzner/inventory_selection.tftest.hcl
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
mock_provider "hcloud" {}
|
||||
mock_provider "template" {}
|
||||
|
||||
run "adopted_host_is_not_managed" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
hcloud_token = "fixture-not-a-credential"
|
||||
inventory_yaml = <<-YAML
|
||||
schema_version: "1.0"
|
||||
servers:
|
||||
- name: adopted-host
|
||||
provider: hosteurope
|
||||
lifecycle_mode: adopted
|
||||
ip: 192.0.2.10
|
||||
ssh_user: admin
|
||||
baseline_profile: ufw-managed
|
||||
YAML
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.managed_server_names) == 0
|
||||
error_message = "adopted Host Europe records must not select Hetzner resources"
|
||||
}
|
||||
}
|
||||
|
||||
run "managed_hetzner_host_is_selected" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
hcloud_token = "fixture-not-a-credential"
|
||||
inventory_yaml = <<-YAML
|
||||
schema_version: "1.0"
|
||||
servers:
|
||||
- name: fixture-hetzner-01
|
||||
provider: hetzner
|
||||
lifecycle_mode: provider-managed
|
||||
ssh_user: admin
|
||||
baseline_profile: ufw-managed
|
||||
provisioning:
|
||||
server_type: cpx21
|
||||
region: nbg1
|
||||
image: ubuntu-24.04
|
||||
role: fixture
|
||||
labels: [test]
|
||||
YAML
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.managed_server_names) == 1 && output.managed_server_names[0] == "fixture-hetzner-01"
|
||||
error_message = "provider-managed Hetzner records must be selected"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
13
tests/fixtures/inventory/invalid-mixed.yaml
vendored
Normal file
13
tests/fixtures/inventory/invalid-mixed.yaml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
schema_version: "1.0"
|
||||
servers:
|
||||
- name: unsafe-mixed-host
|
||||
provider: hosteurope
|
||||
lifecycle_mode: adopted
|
||||
ip: 192.0.2.10
|
||||
ssh_user: admin
|
||||
baseline_profile: ufw-managed
|
||||
provisioning:
|
||||
server_type: cpx21
|
||||
region: nbg1
|
||||
image: ubuntu-24.04
|
||||
role: test
|
||||
13
tests/fixtures/inventory/valid-hetzner.yaml
vendored
Normal file
13
tests/fixtures/inventory/valid-hetzner.yaml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
schema_version: "1.0"
|
||||
servers:
|
||||
- name: fixture-hetzner-01
|
||||
provider: hetzner
|
||||
lifecycle_mode: provider-managed
|
||||
ssh_user: admin
|
||||
baseline_profile: ufw-managed
|
||||
provisioning:
|
||||
server_type: cpx21
|
||||
region: nbg1
|
||||
image: ubuntu-24.04
|
||||
role: test
|
||||
labels: [fixture]
|
||||
62
tests/test_baseline_contract.py
Normal file
62
tests/test_baseline_contract.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from baseline_contract import ( # noqa: E402
|
||||
BaselineError,
|
||||
load_spec,
|
||||
profile_hostvars,
|
||||
validate_repo_consumers,
|
||||
validate_spec,
|
||||
)
|
||||
|
||||
|
||||
class BaselineContractTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.payload = load_spec(ROOT / "spec" / "server-baseline.yaml")
|
||||
|
||||
def test_profiles_resolve_distinct_firewall_controls(self) -> None:
|
||||
managed = profile_hostvars(self.payload, "ufw-managed")
|
||||
external = profile_hostvars(self.payload, "external-firewall")
|
||||
self.assertTrue(managed["ufw_manage"])
|
||||
self.assertIn("ufw", managed["baseline_required_services"])
|
||||
self.assertFalse(external["ufw_manage"])
|
||||
self.assertNotIn("ufw", external["baseline_required_services"])
|
||||
self.assertIn("replacement_control", external["baseline_firewall"])
|
||||
|
||||
def test_governed_package_mismatch_fails(self) -> None:
|
||||
broken = copy.deepcopy(self.payload)
|
||||
broken["defaults"]["packages"].remove("htop")
|
||||
with self.assertRaisesRegex(BaselineError, "htop"):
|
||||
validate_spec(broken)
|
||||
|
||||
def test_governed_ssh_weakening_fails(self) -> None:
|
||||
broken = copy.deepcopy(self.payload)
|
||||
broken["defaults"]["ssh_directives"]["PermitRootLogin"] = "yes"
|
||||
with self.assertRaisesRegex(BaselineError, "SSH"):
|
||||
validate_spec(broken)
|
||||
|
||||
def test_ansible_and_goss_consume_shared_variables(self) -> None:
|
||||
validate_repo_consumers(ROOT)
|
||||
|
||||
def test_dynamic_inventory_resolves_each_host_profile(self) -> None:
|
||||
raw = subprocess.check_output(
|
||||
[sys.executable, str(ROOT / "ansible" / "inventory_from_yaml.py")],
|
||||
text=True,
|
||||
cwd=ROOT,
|
||||
)
|
||||
hostvars = json.loads(raw)["_meta"]["hostvars"]
|
||||
self.assertEqual("external-firewall", hostvars["CoulombCore"]["baseline_profile"])
|
||||
self.assertEqual("ufw-managed", hostvars["Railiance01"]["baseline_profile"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
64
tests/test_handoff.py
Normal file
64
tests/test_handoff.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from s1_handoff import build_receipt # noqa: E402
|
||||
from s1_receipt import validate_receipt # noqa: E402
|
||||
|
||||
|
||||
HOSTS = [
|
||||
{"name": "Railiance01", "baseline_profile": "ufw-managed"},
|
||||
{"name": "CoulombCore", "baseline_profile": "external-firewall"},
|
||||
]
|
||||
|
||||
|
||||
class HandoffTests(unittest.TestCase):
|
||||
def test_all_hosts_and_evidence_produce_pass(self) -> None:
|
||||
receipt = build_receipt(
|
||||
revision="3734a1c",
|
||||
inventory_digest="a" * 64,
|
||||
hosts=HOSTS,
|
||||
results={"Railiance01": 0, "CoulombCore": 0},
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=timezone.utc),
|
||||
freshness_hours=24,
|
||||
evidence=[
|
||||
{"kind": "goss-tap", "sha256": "b" * 64},
|
||||
{"kind": "goss-tap", "sha256": "c" * 64},
|
||||
],
|
||||
)
|
||||
self.assertEqual("pass", receipt["status"])
|
||||
validate_receipt(receipt)
|
||||
|
||||
def test_one_failed_host_fails_aggregate(self) -> None:
|
||||
receipt = build_receipt(
|
||||
revision="3734a1c",
|
||||
inventory_digest="a" * 64,
|
||||
hosts=HOSTS,
|
||||
results={"Railiance01": 0, "CoulombCore": 1},
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=timezone.utc),
|
||||
freshness_hours=24,
|
||||
evidence=[],
|
||||
)
|
||||
self.assertEqual("fail", receipt["status"])
|
||||
|
||||
def test_dry_run_cannot_claim_pass(self) -> None:
|
||||
receipt = build_receipt(
|
||||
revision="3734a1c",
|
||||
inventory_digest="a" * 64,
|
||||
hosts=HOSTS,
|
||||
results=None,
|
||||
observed_at=datetime(2026, 8, 23, tzinfo=timezone.utc),
|
||||
freshness_hours=24,
|
||||
evidence=[],
|
||||
)
|
||||
self.assertEqual("not-run", receipt["status"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
64
tests/test_inventory_contract.py
Normal file
64
tests/test_inventory_contract.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from inventory_contract import ( # noqa: E402
|
||||
InventoryError,
|
||||
load_inventory,
|
||||
managed_hetzner_servers,
|
||||
validate_inventory,
|
||||
)
|
||||
|
||||
|
||||
class InventoryContractTests(unittest.TestCase):
|
||||
def test_current_adopted_inventory_has_no_managed_hetzner_hosts(self) -> None:
|
||||
payload = load_inventory(ROOT / "inventory" / "servers.yaml")
|
||||
self.assertEqual([], managed_hetzner_servers(payload))
|
||||
self.assertEqual(
|
||||
{"CoulombCore", "Railiance01"},
|
||||
{server["name"] for server in payload["servers"]},
|
||||
)
|
||||
|
||||
def test_valid_hetzner_fixture_is_selected(self) -> None:
|
||||
payload = load_inventory(
|
||||
ROOT / "tests" / "fixtures" / "inventory" / "valid-hetzner.yaml"
|
||||
)
|
||||
self.assertEqual(
|
||||
["fixture-hetzner-01"],
|
||||
[server["name"] for server in managed_hetzner_servers(payload)],
|
||||
)
|
||||
|
||||
def test_adopted_host_cannot_carry_provisioning_fields(self) -> None:
|
||||
with self.assertRaisesRegex(InventoryError, "must not carry"):
|
||||
load_inventory(
|
||||
ROOT / "tests" / "fixtures" / "inventory" / "invalid-mixed.yaml"
|
||||
)
|
||||
|
||||
def test_managed_host_requires_complete_provisioning(self) -> None:
|
||||
payload = yaml.safe_load(
|
||||
(ROOT / "tests" / "fixtures" / "inventory" / "valid-hetzner.yaml").read_text()
|
||||
)
|
||||
broken = copy.deepcopy(payload)
|
||||
del broken["servers"][0]["provisioning"]["server_type"]
|
||||
with self.assertRaisesRegex(InventoryError, "server_type"):
|
||||
validate_inventory(broken)
|
||||
|
||||
def test_managed_host_cannot_have_static_ip(self) -> None:
|
||||
payload = yaml.safe_load(
|
||||
(ROOT / "tests" / "fixtures" / "inventory" / "valid-hetzner.yaml").read_text()
|
||||
)
|
||||
payload["servers"][0]["ip"] = "192.0.2.20"
|
||||
with self.assertRaisesRegex(InventoryError, "addresses come from provider"):
|
||||
validate_inventory(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
79
tests/test_secret_and_receipt_contracts.py
Normal file
79
tests/test_secret_and_receipt_contracts.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
from check_secret_paths import is_encrypted_content, is_protected_path # noqa: E402
|
||||
from s1_receipt import ReceiptError, load_receipt, validate_receipt # noqa: E402
|
||||
from sops_rotation import rotation_plan # noqa: E402
|
||||
|
||||
|
||||
class SecretAndReceiptContractTests(unittest.TestCase):
|
||||
def test_inventory_secret_paths_are_protected(self) -> None:
|
||||
self.assertTrue(is_protected_path("secrets/provider.yaml"))
|
||||
self.assertTrue(is_protected_path("inventory/group_vars/secrets.sops.yaml"))
|
||||
self.assertFalse(is_protected_path("inventory/group_vars/all.yaml"))
|
||||
|
||||
def test_plaintext_and_empty_files_fail(self) -> None:
|
||||
self.assertFalse(is_encrypted_content("secrets/example.yaml", "value: clear"))
|
||||
self.assertFalse(is_encrypted_content("secrets/example.age", ""))
|
||||
self.assertTrue(is_encrypted_content("secrets/example.yaml", "sops:\n age: []\n"))
|
||||
|
||||
def test_synthetic_chain_validates(self) -> None:
|
||||
load_receipt(ROOT / "docs" / "evidence" / "s1-receipts" / "synthetic-provisioning-chain.json")
|
||||
|
||||
def test_incomplete_passing_chain_fails(self) -> None:
|
||||
payload = json.loads(
|
||||
(ROOT / "docs" / "evidence" / "s1-receipts" / "synthetic-provisioning-chain.json").read_text()
|
||||
)
|
||||
payload["phases"][2]["status"] = "not-run"
|
||||
with self.assertRaisesRegex(ReceiptError, "every phase"):
|
||||
validate_receipt(payload)
|
||||
|
||||
def test_secret_shaped_receipt_content_fails(self) -> None:
|
||||
payload = json.loads(
|
||||
(ROOT / "docs" / "evidence" / "s1-receipts" / "synthetic-provisioning-chain.json").read_text()
|
||||
)
|
||||
payload["token"] = "not-even-a-real-value"
|
||||
with self.assertRaisesRegex(ReceiptError, "secret-shaped key"):
|
||||
validate_receipt(payload)
|
||||
|
||||
payload.pop("token")
|
||||
payload["provider_access_token"] = "redacted-is-still-not-allowed"
|
||||
with self.assertRaisesRegex(ReceiptError, "secret-shaped key"):
|
||||
validate_receipt(payload)
|
||||
|
||||
def test_passing_verification_without_evidence_fails(self) -> None:
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"receipt_id": str(uuid.uuid4()),
|
||||
"event_type": "verification",
|
||||
"synthetic": False,
|
||||
"created_at": "2026-08-23T09:30:00Z",
|
||||
"source_revision": "3734a1c",
|
||||
"inventory_sha256": "a" * 64,
|
||||
"status": "pass",
|
||||
"hosts": ["Railiance01"],
|
||||
"profiles": {"Railiance01": "ufw-managed"},
|
||||
"observed_at": "2026-08-23T09:30:00Z",
|
||||
"fresh_until": "2026-08-24T09:30:00Z",
|
||||
"evidence": [],
|
||||
}
|
||||
with self.assertRaisesRegex(ReceiptError, "host evidence"):
|
||||
validate_receipt(payload)
|
||||
|
||||
def test_current_sops_recipient_metadata_matches_policy(self) -> None:
|
||||
plan = rotation_plan(ROOT)
|
||||
self.assertTrue(plan)
|
||||
self.assertFalse(any(item["changed"] for item in plan))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue