feat(firewall): public zone + askari's public services in the catalog
Adds a public (0.0.0.0/0) zone and askari's Caddy (80/443) + NetBird STUN (3478/udp) ingress so the base nftables default-deny does not drop the live public services when applied to askari. Molecule + filter unit test cover the public-zone rendering. Mesh-hardening 1/3 (ADR-020/024/016). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39d2ad38ca
commit
3b30e70ba5
4 changed files with 37 additions and 3 deletions
|
|
@ -2,14 +2,27 @@
|
||||||
# Shared firewall topology — single source of truth for the host nftables layer
|
# Shared firewall topology — single source of truth for the host nftables layer
|
||||||
# (base role) and OPNsense (future). See docs/decisions/020-firewall.md.
|
# (base role) and OPNsense (future). See docs/decisions/020-firewall.md.
|
||||||
|
|
||||||
# Zone → subnet (from ADR-007).
|
# Zone → subnet (from ADR-007). `public` = the WAN (anywhere) for deliberately public
|
||||||
|
# off-site services (askari); home/cluster services use the internal zones only.
|
||||||
firewall_zones:
|
firewall_zones:
|
||||||
mgmt: 10.10.0.0/24
|
mgmt: 10.10.0.0/24
|
||||||
srv: 10.20.0.0/24
|
srv: 10.20.0.0/24
|
||||||
lan: 10.30.0.0/24
|
lan: 10.30.0.0/24
|
||||||
iot: 10.40.0.0/24
|
iot: 10.40.0.0/24
|
||||||
guest: 10.50.0.0/24
|
guest: 10.50.0.0/24
|
||||||
|
public: 0.0.0.0/0
|
||||||
|
|
||||||
# Service catalog: <name> → placement (host | group | hosts) + ingress[].
|
# Service catalog: <name> → placement (host | group | hosts) + ingress[].
|
||||||
# Empty until services are built; hosts still get default-deny + the management plane.
|
# askari's public surface (ADR-024 Caddy + ADR-016 NetBird STUN). NOTE: the host
|
||||||
firewall_catalog: {}
|
# nftables template renders IPv4 source rules only; askari is reached via its A record
|
||||||
|
# (no AAAA), so IPv4-only public rules are sufficient (see the spec's IPv6 note).
|
||||||
|
firewall_catalog:
|
||||||
|
reverse_proxy:
|
||||||
|
host: askari
|
||||||
|
ingress:
|
||||||
|
- { from: public, port: 80, proto: tcp }
|
||||||
|
- { from: public, port: 443, proto: tcp }
|
||||||
|
netbird_stun:
|
||||||
|
host: askari
|
||||||
|
ingress:
|
||||||
|
- { from: public, port: 3478, proto: udp }
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
lan: 10.30.0.0/24
|
lan: 10.30.0.0/24
|
||||||
srv: 10.20.0.0/24
|
srv: 10.20.0.0/24
|
||||||
mgmt: 10.10.0.0/24
|
mgmt: 10.10.0.0/24
|
||||||
|
public: 0.0.0.0/0
|
||||||
firewall_catalog:
|
firewall_catalog:
|
||||||
reverse_proxy:
|
reverse_proxy:
|
||||||
host: instance
|
host: instance
|
||||||
|
|
@ -26,5 +27,9 @@
|
||||||
host: instance
|
host: instance
|
||||||
ingress:
|
ingress:
|
||||||
- { from: srv, port: 2342, proto: tcp }
|
- { from: srv, port: 2342, proto: tcp }
|
||||||
|
netbird_stun:
|
||||||
|
host: instance
|
||||||
|
ingress:
|
||||||
|
- { from: public, port: 3478, proto: udp }
|
||||||
roles:
|
roles:
|
||||||
- role: base
|
- role: base
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@
|
||||||
- "'tcp dport 2342 accept' in nft"
|
- "'tcp dport 2342 accept' in nft"
|
||||||
fail_msg: "missing srv->2342 rule for photoprism"
|
fail_msg: "missing srv->2342 rule for photoprism"
|
||||||
|
|
||||||
|
- name: Assert the public->stun:3478/udp ingress rule (0.0.0.0/0 source)
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- "'0.0.0.0/0' in nft"
|
||||||
|
- "'udp dport 3478 accept' in nft"
|
||||||
|
fail_msg: "missing public->3478/udp rule for netbird_stun"
|
||||||
|
|
||||||
- name: Assert the docker_host extension hook is present
|
- name: Assert the docker_host extension hook is present
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that:
|
that:
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,12 @@ def test_ingress_missing_port_raises():
|
||||||
cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan"}]}}
|
cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan"}]}}
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_zone_resolves_to_anywhere():
|
||||||
|
catalog = {"web": {"host": "askari",
|
||||||
|
"ingress": [{"from": "public", "port": 443, "proto": "tcp"}]}}
|
||||||
|
zones = {"public": "0.0.0.0/0"}
|
||||||
|
rules = fr.resolve_firewall_rules(catalog, zones, "askari",
|
||||||
|
{"askari": {"ansible_host": "100.99.226.39"}}, {})
|
||||||
|
assert rules == [{"proto": "tcp", "port": 443, "sources": ["0.0.0.0/0"]}]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue