feat(svc-net-firewall): own the packet filter as one nftables table per role

svc-net-tor rendered its own guard script, its own unit and its own handler, and hooked the rules into DOCKER-USER with an insert, because a rule appended to a chain docker owns lands behind the RETURN docker puts there. The script flushed its chain and refilled it rule by rule, so the boundary was gone for the duration of the refill.

A role that needs packet filter rules now writes templates/nftables.conf.j2 declaring its own table and includes svc-net-firewall, which renders it to /etc/nftables.d/<role>.conf, rejects it with nft -c, loads it, reads the table back and refuses one that carries no chain. Deployed on its own the role installs nftables and the ruleset that loads every fragment at boot. That ruleset is a distribution decision, so its path follows os_family: RedHat reads /etc/sysconfig/nftables.conf, the others /etc/nftables.conf. The unit is only enabled, never started, because on Debian its ExecStop flushes the whole ruleset, docker's tables included.

One table per role removes the shared path: a fragment replaces its own table and touches no other, and docker never touches either. nft -f on an existing table merges, so each fragment creates, deletes and redeclares its table in one transaction. The tests load every fragment twice into a throwaway network namespace and require the listing to match, because a merging fragment loads twice without error and shows up only as a duplicated rule.

The tor guard now applies in both deploy modes. It was pinned to swarm while torrc binds TransPort and DNSPort on the bridge address in compose too, so on a single-host install those ports answered every on-link neighbour. The guard drops rather than admits, so nothing short-circuits another table, and ip saddr carries an implicit ipv4 dependency, which leaves the ipv6 exposure exactly where the iptables rules left it. svc-net-tor reads its own chain back and refuses a guard that drops nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-15 20:02:36 +02:00
1 parent 99f7e03845
commit bf32f57b85
25 files changed
+567 -311

No files matched your search

