diff --git a/Makefile b/Makefile index 4aa7255..c3e1770 100644 --- a/Makefile +++ b/Makefile @@ -167,25 +167,25 @@ deploy-stack: ## Print the full S1→S5 ordered deploy sequence (operator follow @echo "PRE-CONDITIONS" @echo " [ ] SSH key: ~/.ssh/id_ops" @echo " [ ] SOPS key: ~/.config/sops/age/keys.txt (or SOPS_AGE_KEY)" - @echo " [ ] ops-bridge: bridge up state-hub-coulombcore k3s-api-coulombcore k3s-api-railiance01" + @echo " [ ] ops-bridge: bridge up state-hub-railiance01 k3s-api-railiance01" @echo "" @echo "S1 — Infrastructure Substrate (this repo)" @echo " make tf-plan && make tf-apply # provision server (skip if exists)" - @echo " ssh tegwick@92.205.130.254 'cd ~/railiance-infra/ansible && ansible-playbook playbooks/bootstrap.yaml -c local --become -l CoulombCore'" + @echo " ssh tegwick@92.205.62.239 'cd ~/railiance-infra/ansible && ansible-playbook playbooks/bootstrap.yaml -c local --become -l Railiance01'" @echo " make verify" @echo "" @echo "S2 — Cluster Runtime (railiance-cluster)" - @echo " ssh tegwick@92.205.130.254 'cd ~/railiance-cluster && make converge && make smoke'" + @echo " ssh tegwick@92.205.62.239 'cd ~/railiance-cluster && make converge && make smoke'" @echo "" @echo "S3 — Platform Services (railiance-platform)" - @echo " ssh tegwick@92.205.130.254 'cd ~/railiance-platform && make db-deploy && make valkey-deploy'" + @echo " ssh tegwick@92.205.62.239 'cd ~/railiance-platform && make forgejo-db-status'" @echo "" - @echo "S4 — Developer Enablement (no workplan yet — ArgoCD already at cluster)" - @echo " (no steps required)" + @echo "S4 — Developer Enablement (railiance-enablement)" + @echo " make -C ~/railiance-enablement check test" @echo "" - @echo "S5 — Workloads (railiance-apps)" - @echo " ssh tegwick@92.205.130.254 'cd ~/railiance-apps && make gitea-deploy'" - @echo " ssh tegwick@92.205.130.254 'cd ~/railiance-apps && make state-hub-deploy' # T09" + @echo "S5 — Workloads and forge consumer checks" + @echo " make -C ~/railiance-forge gitea-status" + @echo " deploy each application through its owning rapp/release runbook" @echo "" @echo " Full runbook: docs/deploy-stack.md" @@ -302,11 +302,15 @@ converge-tags: ## Run only certain tags: make converge-tags TAGS="base,ufw" converge-firewall: ## Apply only UFW tasks: make converge-firewall HOST=Railiance01 @test -n "$(HOST)" || (echo "Usage: make converge-firewall HOST=Railiance01"; exit 1) - cd $(ANS_DIR) && ansible-playbook playbooks/firewall.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) --tags firewall + cd $(ANS_DIR) && ansible-playbook playbooks/firewall.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) --tags firewall \ + $(if $(REEF_DECLARATION),-e reef_declaration_path="$(abspath $(REEF_DECLARATION))",) + +validate-reef-exposure: ## Validate PORTS against REEF_DECLARATION without changing a host + @test -n "$(REEF_DECLARATION)" || (echo "Usage: make validate-reef-exposure REEF_DECLARATION=... PORTS=80,443"; exit 1) + python3 scripts/validate-reef-exposure.py --reef-declaration "$(REEF_DECLARATION)" --ports "$(or $(PORTS),80,443)" converge-check: ## Dry-run (no changes), great for previews cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG) --check converge-diff: ## Show file/templating diffs while applying changes cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG) --diff - diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 7359a67..2340032 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,7 +12,7 @@ | workplan | RAIL-HO-WP-0007 | finished | — | workplans/RAIL-HO-WP-0007-first-reef-rollout-and-s1-canonicalization.md | | 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 | ready | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md | +| workplan | RAIL-HO-WP-0010 | finished | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md | | task | RAIL-HO-WP-0006-T01 | done | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | | task | RAIL-HO-WP-0006-T02 | done | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | | task | RAIL-HO-WP-0006-T03 | done | — | workplans/RAIL-HO-WP-0006-forgejo-registry-ref-cleanup.md | @@ -35,4 +35,4 @@ | task | RAIL-HO-WP-0009-T04 | done | — | workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md | | 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 | todo | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md | +| task | RAIL-HO-WP-0010-T01 | done | — | workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md | diff --git a/ansible/roles/base/defaults/main.yml b/ansible/roles/base/defaults/main.yml index 1f0b7c0..f5bf8d5 100644 --- a/ansible/roles/base/defaults/main.yml +++ b/ansible/roles/base/defaults/main.yml @@ -58,3 +58,9 @@ flannel_vxlan_allowed_sources: [] # proto: tcp # comment: "nydus-ex-api dashboard agent" ufw_extra_allowed: [] + +# Ports 80/443 are separate from generic exceptions. They stay empty for a new +# reef unless the controller supplies reef_declaration_path and that source +# declaration carries exact ADR-0008 substrate grants. +public_web_ports: [] +reef_declaration_path: "" diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml index b777a6d..da7f01e 100644 --- a/ansible/roles/base/tasks/main.yml +++ b/ansible/roles/base/tasks/main.yml @@ -68,6 +68,41 @@ name: OpenSSH when: ufw_manage | bool +- name: Reject web ports in the generic UFW exception list + tags: [base, firewall, ufw] + ansible.builtin.assert: + that: + - (ufw_extra_allowed | selectattr('port', 'defined') | map(attribute='port') | map('string') | intersect(['80', '443'])) | length == 0 + fail_msg: >- + Ports 80/443 must use public_web_ports plus an ADR-0008 reef declaration; + they cannot be smuggled through ufw_extra_allowed. + when: ufw_manage | bool + +- name: Validate public web ports against the reef declaration + tags: [base, firewall, ufw] + become: false + delegate_to: localhost + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/../../../scripts/validate-reef-exposure.py" + - --reef-declaration + - "{{ reef_declaration_path }}" + - --ports + - "{{ public_web_ports | join(',') }}" + changed_when: false + when: ufw_manage | bool and (public_web_ports | length > 0) + +- name: Allow grant-backed public web ports + tags: [base, firewall, ufw] + ansible.builtin.ufw: + rule: allow + port: "{{ item | string }}" + proto: tcp + comment: "adr-0008-reef-public-web" + loop: "{{ public_web_ports }}" + when: ufw_manage | bool + - name: Allow declared extra UFW ports tags: [base, firewall, ufw] ansible.builtin.ufw: diff --git a/docs/deploy-stack.md b/docs/deploy-stack.md index 96803d5..320853c 100644 --- a/docs/deploy-stack.md +++ b/docs/deploy-stack.md @@ -8,7 +8,8 @@ Before starting, verify you have: -- [ ] SSH access to the target server (COULOMBCORE: 92.205.130.254, user: tegwick, key: `~/.ssh/id_ops`) +- [ ] SSH access to the selected inventory host. `Railiance01` is the current + primary; `CoulombCore` is legacy/frozen and is not a default deployment target. - [ ] SOPS age private key available (`~/.config/sops/age/keys.txt` or `SOPS_AGE_KEY` env) - [ ] ops-bridge running on the workstation (needed for state hub MCP): `make mcp-http` in `~/the-custodian/state-hub/` - [ ] Forgejo accessible (git pull on remote): SSH via `forgejo-remote:coulomb/.git` @@ -27,12 +28,11 @@ cd ~/railiance-infra make tf-plan # review Terraform plan make tf-apply # create/update server -# Converge OS baseline -# NOTE: Ansible runs locally on CoulombCore (workstation has no Ansible installed) -ssh -i ~/.ssh/id_ops tegwick@92.205.130.254 \ +# Converge OS baseline on the selected inventory host +ssh -i ~/.ssh/id_ops tegwick@92.205.62.239 \ 'cd ~/railiance-infra && git pull && \ cd ansible && ansible-playbook playbooks/bootstrap.yaml \ - -c local --become -l CoulombCore' + -c local --become -l Railiance01' # Verify OS baseline make verify @@ -46,15 +46,15 @@ SOPS/age installed. ## S2 — Cluster Runtime (`railiance-cluster`) ```bash -# On CoulombCore (SSH in first) -ssh -i ~/.ssh/id_ops tegwick@92.205.130.254 +# On the selected cluster host (Railiance01 for the current primary) +ssh -i ~/.ssh/id_ops tegwick@92.205.62.239 cd ~/railiance-cluster -make converge # installs k3s, Helm, cert-manager, nginx ingress, cnpg operator +make converge # installs k3s, Helm, cert-manager, Traefik, cnpg operator make smoke # runs cluster health assertions ``` -**Checkpoint:** k3s running, Helm available, cert-manager and nginx-ingress pods Running, +**Checkpoint:** k3s running, Helm available, cert-manager and Traefik pods Running, cnpg-system namespace active. --- @@ -62,7 +62,7 @@ cnpg-system namespace active. ## S3 — Platform Services (`railiance-platform`) ```bash -# On CoulombCore (kubectl available after S2) +# On the current primary (kubectl available after S2) cd ~/railiance-platform && git pull # Deploy Forgejo database cluster (production on railiance01) @@ -73,39 +73,36 @@ make forgejo-db-status # Legacy coulombcore Gitea DB (read-only fallback — retain until T12 retires Gitea) # make db-deploy && make db-status -# Deploy Valkey cache if required by workloads -# Requires: helm/valkey-values.sops.yaml with encrypted password -make valkey-deploy -make valkey-status +# Deploy an optional cache only when a named workload requirement and custody +# path exist. Cache installation is not an unconditional S3 baseline step. ``` -**Checkpoint:** `kubectl get cluster -n databases` shows `forgejo-db` healthy on -railiance01; Valkey pod Running if deployed. +**Checkpoint:** `kubectl get cluster -n databases` shows `forgejo-db` healthy +on Railiance01; optional services are present only when explicitly required. --- ## S4 — Developer Enablement (`railiance-enablement`) -No formal workplan yet. ArgoCD is currently deployed at cluster level (S2 boundary -violation, tracked in RAIL-HO-WP-0004). No S4-specific steps required at this time. +Validate the consumer-facing paved paths; S4 does not deploy the forge runtime: + +```bash +cd ~/railiance-enablement && git pull +make check +make test +``` --- ## S5 — Workloads & Experience (`railiance-apps`) ```bash -# On CoulombCore -cd ~/railiance-apps && git pull +# Verify the forge through its operational owner. +cd ~/railiance-forge && git pull +make gitea-status -# Deploy Forgejo (production source forge on railiance01) -# See railiance-apps/docs/forgejo-on-railiance01.md -make forgejo-deploy -make forgejo-verify - -# Legacy Gitea on coulombcore — read-only fallback; do not use for new repos -# make gitea-deploy && make gitea-status - -# Deploy state-hub, activity-core, and other workloads per railiance-apps Makefile +# Deploy each application through its owning rapp/release runbook. S5 does not +# own the forge runtime or generic rail/template behavior. ``` **Checkpoint:** `https://forgejo.coulomb.social` serves HTTPS; git clone/push via @@ -124,7 +121,7 @@ bridge up k3s-api-coulombcore # CoulombCore k3s API (port 16443 local) bridge up k3s-api-railiance01 # Railiance01 k3s API (port 16444 local) ``` -Verify: `bridge status` shows all three connected. +Verify: `bridge status` shows the tunnels needed for the selected host connected. --- diff --git a/goss/baseline.yaml.j2 b/goss/baseline.yaml.j2 index a1833a5..c9e4328 100644 --- a/goss/baseline.yaml.j2 +++ b/goss/baseline.yaml.j2 @@ -67,6 +67,9 @@ command: {% for rule in ufw_extra_allowed | default([]) %} - '/{{ rule.port }}\/{{ rule.proto | default("tcp") }}\s+ALLOW/' {% endfor %} +{% for port in public_web_ports | default([]) %} + - '/{{ 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 diff --git a/scripts/validate-reef-exposure.py b/scripts/validate-reef-exposure.py new file mode 100755 index 0000000..a1df78d --- /dev/null +++ b/scripts/validate-reef-exposure.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Validate requested public web ports against an ADR-0008 reef declaration.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +import yaml + + +class GrantError(ValueError): + pass + + +def validate(payload: Any, ports: list[int]) -> dict[str, Any]: + if not isinstance(payload, dict): + raise GrantError("reef declaration must be a YAML object") + if any(port == 6443 for port in ports): + raise GrantError("6443 is operator-only and cannot be publicly granted") + if any(port not in {80, 443} for port in ports): + raise GrantError("the public web gate accepts only ports 80 and 443") + if not payload.get("primary_rail") and not payload.get("hosted_rails"): + raise GrantError("provider-delegated reefs cannot publish a public listener") + exposure = payload.get("exposure") or {} + if exposure.get("posture") != "public": + raise GrantError("reef exposure.posture must be public") + grants = exposure.get("grants") or [] + for port in ports: + matches = [grant for grant in grants if isinstance(grant, dict) and grant.get("port") in {port, str(port)}] + if not matches: + raise GrantError(f"reef has no substrate grant for port {port}") + grant = matches[0] + missing = [field for field in ("reason", "approved_on", "residual_risk_owner") if not grant.get(field)] + if missing: + raise GrantError(f"port {port} grant is missing {', '.join(missing)}") + return { + "reef_id": payload.get("reef_id"), + "posture": "public", + "validated_ports": sorted(set(ports)), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--reef-declaration", type=Path, required=True) + parser.add_argument("--ports", required=True, help="Comma-separated ports; only 80 and 443 are grantable") + args = parser.parse_args() + try: + ports = [int(value) for value in args.ports.split(",") if value] + if not ports: + raise GrantError("at least one requested port is required") + payload = yaml.safe_load(args.reef_declaration.read_text(encoding="utf-8")) + result = validate(payload, ports) + except (OSError, yaml.YAMLError, ValueError) as exc: + print(f"reef exposure validation failed: {exc}", file=sys.stderr) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_reef_exposure.py b/tests/test_reef_exposure.py new file mode 100644 index 0000000..cbf46d0 --- /dev/null +++ b/tests/test_reef_exposure.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import importlib.util +import copy +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("reef_exposure", ROOT / "scripts" / "validate-reef-exposure.py") +assert SPEC and SPEC.loader +module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(module) +GrantError = module.GrantError +validate = module.validate + + +VALID = { + "kind": "substrate-reef", + "reef_id": "reef-example", + "primary_rail": "rail-kubernetes", + "exposure": { + "posture": "public", + "grants": [ + {"port": 80, "reason": "redirect", "approved_on": "2026-08-22", "residual_risk_owner": "infra"}, + {"port": 443, "reason": "tls", "approved_on": "2026-08-22", "residual_risk_owner": "infra"}, + ], + }, +} + + +class ReefExposureTests(unittest.TestCase): + def test_valid_public_web_grants(self) -> None: + self.assertEqual([80, 443], validate(VALID, [80, 443])["validated_ports"]) + + def test_private_reef_fails(self) -> None: + payload = {**VALID, "exposure": {"posture": "private"}} + with self.assertRaisesRegex(GrantError, "must be public"): + validate(payload, [443]) + + def test_missing_port_fails(self) -> None: + payload = copy.deepcopy(VALID) + payload["exposure"]["grants"] = payload["exposure"]["grants"][:1] + with self.assertRaisesRegex(GrantError, "port 443"): + validate(payload, [443]) + + def test_kubernetes_api_is_never_grantable(self) -> None: + with self.assertRaisesRegex(GrantError, "operator-only"): + validate(VALID, [6443]) + + def test_provider_delegated_reef_fails(self) -> None: + payload = {key: value for key, value in VALID.items() if key != "primary_rail"} + with self.assertRaisesRegex(GrantError, "provider-delegated"): + validate(payload, [443]) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md b/workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md index 4aadf0f..737d4a5 100644 --- a/workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md +++ b/workplans/RAIL-HO-WP-0010-new-reef-ports-need-a-grant.md @@ -4,11 +4,11 @@ type: workplan title: "New reefs do not get 80/443 until a substrate grant exists" domain: financials repo: railiance-infra -status: ready +status: finished owner: codex topic_slug: railiance created: "2026-08-15" -updated: "2026-08-15" +updated: "2026-08-22" related: - RMASTER-WP-0023 - RAIL-HO-WP-0009 @@ -31,7 +31,7 @@ stays a named exception. ```task id: RAIL-HO-WP-0010-T01 -status: todo +status: done priority: high state_hub_task_id: "f4cc01f8-5b5e-4693-b389-9bd81e26a799" ``` @@ -42,3 +42,17 @@ add per-rapp UFW rules on a shared ingress host. **Done when:** a new reef playbook cannot open 80/443 without naming the grant, and `6443` cannot be opened by this path. + +**Outcome (2026-08-22):** the base role now rejects 80/443 in generic UFW +extras and delegates public-web admission to a reef declaration validator. +Only ports 80 and 443 are accepted, each must have a matching public substrate +grant with reason, approval date, and owner, and 6443 is unconditionally +rejected. Goss receives only the resulting grant-backed port list. + +## Completion evidence + +- `python3 -m unittest discover -s tests -v`: 5 tests passed. +- Live source validation against `reef-railiance/declarations/reef.yaml` for + ports 80 and 443: passed. +- Ansible syntax validation remains unavailable on this workstation because + `ansible-playbook` is not installed; no live firewall change was applied.