diff --git a/inventories/production/group_vars/all/firewall.yml b/inventories/production/group_vars/all/firewall.yml index f0ad76b..a895429 100644 --- a/inventories/production/group_vars/all/firewall.yml +++ b/inventories/production/group_vars/all/firewall.yml @@ -2,14 +2,27 @@ # Shared firewall topology — single source of truth for the host nftables layer # (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: mgmt: 10.10.0.0/24 srv: 10.20.0.0/24 lan: 10.30.0.0/24 iot: 10.40.0.0/24 guest: 10.50.0.0/24 + public: 0.0.0.0/0 # Service catalog: → placement (host | group | hosts) + ingress[]. -# Empty until services are built; hosts still get default-deny + the management plane. -firewall_catalog: {} +# askari's public surface (ADR-024 Caddy + ADR-016 NetBird STUN). NOTE: the host +# 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 } diff --git a/roles/base/molecule/default/converge.yml b/roles/base/molecule/default/converge.yml index 61d44c9..88afbae 100644 --- a/roles/base/molecule/default/converge.yml +++ b/roles/base/molecule/default/converge.yml @@ -17,6 +17,7 @@ lan: 10.30.0.0/24 srv: 10.20.0.0/24 mgmt: 10.10.0.0/24 + public: 0.0.0.0/0 firewall_catalog: reverse_proxy: host: instance @@ -26,5 +27,9 @@ host: instance ingress: - { from: srv, port: 2342, proto: tcp } + netbird_stun: + host: instance + ingress: + - { from: public, port: 3478, proto: udp } roles: - role: base diff --git a/roles/base/molecule/default/verify.yml b/roles/base/molecule/default/verify.yml index ae20e37..2557f69 100644 --- a/roles/base/molecule/default/verify.yml +++ b/roles/base/molecule/default/verify.yml @@ -38,6 +38,13 @@ - "'tcp dport 2342 accept' in nft" 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 ansible.builtin.assert: that: diff --git a/tests/test_firewall_rules.py b/tests/test_firewall_rules.py index fe91c22..934e769 100644 --- a/tests/test_firewall_rules.py +++ b/tests/test_firewall_rules.py @@ -97,3 +97,12 @@ def test_ingress_missing_port_raises(): cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan"}]}} with pytest.raises(ValueError): 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"]}]