Gate reef web ports on exposure grants
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02669-87ee-7a31-b111-edc95a16e0fa
This commit is contained in:
parent
c569fe168a
commit
61c6082ea8
9 changed files with 228 additions and 46 deletions
26
Makefile
26
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
66
scripts/validate-reef-exposure.py
Executable file
66
scripts/validate-reef-exposure.py
Executable 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())
|
||||
57
tests/test_reef_exposure.py
Normal file
57
tests/test_reef_exposure.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue