Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8655897
jool: stateful NAT64 role with a fail-closed source guard
maurerle Nov 11, 2026
4bdb8c2
dns64: single-instance BIND role synthesising AAAA for parker LANs
maurerle Nov 11, 2026
1d24641
parker_concentrator: apply parker records to a routed wg interface
maurerle Nov 11, 2026
ac81cfc
parker: deploy the DNS64, NAT64 and guard on a concentrator
maurerle Nov 11, 2026
b617de4
bird: learn the parker routes from the kernel table
maurerle Nov 11, 2026
8723bdf
networkd: allow destination-based policy rules with a priority
maurerle Nov 11, 2026
f4c289a
wgbroker: parker config service with a git-backed allocator
maurerle Nov 11, 2026
9dde5e6
tests: cover the parker allocator and run pytest in CI
maurerle Nov 11, 2026
7146513
secrets: add the parker usign signing key
maurerle Nov 11, 2026
7237d42
wgbroker: bring its own nginx vhost and certificate
maurerle Nov 11, 2026
5454c92
broker: run the wgbroker role on sn03
maurerle Nov 11, 2026
a4cc1ae
secrets: add the sn03 parker tunnel key
maurerle Nov 11, 2026
1731905
parker: wire sn03 up as concentrator and config broker
maurerle Nov 11, 2026
05b8aff
parker_concentrator: only ever touch the delegated routes
maurerle Nov 11, 2026
f6ff4c8
ff.backbone: give static protocols a channel
maurerle Nov 12, 2026
3a81a57
sn03: originate the parker pool as one /56
maurerle Nov 12, 2026
d635811
parker_lib: ask parker nodes for respondd and validate the answer
maurerle Nov 12, 2026
82e1a62
tests: cover the respondd helpers
maurerle Nov 12, 2026
6f7c659
parker: forward node data to the monitor's yanic
maurerle Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: test

on:
push:
branches: [main]
pull_request:

jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.1.0
with:
python-version: "3.13"
- name: Install pytest
run: pip install pytest
- name: Run unit tests
run: pytest tests/
514 changes: 273 additions & 241 deletions group_vars/all/secrets.yml

Large diffs are not rendered by default.

80 changes: 80 additions & 0 deletions group_vars/parker_concentrators/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
# Parker uplink: nodes register over HTTP and receive a routed /63; their
# clients reach IPv4 through the NAT64 + DNS64 pair that playbooks/parker.yml
# installs.

# Our own segments use 2a03:2260:3006:0100::/56, :0200::/56 and :0300::/56,
# so the upper half of the /48 is free for parker slots.
#
# One /56 for now: 128 slots, ids 1..127, which no FFRL filter can object to -
# they already route our other /56s. Short of the surrounding /49 (16384
# slots) on purpose, and growing later is purely additive: the id -> /64
# mapping is 2*id, the same in both, so no node is renumbered when the
# announced prefix gets shorter. That /49 is 2a03:2260:3006:8000::/49.
parker_pool6: "2a03:2260:3006:8000::/56"

# Pool the reconciler derives the node prefixes from; the broker allocates out
# of the same one, so both sides have to read it from here.
parker_prefix6: "{{ parker_pool6 }}"

# Publish each node's last handshake into the registry, so the broker-side GC
# can also see nodes that only ever reach this one concentrator.
parker_gc_publish: true

# Hand each node's respondd answer to the monitoring yanic. The monitor cannot
# poll a parker node itself: no batman interface to multicast into, and it
# records this host as the node's address, so it never asks the node either.
# The concentrator puts the request into the tunnel and forwards the answer
# unchanged to the monitor's central yanic listener (its own respondd
# connection does exactly the same for the bat nodes).
parker_respondd_push: true

# DNS64 must answer only clients whose IPv4 transits our NAT64: synthesised
# AAAA for native-IPv4 clients breaks Happy Eyeballs, and an open resolver on
# a supernode uplink is worse.
dns64_networks:
- "{{ parker_pool6 }}"

# Same /96 for both halves of the pair, so a client's synthesised AAAA is an
# address Jool actually translates. The play asserts the two agree; this line
# keeps them from drifting apart in the first place.
dns64_prefix: "{{ jool_pool6 }}"

# The public IPv4 this host's NAT64 masks translated flows with. Every
# concentrator sets it in its host_vars; the empty default is what makes a
# forgotten one fail the jool role's assert instead of the whole play with an
# undefined variable.
parker_pool4: ""

