mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-07 11:22:31 +00:00
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:
1 parent
99f7e03845
commit
bf32f57b85
25 files changed
+567
-311
No files matched your search
@@ -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/) |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
nftables:
|
||||
Archlinux:
|
||||
- nftables
|
||||
Debian:
|
||||
- nftables
|
||||
RedHat:
|
||||
- nftables
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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]', '_') }}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -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 %}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user