+1
View File
@@ -92,6 +92,7 @@ Every invokable role in the tested lifecycle envelope, with its upstream homepag
| [Fediwall](roles/web-app-fediwall/) | beta | Self-hosted Fediverse wall for showing recent public posts by hashtag or account in a live masonry grid | [🌐](https://github.com/defnull/fediwall) [🛠️](roles/web-app-fediwall/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Friendica](roles/web-app-friendica/), [Mastodon](roles/web-app-mastodon/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) |
| [Fider](roles/web-app-fider/) | beta | Open-source community feedback platform for collecting, voting on, and prioritizing feature requests and ideas | [🌐](https://www.fider.io/) [🛠️](roles/web-app-fider/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Hermes Agent](roles/web-app-hermes/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Open WebUI](roles/web-app-openwebui/), [OpenClaw](roles/web-app-openclaw/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) |
| [File Server](roles/web-svc-file/) | beta | Static file server over HTTPS with directory listing, .well-known handling, and automatic TLS certificates | [🛠️](roles/web-svc-file/README.md#quick-setup) | [Corporate Design](roles/web-svc-css/), [Matomo](roles/web-app-matomo/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/) |
| [Firewall ruleset](roles/svc-net-firewall/) | beta | nftables packet filter whose rules each role owns as its own table, replaced atomically and reloaded at boot | [🌐](https://www.netfilter.org/projects/nftables/) [🎬](https://www.youtube.com/watch?v=EGKhIljDPCw) [🛠️](roles/svc-net-firewall/README.md#quick-setup) | |
| [Flowise](roles/web-app-flowise/) | beta | Low-code visual builder for creating, testing, and publishing LLM and RAG workflows as HTTP endpoints | [🌐](https://flowiseai.com/) [🎬](https://www.youtube.com/watch?v=h_0TJTY6Ulg) [🛠️](roles/web-app-flowise/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [LiteLLM Gateway](roles/svc-ai-litellm/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [Ollama](roles/svc-ai-ollama/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Qdrant](roles/svc-db-qdrant/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) |
| [Friendica](roles/web-app-friendica/) | beta | Decentralized, federated social-networking platform for peer-to-peer communication and community building | [🌐](https://friendi.ca/) [🛠️](roles/web-app-friendica/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [MariaDB](roles/svc-db-mariadb/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [Prometheus](roles/web-app-prometheus/), [Tor Onion Service](roles/svc-net-tor/), [Universal Logout](roles/web-svc-logout/) |
| [Funkwhale](roles/web-app-funkwhale/) | beta | Self-hosted, federated platform for sharing, hosting, and streaming your own music and audio library | [🌐](https://www.funkwhale.audio/) [🛠️](roles/web-app-funkwhale/README.md#quick-setup) | [Backup Docker Volumes](roles/svc-bkp-volume-2-local/), [Corporate Design](roles/web-svc-css/), [Dashboard](roles/web-app-dashboard/), [Keycloak](roles/web-app-keycloak/), [Mailu](roles/web-app-mailu/), [Matomo](roles/web-app-matomo/), [OpenLDAP](roles/svc-db-openldap/), [PostgreSQL](roles/svc-db-postgres/), [Prometheus](roles/web-app-prometheus/), [Redis](roles/svc-db-redis/), [SeaweedFS](roles/web-app-seaweedfs/), [Tor Onion Service](roles/svc-net-tor/), [Typesense](roles/svc-db-typesense/), [Universal Logout](roles/web-svc-logout/) |
+1 -1
View File
@@ -488,7 +488,7 @@ A future exception requires a new requirement with a fixed operation list, dedic
**`host_execution_boundary` (39):** `desk-bluray-player`, `desk-chromium`, `desk-copyq`, `desk-docker`, `desk-dotlinker`, `desk-firefox`, `desk-git`, `desk-gnome`, `desk-gnome-caffeine`, `desk-gnome-extensions`, `desk-gnome-terminal`, `desk-gnucash`, `desk-jrnl`, `desk-keepassxc`, `desk-libreoffice`, `desk-micro`, `desk-neovim`, `desk-nextcloud`, `desk-obs`, `desk-qbittorrent`, `desk-retroarch`, `desk-spotify`, `desk-ssh`, `desk-torbrowser`, `desk-virtualbox`, `desk-zoom`, `dev-arduino`, `dev-core`, `dev-java`, `dev-locales`, `dev-make`, `dev-nix`, `dev-nodejs`, `dev-python`, `drv-epson-multiprinter`, `drv-intel`, `drv-lid-switch`, `drv-non-free`, `gen-hunspell`.
These roles operate a workstation, developer toolchain, device, or host package; bridging them would amount to shared shell, filesystem, browser-session, device, or host execution without an application-specific remote identity.
**`privileged_control_plane` (23):** `svc-bkp-local-2-device`, `svc-bkp-nfs-2-local`, `svc-bkp-remote-2-local`, `svc-bkp-secrets-2-local`, `svc-bkp-volume-2-local`, `svc-dns-unbound`, `svc-net-tor`, `svc-net-wireguard-core`, `svc-net-wireguard-firewalled`, `svc-net-wireguard-plain`, `svc-opt-keyboard-color`, `svc-opt-ssd-hdd`, `svc-opt-swapfile`, `svc-prx-openresty`, `svc-registry-cache`, `svc-registry-docker`, `svc-runner`, `svc-storage-nfs-client`, `svc-storage-nfs-server`, `svc-swarm-manager`, `svc-swarm-node`, `svc-virt-kata`, `update`.
**`privileged_control_plane` (24):** `svc-bkp-local-2-device`, `svc-bkp-nfs-2-local`, `svc-bkp-remote-2-local`, `svc-bkp-secrets-2-local`, `svc-bkp-volume-2-local`, `svc-dns-unbound`, `svc-net-firewall`, `svc-net-tor`, `svc-net-wireguard-core`, `svc-net-wireguard-firewalled`, `svc-net-wireguard-plain`, `svc-opt-keyboard-color`, `svc-opt-ssd-hdd`, `svc-opt-swapfile`, `svc-prx-openresty`, `svc-registry-cache`, `svc-registry-docker`, `svc-runner`, `svc-storage-nfs-client`, `svc-storage-nfs-server`, `svc-swarm-manager`, `svc-swarm-node`, `svc-virt-kata`, `update`.
These roles can recover secrets, alter routing or host state, run code, change deployment state, or reach storage/control-plane sockets; they require a separate audited operations gateway and human approval rather than general-purpose application MCP.
**`shared_engine_isolation` (4):** `svc-db-memcached`, `svc-db-openldap`, `svc-db-postgres`, `svc-db-rabbitmq`.
+90
View File
@@ -0,0 +1,90 @@
# Firewall ruleset
## Description
[nftables](https://www.netfilter.org/projects/nftables/) is the packet filtering framework of the Linux kernel and the successor to `iptables`. Its rules live in memory: a reboot drops them unless something loads them again.
## Overview
This role gives every other role one place to put packet filter rules. A role that needs them writes `templates/nftables.conf.j2` declaring its own nftables table and includes this role; the fragment is rendered to `/etc/nftables.d/<role>.conf`, checked, loaded, and read back, so a deploy fails rather than quietly leaving a gap. Deployed on its own, the role installs nftables, the ruleset that loads every fragment, and the `nftables.service` that replays them at boot.
One table per role is what makes the rules composable. A fragment replaces its own table and touches no other, so two roles on one host neither overwrite nor merge into each other, and a broken fragment fails alone instead of freezing everyone's rules.
Replacing beats adding. `nft -f` on an existing table merges, so a rule dropped from the declaration would stay in the kernel and the ruleset could only ever grow. Each fragment therefore creates, deletes and redeclares its table in one transaction, which is also why the table is never observed empty: unlike a flush followed by rules, there is no window in which the boundary is gone.
## Cosmos
The diagram places Firewall ruleset in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks).
```mermaid
flowchart LR
subgraph role [svc-net-firewall 💻]
svc_firewall["firewall"]
end
```
Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (enabled only in matching deployments); red `0..0` edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in `meta/main.yml`.
## Features
- **Owned per role:** Each consumer declares one table under its own name, so rules are added and retired with the role that needs them.
- **Atomic:** A fragment is applied in a single `nft -f` transaction, so the table is never observed half built.
- **Persistent:** `nftables.service` loads every fragment at boot, so rules outlive a reboot without a unit of this project's own. The ruleset it reads is a distribution decision, so the path follows `os_family`: RedHat reads `/etc/sysconfig/nftables.conf`, the others `/etc/nftables.conf`.
- **Independent of docker:** Docker owns its own tables and never touches these, so no rule has to be inserted into a chain another component controls. The unit is only enabled, never started or restarted: on Debian its `ExecStop` flushes the entire ruleset, docker's tables included, and the fragments are loaded explicitly anyway.
- **Verified:** `nft -c` rejects a fragment before it is written, and the loaded table is read back and refused when it carries no chain. A table that exists is not a rule that runs, so the policy itself is proven by the role that owns it.
## Use Cases
- Bounding a service port that carries no access policy of its own, where the packet filter is the only boundary available.
- Holding a forwarding or masquerade rule that a tunnel needs, without losing it at the next reboot.
## Quick Setup
### Development
Clone, set up the workstation, and deploy Firewall ruleset onto the local stack:
```bash
git clone https://github.com/infinito-nexus/core.git
cd core
make onboard
make compose-deploy mode=reinstall apps=svc-net-firewall full_cycle=false
```
### Production
Install Firewall ruleset directly onto the target machine: clone the repository, install the OS prerequisites and the repository toolchain, then deploy against localhost over a local connection (no SSH, no container):
```bash
git clone https://github.com/infinito-nexus/core.git
cd core
bash scripts/install/package.sh
make install
source scripts/meta/env/load.sh
APP=svc-net-firewall
DOMAIN=<your-domain>
TLS_MODE=self_signed
SSH_PUBLIC_KEY="<your-ssh-public-key>"
INVENTORY=inventories/production
infinito administration inventory provision "$INVENTORY" \
--inventory-file "$INVENTORY/devices.yml" \
--host localhost \
--include "$APP" \
--vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}"
infinito administration deploy dedicated "$INVENTORY/devices.yml" \
--password-file "$INVENTORY/.password" \
--diff -vv
```
## Further Resources
- [nftables Wiki](https://wiki.nftables.org/)
- [nft(8) manual page](https://www.netfilter.org/projects/nftables/manpage.html)
- [Moving from iptables to nftables](https://wiki.nftables.org/wiki-nftables/index.php/Moving_from_iptables_to_nftables)
## Credits
Implemented by **[Kevin Veen-Birkenbach](https://www.veen.world)**.
Part of the [Infinito.Nexus Project](https://s.infinito.nexus/code) and maintained by [Kevin Veen-Birkenbach](https://www.veen.world).
Licensed under the [Infinito.Nexus Community License (Non-Commercial)](https://s.infinito.nexus/license).
+5
View File
@@ -0,0 +1,5 @@
---
logo:
class: fa-solid fa-shield-halved
homepage: https://www.netfilter.org/projects/nftables/
video: https://www.youtube.com/watch?v=EGKhIljDPCw
+27
View File
@@ -0,0 +1,27 @@
---
galaxy_info:
author: Kevin Veen-Birkenbach
description: nftables packet filter whose rules each role owns as its own table, replaced atomically and reloaded at boot
company: |
Kevin Veen-Birkenbach
https://www.veen.world
license: Infinito.Nexus Community License (Non-Commercial)
min_ansible_version: "2.9"
galaxy_tags:
- firewall
- nftables
- netfilter
- security
issue_tracker_url: https://s.infinito.nexus/issues
platforms:
- name: ArchLinux
versions: [all]
- name: Debian
versions: [all]
- name: EL
versions: [all]
- name: Fedora
versions: [all]
- name: Ubuntu
versions: [all]
dependencies: []
+8
View File
@@ -0,0 +1,8 @@
---
nftables:
Archlinux:
- nftables
Debian:
- nftables
RedHat:
- nftables
+16
View File
@@ -0,0 +1,16 @@
---
# nocheck: playwright-service-flag host packet filter, no container and no web surface
firewall:
modes:
host:
enabled: true
compose:
enabled: false
swarm:
enabled: false
lifecycle: beta
min_storage: "50MB"
cpus: "0.1"
mem_reservation: "32m"
mem_limit: "64m"
pids_limit: 64
+32
View File
@@ -0,0 +1,32 @@
---
- name: "📦 Firewall | Install nftables"
become: true
package_install:
id: nftables
- name: "📁 Firewall | Ensure the fragment directory exists"
become: true
ansible.builtin.file:
path: "{{ FIREWALL_FRAGMENT_DIR }}"
state: directory
owner: root
group: root
mode: "0750"
- name: "🧱 Firewall | Install the ruleset every fragment is loaded from"
become: true
ansible.builtin.template: # nocheck: stack-host-template per-host packet filter: every host carrying rules needs the ruleset, not only the stack host
src: main.nft.j2
dest: "{{ FIREWALL_MAIN_CONF }}"
owner: root
group: root
mode: "0640"
backup: true
- name: "🚀 Firewall | Arm the ruleset for boot without touching the live one"
become: true
ansible.builtin.systemd_service:
name: "{{ FIREWALL_SERVICE }}"
enabled: true
- include_tasks: utils/once/flag.yml
@@ -0,0 +1,43 @@
---
- name: "🔍 Firewall | Resolve the fragment of '{{ FIREWALL_FRAGMENT_ROLE }}'"
ansible.builtin.set_fact:
firewall_fragment_src: >-
{{ lookup('first_found',
{
'files': [ FIREWALL_FRAGMENT_SRC ],
'paths': [ lookup('path_absolute', 'roles', FIREWALL_FRAGMENT_ROLE) ]
}
) }}
firewall_loaded_table: "{{ FIREWALL_TABLE }}"
- name: "🧱 Firewall | Stage the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'"
become: true
ansible.builtin.template: # nocheck: stack-host-template per-host packet filter: every host carrying the rules needs the fragment, not only the stack host
src: "{{ firewall_fragment_src }}"
dest: "{{ FIREWALL_FRAGMENT_FILE }}"
owner: root
group: root
mode: "0640"
validate: "nft -c -f %s"
- name: "🚀 Firewall | Load the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'"
become: true
ansible.builtin.command:
argv: [nft, -f, "{{ FIREWALL_FRAGMENT_FILE }}"]
changed_when: true
- name: "🔍 Firewall | Read back the table of '{{ FIREWALL_FRAGMENT_ROLE }}'"
become: true
ansible.builtin.command:
argv: [nft, list, table, inet, "{{ FIREWALL_TABLE }}"]
register: firewall_loaded_ruleset
changed_when: false
- name: "✅ Firewall | Refuse a table that carries no chain"
ansible.builtin.assert:
that:
- "'chain ' in firewall_loaded_ruleset.stdout"
fail_msg: >-
{{ FIREWALL_FRAGMENT_ROLE }} loaded {{ FIREWALL_TABLE }} but the table
holds no chain, so nothing it declares is hooked into the packet path.
A table that exists is not a rule that runs.
+8
View File
@@ -0,0 +1,8 @@
---
- name: "🧱 Firewall | Prepare the host ruleset"
ansible.builtin.include_tasks: 01_host.yml
when: run_once_svc_net_firewall is not defined
- name: "🧱 Firewall | Apply the ruleset of '{{ FIREWALL_FRAGMENT_ROLE }}'"
ansible.builtin.include_tasks: 02_fragment.yml
when: FIREWALL_FRAGMENT_ROLE | length > 0
@@ -0,0 +1,3 @@
#!/usr/sbin/nft -f
# Managed by Infinito.Nexus (roles/svc-net-firewall). Do not edit by hand.
include "{{ FIREWALL_FRAGMENT_DIR }}/*.conf"
+15
View File
@@ -0,0 +1,15 @@
---
application_id: svc-net-firewall
FIREWALL_MAIN_CONF_BY_OS_FAMILY:
Archlinux: "/etc/nftables.conf"
Debian: "/etc/nftables.conf"
RedHat: "/etc/sysconfig/nftables.conf"
FIREWALL_MAIN_CONF: "{{ FIREWALL_MAIN_CONF_BY_OS_FAMILY[ansible_facts['os_family']] }}"
FIREWALL_FRAGMENT_DIR: "/etc/nftables.d"
FIREWALL_SERVICE: "nftables.service"
FIREWALL_FRAGMENT_SRC: "templates/nftables.conf.j2"
FIREWALL_FRAGMENT_ROLE: "{{ ansible_parent_role_names | default([]) | first | default('') }}"
FIREWALL_FRAGMENT_FILE: "{{ [ FIREWALL_FRAGMENT_DIR, FIREWALL_FRAGMENT_ROLE ~ '.conf' ] | path_join }}"
FIREWALL_TABLE: "infinito_{{ FIREWALL_FRAGMENT_ROLE | regex_replace('[^a-zA-Z0-9]', '_') }}"
+2 -6
View File
@@ -61,15 +61,11 @@ def _required(name: str, hint: str) -> str:
return value
def connect_through_socks(
proxy: str, host: str, port: int, timeout: float
) -> None:
def connect_through_socks(proxy: str, host: str, port: int, timeout: float) -> None:
"""Open a SOCKS5 CONNECT to ``host:port`` and raise when it is refused."""
proxy_host, _, proxy_port = proxy.rpartition(":")
try:
sock = socket.create_connection(
(proxy_host, int(proxy_port)), timeout=timeout
)
sock = socket.create_connection((proxy_host, int(proxy_port)), timeout=timeout)
except OSError as error:
raise ProbeError(f"socks proxy unreachable: {error}") from None
-8
View File
@@ -1,8 +0,0 @@
---
- name: "🛡️ Apply the Tor egress guard"
listen: apply tor egress guard
ansible.builtin.systemd_service:
name: "{{ TOR_EGRESS_GUARD_UNIT | basename }}"
state: restarted
daemon_reload: true
become: true
@@ -1,21 +1,10 @@
"""Lookup `tor_egress_clients`: who may use this node's Tor egress ports.
"""Lookup `tor_egress_clients`: the address space Tor's egress ports admit.
Tor's DNSPort and TransPort carry no access policy of their own, unlike
SocksPort with its SocksPolicy, and a swarm-published port cannot be bound to a
single host address. The filter chain installed by ``tasks/router.yml`` is
therefore what bounds them, and this lookup is the list it admits.
Deliberately not the three RFC1918 blocks: ``10.0.0.0/8`` would hand the
operator's whole LAN a resolver and a transparent proxy. Admitted is exactly the
address space this deployment declares for itself:
* loopback, for the node's own dnsmasq,
* every base of ``NETWORK_DOCKER_ADDRESS_POOLS``,
* ``NETWORK_SWARM_GWBRIDGE_POOL``, the gateway bridge docker creates outside
those pools and without which overlay tasks lose their route out,
* ``NETWORK_ROLE_SUBNET_POOL`` and the blocks of the roles named in
``NETWORK_ROLE_SUBNET_EXCEPTION_ROLES``, resolved through ``role_subnets`` so
the CIDR stays declared in the role that owns it.
Deliberately not the three RFC1918 blocks: 10.0.0.0/8 would hand the operator's
whole LAN a resolver and a transparent proxy that carry no access policy of
their own. What is admitted is what this deployment declares for itself, in rule
order: loopback, docker's address pools, the swarm gateway bridge, the role
subnet pool, and the subnets of the roles that sit outside it.
Usage:
@@ -29,6 +18,7 @@ from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.loader import lookup_loader
from ansible.plugins.lookup import LookupBase
from ansible.template import trust_as_template
_REQUIRED = (
"NETWORK_LOOPBACK_CIDR",
@@ -39,20 +29,21 @@ _REQUIRED = (
)
def _pool_bases(pools: Any) -> list[str]:
if not isinstance(pools, (list, tuple)):
raise AnsibleError(
"tor_egress_clients: NETWORK_DOCKER_ADDRESS_POOLS must be a list"
)
bases: list[str] = []
for pool in pools:
base = pool.get("base") if isinstance(pool, dict) else None
if not base:
raise AnsibleError(
f"tor_egress_clients: address pool {pool!r} declares no base"
)
bases.append(str(base).strip())
return bases
def _rendered(templar: Any, value: Any) -> str:
"""A variable's value with its Jinja resolved.
Group vars reach a lookup as whatever the file holds, and several of these
are references rather than literals. Passing one through unrendered would
put the template text itself into a source address, where it cannot match
and therefore silently narrows the admitted set.
"""
text = "" if value is None else str(value).strip()
if "{{" not in text or templar is None:
return text
rendered = str(templar.template(trust_as_template(text))).strip()
if "{{" in rendered:
raise AnsibleError(f"tor_egress_clients: {text!r} did not render to an address")
return rendered
class LookupModule(LookupBase):
@@ -68,28 +59,43 @@ class LookupModule(LookupBase):
missing = [name for name in _REQUIRED if variables.get(name) is None]
if missing:
raise AnsibleError(
"tor_egress_clients: missing group variable(s) "
f"{', '.join(missing)}; the guard must never be narrowed by a "
"value that silently defaulted"
"tor_egress_clients: missing variable(s) "
f"{', '.join(missing)}; an incomplete list would install a guard "
"that admits more than it was asked to"
)
templar = getattr(self, "_templar", None)
excepted = lookup_loader.get(
"role_subnets",
loader=getattr(self, "_loader", None),
templar=getattr(self, "_templar", None),
templar=templar,
).run(
[variables["NETWORK_ROLE_SUBNET_EXCEPTION_ROLES"]],
variables=variables,
)[0]
cidrs = [
str(variables["NETWORK_LOOPBACK_CIDR"]).strip(),
*_pool_bases(variables["NETWORK_DOCKER_ADDRESS_POOLS"]),
str(variables["NETWORK_SWARM_GWBRIDGE_POOL"]).strip(),
str(variables["NETWORK_ROLE_SUBNET_POOL"]).strip(),
*excepted,
pools = variables["NETWORK_DOCKER_ADDRESS_POOLS"]
if not isinstance(pools, (list, tuple)):
raise AnsibleError(
"tor_egress_clients: NETWORK_DOCKER_ADDRESS_POOLS must be a list"
)
bases = []
for pool in pools:
base = pool.get("base") if isinstance(pool, dict) else None
if not base:
raise AnsibleError(
f"tor_egress_clients: address pool {pool!r} declares no base"
)
bases.append(_rendered(templar, base))
ordered = [
_rendered(templar, variables["NETWORK_LOOPBACK_CIDR"]),
*bases,
_rendered(templar, variables["NETWORK_SWARM_GWBRIDGE_POOL"]),
_rendered(templar, variables["NETWORK_ROLE_SUBNET_POOL"]),
*[_rendered(templar, subnet) for subnet in excepted],
]
seen: dict[str, None] = {}
for cidr in cidrs:
for cidr in ordered:
seen.setdefault(cidr, None)
return [list(seen)]
+15 -94
View File
@@ -88,101 +88,22 @@
regexp: "^(hosts:.*\\bresolve)\\s*\\[!UNAVAIL=return\\](.*)$"
line: "\\1 [SUCCESS=return NOTFOUND=continue TRYAGAIN=return]\\2"
backrefs: true
- name: "🔀 Egress router | Redirect .onion virtual-IP TCP to Tor TransPort"
ansible.builtin.iptables:
table: nat
chain: OUTPUT
protocol: tcp
destination: "{{ TOR_EGRESS_VIRTUAL_NET_IPV4 }}"
jump: REDIRECT
to_ports: "{{ TOR_TRANS_PORT | string }}"
comment: "Tor egress router: .onion virtual range -> TransPort"
- name: "🔀 Egress router | Route .onion traffic and bound the ports that carry no policy"
ansible.builtin.include_role:
name: svc-net-firewall
- name: "🔀 Egress router | Send container .onion traffic to the bridge TransPort"
when: TOR_CONTAINER_DNS_HOST | length > 0
ansible.builtin.iptables:
table: nat
chain: PREROUTING
protocol: tcp
destination: "{{ TOR_EGRESS_VIRTUAL_NET_IPV4 }}"
jump: DNAT
to_destination: "{{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }}"
comment: "Tor egress router: container .onion virtual range -> TransPort"
- name: "🔀 Egress router | Prove the container redirect is installed"
when: TOR_CONTAINER_DNS_HOST | length > 0
- name: "🛡️ Egress guard | Read back the chain that bounds the ports"
become: true
ansible.builtin.command:
argv:
- iptables
- -t
- nat
- -C
- PREROUTING
- -p
- tcp
- -d
- "{{ TOR_EGRESS_VIRTUAL_NET_IPV4 }}"
- -j
- DNAT
- --to-destination
- "{{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }}"
argv: [nft, list, chain, inet, "{{ firewall_loaded_table }}", guard]
register: tor_egress_guard_chain
changed_when: false
- name: "🛡️ Egress guard | Stage the guard applier"
when: DEPLOYMENT_MODE == 'swarm'
ansible.builtin.template:
src: tor-egress-guard.sh.j2
dest: "{{ TOR_EGRESS_GUARD_SCRIPT }}"
owner: root
group: root
mode: "0750"
notify: apply tor egress guard
- name: "🛡️ Egress guard | Install the unit that reapplies it after docker"
when: DEPLOYMENT_MODE == 'swarm'
ansible.builtin.template:
src: tor-egress-guard.service.j2
dest: "{{ TOR_EGRESS_GUARD_UNIT }}"
owner: root
group: root
mode: "0644"
notify: apply tor egress guard
- name: "🛡️ Egress guard | Enable the guard across reboots"
when: DEPLOYMENT_MODE == 'swarm'
ansible.builtin.systemd_service:
name: "{{ TOR_EGRESS_GUARD_UNIT | basename }}"
enabled: true
state: started
daemon_reload: true
- name: "🛡️ Egress guard | Flush handlers so the guard is live before checking"
when: DEPLOYMENT_MODE == 'swarm'
ansible.builtin.meta: flush_handlers
- name: "🛡️ Egress guard | Prove the guard rejects everything it must"
when: DEPLOYMENT_MODE == 'swarm'
ansible.builtin.command:
argv:
- iptables
- -C
- "{{ TOR_EGRESS_GUARD_CHAIN }}"
- -j
- DROP
changed_when: false
- name: "🛡️ Egress guard | Prove every guarded port reaches the guard"
when: DEPLOYMENT_MODE == 'swarm'
ansible.builtin.command:
argv:
- iptables
- -C
- "{{ item.0 }}"
- -p
- "{{ item.1.protocol }}"
- --dport
- "{{ item.1.port | string }}"
- -j
- "{{ TOR_EGRESS_GUARD_CHAIN }}"
changed_when: false
loop: "{{ TOR_EGRESS_GUARD_HOOK_CHAINS | product(TOR_EGRESS_GUARDED_PORTS) | list }}"
- name: "🛡️ Egress guard | Refuse a guard that drops nothing"
ansible.builtin.assert:
that:
- "'drop' in tor_egress_guard_chain.stdout"
fail_msg: >-
The guard chain in {{ firewall_loaded_table }} carries no drop, so
TransPort and DNSPort are reachable from every source. These two ports
have no access policy of their own; this chain is the only boundary.
@@ -0,0 +1,38 @@
{# Loaded by roles/svc-net-firewall into {{ FIREWALL_TABLE }}. #}
table inet {{ FIREWALL_TABLE }}
delete table inet {{ FIREWALL_TABLE }}
table inet {{ FIREWALL_TABLE }} {
set admitted {
type ipv4_addr
flags interval
elements = { {{ TOR_EGRESS_CLIENT_CIDRS | join(', ') }} }
}
chain guard {
{% for entry in TOR_EGRESS_GUARDED_PORTS %}
ip saddr != @admitted {{ entry.protocol }} dport {{ entry.port }} drop
{% endfor %}
}
chain input {
type filter hook input priority -10; policy accept;
jump guard
}
chain forward {
type filter hook forward priority -10; policy accept;
jump guard
}
chain output {
type nat hook output priority -150; policy accept;
ip daddr {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} meta l4proto tcp redirect to :{{ TOR_TRANS_PORT }}
}
{% if TOR_CONTAINER_DNS_HOST | length > 0 %}
chain prerouting {
type nat hook prerouting priority -150; policy accept;
ip daddr {{ TOR_EGRESS_VIRTUAL_NET_IPV4 }} meta l4proto tcp dnat ip to {{ TOR_CONTAINER_DNS_HOST }}:{{ TOR_TRANS_PORT }}
}
{% endif %}
}
@@ -1,13 +0,0 @@
[Unit]
Description=Infinito.Nexus Tor egress guard
Documentation=file://{{ TOR_EGRESS_GUARD_SCRIPT }}
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart={{ TOR_EGRESS_GUARD_SCRIPT }}
[Install]
WantedBy=multi-user.target
@@ -1,21 +0,0 @@
#!/bin/bash
# Managed by Infinito.Nexus (roles/svc-net-tor) — do not edit by hand.
set -euo pipefail
CHAIN={{ TOR_EGRESS_GUARD_CHAIN | quote }}
iptables -N "${CHAIN}" 2>/dev/null || true
iptables -F "${CHAIN}"
{% for cidr in TOR_EGRESS_CLIENT_CIDRS %}
iptables -A "${CHAIN}" -s {{ cidr | quote }} -j ACCEPT
{% endfor %}
iptables -A "${CHAIN}" -j DROP
{% for chain in TOR_EGRESS_GUARD_HOOK_CHAINS %}
iptables -N {{ chain | quote }} 2>/dev/null || true
{% for entry in TOR_EGRESS_GUARDED_PORTS %}
iptables -C {{ chain | quote }} -p {{ entry.protocol | quote }} --dport {{ entry.port | quote }} -j "${CHAIN}" 2>/dev/null \
|| iptables -I {{ chain | quote }} -p {{ entry.protocol | quote }} --dport {{ entry.port | quote }} -j "${CHAIN}"
{% endfor %}
{% endfor %}
+1 -7
View File
@@ -13,13 +13,6 @@ TOR_TRANS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.t
TOR_DNS_PORT: "{{ lookup('config', application_id, 'services.tor.ports.local.dns') }}"
TOR_DNSMASQ_CONF: "/etc/dnsmasq.d/tor-onion.conf"
TOR_EGRESS_GUARD_CHAIN: INFINITO_TOR_EGRESS
TOR_EGRESS_GUARD_SCRIPT: "/usr/local/bin/tor-egress-guard.sh"
TOR_EGRESS_GUARD_UNIT: "/etc/systemd/system/tor-egress-guard.service"
TOR_EGRESS_GUARD_HOOK_CHAINS:
- INPUT
- DOCKER-USER
TOR_EGRESS_CLIENT_CIDRS: "{{ lookup('tor_egress_clients') }}"
TOR_EGRESS_GUARDED_PORTS:
- port: "{{ TOR_TRANS_PORT }}"
protocol: tcp
@@ -27,6 +20,7 @@ TOR_EGRESS_GUARDED_PORTS:
protocol: tcp
- port: "{{ TOR_DNS_PORT }}"
protocol: udp
TOR_EGRESS_CLIENT_CIDRS: "{{ lookup('tor_egress_clients') }}"
TOR_DNSMASQ_DROPIN: "/etc/systemd/system/dnsmasq.service.d/infinito-restart.conf"
# nocheck: host-local-fact meta/services.yml pins this role to the manager, so the fact describes the node the hidden service forwards on
TOR_CONTAINER_DNS_HOST: "{{ ansible_facts['docker0']['ipv4']['address'] | default('', true) }}"
@@ -246,6 +246,7 @@ class TestVariableDefinitions(unittest.TestCase):
"ansible_memtotal_mb",
"omit",
"group_names",
"ansible_parent_role_names",
"ansible_processor_vcpus",
"ansible_service_mgr",
"ansible_distribution_release",
@@ -293,6 +294,7 @@ class TestVariableDefinitions(unittest.TestCase):
"ansible_memtotal_mb",
"omit",
"group_names",
"ansible_parent_role_names",
"ansible_processor_vcpus",
"ansible_distribution_release",
"ansible_distribution",
@@ -77,15 +77,7 @@ class _Placeholder(jinja2.Undefined):
return self
_SAMPLE = {
"TOR_EGRESS_GUARD_CHAIN": "INFINITO_TOR_EGRESS",
"TOR_EGRESS_CLIENT_CIDRS": ["127.0.0.0/8", "10.208.0.0/12"],
"TOR_EGRESS_GUARD_HOOK_CHAINS": ["INPUT", "DOCKER-USER"],
"TOR_EGRESS_GUARDED_PORTS": [
{"port": "9040", "protocol": "tcp"},
{"port": "9053", "protocol": "udp"},
],
}
_SAMPLE: dict[str, object] = {}
def _shell_templates() -> list[Path]:
@@ -0,0 +1,3 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[5]
@@ -0,0 +1,208 @@
"""Every role's nftables fragment parses, and replaces its table instead of merging.
``nft -f`` on ``table inet x { ... }`` MERGES into an existing table: a rule
dropped from the declaration stays in the kernel, and the ruleset can only ever
grow. The three-line idiom below is what makes a fragment a replacement:
table inet x <- creates it when absent, so the delete cannot fail
delete table inet x <- removes whatever the last deploy left
table inet x { ... } <- the declaration, applied in the same transaction
``nft -f`` is atomic, so the table is never observed empty. Without the first
line the delete fails on a fresh host; without the second the fragment merges.
The checks run the real ``nft`` over the rendered text, so a fragment that is
valid Jinja but invalid nftables fails here rather than on the host. They render
every deploy mode, because a fragment may branch on one, and feed plausible
rather than real values: nft needs shapes, not this host's addresses.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from contextlib import contextmanager
from functools import lru_cache
from pathlib import Path
import jinja2
from utils.cache.files import iter_project_files, read_text
from . import PROJECT_ROOT
FRAGMENT = "templates/nftables.conf.j2"
TABLE = "infinito_probe"
COMMON = {
"FIREWALL_TABLE": TABLE,
"TOR_EGRESS_CLIENT_CIDRS": ["127.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"],
"TOR_EGRESS_GUARDED_PORTS": [
{"port": "9040", "protocol": "tcp"},
{"port": "9053", "protocol": "tcp"},
{"port": "9053", "protocol": "udp"},
],
"WIREGUARD_CLIENT_INTERFACE": "wg0-client",
"WIREGUARD_UPLINK_INTERFACE": "eth0",
"TOR_EGRESS_VIRTUAL_NET_IPV4": "10.192.0.0/10",
"TOR_TRANS_PORT": "9040",
"TOR_CONTAINER_DNS_HOST": "172.17.0.1",
}
MODES = ("compose", "swarm")
def _run(argv: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(argv, capture_output=True, text=True, check=False)
@lru_cache(maxsize=1)
def _nft_can_check() -> bool:
"""Whether `nft -c` works here.
It is not a parser: it opens netlink and builds a cache against the live
kernel first, so on a runner without CAP_NET_ADMIN it fails on a valid
ruleset. Probing the binary's presence would make this suite red there.
"""
if shutil.which("nft") is None:
return False
with tempfile.NamedTemporaryFile("w", suffix=".conf") as handle:
handle.write("table inet infinito_capability_probe\n")
handle.flush()
return _run(["nft", "-c", "-f", handle.name]).returncode == 0
@contextmanager
def _network_namespace(name: str):
"""An empty namespace, so a loaded ruleset cannot reach the live one."""
created = _run(["ip", "netns", "add", name])
if created.returncode != 0:
raise unittest.SkipTest(f"cannot create a network namespace: {created.stderr}")
try:
yield ["ip", "netns", "exec", name]
finally:
_run(["ip", "netns", "del", name])
def _fragments() -> list[str]:
suffix = "/" + FRAGMENT
return sorted(
path
for path in iter_project_files(extensions=(".j2",), exclude_tests=True)
if path.endswith(suffix)
)
def _render(path: str, mode: str = "swarm") -> str:
environment = jinja2.Environment(
keep_trailing_newline=True,
trim_blocks=True,
autoescape=False, # noqa: S701 nftables, not markup
)
return environment.from_string(read_text(path)).render(
DEPLOYMENT_MODE=mode, **COMMON
)
class TestFirewallFragments(unittest.TestCase):
def test_at_least_one_fragment_exists(self) -> None:
"""Otherwise every assertion below passes over an empty list."""
self.assertTrue(_fragments(), "no role declares templates/nftables.conf.j2")
def test_each_fragment_replaces_its_table(self) -> None:
for path in _fragments():
rel = Path(path).relative_to(PROJECT_ROOT).as_posix()
with self.subTest(fragment=rel):
lines = [
line.strip() for line in _render(path).splitlines() if line.strip()
]
declarations = [
line for line in lines if re.match(r"^(delete )?table inet ", line)
]
self.assertEqual(
declarations[:3],
[
f"table inet {TABLE}",
f"delete table inet {TABLE}",
f"table inet {TABLE} {{",
],
f"{rel} must open with the create, delete and declare idiom, "
"or nft merges the new rules into the old table",
)
def test_each_fragment_parses(self) -> None:
if not _nft_can_check():
self.skipTest("nft -c needs CAP_NET_ADMIN, which this runner lacks")
nft = shutil.which("nft")
for path in _fragments():
rel = Path(path).relative_to(PROJECT_ROOT).as_posix()
for mode in MODES:
with self.subTest(fragment=rel, mode=mode):
with tempfile.NamedTemporaryFile("w", suffix=".conf") as handle:
handle.write(_render(path, mode))
handle.flush()
result = subprocess.run(
[nft, "-c", "-f", handle.name],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(
result.returncode,
0,
f"{rel} is not valid nftables in {mode}:\n{result.stderr}",
)
def test_each_fragment_loads_and_reloads(self) -> None:
"""A dry run cannot show what happens on the second deploy.
Loading the same fragment twice is what proves the idiom: the second
pass meets the table the first one left and must replace it, which is
exactly the case ``nft -c`` never sees.
"""
if not _nft_can_check() or shutil.which("ip") is None:
self.skipTest("loading a ruleset needs nft with CAP_NET_ADMIN and ip")
for index, path in enumerate(_fragments()):
rel = Path(path).relative_to(PROJECT_ROOT).as_posix()
for mode in MODES:
with self.subTest(fragment=rel, mode=mode):
with (
tempfile.NamedTemporaryFile("w", suffix=".conf") as handle,
_network_namespace(f"nftprobe{os.getpid()}x{index}") as netns,
):
handle.write(_render(path, mode))
handle.flush()
listings = []
for attempt in ("first", "second"):
loaded = _run([*netns, "nft", "-f", handle.name])
self.assertEqual(
loaded.returncode,
0,
f"{rel} failed to load on the {attempt} pass "
f"in {mode}:\n{loaded.stderr}",
)
listed = _run(
[*netns, "nft", "list", "table", "inet", TABLE]
)
self.assertEqual(
listed.returncode,
0,
f"{rel} loaded but left no table on the {attempt} "
f"pass in {mode}:\n{listed.stderr}",
)
listings.append(listed.stdout)
self.assertIn(f"table inet {TABLE}", listings[0])
self.assertEqual(
listings[0],
listings[1],
f"{rel} grew on the second pass in {mode}, so it merges "
"into the table it found instead of replacing it",
)
if __name__ == "__main__":
unittest.main()
@@ -1,110 +0,0 @@
"""The rendered Tor egress guard rebuilds its chain instead of adding to it.
The first version of this guard appended: it created the chain when absent and
appended one ACCEPT per admitted range. Narrowing the admitted set then did
nothing, because the retired range had no removal step and the new one landed
below the DROP, where no packet reaches it. The chain could be loosened and
never tightened, which is the wrong direction for a rule that is the only
boundary two policy-less ports have.
What keeps that from coming back is an order: flush, then admit, then drop, then
hook. These tests pin it against the rendered script, because the ordering is
invisible in the template's individual lines.
"""
from __future__ import annotations
import shlex
import unittest
from pathlib import Path
import jinja2
from . import PROJECT_ROOT
TEMPLATE = (
PROJECT_ROOT / "roles" / "svc-net-tor" / "templates" / "tor-egress-guard.sh.j2"
)
CHAIN = "INFINITO_TOR_EGRESS"
CLIENTS = ["127.0.0.0/8", "10.208.0.0/12"]
HOOKS = ["INPUT", "DOCKER-USER"]
PORTS = [
{"port": "9040", "protocol": "tcp"},
{"port": "9053", "protocol": "udp"},
]
def _render() -> list[str]:
environment = jinja2.Environment(
keep_trailing_newline=True,
autoescape=False, # noqa: S701 shell, not markup
)
environment.filters["quote"] = shlex.quote
rendered = environment.from_string(
Path(TEMPLATE).read_text(encoding="utf-8")
).render(
TOR_EGRESS_GUARD_CHAIN=CHAIN,
TOR_EGRESS_CLIENT_CIDRS=CLIENTS,
TOR_EGRESS_GUARD_HOOK_CHAINS=HOOKS,
TOR_EGRESS_GUARDED_PORTS=PORTS,
)
return [line.strip() for line in rendered.splitlines() if line.strip()]
def _index_of(lines: list[str], needle: str) -> int:
for position, line in enumerate(lines):
if needle in line:
return position
raise AssertionError(f"the rendered guard never {needle!r}:\n" + "\n".join(lines))
class TestEgressGuardScript(unittest.TestCase):
def test_the_chain_is_flushed_before_it_is_filled(self) -> None:
lines = _render()
flush = _index_of(lines, '-F "${CHAIN}"')
first_admit = _index_of(lines, f"-s {CLIENTS[0]}")
self.assertLess(
flush,
first_admit,
"the chain must be flushed before the admitted ranges are added, or a "
"narrowed client list leaves the old range in place",
)
def test_every_admitted_range_precedes_the_drop(self) -> None:
lines = _render()
drop = _index_of(lines, "-j DROP")
for cidr in CLIENTS:
with self.subTest(cidr=cidr):
self.assertLess(
_index_of(lines, f"-s {cidr}"),
drop,
f"{cidr} is admitted below the DROP, where it never matches",
)
def test_the_hooks_come_after_the_chain_is_complete(self) -> None:
"""A hook installed first would route packets into a half-built chain."""
lines = _render()
drop = _index_of(lines, "-j DROP")
for chain in HOOKS:
for entry in PORTS:
with self.subTest(chain=chain, port=entry["port"]):
hook = _index_of(lines, f"-I {chain} -p {entry['protocol']}")
self.assertGreater(hook, drop)
def test_every_admitted_range_and_port_is_rendered(self) -> None:
lines = _render()
body = "\n".join(lines)
for cidr in CLIENTS:
self.assertIn(cidr, body)
for chain in HOOKS:
for entry in PORTS:
self.assertIn(
f"--dport {entry['port']}",
body,
f"{chain} never guards {entry['protocol']}/{entry['port']}",
)
if __name__ == "__main__":
unittest.main()