# NAT64 masks translated flows with the host's public IPv4 (see host_vars).
jool_pool4_prefix: "{{ parker_pool4 }}"

# Where the delegated /64s are routed instead of main: bird's kernel table on a
# supernode (host_vars: bird.kernel ip_table 42 with `learn: true` and the
# accept_parker_pool_v6 import filter). bird announces them in OSPF, and the
# destination-based ip rule in host_vars sends this host's own lookups for the
# pool into the same table - without both, the prefixes would be announced but
# the NAT64's return packets would leave through the backbone default.
parker_route_table: "42"

# Ports NAT64 may mask translated flows to on the pool4 address. One BIB entry
# per client source port and protocol, held for udp 5 min and tcp 2 h, so the
# width is the flow capacity of the whole concentrator: 11000 ports carry
# ~36 new UDP sockets/s or ~1.5 new TCP connections/s in steady state, or
# 11000 simultaneous TCP connections. Kept below the host's ephemeral range
# (32768-60999) and clear of its listening ports; the role asserts both. A
# dedicated second pool4 address lifts the ceiling to the full port space once
# real traffic outgrows this.
jool_port_range: "20000-30999"

# Only parker clients may use the NAT64. Their addresses come out of the same
# pool the broker hands prefixes from, so the guard and the DNS64 ACL are the
# same set - anything outside it would be turned into an open relay.
jool_source6_allow:
- "{{ parker_pool6 }}"

# ... and they have to come through the tunnel. Without this the guard trusts
# the source prefix alone, so a packet with a spoofed pool address (sent from
# anywhere that can route to 64:ff9b::/96) would be translated too. On wg-nodes
# wireguard has already checked the peer's allowed-ips.
jool_source6_interface: "wg-nodes"
1 change: 1 addition & 0 deletions host_vars/bb-a.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ bird:
authentication: "none"
staticroutes:
- name: uplink_hostroute
is_ipv6: no
rules:
- "route 185.66.193.46/32 via 100.64.17.157"
filter:
Expand Down
105 changes: 105 additions & 0 deletions host_vars/sn03.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ networkd_interfaces:
table: 42
- from: "10.0.0.0/11"
table: 42
# The parker pool is routed in bird's table 42, and the packets Jool
# translates back carry 64:ff9b:: as their source, so the `from` rules
# above never match them. Without this destination-based rule the kernel
# looks the pool up in main, finds no route there, and sends the return
# path to the backbone default instead of the tunnel.
- to: "{{ parker_pool6 }}"
table: 42
# clear of the auto-assigned block (3276x) and above the kernel's
# `from all lookup main` at 32766, which would otherwise win
priority: 32750
# ... and the forward direction needs the same push: traffic sourced
# from a pool address is the node talking to the outside world. The pool
# is announced to FFRL, not to the site uplink, so only the backbone link
# has a path back. Left to main the packet leaves via eth0 and the answer
# either never returns or is dropped as spoofed at the site uplink's
# ingress filter.
- from: "{{ parker_pool6 }}"
table: 42
# ahead of the `to` rule so a node-to-node flow uses one table either
# way, and still above `from all lookup main`
priority: 32749
- name: ens19
# IPv4 only, no router advertisement on this link either
no_radv_accept: True
Expand All @@ -73,6 +94,15 @@ bird:
- "filter filter_kernel_v4"
- is_ipv6: yes
ip_table: 42
# ff.parker_concentrator writes the delegated /64s into table 42 with
# `ip route replace`. bird ignores routes it did not install itself
# unless it is told to learn them: without this the prefixes sit in 42,
# reach neither master6 nor OSPF, and nothing forwards to them. The
# import filter keeps table 42 from feeding arbitrary routes into our
# announcements - only the parker pool is taken over.
learn: true
imports:
- "filter accept_parker_pool_v6"
# bird_table: auxtable
exports:
- "filter filter_kernel_v6"
Expand Down Expand Up @@ -119,6 +149,17 @@ bird:
dead: 20
check: link
authentication: "none"
staticroutes:
# Originate the parker pool as one aggregate, the way the bat /56 is
# originated from lo: accept_public_prefix_v6 carries it to the
# backbones over OSPF and they announce it to FFRL, which routes no
# longer prefix for us. Kept here rather than as a static on the
# backbones, so a backbone that loses its adjacency to sn03 withdraws
# the pool instead of blackholing it. In table 42 the node /64s shadow
# it; a free slot answers unreachable instead of looping to the default.
- name: parker_pool
rules:
- "route {{ parker_pool6 }} unreachable"
filter:
- name: reject_internal_prefixes_v4
default_accept: yes
Expand All @@ -136,6 +177,13 @@ bird:
default_accept: no
rules:
- "if net ~ [2a03:2260:3006::/48+] then accept;"
- name: accept_parker_pool_v6
# Import filter of the kernel protocol: the parker reconciler adds one
# /64 per node to table 42 without telling bird. `learn` picks them up,
# this filter decides which ones are ours.
default_accept: no
rules:
- "if net ~ [{{ parker_pool6 }}+] then accept;"
- name: filter_kernel_v4
default_accept: yes
rules:
Expand All @@ -156,3 +204,60 @@ radv_announce_default: true
#local_vxlan_supernode: 2a00:fe0:43::201
#remote_vxlan_supernode: 2a00:fe0:43::202
yanic_central: "true"

