Gate reef web ports on exposure grants
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
codex 2026-08-22 12:34:25 +02:00
parent c569fe168a
commit 61c6082ea8
9 changed files with 228 additions and 46 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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: ""

View file

@ -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:

View file

@ -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/<repo>.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.
---

View file

@ -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

View file

@ -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())

View file

@ -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()

View file

@ -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.