# parker uplink (playbooks/parker.yml): the address NAT64 masks translated
# flows with - eth0's public IPv4, whose return path is already routed here
parker_pool4: "5.145.135.153/32"

# This concentrator's end of the parker tunnels. The address sits in the id-0
# /63 of the pool, the one slot the allocator never hands out, and the node
# configures it as the peer of its own /128 - so it needs no route of its own.
parker_address6: "2a03:2260:3006:8000::1/128"
parker_privatekey: "{{ parker_sn03_privatekey }}"

# Parker config service. The broker itself comes from playbooks/broker.yml;
# `monitor` keeps its instance for images that still have
# wg-broker.freifunk-aachen.de baked in. usign_secret is the vaulted key
# content the role writes to wgbroker_parker_secret_key.
#
# Everything a node is told is in this block; the registry it builds starts
# local to this host.
wgbroker_parker:
enabled: true
usign_secret: "{{ parker_usign_secret }}"
prefix6: "{{ parker_pool6 }}"
# Community-wide client IPv4: no per-node assignment, so every node hands
# its clients an address out of this /22 and the first 16 are reserved for
# the routers themselves (clientaddr.lua). 10.80.96.0/22 is free in our
# network - the mesh carries no other client IPv4.
range4: "10.80.96.0/22"
address4: "10.80.96.1"
mtu: 1406
wg_keepalive: 25
retry: 120
# Omit xlat to fall back to routed IPv4 in the tunnel fleet-wide; the
# concentrator interface carries IPv6 only, so this stays on.
xlat: true
rate_limit:
burst: 10
window: 3600
daily: 200
# Both concentrators are always sent, so a node fails over on a stale
# handshake instead of waiting for a reflash. address4 is only read in the
# routed mode; under xlat the node ignores it.
concentrators:
- id: 1
pubkey: "7IRdL29opT9mPWqLL/BR2uOprNYa0Evh7zDUsiM6kzs="
endpoint: "03.wg-node.freifunk-aachen.de:10000"
address6: "2a03:2260:3006:8000::1"
address4: "10.80.96.1"
# Log-only for the first cycle, so we see what it would remove.
gc:
enabled: true
dry_run: true
never_handshaked_days: 7
silent_days: 90

# site.conf sends both broker endpoints (add_key, /config) here, so the role
# brings its own nginx vhost and certificate (playbooks/broker.yml).
wgbroker_vhost: 03.wg-node.freifunk-aachen.de
12 changes: 12 additions & 0 deletions inventory
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ supernodes:
sn02:
sn03:

# Hosts that terminate parker uplinks next to their supernode duties. One at
# first.
parker_concentrators:
hosts:
sn03:

# The wireguard broker (add_key and /config). It still runs on `monitor` via
# playbooks/monitoring.yml; this group is the host it is moving to.
wgbrokers:
hosts:
sn03:

monitoring:
hosts:
monitor:
Expand Down
17 changes: 17 additions & 0 deletions playbooks/broker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---

- name: Configure the wireguard broker
hosts: "wgbrokers"
pre_tasks:
- name: "Import pre_tasks: reboot-required.yml"
ansible.builtin.import_tasks: "include/reboot-required.yml"
tags: [always]
- name: "Import pre_task etckeeper"
ansible.builtin.import_tasks: "include/etckeeper_pre.yml"
tags: [always]
roles:
- { role: ff.wgbroker, tags: wgbroker, become: true }
post_tasks:
- name: "Import post_task etckeeper"
ansible.builtin.import_tasks: "include/etckeeper_post.yml"
tags: [always]
Loading
Loading