From 865589717cef7a1a6c4b1c81cf02d2e9e5f487cd Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:08 +0000 Subject: [PATCH 01/19] jool: stateful NAT64 role with a fail-closed source guard One SIIT+PLAT instance; the role applies the config itself, so no dropped handler can leave the file and the running instance apart, and jool_port_range is asserted against the host's own port space because NAT64 on a public pool4 address shares it. The guard drops traffic to pool6 that is not from an allowed prefix on the expected interface, with a DROP that exists before anything can jump into it. --- playbooks/roles/ff.jool/defaults/main.yml | 41 +++++ playbooks/roles/ff.jool/tasks/main.yml | 167 ++++++++++++++++++ .../ff.jool/templates/jool-guard.service.j2 | 16 ++ .../roles/ff.jool/templates/jool-guard.sh.j2 | 112 ++++++++++++ .../roles/ff.jool/templates/jool.conf.j2 | 28 +++ 5 files changed, 364 insertions(+) create mode 100644 playbooks/roles/ff.jool/defaults/main.yml create mode 100644 playbooks/roles/ff.jool/tasks/main.yml create mode 100644 playbooks/roles/ff.jool/templates/jool-guard.service.j2 create mode 100644 playbooks/roles/ff.jool/templates/jool-guard.sh.j2 create mode 100644 playbooks/roles/ff.jool/templates/jool.conf.j2 diff --git a/playbooks/roles/ff.jool/defaults/main.yml b/playbooks/roles/ff.jool/defaults/main.yml new file mode 100644 index 0000000..508ae1d --- /dev/null +++ b/playbooks/roles/ff.jool/defaults/main.yml @@ -0,0 +1,41 @@ +--- +# Stateful NAT64 (SIIT+PLAT) via the jool kernel module. One instance, +# installed from the Debian packages. The role applies /etc/jool/jool.conf +# with `jool file handle` on every run: that replaces the config of the +# running instance and keeps its state. A `systemctl restart` is not +# equivalent - the packaged unit stops the instance with `instance remove`, +# and every dynamic BIB entry and session dies with it. + +jool_instance: nat64 + +# The well-known SYN64 prefix. The parker nodes hardcode announcing +# 64:ff9b::/96 to their clients, so this must match them. +jool_pool6: "64:ff9b::/96" + +# Address(es) translated traffic leaves from. Required; for a first test the +# host's own public IPv4 /32 works when its return path is already routed. +jool_pool4_prefix: "" + +# Ports NAT64 may use on jool_pool4_prefix, "-". Deliberately no +# default: which range is safe depends on the host's ephemeral range +# (/proc/sys/net/ipv4/ip_local_port_range) and on what listens where, and the +# role asserts both. The width is the whole concentrator's flow capacity - +# one BIB entry per client source port and protocol, held for udp 5 min and +# tcp 2 h - so it is a per-host decision, not a constant. +jool_port_range: "" + +# Source prefixes allowed to use the NAT64. Jool hooks PRE_ROUTING only and +# translates whatever is addressed to jool_pool6, so without this list every +# host that can route to us may reach IPv4 through our address. Empty means no +# guard is installed at all; the guard is opt-in and is not torn down when the +# list is emptied later on (that needs `systemctl disable --now jool-guard` +# plus `ip6tables -t raw -D PREROUTING -d -j JOOL_GUARD`). +jool_source6_allow: [] + +# Interface the traffic has to arrive on to reach the guard's chain. A source +# prefix alone proves nothing - anyone can write it into a packet header. On a +# concentrator this is the wireguard interface: its peers' allowed-ips already +# decided which source may appear there, so the interface check makes the +# source trustworthy. Empty leaves the source prefix as the only check, which +# is all that is left for clients arriving on a bridge or a lab veth. +jool_source6_interface: "" diff --git a/playbooks/roles/ff.jool/tasks/main.yml b/playbooks/roles/ff.jool/tasks/main.yml new file mode 100644 index 0000000..4062c2b --- /dev/null +++ b/playbooks/roles/ff.jool/tasks/main.yml @@ -0,0 +1,167 @@ +--- + +- name: Assert that the required jool variables are set + ansible.builtin.assert: + that: + - jool_pool4_prefix | length > 0 + - jool_port_range is match('^[0-9]{1,5}-[0-9]{1,5}$') + fail_msg: >- + jool_pool4_prefix is required (e.g. the host's public IPv4 /32) and + jool_port_range must be "-", got + "{{ jool_port_range }}". The range has no default on purpose: what is + safe depends on this host's ephemeral range and on which ports already + listen here. + quiet: true + +- name: Assert that jool_port_range describes a real range + vars: + jool_port_first: "{{ jool_port_range.split('-')[0] | int }}" + jool_port_last: "{{ jool_port_range.split('-')[1] | int }}" + ansible.builtin.assert: + that: + - jool_port_first > 0 + - jool_port_last < 65536 + - jool_port_first < jool_port_last + fail_msg: >- + jool_port_range "{{ jool_port_range }}" must satisfy + 1 <= first < last <= 65535, got {{ jool_port_first }}-{{ jool_port_last }}. + quiet: true + +- name: Read the host's ephemeral port range + # The host opens its own connections (apt, monitoring, ...) with a source + # port from here, on the same pool4 address that the NAT64 masks to. An + # overlap lets a host socket and a translated flow claim the same port, and + # one of them receives the other's replies. + ansible.builtin.slurp: + src: /proc/sys/net/ipv4/ip_local_port_range + register: jool_ephemeral_ports + +- name: Assert that jool_port_range stays clear of the ephemeral range + vars: + jool_ephemeral: "{{ (jool_ephemeral_ports.content | b64decode).split() | map('int') | list }}" + jool_port_first: "{{ jool_port_range.split('-')[0] | int }}" + jool_port_last: "{{ jool_port_range.split('-')[1] | int }}" + ansible.builtin.assert: + that: + - jool_port_last < jool_ephemeral[0] or jool_port_first > jool_ephemeral[1] + fail_msg: >- + jool_port_range {{ jool_port_range }} overlaps this host's ephemeral range + {{ jool_ephemeral[0] }}-{{ jool_ephemeral[1] }} + (/proc/sys/net/ipv4/ip_local_port_range): the host and the NAT64 would + draw from the same ports on the same address. Put the range outside it, + as the concentrator group vars do. + quiet: true + +- name: Find local sockets that listen inside jool_port_range + # Jool handles packets addressed to the pool4 address in PRE_ROUTING, before + # local delivery: a service bound there would see translated traffic it never + # asked for, and drop it for having no session. A wildcard bind owns the port + # on the pool4 address as well, so it counts too. Read-only, and also wanted + # under --check, hence check_mode: false. + ansible.builtin.shell: + executable: /bin/bash + cmd: | + set -o pipefail + { ss -H -lnt; ss -H -lnu; } | awk -v range="{{ jool_port_range }}" -v pool4="{{ jool_pool4_prefix | regex_replace('/.*$', '') }}" ' + BEGIN { split(range, r, "-"); lo = r[1] + 0; hi = r[2] + 0 } + { + n = split($4, a, ":"); port = a[n] + 0 + addr = substr($4, 1, length($4) - length(a[n]) - 1) + if (port >= lo && port <= hi && + (addr == "0.0.0.0" || addr == "*" || addr == "[::]" || addr == "::" || addr == pool4)) + print + }' + register: jool_listener_hits + changed_when: false + check_mode: false + +- name: Assert that no local socket listens inside jool_port_range + ansible.builtin.assert: + that: + - jool_listener_hits.stdout | trim | length == 0 + fail_msg: >- + jool_port_range {{ jool_port_range }} contains ports this host already + listens on: + {{ jool_listener_hits.stdout | trim }} + Jool catches packets to the pool4 address before local delivery, so those + services would stop seeing their own traffic. Move the range, or move the + service. + quiet: true + +- name: Install jool and the headers its dkms module builds against + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - jool-dkms + - jool-tools + - linux-headers-amd64 + +- name: Install iptables for the NAT64 source guard + when: jool_source6_allow | length > 0 + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: iptables + +- name: Install the NAT64 source guard script + when: jool_source6_allow | length > 0 + ansible.builtin.template: + src: jool-guard.sh.j2 + dest: /usr/local/sbin/jool-guard.sh + mode: "0755" + +- name: Install the NAT64 source guard unit + when: jool_source6_allow | length > 0 + ansible.builtin.template: + src: jool-guard.service.j2 + dest: /etc/systemd/system/jool-guard.service + mode: "0644" + +- name: Apply the source guard before NAT64 starts translating + when: jool_source6_allow | length > 0 + # No handler needed: the guard is a oneshot without RemainAfterExit, so it is + # dead after every run and systemd executes it again here, picking up a + # rewritten unit file and any change to the allow list. Starting a dead + # oneshot is a change every single run, which would hide real ones. + ansible.builtin.systemd: + name: jool-guard.service + state: started + enabled: true + daemon_reload: true + changed_when: false + +- name: Ensure the jool config directory + # jool-tools ships no /etc/jool; the packaged unit skips itself while + # /etc/jool/jool.conf is missing, so it stays dead without this + ansible.builtin.file: + path: /etc/jool + state: directory + mode: "0755" + +- name: Write jool config + # no notify: the config is applied by the task below, in this same run. A + # handler would only fire at the end of the play, and one still queued when + # a later task fails is dropped while the file on disk is already new - the + # next run then finds the file unchanged, notifies nothing, and stays green + # with an instance that never saw the change. + ansible.builtin.template: + src: jool.conf.j2 + dest: /etc/jool/jool.conf + mode: "0644" + +- name: Enable and start the NAT64 instance + ansible.builtin.systemd: + name: jool.service + state: started + enabled: true + daemon_reload: true + +- name: Apply the config to the running NAT64 instance + # `jool file handle` swaps the config of the running instance and keeps its + # state. A `systemctl restart` is not an equivalent: the packaged unit stops + # the instance with `jool ... instance remove`, which throws away every + # dynamic BIB entry and session. Always apply, so a play that died after + # writing the file still converges on the next run. + ansible.builtin.command: jool file handle /etc/jool/jool.conf + changed_when: false diff --git a/playbooks/roles/ff.jool/templates/jool-guard.service.j2 b/playbooks/roles/ff.jool/templates/jool-guard.service.j2 new file mode 100644 index 0000000..62de5c8 --- /dev/null +++ b/playbooks/roles/ff.jool/templates/jool-guard.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=Restrict which IPv6 sources Jool translates (NAT64 source guard) +Documentation=https://nicmx.github.io/Jool +After=network-pre.target +Before=jool.service + +[Service] +Type=oneshot +# The logic lives in /usr/local/sbin/jool-guard.sh: it needs rule parsing and +# loops, which do not survive systemd's own ExecStart quoting and $-expansion. +# See the script for what the chain looks like and why a failed run leaves it +# stricter instead of open. +ExecStart=/usr/local/sbin/jool-guard.sh + +[Install] +WantedBy=multi-user.target diff --git a/playbooks/roles/ff.jool/templates/jool-guard.sh.j2 b/playbooks/roles/ff.jool/templates/jool-guard.sh.j2 new file mode 100644 index 0000000..a088f2f --- /dev/null +++ b/playbooks/roles/ff.jool/templates/jool-guard.sh.j2 @@ -0,0 +1,112 @@ +#!/bin/sh +# NAT64 source guard for Jool on {{ inventory_hostname }}. +# +# Jool registers a PRE_ROUTING hook only, so it translates every forwarded +# packet addressed to the NAT64 prefix no matter where that packet came from. +# Left alone, the host is an open NAT64 relay: anyone who can route a packet +# to the pool6 prefix gets their IPv4 traffic masked with our address. +# +# The chain is jumped to only for packets addressed to the NAT64 prefix, so it +# cannot affect the host's other IPv6 forwarding. Raw PREROUTING runs at +# priority -300, ahead of Jool's -75, so nothing is translated before the +# source has been checked. +# +# A source prefix is not an identity: anyone can put it in a packet header. +# With jool_source6_interface set, the jump additionally matches the interface +# the traffic has to arrive on - on a concentrator the wireguard interface, +# where the peer's allowed-ips already decided which source may appear at all. +# Both checks are kept: the interface proves the packet came through the +# tunnel, the prefix that the peer may use the NAT64. +# +# The chain is built the fail-closed way round: the terminal DROP is created +# before the chain is reachable at all and stays the last rule, so the user +# chain is never empty - an empty chain returns every packet instead of +# dropping it. Permissions are only inserted above the DROP, and stale ones are +# deleted by name, never by flushing the chain. A run that dies halfway +# therefore leaves the guard stricter than required, not open, and a raw table +# flushed by a reboot is rebuilt on the next boot. +# +# Generated by the ff.jool role - edit the template, not this file. + +set -eu + +chain=JOOL_GUARD +pool6='{{ jool_pool6 }}' +allowed='{{ jool_source6_allow | join(" ") }}' +iface='{{ jool_source6_interface }}' + +ipt() { + ip6tables -w -t raw "$@" +} + +# 1. The chain, with its terminal DROP, before anything can jump into it. +if ! ipt -N "$chain" 2>/dev/null; then + : +fi +if ! ipt -C "$chain" -j DROP 2>/dev/null; then + ipt -A "$chain" -j DROP +fi + +# 2. The jump from PREROUTING, only for traffic addressed to the NAT64 prefix. +if [ -n "$iface" ]; then + jump="-d $pool6 -i $iface" +else + jump="-d $pool6" +fi +if ! ipt -C PREROUTING $jump -j "$chain" 2>/dev/null; then + ipt -I PREROUTING 1 $jump -j "$chain" +fi + +# 3. One RETURN above the DROP per allowed source prefix. +for src in $allowed; do + if ! ipt -C "$chain" -s "$src" -j RETURN 2>/dev/null; then + ipt -I "$chain" 1 -s "$src" -j RETURN + fi +done + +# 4. Delete RETURN rules for prefixes that are no longer allowed. +ipt -S "$chain" | while read -r rule; do + case "$rule" in + "-A $chain -s "*" -j RETURN") + src=${rule#"-A $chain -s "} + src=${src%" -j RETURN"} + case " $allowed " in + *" $src "*) ;; + *) ipt -D "$chain" -s "$src" -j RETURN ;; + esac + ;; + esac +done + +# 5. Delete jumps for a pool6 or interface that is no longer configured. +ipt -S PREROUTING | while read -r rule; do + case "$rule" in + "-A PREROUTING "*) ;; + *) continue ;; + esac + case "$rule" in + *"-j $chain") ;; + *) continue ;; + esac + dst='' + got='' + prev='' + for tok in $rule; do + case "$prev" in + -d) dst=$tok ;; + -i) got=$tok ;; + esac + prev=$tok + done + # A jump without a destination was not written by this script; deleting it + # would need its exact argument list, and leaving it only makes the guard + # stricter. Our own rules always carry -d. + [ -n "$dst" ] || continue + if [ "$dst" != "$pool6" ] || [ "$got" != "$iface" ]; then + del="-d $dst" + if [ -n "$got" ]; then + del="$del -i $got" + fi + ipt -D PREROUTING $del -j "$chain" + fi +done diff --git a/playbooks/roles/ff.jool/templates/jool.conf.j2 b/playbooks/roles/ff.jool/templates/jool.conf.j2 new file mode 100644 index 0000000..1acf26b --- /dev/null +++ b/playbooks/roles/ff.jool/templates/jool.conf.j2 @@ -0,0 +1,28 @@ +{ + "comment": { + "description": "Stateful NAT64 for parker node clients, managed by Ansible.", + "note": "bib entries are static mappings only; dynamic clients get mapped from pool4 automatically." + }, + + "instance": "{{ jool_instance }}", + "framework": "netfilter", + + "global": { + "pool6": "{{ jool_pool6 }}", + "logging-debug": false + }, + + "pool4": [ +{% for proto in ['TCP', 'UDP'] %} + { + "protocol": "{{ proto }}", + "prefix": "{{ jool_pool4_prefix }}", + "port range": "{{ jool_port_range }}" + }, +{% endfor %} + { + "protocol": "ICMP", + "prefix": "{{ jool_pool4_prefix }}" + } + ] +} From 4bdb8c2a82512839d1f54b5532e6a8f2ce6c915e Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:08 +0000 Subject: [PATCH 02/19] dns64: single-instance BIND role synthesising AAAA for parker LANs Forward-only recursion with dns64 for 64:ff9b::/96 and DNSSEC validation off, since synthesised records carry no RRSIG. The ACL names the networks whose IPv4 transits our NAT64 - synthesised answers handed to native IPv4 clients break their Happy Eyeballs. Reloads after the start unconditionally, so a dropped handler cannot leave named serving the previous file. --- playbooks/roles/ff.dns64/defaults/main.yml | 27 ++++++++ playbooks/roles/ff.dns64/tasks/main.yml | 62 +++++++++++++++++++ .../ff.dns64/templates/named.conf.options.j2 | 38 ++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 playbooks/roles/ff.dns64/defaults/main.yml create mode 100644 playbooks/roles/ff.dns64/tasks/main.yml create mode 100644 playbooks/roles/ff.dns64/templates/named.conf.options.j2 diff --git a/playbooks/roles/ff.dns64/defaults/main.yml b/playbooks/roles/ff.dns64/defaults/main.yml new file mode 100644 index 0000000..9858eb9 --- /dev/null +++ b/playbooks/roles/ff.dns64/defaults/main.yml @@ -0,0 +1,27 @@ +--- +# DNS64: recursive resolver that synthesises AAAA from A under +# jool_pool6-style prefix. Only for clients whose IPv4 transits our NAT64 - +# handing synthesised answers to native-IPv4 clients breaks Happy Eyeballs. +# Therefore dns64_networks must be exactly the networks those clients come +# from (for parker LANs: the delegated pool). + +dns64_prefix: "64:ff9b::/96" + +# Networks allowed to query and to receive synthesised answers. Required, +# e.g. ["2a03:2260:3006:8000::/56"]. +dns64_networks: [] + +dns64_forwarders: + - "2620:fe::fe" + - "9.9.9.9" + +# Listen addresses. Clients reach this via RDNSS, i.e. over IPv6; keeping v4 +# loopback-only avoids a stray v53 on the host's backbone interfaces. +# The IPv6 wildcard is spelled "any", not "::": BIND reads a literal address +# and skips it silently when it is not assigned here, which left the instance +# without a single IPv6 socket on sn03 and would also hide any address added +# after named started. +dns64_listen_v6: + - "any" +dns64_listen_v4: + - "127.0.0.1" diff --git a/playbooks/roles/ff.dns64/tasks/main.yml b/playbooks/roles/ff.dns64/tasks/main.yml new file mode 100644 index 0000000..9a04128 --- /dev/null +++ b/playbooks/roles/ff.dns64/tasks/main.yml @@ -0,0 +1,62 @@ +--- + +- name: Assert dns64 variables + ansible.builtin.assert: + that: + - dns64_networks | length > 0 + - "'::/0' not in dns64_networks" + - "'0.0.0.0/0' not in dns64_networks" + fail_msg: >- + dns64_networks is required and must not contain "::/0" or + "0.0.0.0/0": DNS64 may only answer clients whose IPv4 transits our + NAT64. An open resolver on a supernode uplink hands synthesised AAAA to + anyone and breaks Happy Eyeballs for native-IPv4 clients. + quiet: true + +- name: Install bind9 + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - bind9 + # dig is how the instance gets checked from a client's point of view; + # the Debian split keeps it out of the server package + - bind9-dnsutils + +- name: Configure the DNS64 instance + ansible.builtin.template: + src: named.conf.options.j2 + dest: /etc/bind/named.conf.options + mode: "0644" + # the fragment is a complete config on its own (acl + options), so + # checkconf can parse it before it replaces the working file + validate: /usr/bin/named-checkconf %s + +- name: Keep named.conf.local empty + # Debian's named.conf includes this file, and a pure DNS64 instance defines + # no zones of its own. The package ships only a comment in it - there is no + # default-zones file any more - so writing it keeps whatever a previous use + # left there from being served. + ansible.builtin.copy: + content: "// managed by ansible - no zones on a pure DNS64 instance\n" + dest: /etc/bind/named.conf.local + mode: "0644" + +- name: Enable and start bind9 + ansible.builtin.systemd: + name: named.service + state: started + enabled: true + daemon_reload: true + +- name: Reapply the configuration to the running instance + # apt starts named with Debian's own options before the template lands, and + # a handler only fires when a file changes. A play that aborts before the + # end therefore leaves a daemon that never read our config, and the next + # run has nothing to notify: on sn03 that meant no dns64 and no ACL at all. + # Reloading on every run converges the daemon onto the file on disk, and + # `rndc reload` re-reads it without dropping the listening sockets. + ansible.builtin.systemd: + name: named.service + state: reloaded + changed_when: false diff --git a/playbooks/roles/ff.dns64/templates/named.conf.options.j2 b/playbooks/roles/ff.dns64/templates/named.conf.options.j2 new file mode 100644 index 0000000..dc9fbd6 --- /dev/null +++ b/playbooks/roles/ff.dns64/templates/named.conf.options.j2 @@ -0,0 +1,38 @@ +// {{ ansible_managed }} +// DNS64 for parker LANs only. Single-purpose instance: Debian's +// named.conf includes this whole file, so the options block lives here and +// everything zone-related stays empty. + +acl dns64_clients { +{% for net in dns64_networks %} + {{ net }}; +{% endfor %} +}; + +options { + directory "/var/cache/bind"; + + recursion yes; + allow-recursion { dns64_clients; }; + allow-query { dns64_clients; }; + + // synthesised AAAA records cannot carry RRSIGs, so this instance + // must not validate for its clients + dnssec-validation no; + + // BIND >= 9.19 grammar: block statement. With the well-known /96 the + // default suffix embeds IPv4 addresses per RFC 6052, as the nodes expect. + dns64 {{ dns64_prefix }} { + }; + + forwarders { +{% for fwd in dns64_forwarders %} + {{ fwd }}; +{% endfor %} + }; + + listen-on { {{ dns64_listen_v4 | join('; ') }}; }; + listen-on-v6 { {{ dns64_listen_v6 | join('; ') }}; }; + + forward only; +}; From 1d2464125518f6e76a856308600454d74c750b4a Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:08 +0000 Subject: [PATCH 03/19] parker_concentrator: apply parker records to a routed wg interface Minute cron like autoupdate_wg.py, but AllowedIPs are the delegated /64s the broker told the node, and their routes in parker_route_table are diffed against the interface so a deleted record takes its routes with it. A record conflict aborts the pass instead of routing two nodes' client LANs to one prefix. --- .../ff.parker_concentrator/defaults/main.yml | 28 +++ .../ff.parker_concentrator/handlers/main.yml | 6 + .../ff.parker_concentrator/tasks/main.yml | 95 ++++++++ .../templates/autoupdate_parker.py.j2 | 213 ++++++++++++++++++ .../templates/parker.netdev.j2 | 8 + .../templates/parker.network.j2 | 9 + 6 files changed, 359 insertions(+) create mode 100644 playbooks/roles/ff.parker_concentrator/defaults/main.yml create mode 100644 playbooks/roles/ff.parker_concentrator/handlers/main.yml create mode 100644 playbooks/roles/ff.parker_concentrator/tasks/main.yml create mode 100644 playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 create mode 100644 playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 create mode 100644 playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 diff --git a/playbooks/roles/ff.parker_concentrator/defaults/main.yml b/playbooks/roles/ff.parker_concentrator/defaults/main.yml new file mode 100644 index 0000000..7bd1120 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/defaults/main.yml @@ -0,0 +1,28 @@ +--- +# Parked (routed wireguard uplink) concentrator side. The broker assigns +# prefixes; this role applies them to one wireguard interface. +parker_iface: wg-nodes + +# Registry the broker writes (pubkey -> id -> concentrators), read here every +# minute. Unset remote = the registry is local to this host, nothing to pull. +parker_repo: /var/lib/parker/registry +parker_repo_remote: "" + +parker_listen_port: 10000 + +# Pool the broker allocates from; both must use the same value so the +# reconciler derives the same prefixes the nodes were told. +parker_prefix6: "" + +# This concentrator's address on the tunnel, the concentrators[].address6 +# the broker sends (a /128 is enough - only point-to-point peers use it). +parker_address6: "" +parker_privatekey: "" + +# Table the delegated /64s are routed in. supernode.yml exports table 42 +# into BGP; main works for a plain router. +parker_route_table: "main" + +# Publish handshake liveness into the registry so the broker-side garbage +# collector can tell dead nodes from alive ones. +parker_gc_publish: false diff --git a/playbooks/roles/ff.parker_concentrator/handlers/main.yml b/playbooks/roles/ff.parker_concentrator/handlers/main.yml new file mode 100644 index 0000000..451d454 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/handlers/main.yml @@ -0,0 +1,6 @@ +--- + +- name: Restart networkd + ansible.builtin.systemd: + name: systemd-networkd + state: restarted diff --git a/playbooks/roles/ff.parker_concentrator/tasks/main.yml b/playbooks/roles/ff.parker_concentrator/tasks/main.yml new file mode 100644 index 0000000..4eb78db --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/tasks/main.yml @@ -0,0 +1,95 @@ +--- +# Concentrator side of the parker uplink: one wireguard interface whose +# peers and prefix routes follow the parker/ records in the parker registry +# (a repository of its own, not the batman peer list). The broker +# (ff.wgbroker) hands those records out over /config. + +- name: Assert parker concentrator variables + ansible.builtin.assert: + that: + - parker_prefix6 | length > 0 + - parker_address6 | length > 0 + - parker_privatekey | length > 0 + fail_msg: "parker_prefix6, parker_address6 and parker_privatekey are required" + quiet: true + +- name: Install python3, pyroute2 and wireguard-tools + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - python3 + - python3-pyroute2 + - wireguard-tools + +- name: Ensure wireguard config directory + ansible.builtin.file: + path: /etc/wireguard + state: directory + mode: u=rwx,g=rx,o=rx + owner: root + group: systemd-network + +- name: Create parker privatekey file (DIFF REDACTED) + ansible.builtin.copy: + content: "{{ parker_privatekey }}" + dest: "/etc/wireguard/privatekey-{{ parker_iface }}" + mode: "0640" + owner: root + group: systemd-network + no_log: true + diff: "{{ show_secret_diffs }}" + +- name: Generate parker netdev (DIFF REDACTED) + ansible.builtin.template: + src: parker.netdev.j2 + dest: "/etc/systemd/network/31-{{ parker_iface }}.netdev" + mode: "0644" + owner: root + group: systemd-network + diff: "{{ show_secret_diffs }}" + notify: Restart networkd + +- name: Generate parker network + ansible.builtin.template: + src: parker.network.j2 + dest: "/etc/systemd/network/31-{{ parker_iface }}.network" + mode: "0644" + notify: Restart networkd + +- name: Ensure the parker library directory + ansible.builtin.file: + path: /usr/local/lib/parker + state: directory + mode: "0755" + owner: root + group: root + +- name: Install parker helper library + ansible.builtin.copy: + src: "{{ playbook_dir }}/files/parker_lib.py" + dest: /usr/local/lib/parker/parker_lib.py + mode: "0644" + owner: root + group: root + +- name: Install parker peer reconciler + ansible.builtin.template: + src: autoupdate_parker.py.j2 + dest: /usr/local/sbin/autoupdate_parker.py + mode: "0755" + owner: root + group: root + +- name: Install cron + ansible.builtin.apt: + name: cron + +- name: Install crontab for the parker peer reconciler + ansible.builtin.cron: + name: "autoupdate parker peers from git" + user: root + minute: "*/1" + # Cron would mail whatever the script prints; the journal is where we look + # for what a pass did. + job: /usr/local/sbin/autoupdate_parker.py 2>&1 | logger -t autoupdate_parker diff --git a/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 b/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 new file mode 100644 index 0000000..7d82762 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Apply parker node records from the registry to the concentrator interface. + +Runs as root from cron; git operations demote to the ``auto`` user, wireguard +and route changes need root. Mirrors what autoupdate_wg.py does for the +batman-side wg- interfaces, with two differences: + +* AllowedIPs are the delegated prefixes from the registry instead of a + key-derived link-local address, so they are asserted together with the + route on every pass. +* With parker_gc_publish the latest handshake per node is committed to + parker-status/ so the broker-side GC can also see nodes that only ever + reach this one gateway. + +A record conflict stops all changes: better stale peers than routing two +nodes' client prefixes to the same /63. +""" + +import os +import re +import subprocess +import sys + +sys.path.insert(0, "/usr/local/lib/parker") + +import parker_lib # noqa: E402 +from pyroute2 import WireGuard # noqa: E402 + +IFACE = "{{ parker_iface }}" +REPO = {{ parker_repo | tojson }} +SYNC = {{ 'True' if (parker_repo_remote | default('')) else 'False' }} +PREFIX6 = "{{ parker_prefix6 }}" +ROUTE_TABLE = {{ parker_route_table | tojson }} +GC_PUBLISH = {{ parker_gc_publish | default(false) | bool | ternary('True', 'False') }} +HOSTNAME = "{{ inventory_hostname }}" + +git = parker_lib.demote_runner("auto") + +DAY = 86400 + + +def peer_keys(): + out = subprocess.check_output(["wg", "show", IFACE]).decode() + return re.findall(r"^peer:\s+(\S+)$", out, re.MULTILINE) + + +def last_handshakes(): + out = subprocess.check_output( + ["wg", "show", IFACE, "latest-handshakes"] + ).decode() + found = {} + for line in out.splitlines(): + pubkey, _, stamp = line.partition("\t") + stamp = stamp.strip() + if pubkey and stamp.isdigit(): + found[pubkey] = int(stamp) + return found + + +def prefixes_of(record): + """The two delegated /64s of a record, or [] if its id is out of pool.""" + try: + return list(parker_lib.id_to_prefixes(PREFIX6, record["id"])) + except parker_lib.PoolExhausted: + print(f"parker: id {record['id']} outside {PREFIX6}, skipping") + return [] + + +def current_routes(): + out = subprocess.check_output( + ["ip", "-6", "route", "show", "table", str(ROUTE_TABLE), "dev", IFACE] + ).decode() + return {line.split()[0] for line in out.splitlines() if line} + + +def sync_peers(records): + """Add/remove wireguard peers; return the set of expected prefixes.""" + iface_keys = set(peer_keys()) + repo_keys = set(records) + expected = set() + + with WireGuard() as wg: + for key in sorted(iface_keys - repo_keys): + wg.set(IFACE, peer={"public_key": key, "remove": True}) + print(f"parker: removed peer {key} from {IFACE}") + for key in sorted(repo_keys - iface_keys): + record = records[key] + prefixes = prefixes_of(record) + wg.set(IFACE, peer={"public_key": key, "allowed_ips": prefixes}) + print(f"parker: added peer {key} id {record['id']} {prefixes}") + for key in sorted(iface_keys & repo_keys): + # Re-assert: the id (and with it the prefixes) of a record is + # only ever rewritten by the GC and a re-registration, but a + # hand-run `wg` command must not survive a minute. + prefixes = prefixes_of(records[key]) + wg.set(IFACE, peer={"public_key": key, "allowed_ips": prefixes}) + expected.update(prefixes) + return expected + + +def sync_routes(expected): + """Routes are what makes a prefix reachable; keep them in lockstep.""" + present = current_routes() + for prefix in sorted(expected - present): + subprocess.run( + ["ip", "-6", "route", "replace", prefix, "dev", IFACE, + "table", str(ROUTE_TABLE), "proto", "static"], + check=True, + ) + print(f"parker: route {prefix} -> {IFACE} table {ROUTE_TABLE}") + for prefix in sorted(present - expected): + subprocess.run( + ["ip", "-6", "route", "del", prefix, "dev", IFACE, + "table", str(ROUTE_TABLE)], + check=False, + ) + print(f"parker: removed route {prefix} from table {ROUTE_TABLE}") + + +def publish_status(): + """Commit this gateway's handshake day-buckets, push if we are ahead.""" + samples = { + pubkey: stamp // DAY + for pubkey, stamp in last_handshakes().items() + if stamp > 0 + } + relpath = parker_lib.status_relpath(HOSTNAME) + fullpath = f"{REPO}/{relpath}" + + if not samples: + # Nothing this gateway can report: a concentrator without peers keeps + # no file, and one whose peers all left does not leave stale buckets + # behind. The gc reads "no bucket" as "never handshaked here". + tracked = parker_lib.git( + git, REPO, "ls-files", "--error-unmatch", "--", relpath, check=False + ) + if tracked.returncode != 0: + return + if os.path.exists(fullpath): + os.unlink(fullpath) + parker_lib.git(git, REPO, "rm", "--quiet", "--", relpath) + parker_lib.git( + git, REPO, "commit", "-m", f"parker status: {HOSTNAME}: no peers" + ) + return + + new_content = parker_lib.format_status(samples) + + changed = True + try: + with open(fullpath, encoding="utf-8") as handle: + changed = handle.read() != new_content + except FileNotFoundError: + pass + + if changed: + # This script runs as root, the repository belongs to auto: without + # the chown the next git commit as auto would meet a root directory + # and a root file in a tree it is supposed to own. + import grp + import pwd + + status_dir = f"{REPO}/{parker_lib.STATUS_SUBDIR}" + os.makedirs(status_dir, exist_ok=True) + with open(fullpath, "w", encoding="utf-8") as handle: + handle.write(new_content) + uid = pwd.getpwnam("auto").pw_uid + gid = grp.getgrnam("auto").gr_gid + os.chown(status_dir, uid, gid) + os.chown(fullpath, uid, gid) + parker_lib.git(git, REPO, "add", "--", relpath) + parker_lib.git(git, REPO, "commit", "-m", f"parker status: {HOSTNAME}") + + ahead = subprocess.run( + ["git", "-C", REPO, "rev-list", "--max-count=1", "@{u}..HEAD"], + capture_output=True, + ) + if SYNC and ahead.stdout.strip(): + try: + parker_lib.git(git, REPO, "push") + except parker_lib.GitError as exc: + print(f"parker: status push failed, retrying next pass: {exc}") + + +def main(): + # A failed pull must not stop us: the local records are stale but safe, + # and aborting here would drop live peers instead. A local-only registry + # has nothing to pull in the first place. + if SYNC: + proc = subprocess.run( + ["git", "-C", REPO, "pull", "--rebase", "--autostash"], + capture_output=True, + ) + if proc.returncode != 0: + print("parker: pull failed: " + + proc.stderr.decode(errors="replace").strip()) + + try: + records = parker_lib.load_records(REPO) + except parker_lib.RecordConflict as exc: + print(f"parker: refusing to touch {IFACE}: {exc}") + return 1 + + expected = sync_peers(records) + sync_routes(expected) + # Nothing to publish into before the broker has created the registry. + if GC_PUBLISH and os.path.isdir(os.path.join(REPO, ".git")): + publish_status() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 b/playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 new file mode 100644 index 0000000..7924873 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 @@ -0,0 +1,8 @@ +# {{ ansible_managed }} +[NetDev] +Name={{ parker_iface }} +Kind=wireguard + +[WireGuard] +PrivateKeyFile=/etc/wireguard/privatekey-{{ parker_iface }} +ListenPort={{ parker_listen_port }} diff --git a/playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 b/playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 new file mode 100644 index 0000000..75e9729 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 @@ -0,0 +1,9 @@ +# {{ ansible_managed }} +[Match] +Name={{ parker_iface }} + +[Network] +Address={{ parker_address6.split('/')[0] }}/128 +# the concentrator is the other end of point-to-point tunnels, nothing +# announces routes here +IPv6AcceptRA=false From ac81cfc36fcac6a60ba72ff05536fb0a7fbc64f1 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:14 +0000 Subject: [PATCH 04/19] parker: deploy the DNS64, NAT64 and guard on a concentrator ff.dns64 and ff.jool existed, but no play referenced them, so the pair we have never run could not be tried at all. New parker_concentrators group (one host, sn03); the pool is the first /56 of the upper half of our /48, and DNS64 ACL plus the NAT64 guard allow only that pool. The play also checks what it does not install: IPv6 forwarding (ff.ipforward lives in supernode.yml) and that DNS64 and NAT64 agree on the /96. --- group_vars/parker_concentrators/main.yml | 56 ++++++++++++++++++++++++ host_vars/sn03.yml | 4 ++ inventory | 6 +++ playbooks/parker.yml | 55 +++++++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 group_vars/parker_concentrators/main.yml create mode 100644 playbooks/parker.yml diff --git a/group_vars/parker_concentrators/main.yml b/group_vars/parker_concentrators/main.yml new file mode 100644 index 0000000..ff407f5 --- /dev/null +++ b/group_vars/parker_concentrators/main.yml @@ -0,0 +1,56 @@ +--- +# 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" + +# 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 }}" + +# 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" diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index 0f8ce7c..d142a30 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -156,3 +156,7 @@ 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" diff --git a/inventory b/inventory index 1ab0c0f..ff04612 100644 --- a/inventory +++ b/inventory @@ -39,6 +39,12 @@ supernodes: sn02: sn03: +# Hosts that terminate parker uplinks next to their supernode duties. One at +# first. +parker_concentrators: + hosts: + sn03: + monitoring: hosts: monitor: diff --git a/playbooks/parker.yml b/playbooks/parker.yml new file mode 100644 index 0000000..be4b5e8 --- /dev/null +++ b/playbooks/parker.yml @@ -0,0 +1,55 @@ +--- + +- name: Configure the parker uplink + hosts: "parker_concentrators" + pre_tasks: + - name: Read the IPv6 forwarding setting + # ff.ipforward in playbooks/supernode.yml sets this; the parker play + # does not install it, so a concentrator that never ran that play would + # silently drop every packet it is supposed to translate. + ansible.builtin.slurp: + src: /proc/sys/net/ipv6/conf/all/forwarding + register: ipv6_forwarding + changed_when: false + + - name: Assert that IPv6 forwarding is enabled + ansible.builtin.assert: + that: + - ipv6_forwarding.content | b64decode | trim == "1" + fail_msg: >- + net.ipv6.conf.all.forwarding is + {{ ipv6_forwarding.content | b64decode | trim }} - NAT64 and DNS64 + only serve other hosts' traffic, and Jool hooks PRE_ROUTING, so + forwarded packets must arrive here. Run playbooks/supernode.yml + (role ff.ipforward) on this host first. + quiet: true + + - name: Assert that DNS64 and NAT64 use the same prefix + # Two independent defaults for one /96: AAAA records synthesised under a + # prefix that Jool does not translate (and vice versa) fail silently, + # with a resolvable name whose address goes nowhere. + ansible.builtin.assert: + that: + - dns64_prefix == jool_pool6 + fail_msg: >- + dns64_prefix ({{ dns64_prefix }}) and jool_pool6 + ({{ jool_pool6 }}) differ - DNS64 would synthesise addresses that + nothing translates. + quiet: true + + - 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: + # The pair that has never run: DNS64 answers AAAA from A for clients in + # the parker pool, NAT64 translates the resulting 64:ff9b::/96 traffic to + # IPv4. ff.parker_concentrator joins them once a node registers. + - { role: ff.dns64, tags: dns64, become: true } + - { role: ff.jool, tags: jool, become: true } + post_tasks: + - name: "Import post_task etckeeper" + ansible.builtin.import_tasks: "include/etckeeper_post.yml" + tags: [always] From b617de4a1600a36c4c8d45345db6cac55919284f Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:14 +0000 Subject: [PATCH 05/19] bird: learn the parker routes from the kernel table ff.parker_concentrator adds one /64 per node with 'ip route replace' and never tells bird, so the routes reached neither master6 nor OSPF and no return traffic found its way back. The kernel protocol gains an optional learn flag; sn03 turns it on with an import filter for the pool, and the play asserts that a kernel protocol is attached to parker_route_table and learns. --- group_vars/parker_concentrators/main.yml | 8 +++++++ host_vars/sn03.yml | 16 ++++++++++++++ playbooks/parker.yml | 22 +++++++++++++++++++ .../roles/ff.backbone/templates/bird2.conf.j2 | 3 +++ 4 files changed, 49 insertions(+) diff --git a/group_vars/parker_concentrators/main.yml b/group_vars/parker_concentrators/main.yml index ff407f5..10bf80e 100644 --- a/group_vars/parker_concentrators/main.yml +++ b/group_vars/parker_concentrators/main.yml @@ -33,6 +33,14 @@ 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 diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index d142a30..945038a 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -73,6 +73,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" @@ -136,6 +145,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: diff --git a/playbooks/parker.yml b/playbooks/parker.yml index be4b5e8..dc1ca70 100644 --- a/playbooks/parker.yml +++ b/playbooks/parker.yml @@ -37,6 +37,28 @@ nothing translates. quiet: true + - name: Assert that bird reads the parker route table + # ff.parker_concentrator writes the delegated /64s into + # parker_route_table instead of main. Any other table only works if + # bird's kernel protocol is attached to it and learns routes it did not + # install itself - otherwise the prefixes exist, are announced by nobody, + # and nothing forwards to them either. + ansible.builtin.assert: + that: + - parker_route_table | default('main') == 'main' or + (bird | default({})).get('kernel', []) | selectattr('ip_table', 'defined') + | map(attribute='ip_table') | map('string') + | select('equalto', parker_route_table | string) | list | length > 0 + - parker_route_table | default('main') == 'main' or + (bird | default({})).get('kernel', []) | selectattr('learn', 'defined') + | selectattr('learn') | list | length > 0 + fail_msg: >- + parker_route_table is "{{ parker_route_table | default('main') }}", + but no bird kernel protocol uses that table, or it does not learn + foreign routes. Add `learn: true` and an import filter for the pool + to that kernel protocol (see host_vars/sn03.yml). + quiet: true + - name: "Import pre_tasks: reboot-required.yml" ansible.builtin.import_tasks: "include/reboot-required.yml" tags: [always] diff --git a/playbooks/roles/ff.backbone/templates/bird2.conf.j2 b/playbooks/roles/ff.backbone/templates/bird2.conf.j2 index 137bc6a..6ca5270 100644 --- a/playbooks/roles/ff.backbone/templates/bird2.conf.j2 +++ b/playbooks/roles/ff.backbone/templates/bird2.conf.j2 @@ -44,6 +44,9 @@ protocol kernel { {% if kernel.bird_table is defined %} table {{ kernel.bird_table }}; {%endif %} +{% if kernel.learn is defined and kernel.learn %} + learn; +{% endif %} } {% endfor %} From 8723bdf1e3a04d4f451d2aa824133e781690b8de Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:14 +0000 Subject: [PATCH 06/19] networkd: allow destination-based policy rules with a priority The NAT64's return packets carry 64:ff9b:: as their source, so none of the existing from-based rules match them and the pool is looked up in main. A RoutingPolicyRule learns optional To= and Priority=; without them it renders as before. sn03 routes traffic to the pool (32750) and from it (32749) into table 42, clear of the auto-assigned 3276x block and above the kernel's 'from all lookup main': the pool is announced to FFRL only, so main's default via the site uplink has no path back. --- host_vars/sn03.yml | 21 +++++++++++++++++++ .../ff.networkd/templates/1x-iface.network.j2 | 8 +++++++ 2 files changed, 29 insertions(+) diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index 945038a..c3c197c 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -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 diff --git a/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 b/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 index 5f9b206..82fed48 100644 --- a/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 +++ b/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 @@ -72,7 +72,15 @@ MultiPathRoute={{ mpr }} {% for policy_rule in interface.routing_policies | default([]) %} [RoutingPolicyRule] +{% if policy_rule.from is defined %} From={{ policy_rule.from }} +{% endif %} +{% if policy_rule.to is defined %} +To={{ policy_rule.to }} +{% endif %} +{% if policy_rule.priority is defined %} +Priority={{ policy_rule.priority }} +{% endif %} Table={{ policy_rule.table }} {% endfor %} From f4c289a245bc4412ad94ec4ce65d3ff53d69f7e1 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:21 +0000 Subject: [PATCH 07/19] wgbroker: parker config service with a git-backed allocator GET /config mints global state, unlike add_key: look up by pubkey first (a node re-asks every retry seconds), allocate a sequential id under flock, commit before answering, and check every git return code - the reply carries a prefix, so a swallowed push failure hands two nodes the same /63. parker_lib.py holds the record format, the id <-> prefix bijection (range6 + xlat_range6, 464XLAT first), first-fit reuse for the GC, the rate limiter and usign signing by file (signify-openbsd writes the same format). The records live in a registry of their own, /var/lib/parker/registry, not in peers-wg: that tree is rewritten by every batman node poll, and parker keys have a different owner and lifetime. It starts without a remote, so the broker commits and the log on that host is the audit; the daily GC deletes a record only when no gateway published a recent handshake for it. --- playbooks/files/parker_lib.py | 523 ++++++++++++++++++ playbooks/roles/ff.wgbroker/defaults/main.yml | 53 ++ playbooks/roles/ff.wgbroker/tasks/main.yml | 106 +++- .../roles/ff.wgbroker/templates/broker.py | 122 +++- .../ff.wgbroker/templates/parker_gc.py.j2 | 90 +++ .../ff.wgbroker/templates/wgbroker.service.j2 | 2 +- 6 files changed, 885 insertions(+), 11 deletions(-) create mode 100644 playbooks/files/parker_lib.py create mode 100644 playbooks/roles/ff.wgbroker/defaults/main.yml create mode 100644 playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 diff --git a/playbooks/files/parker_lib.py b/playbooks/files/parker_lib.py new file mode 100644 index 0000000..706a705 --- /dev/null +++ b/playbooks/files/parker_lib.py @@ -0,0 +1,523 @@ +"""Shared helpers for the parker config service. + +Used by the ff.wgbroker broker (the /config endpoint and its garbage +collector) and by the ff.parker_concentrator reconciler. Kept in +playbooks/files/ so all three install the same copy. + +The module is stdlib-only and free of Ansible templating so it can be +unit-tested as-is (tests/test_parker_lib.py). Every operation that runs a +subprocess takes an injectable ``run`` callable with the signature of +subprocess.run; the broker runs as the ``auto`` user directly, the +reconciler and the GC run as root and pass demote_runner() so that git +operations keep the ``auto`` identity. + +Registry format, one file per parker node below ``REPO/parker/``:: + + + id: 412 + conc: 1,2 + +The first line is a plain key like in the batman-side files, so +autoupdate_wg.py keeps ignoring these records: they live in a subdirectory +it never scans. The id maps to the node's delegated prefix pair +(range6 + xlat_range6) via id_to_prefixes(); it is allocated once and +never re-derived from the key. + +The registry is a git repository of its own, not the batman peer list: the +two have different lifetimes and only one of them is rewritten by every +node poll. It may live without a remote (sync=False below), in which case +the commits are the local audit log and nothing leaves the host. +""" + +import fcntl +import os +import re +import subprocess +import tempfile +import threading +import time +from ipaddress import IPv6Address, ip_network + +PUBKEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") +NONCE_RE = re.compile(r"^[0-9a-f]{32}$") + +RECORDS_SUBDIR = "parker" +STATUS_SUBDIR = "parker-status" + +# A delegated unit is one /64; every id owns range6 and the xlat sibling, +# so it consumes two of them. +_DELEGATION_BITS = 64 + + +class ParkerError(Exception): + """Base class for everything that can go wrong below.""" + + +class InvalidRequest(ParkerError): + """Malformed request (bad pubkey or nonce).""" + + +class PoolExhausted(ParkerError): + """No free id in the parker prefix pool.""" + + +class RecordConflict(ParkerError): + """A record is malformed or claims an id/pubkey that is in use.""" + + +class RateLimited(ParkerError): + """Too many new nodes at once, try again later.""" + + +class GitError(ParkerError): + """A git operation failed.""" + + +class PushPending(GitError): + """The record is committed locally but not pushed: do not answer.""" + + +class SignError(ParkerError): + """usign failed or produced something unexpected.""" + + +# --------------------------------------------------------------------------- +# validation + + +def validate_pubkey(pubkey): + if not PUBKEY_RE.match(pubkey): + raise InvalidRequest(f"not a wireguard public key: {pubkey!r}") + return pubkey + + +def validate_nonce(nonce): + if not NONCE_RE.match(nonce): + raise InvalidRequest(f"not a 32 hex digit nonce: {nonce!r}") + return nonce + + +# --------------------------------------------------------------------------- +# records + + +def sanitize_pubkey(pubkey): + """Filename-safe form of a base64 key (+ -> -, / -> _, no padding).""" + return pubkey.translate(str.maketrans("+/", "-_")).rstrip("=") + + +def record_relpath(pubkey): + return os.path.join(RECORDS_SUBDIR, sanitize_pubkey(pubkey)) + + +def format_record(pubkey, node_id, conc_ids): + return f"{pubkey}\nid: {node_id}\nconc: {','.join(str(c) for c in conc_ids)}\n" + + +def parse_record(text, path=RECORDS_SUBDIR): + """Parse one registry record. Extra fields are ignored on purpose.""" + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + raise RecordConflict(f"{path}: empty record") + + pubkey = lines[0] + if not PUBKEY_RE.match(pubkey): + raise RecordConflict(f"{path}: {pubkey!r} is not a wireguard public key") + + node_id = None + conc = [] + for line in lines[1:]: + key, _, value = line.partition(":") + key, value = key.strip().lower(), value.strip() + if key == "id": + try: + node_id = int(value) + except ValueError: + raise RecordConflict(f"{path}: invalid id {value!r}") from None + elif key == "conc": + try: + conc = [int(part) for part in re.split(r"[,\s]+", value) if part] + except ValueError: + raise RecordConflict(f"{path}: invalid conc {value!r}") from None + + if node_id is None: + raise RecordConflict(f"{path}: missing id") + return {"pubkey": pubkey, "id": node_id, "conc": conc, "path": path} + + +def load_records(repo_dir): + """All parker records, keyed by pubkey. Raises on duplicate pubkey or id.""" + records = {} + seen_ids = {} + directory = os.path.join(repo_dir, RECORDS_SUBDIR) + if not os.path.isdir(directory): + return records + for entry in sorted(os.scandir(directory), key=lambda e: e.name): + if not entry.is_file() or entry.name.startswith("."): + continue + relpath = os.path.join(RECORDS_SUBDIR, entry.name) + with open(entry.path, encoding="utf-8") as handle: + record = parse_record(handle.read(), relpath) + if record["pubkey"] in records: + raise RecordConflict( + f"{relpath}: pubkey also in {records[record['pubkey']]['path']}" + ) + if record["id"] in seen_ids: + raise RecordConflict( + f"{relpath}: id {record['id']} also in {seen_ids[record['id']]}" + ) + seen_ids[record["id"]] = relpath + records[record["pubkey"]] = record + return records + + +# --------------------------------------------------------------------------- +# id <-> prefix mapping + + +def _pool_network(prefix6): + net = ip_network(prefix6) + if net.prefixlen > _DELEGATION_BITS: + raise ValueError(f"{prefix6}: pool is smaller than one /{_DELEGATION_BITS}") + return net + + +def id_capacity(prefix6): + """Number of ids the pool holds; ids 1..capacity-1 are allocatable, 0 is infra.""" + net = _pool_network(prefix6) + return net.num_addresses // (2 ** (_DELEGATION_BITS + 1)) + + +def id_to_prefixes(prefix6, node_id): + """Return (range6, xlat_range6) as /64 strings for one id. + + The mapping is a bijection over the pool: range6 is the 2*id-th /64 of + the pool, xlat_range6 its sibling. Renumbering the pool is therefore a + config change, not a migration of records. + """ + net = _pool_network(prefix6) + capacity = net.num_addresses // (2 ** _DELEGATION_BITS) + if node_id < 1 or 2 * node_id + 1 >= capacity: + raise PoolExhausted(f"id {node_id} does not fit into {prefix6}") + step = 2**_DELEGATION_BITS + base = int(net.network_address) + 2 * node_id * step + return f"{IPv6Address(base)}/64", f"{IPv6Address(base + step)}/64" + + +def first_free_id(used_ids, prefix6): + """Lowest free id in the pool, or PoolExhausted. + + First fit, so ids freed by the GC are reused. With auto-approval a + monotonic cursor would turn unauthenticated spam into permanent prefix + exhaustion. + """ + used = set(used_ids) + capacity = id_capacity(prefix6) + for candidate in range(1, capacity): + if candidate not in used: + return candidate + raise PoolExhausted(f"no free id below {capacity} in {prefix6}") + + +# --------------------------------------------------------------------------- +# config response body + + +def build_config(pool_cfg, pubkey, node_id, nonce, now=None): + """The /config answer for one node, as a dict. + + pool_cfg holds the community-wide constants; the concentrator list is + always sent in full so the node keeps a second tunnel and can fail over + without being reflashed. The node caps mtu against its own WAN MTU + (nodeconfig.lua), so the value here is the community maximum, not a + per-link computation. + """ + range6, xlat_range6 = id_to_prefixes(pool_cfg["prefix6"], node_id) + address6 = str(ip_network(range6)[1]) + + conf = { + "nonce": nonce, + "time": int(time.time() if now is None else now), + "id": pubkey, + "mtu": pool_cfg["mtu"], + "wg_keepalive": pool_cfg["wg_keepalive"], + "retry": pool_cfg["retry"], + "range6": range6, + "address6": address6, + "range4": pool_cfg["range4"], + "address4": pool_cfg["address4"], + "concentrators": [ + { + key: conc[key] + for key in ("id", "pubkey", "endpoint", "address6", "address4") + } + for conc in pool_cfg["concentrators"] + ], + } + # 464XLAT: without this field the node falls back to routed IPv4 in the + # tunnel. Leaving it out is the documented per-node kill switch. + if pool_cfg.get("xlat", True): + conf["xlat_range6"] = xlat_range6 + return conf + + +# --------------------------------------------------------------------------- +# subprocess plumbing + + +def demote_runner(user="auto"): + """A subprocess.run lookalike that drops privileges before exec. + + Needed by the root-run cron scripts; the broker already runs as user. + """ + import pwd + + pw = pwd.getpwnam(user) + + def demote(): + os.setgid(pw.pw_gid) + os.setuid(pw.pw_uid) + + def run(cmd, **kwargs): + return subprocess.run(cmd, preexec_fn=demote, **kwargs) + + return run + + +def git(run, repo, *args, check=True): + proc = run(["git", "-C", repo, *[str(a) for a in args]], capture_output=True) + if check and proc.returncode != 0: + raise GitError( + "git " + + " ".join(str(a) for a in args) + + f": exit {proc.returncode}: " + + proc.stderr.decode(errors="replace").strip() + ) + return proc + + +class AllocationLock: + """Advisory lock so concurrent /config requests allocate one id each.""" + + def __init__(self, path): + self.path = path + self._handle = None + + def __enter__(self): + self._handle = open(self.path, "a+") + fcntl.flock(self._handle, fcntl.LOCK_EX) + return self + + def __exit__(self, *_exc): + fcntl.flock(self._handle, fcntl.LOCK_UN) + self._handle.close() + self._handle = None + + +# --------------------------------------------------------------------------- +# allocation + + +def get_or_allocate( + repo_dir, + pool_cfg, + pubkey, + *, + run=subprocess.run, + lock_path=None, + push_flag=None, + before_create=None, + sync=True, +): + """Return the record for pubkey, creating and pushing one if it is new. + + Ordering matters: once the answer carries a prefix, a lost push means + the next node gets the same /63. So the record is committed and pushed + *before* the caller is allowed to answer; anything else raises and the + node retries on its next poll. + + A push failure leaves a record behind that the next gateway will never + learn about from the remote. It is kept (the local concentrator already + serves it) but flagged; while the flag exists no further allocation + happens and every call retries the push first. + + sync=False is for a registry that only exists on this host: no pull, no + push, no push flag. The commit stays local, which keeps the ordering + guarantee trivially - there is nothing that can be lost on the way out. + + before_create(pubkey) is called once per new node under the lock; the + broker passes its rate limiter here. + """ + if lock_path is None: + lock_path = os.path.join(tempfile.gettempdir(), "ff-parker-allocate.lock") + + with AllocationLock(lock_path): + if sync: + if push_flag and os.path.exists(push_flag): + # A previous allocation is committed but unpushed. Answering + # more nodes before that lands doubles out the concentrators. + git(run, repo_dir, "push") + os.remove(push_flag) + + git(run, repo_dir, "pull", "--ff-only") + records = load_records(repo_dir) + + record = records.get(pubkey) + if record is not None: + return record + + if before_create is not None: + before_create(pubkey) + + node_id = first_free_id( + (rec["id"] for rec in records.values()), pool_cfg["prefix6"] + ) + conc = [conc["id"] for conc in pool_cfg["concentrators"]] + relpath = record_relpath(pubkey) + fullpath = os.path.join(repo_dir, relpath) + os.makedirs(os.path.dirname(fullpath), exist_ok=True) + + tmp = fullpath + ".tmp" + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(format_record(pubkey, node_id, conc)) + os.rename(tmp, fullpath) + + git(run, repo_dir, "add", "--", relpath) + git(run, repo_dir, "commit", "-m", f"parker: auto add id {node_id}") + if sync: + try: + git(run, repo_dir, "push") + except GitError as exc: + if push_flag: + with open(push_flag, "w", encoding="utf-8") as handle: + handle.write(f"{pubkey} id {node_id}\n") + raise PushPending( + f"record for {pubkey} committed but not pushed" + ) from exc + + return {"pubkey": pubkey, "id": node_id, "conc": conc, "path": relpath} + + +# --------------------------------------------------------------------------- +# rate limiting + + +class RateLimiter: + """Sliding window per source plus a cap on new nodes per day. + + The /config endpoint is unauthenticated and, unlike the batman side, + every accepted new node consumes a routed prefix. The limits make spam + annoying instead of damaging; the GC gives the burnt ids back. + """ + + def __init__(self, burst=10, window=3600, daily=200): + self.burst = burst + self.window = window + self.daily = daily + self._per_source = {} + self._today = [] + self._lock = threading.Lock() + + def allow_new(self, key, now=None): + now = time.time() if now is None else now + with self._lock: + self._today = [t for t in self._today if t > now - 86400] + if len(self._today) >= self.daily: + return False + seen = [t for t in self._per_source.get(key, []) if t > now - self.window] + if len(seen) >= self.burst: + self._per_source[key] = seen + return False + seen.append(now) + self._per_source[key] = seen + self._today.append(now) + return True + + +# --------------------------------------------------------------------------- +# signing + + +def sign_message(secret_path, message, *, binary="usign", run=subprocess.run): + """Detached usign signature over message (bytes) as a single line. + + nodeconfig.sh splits the response at the last line and verifies + everything before it, so exactly one signature line must come back. + ``binary`` names the signing tool; Debian's signify-openbsd writes the + same format and accepts the same flags. + """ + with tempfile.TemporaryDirectory() as tmpdir: + msgfile = os.path.join(tmpdir, "message") + sigfile = os.path.join(tmpdir, "message.sig") + with open(msgfile, "wb") as handle: + handle.write(message) + # The tool writes the detached signature to .sig and prints + # nothing, so name the file explicitly and read it back. + proc = run( + [binary, "-S", "-m", msgfile, "-s", secret_path, "-x", sigfile], + capture_output=True, + ) + if proc.returncode != 0: + raise SignError( + f"{binary}: exit " + f"{proc.returncode}: " + + proc.stderr.decode(errors="replace").strip() + ) + try: + with open(sigfile, encoding="utf-8") as handle: + siglines = handle.read().splitlines() + except OSError as exc: + raise SignError(f"{binary} wrote no signature file: {exc}") from exc + sigs = [ + line + for line in siglines + if line.strip() and not line.startswith("untrusted comment") + ] + if len(sigs) != 1: + raise SignError(f"{binary} produced {len(sigs)} signature lines") + return sigs[0] + + +# --------------------------------------------------------------------------- +# gateway status files (GC input) + + +def status_relpath(hostname): + return os.path.join(STATUS_SUBDIR, f"{hostname}.tsv") + + +def format_status(samples): + """One day-bucket per line: \\t. + + Buckets instead of timestamps so an active fleet only changes the file + once a day and publishing it does not spam the repo. + """ + return "".join(f"{pk}\t{bucket}\n" for pk, bucket in sorted(samples.items())) + + +def load_status(repo_dir): + """Merge the per-gateway status files, newest day-bucket per pubkey.""" + merged = {} + directory = os.path.join(repo_dir, STATUS_SUBDIR) + if not os.path.isdir(directory): + return merged + for entry in sorted(os.scandir(directory), key=lambda e: e.name): + if not entry.is_file() or not entry.name.endswith(".tsv"): + continue + with open(entry.path, encoding="utf-8") as handle: + for line in handle: + pubkey, _, bucket = line.strip().partition("\t") + if pubkey and bucket.isdigit(): + merged[pubkey] = max(merged.get(pubkey, 0), int(bucket)) + return merged + + +def record_age_days(run, repo_dir, relpath, now=None): + """Days since the record was first committed, or None if unknown.""" + proc = git( + run, repo_dir, "log", "--diff-filter=A", "--format=%ct", "--", relpath, check=False + ) + stamps = proc.stdout.decode().split() + if proc.returncode != 0 or not stamps: + return None + return ((time.time() if now is None else now) - int(stamps[0])) / 86400 diff --git a/playbooks/roles/ff.wgbroker/defaults/main.yml b/playbooks/roles/ff.wgbroker/defaults/main.yml new file mode 100644 index 0000000..406b5ed --- /dev/null +++ b/playbooks/roles/ff.wgbroker/defaults/main.yml @@ -0,0 +1,53 @@ +--- +# Parker config service (GET /config). Leave wgbroker_parker undefined (or +# without enabled: true) to keep the broker at add_key only. Example: +# +# wgbroker_parker: +# enabled: true +# prefix6: "2a03:2260:3006:8000::/56" # pool; id -> 2*id-th /64 (+ sibling for xlat) +# range4: "10.80.96.0/22" # community constant, only its length sizes the LAN DHCP +# address4: "10.80.96.1" +# mtu: 1406 +# wg_keepalive: 25 +# retry: 120 +# xlat: true # omit in a config change to drop 464XLAT per fleet +# usign_secret: | # key content, `usign -G -p parker.pub -s parker.sec` +# untrusted comment: ... +# rWV2... +# lock_path: /home/auto/.parker-allocation.lock +# push_flag: /home/auto/.parker-push-pending +# rate_limit: +# burst: 10 # new nodes per source per window +# window: 3600 +# daily: 200 # new nodes per day overall +# concentrators: +# - id: 1 +# pubkey: "" +# endpoint: "01.wg-node.freifunk-aachen.de:10000" +# address6: "2a03:2260:3006:8000::" +# address4: "10.80.96.2" +# - id: 2 +# ... +# gc: +# enabled: false # cron on the broker host +# dry_run: true # log what would be deleted +# never_handshaked_days: 7 +# silent_days: 90 +wgbroker_parker: {} + +# Registry for the parker records (pubkey -> id -> concentrators) and the +# gateway status files. Its own repository, not the batman peer list: parker +# keys must not live in a tree that every node registration rewrites, and +# the concentrator reads it on a minute schedule of its own. +# Empty remote = local only: the broker commits and never pulls or pushes, +# so the commits on this host are the audit log. +wgbroker_parker_repo: /var/lib/parker/registry +wgbroker_parker_repo_remote: "" + +# Where the role writes wgbroker_parker.usign_secret (usign/signify key +# content) and the tool the broker signs /config with. Debian ships OpenBSD +# signify as signify-openbsd and has no usign package; the signature format +# and the key files are the same, so a node's usign verifies either. Unlike +# usign, signify insists that a secret key file be named *.sec. +wgbroker_parker_secret_key: /home/auto/usign/parker.sec +wgbroker_parker_signer: signify-openbsd diff --git a/playbooks/roles/ff.wgbroker/tasks/main.yml b/playbooks/roles/ff.wgbroker/tasks/main.yml index f2c1344..0d54650 100644 --- a/playbooks/roles/ff.wgbroker/tasks/main.yml +++ b/playbooks/roles/ff.wgbroker/tasks/main.yml @@ -8,7 +8,6 @@ vars: packages: - python3 - - python3-pip - git - name: Create auto user @@ -53,12 +52,12 @@ group: auto register: updatebroker -- name: Install gunicorn and Flask python package - become: true - become_user: auto - ansible.builtin.pip: +- name: Install flask and gunicorn + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 name: - - flask + - python3-flask - gunicorn - name: Set Git Config Mail @@ -89,6 +88,17 @@ scope: local value: "auto" +# The workers import this at start, so it has to be in place before the +# service is (re)started below. +- name: Install parker helper library + ansible.builtin.copy: + src: "{{ playbook_dir }}/files/parker_lib.py" + dest: /home/auto/parker_lib.py + mode: "0644" + owner: auto + group: auto + register: updateparkerlib + - name: Install broker service ansible.builtin.template: src: wgbroker.service.j2 @@ -102,3 +112,87 @@ daemon_reload: true enabled: true when: servicefile is changed or updatebroker is changed + or updateparkerlib is changed + +# parker config service (GET /config), see defaults/main.yml + +- name: Install the parker config signer + ansible.builtin.apt: + name: "{{ wgbroker_parker_signer }}" + when: wgbroker_parker.get('enabled', false) | bool + +- name: Ensure the signing key directory + ansible.builtin.file: + path: "{{ wgbroker_parker_secret_key | dirname }}" + state: directory + owner: auto + group: auto + mode: "0750" + when: wgbroker_parker.get('enabled', false) | bool + +- name: Install parker signing key + ansible.builtin.copy: + content: "{{ wgbroker_parker.usign_secret }}" + dest: "{{ wgbroker_parker_secret_key }}" + mode: "0600" + owner: auto + group: auto + directory_mode: "0750" + no_log: true + when: wgbroker_parker.get('enabled', false) | bool + +# The registry is a repository of its own, so the parker records never mix +# with the batman peer keys in peers-wg. Local only unless a remote is set: +# the broker then commits here and the concentrators read the same tree. +- name: Create the parker registry directory + ansible.builtin.file: + path: "{{ wgbroker_parker_repo }}" + state: directory + owner: auto + group: auto + mode: "0750" + when: wgbroker_parker.get('enabled', false) | bool + +# git init has no module (the git module clones, it does not create), and a +# local registry needs no clone. +- name: Initialise the parker registry repository + become: true + become_user: auto + ansible.builtin.command: # noqa: command-instead-of-module + cmd: git init --initial-branch=main . + chdir: "{{ wgbroker_parker_repo }}" + creates: "{{ wgbroker_parker_repo }}/.git" + when: wgbroker_parker.get('enabled', false) | bool + +- name: Set the parker registry commit identity + become: true + become_user: auto + community.general.git_config: + name: "{{ item.name }}" + value: "{{ item.value }}" + repo: "{{ wgbroker_parker_repo }}" + scope: local + loop: + - { name: user.name, value: "auto" } + - { name: user.email, value: "auto@{{ dns_searchdomains }}" } + when: wgbroker_parker.get('enabled', false) | bool + +- name: Install parker garbage collector + ansible.builtin.template: + src: parker_gc.py.j2 + dest: /home/auto/parker_gc.py + mode: "0744" + owner: auto + group: auto + when: wgbroker_parker.get('enabled', false) | bool + and wgbroker_parker.get('gc', {}).get('enabled', false) | bool + +- name: Install crontab for the parker garbage collector + ansible.builtin.cron: + name: "parker gc" + user: auto + minute: "30" + hour: "4" + job: /usr/bin/python3 /home/auto/parker_gc.py + when: wgbroker_parker.get('enabled', false) | bool + and wgbroker_parker.get('gc', {}).get('enabled', false) | bool diff --git a/playbooks/roles/ff.wgbroker/templates/broker.py b/playbooks/roles/ff.wgbroker/templates/broker.py index 6643379..49d19c8 100644 --- a/playbooks/roles/ff.wgbroker/templates/broker.py +++ b/playbooks/roles/ff.wgbroker/templates/broker.py @@ -1,4 +1,5 @@ import glob +import json import os import pwd import re @@ -7,16 +8,48 @@ from pathlib import Path -from flask import Flask, jsonify, request +import parker_lib +from flask import Flask, Response, jsonify, request REPO = "/etc/wireguard/peers-wg" +# The parker records live in a registry of their own, not in the batman peer +# list above. Empty remote = local only: no pull, no push, the commits on +# this host are the audit log. +PARKER_REPO = {{ wgbroker_parker_repo | tojson }} +PARKER_REPO_REMOTE = {{ wgbroker_parker_repo_remote | default("") | tojson }} + +# Parker config service: community constants and concentrator list from +# group/host vars, null disables the /config endpoint. See +# playbooks/files/parker_lib.py. +# (Double tojson: the config arrives as one JSON string literal, since +# tojson's true/false/null are not python literals.) +# usign_secret is key content, not runtime config: drop it so the key never +# lands in this readable file; the broker signs with PARKER_SIGN_KEY below. +{% set _parker = (wgbroker_parker | default({}, true)) | dict2items + | rejectattr("key", "equalto", "usign_secret") | items2dict %} +PARKER = json.loads({{ _parker | tojson | tojson }}) + +# The role writes the signing key to this path (owner auto, mode 0600). +PARKER_SIGN_KEY = {{ wgbroker_parker_secret_key | tojson }} +PARKER_SIGNER = {{ wgbroker_parker_signer | tojson }} + app = Flask(__name__) # TODO(ruairi): Refactor load_config to return Dataclass. WG_PUBKEY_PATTERN = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") +_parker_cfg = PARKER if PARKER and PARKER.get("enabled") else None +_parker_limiter = None +if _parker_cfg: + _limits = _parker_cfg.get("rate_limit", {}) + _parker_limiter = parker_lib.RateLimiter( + burst=_limits.get("burst", 10), + window=_limits.get("window", 3600), + daily=_limits.get("daily", 200), + ) + # https://gist.github.com/berlotto/6295018 _slugify_strip_re = re.compile(r'[^\w\s-]') _slugify_hyphenate_re = re.compile(r'[-\s]+') @@ -48,9 +81,16 @@ def execute_autouser(cmd): env = os.environ.copy() env.update({'HOME': homedir, 'LOGNAME': autouser, 'USER': autouser}) - s = subprocess.Popen([cmd], shell=True, env=env, - preexec_fn=demote(user_uid, user_gid), stdout=subprocess.PIPE) - s.wait() + s = subprocess.run([cmd], shell=True, env=env, + preexec_fn=demote(user_uid, user_gid), + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if s.returncode != 0: + # Before the reply carried allocated prefixes a lost commit or push + # was invisible and harmless; the node would just retry. Surface it + # in the response instead of answering "OK" regardless. + raise RuntimeError(f"{cmd}: exit {s.returncode}: " + + s.stderr.decode(errors='replace').strip()) + return s def push_repo(): execute_autouser(f"git -C {REPO} push") @@ -114,6 +154,80 @@ def add_key(): return jsonify({"Message": "OK"}), 200 +def _parker_error(message, status): + # nodeconfig.sh retries on any failed fetch; a plain status code is all + # the node reads, the body is for journalctl. + app.logger.warning(f"parker /config {status}: {message}") + return Response(f"{message}\n", status=status, content_type="text/plain") + + +@app.route("/config", methods=["GET"]) +def parker_config(): + """Introduce a parker node and hand it its delegated prefixes. + + Query parameters as sent by nodeconfig.sh: pubkey, nonce, v6mtu, + version and optionally tc_ingress/tc_egress. The response is the JSON + config followed by a newline and one detached usign signature line; + the node verifies it against site.parker.config_pubkey. + """ + if _parker_cfg is None: + return _parker_error("parker config service disabled", 404) + + try: + pubkey = parker_lib.validate_pubkey(request.args.get("pubkey", "").strip()) + nonce = parker_lib.validate_nonce(request.args.get("nonce", "")) + except parker_lib.InvalidRequest as exc: + return _parker_error(str(exc), 400) + + app.logger.info( + "parker /config from %s: version=%s v6mtu=%s tc=%s/%s", + request.remote_addr, + request.args.get("version"), + request.args.get("v6mtu"), + request.args.get("tc_ingress"), + request.args.get("tc_egress"), + ) + + def rate_limit(new_pubkey): + source = request.remote_addr or "unknown" + if not _parker_limiter.allow_new(source): + raise parker_lib.RateLimited( + f"allocation limit reached for {source}" + ) + + try: + record = parker_lib.get_or_allocate( + PARKER_REPO, + _parker_cfg, + pubkey, + lock_path=_parker_cfg.get("lock_path", "/home/auto/.parker-allocation.lock"), + push_flag=_parker_cfg.get("push_flag", "/home/auto/.parker-push-pending"), + before_create=rate_limit, + sync=bool(PARKER_REPO_REMOTE), + ) + conf = parker_lib.build_config(_parker_cfg, pubkey, record["id"], nonce) + body = json.dumps(conf, separators=(",", ":")) + "\n" + signature = parker_lib.sign_message( + PARKER_SIGN_KEY, body.encode("utf-8"), binary=PARKER_SIGNER + ) + except parker_lib.RateLimited as exc: + return _parker_error(str(exc), 429) + except parker_lib.PushPending: + # Committed here but not on the git server: the second gateway + # would not program this prefix. Retry the push on the next poll. + return _parker_error("registry not in sync, retrying", 503) + except (parker_lib.PoolExhausted, parker_lib.RecordConflict) as exc: + return _parker_error(f"parker registry: {exc}", 503) + except parker_lib.GitError as exc: + return _parker_error(f"registry unavailable: {exc}", 503) + except parker_lib.SignError as exc: + return _parker_error(f"signing failed: {exc}", 500) + + return Response( + body + signature + "\n", status=200, content_type="text/plain; charset=utf-8" + ) + + #@app.route("/api/del_key", methods=["POST"]) def del_key(): data = request.get_json(force=True) diff --git a/playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 b/playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 new file mode 100644 index 0000000..59ecfc1 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Delete parker records whose node is gone, freeing the id for reuse. + +Runs as user ``auto`` on the broker host. With auto-approval the /config +endpoint mints routed prefixes to whoever asks, so the GC is the valve that +keeps unauthenticated spam from permanently eating the pool: a record that +never handshaked or went silent for long enough is removed and its id +becomes allocatable again. + +Liveness comes from the per-gateway status files the reconcilers publish +below parker-status/ in the registry. A record is only deleted when no +gateway has seen a recent handshake: a node that only ever reaches one +concentrator stays alive on the strength of that one file. + +Until parker_gc.dry_run is turned off this only logs. +""" + +import json +import subprocess +import sys +import time + +import parker_lib + +REPO = {{ wgbroker_parker_repo | tojson }} +SYNC = {{ 'True' if (wgbroker_parker_repo_remote | default('')) else 'False' }} + +# double tojson: json.loads of a string literal, because tojson's +# true/false/null are not python literals +CFG = json.loads({{ wgbroker_parker | tojson | tojson }}) +GC = CFG.get("gc", {}) + +NEVER_HANDSHAKED_DAYS = GC.get("never_handshaked_days", 7) +SILENT_DAYS = GC.get("silent_days", 90) +DRY_RUN = GC.get("dry_run", True) + +run = subprocess.run +DAY = 86400 + + +def find_dead(records, status, now): + today_bucket = int(now) // DAY + dead = [] + for pubkey, record in records.items(): + bucket = status.get(pubkey) + if bucket is None: + age = parker_lib.record_age_days(run, REPO, record["path"], now=now) + if age is not None and age > NEVER_HANDSHAKED_DAYS: + dead.append((record, f"never handshaked, {age:.0f} d old")) + elif today_bucket - bucket > SILENT_DAYS: + dead.append((record, f"silent since day {bucket}, {today_bucket - bucket} d")) + return dead + + +def main(): + if SYNC: + parker_lib.git(run, REPO, "pull", "--ff-only") + + records = parker_lib.load_records(REPO) + status = parker_lib.load_status(REPO) + dead = find_dead(records, status, time.time()) + + if not dead: + print(f"parker gc: {len(records)} records, nothing to do") + return 0 + + for record, reason in dead: + print(f"parker gc: {'would delete' if DRY_RUN else 'deleting'} " + f"{record['path']} (id {record['id']}): {reason}") + + if DRY_RUN: + return 0 + + for record, _reason in dead: + parker_lib.git(run, REPO, "rm", "--quiet", "--", record["path"]) + parker_lib.git( + run, + REPO, + "commit", + "-m", + f"parker gc: remove {len(dead)} dead record(s)", + ) + if SYNC: + parker_lib.git(run, REPO, "push") + print(f"parker gc: done, {len(dead)} record(s) removed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 index de444cb..4f3e25f 100644 --- a/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 @@ -7,7 +7,7 @@ After=network.target User=auto WorkingDirectory=/home/auto Environment="PYTHONUNBUFFERED=TRUE" -ExecStart=/home/auto/.local/bin/gunicorn -b [::]:8000 broker:app +ExecStart=/usr/bin/gunicorn -b [::]:8000 broker:app Restart=always [Install] From 9dde5e63885eaab8e5f63d8d161706a915452b64 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:24 +0000 Subject: [PATCH 08/19] tests: cover the parker allocator and run pytest in CI The id <-> /63-pair bijection, first-fit reuse and exhaustion, idempotent re-registration, commit-but-unpushed deferral, a registry without a remote, concurrent registration of one key, the record parser and the rate limiter, against real scratch git repositories; usign is stubbed. --- .github/workflows/test.yml | 19 ++ tests/test_parker_lib.py | 451 +++++++++++++++++++++++++++++++++++++ 2 files changed, 470 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 tests/test_parker_lib.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..da439db --- /dev/null +++ b/.github/workflows/test.yml @@ -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/ diff --git a/tests/test_parker_lib.py b/tests/test_parker_lib.py new file mode 100644 index 0000000..f09cb6a --- /dev/null +++ b/tests/test_parker_lib.py @@ -0,0 +1,451 @@ +"""Tests for playbooks/files/parker_lib.py. + +Run with: python3 -m pytest tests/ +""" + +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "playbooks" / "files")) + +import parker_lib # noqa: E402 + +# wireguard keys: 42 base64 chars, one check char, one '=' +PUBKEY = "A" * 43 + "=" +PUBKEY2 = "Ag" + "A" * 40 + "E=" +PUBKEY3 = "A" * 42 + "M=" +NONCE = "0123456789abcdef0123456789abcdef" +POOL = "2a03:2260:3006:8000::/56" +# exactly two /64 pairs: only id 1 exists, id 2 is already past the end +SMALL_POOL = "2a03:2260:3006:c000::/62" + + +def pool_cfg(prefix=POOL): + return { + "prefix6": prefix, + "range4": "10.80.96.0/22", + "address4": "10.80.96.1", + "mtu": 1406, + "wg_keepalive": 25, + "retry": 120, + "concentrators": [ + { + "id": 1, + "pubkey": "B" * 42 + "E=", + "endpoint": "01.example:10000", + "address6": "2a03:2260:3006:8000::", + "address4": "10.80.96.2", + "uninteresting": "dropped", + } + ], + } + + +@pytest.fixture +def repo(tmp_path): + """A clone with a bare origin, like /etc/wireguard/peers-wg.""" + origin = tmp_path / "origin.git" + work = tmp_path / "peers-wg" + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True + ) + subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True) + for key, value in ( + ("user.name", "auto"), + ("user.email", "auto@example"), + ("commit.gpgsign", "false"), + ): + subprocess.run(["git", "-C", str(work), "config", key, value], check=True) + (work / "README").write_text("peers\n") + subprocess.run(["git", "-C", str(work), "add", "README"], check=True) + subprocess.run(["git", "-C", str(work), "commit", "-qm", "init"], check=True) + subprocess.run( + ["git", "-C", str(work), "push", "-q", "-u", "origin", "main"], check=True + ) + return work + + +@pytest.fixture +def local_repo(tmp_path): + """A registry without any remote, like /var/lib/parker/registry.""" + work = tmp_path / "registry" + subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True) + for key, value in ( + ("user.name", "auto"), + ("user.email", "auto@example"), + ("commit.gpgsign", "false"), + ): + subprocess.run(["git", "-C", str(work), "config", key, value], check=True) + return work + + +def remote_log(work, branch="main"): + return subprocess.run( + ["git", "-C", str(work), "log", "--format=%s", f"origin/{branch}"], + capture_output=True, + check=True, + ).stdout.decode().splitlines() + + +def run_but_fail_on(fail_on): + """A subprocess.run lookalike that fails for git subcommands in fail_on.""" + + def run(cmd, **kwargs): + if any(arg in fail_on for arg in cmd): + return subprocess.CompletedProcess(cmd, 1, b"", b"simulated failure") + return subprocess.run(cmd, **kwargs) + + return run + + +# --- validation ------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bad", ["", "short", "a" * 43, PUBKEY[:-2] + "a=", PUBKEY + "x"] +) +def test_validate_pubkey_rejects(bad): + with pytest.raises(parker_lib.InvalidRequest): + parker_lib.validate_pubkey(bad) + + +def test_validate_pubkey_and_nonce_accept(): + assert parker_lib.validate_pubkey(PUBKEY) == PUBKEY + assert parker_lib.validate_nonce(NONCE) == NONCE + with pytest.raises(parker_lib.InvalidRequest): + parker_lib.validate_nonce("xyz") + + +# --- records ---------------------------------------------------------------- + + +def test_record_roundtrip_ignores_extra_fields(): + text = parker_lib.format_record(PUBKEY, 412, [1, 2]) + "reviewed: yes\n" + rec = parker_lib.parse_record(text) + assert rec == {"pubkey": PUBKEY, "id": 412, "conc": [1, 2], "path": "parker"} + + +@pytest.mark.parametrize( + "text", + [ + "\n", + "notapubkey\nid: 1\n", + PUBKEY + "\n", + PUBKEY + "\nid: twelve\n", + PUBKEY + "\nid: 3\nconc: a,b\n", + ], +) +def test_parse_record_rejects(text): + with pytest.raises(parker_lib.RecordConflict): + parker_lib.parse_record(text) + + +def test_load_records_detects_duplicates(repo): + (repo / "parker").mkdir() + (repo / "parker" / "k1").write_text(parker_lib.format_record(PUBKEY, 5, [1])) + assert parker_lib.load_records(str(repo))[PUBKEY]["id"] == 5 + + (repo / "parker" / "k2").write_text(parker_lib.format_record(PUBKEY, 6, [1])) + with pytest.raises(parker_lib.RecordConflict, match="pubkey"): + parker_lib.load_records(str(repo)) + + (repo / "parker" / "k2").write_text(parker_lib.format_record(PUBKEY2, 5, [1])) + with pytest.raises(parker_lib.RecordConflict, match="id"): + parker_lib.load_records(str(repo)) + + +def test_load_records_missing_dir(tmp_path): + assert parker_lib.load_records(str(tmp_path)) == {} + + +def test_record_filename_is_filesystem_safe(): + weird = "ab+cd/efghijklmnopqrstuvwxyz0123456789012E=" + name = parker_lib.record_relpath(weird) + assert "/" not in Path(name).name and "+" not in name + assert name.startswith("parker/") + + +# --- id <-> prefix ---------------------------------------------------------- + + +def test_pool_sizing(): + assert parker_lib.id_capacity("2a03:2260:3006::/48") == 32768 + assert parker_lib.id_capacity(POOL) == 128 + # growing to the surrounding /49 later is a config change, not a + # migration: 2*id maps onto the same /64 in both + assert parker_lib.id_capacity("2a03:2260:3006:8000::/49") == 16384 + assert parker_lib.id_capacity(SMALL_POOL) == 2 + + +def test_id_to_prefixes_bijection(): + # an id owns two consecutive /64s of the pool; id 0 is reserved for infra, + # so id 1 starts at the *second* pair of /64s of the pool + assert parker_lib.id_to_prefixes(POOL, 1) == ( + "2a03:2260:3006:8002::/64", + "2a03:2260:3006:8003::/64", + ) + assert parker_lib.id_to_prefixes(POOL, 12) == ( + "2a03:2260:3006:8018::/64", + "2a03:2260:3006:8019::/64", + ) + with pytest.raises(parker_lib.PoolExhausted): + parker_lib.id_to_prefixes(POOL, 0) + with pytest.raises(parker_lib.PoolExhausted): + parker_lib.id_to_prefixes(POOL, parker_lib.id_capacity(POOL)) + + +def test_first_free_id_reuses_and_exhausts(): + assert parker_lib.first_free_id([], POOL) == 1 + assert parker_lib.first_free_id({1, 2, 4}, POOL) == 3 + with pytest.raises(parker_lib.PoolExhausted): + parker_lib.first_free_id({1}, SMALL_POOL) + + +# --- config body ------------------------------------------------------------- + + +def test_build_config_fields(): + conf = parker_lib.build_config(pool_cfg(), PUBKEY, 12, NONCE, now=1762000000) + assert conf["id"] == PUBKEY + assert conf["nonce"] == NONCE + assert conf["time"] == 1762000000 + assert conf["range6"] == "2a03:2260:3006:8018::/64" + assert conf["address6"] == "2a03:2260:3006:8018::1" + assert conf["xlat_range6"] == "2a03:2260:3006:8019::/64" + assert conf["range4"] == "10.80.96.0/22" + assert conf["mtu"] == 1406 + assert conf["concentrators"] == [ + { + "id": 1, + "pubkey": "B" * 42 + "E=", + "endpoint": "01.example:10000", + "address6": "2a03:2260:3006:8000::", + "address4": "10.80.96.2", + } + ] + + +def test_build_config_xlat_kill_switch(): + cfg = pool_cfg() + cfg["xlat"] = False + assert "xlat_range6" not in parker_lib.build_config(cfg, PUBKEY, 1, NONCE) + + +# --- rate limiting ----------------------------------------------------------- + + +def test_rate_limiter_window_per_source(): + limiter = parker_lib.RateLimiter(burst=2, window=60, daily=4) + t = 1000.0 + assert limiter.allow_new("1.2.3.4", now=t) + assert limiter.allow_new("1.2.3.4", now=t) + assert not limiter.allow_new("1.2.3.4", now=t) + assert limiter.allow_new("5.6.7.8", now=t) # other source unaffected + assert not limiter.allow_new("1.2.3.4", now=t + 59) + assert limiter.allow_new("1.2.3.4", now=t + 61) # window passed + + +def test_rate_limiter_daily_cap(): + limiter = parker_lib.RateLimiter(burst=100, window=60, daily=2) + t = 1000.0 + assert limiter.allow_new("a", now=t) + assert limiter.allow_new("b", now=t) + assert not limiter.allow_new("c", now=t) + + +# --- allocation -------------------------------------------------------------- + + +def allocate(repo, pubkey, cfg=None, **kwargs): + kwargs.setdefault("lock_path", str(repo.parent / "alloc.lock")) + kwargs.setdefault("push_flag", str(repo.parent / "push.flag")) + return parker_lib.get_or_allocate(str(repo), cfg or pool_cfg(), pubkey, **kwargs) + + +def test_allocate_creates_and_pushes(repo): + rec = allocate(repo, PUBKEY) + assert rec["id"] == 1 and rec["conc"] == [1] + content = (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY)).read_text() + assert content == parker_lib.format_record(PUBKEY, 1, [1]) + assert "parker: auto add id 1" in remote_log(repo) + + +def test_allocate_without_remote_keeps_the_commit_local(local_repo): + # no pull, no push, no push flag: the registry never leaves the host + rec = allocate(local_repo, PUBKEY, sync=False) + assert rec["id"] == 1 + assert not (local_repo.parent / "push.flag").exists() + assert allocate(local_repo, PUBKEY, sync=False)["id"] == 1 # idempotent + log = subprocess.run( + ["git", "-C", str(local_repo), "log", "--format=%s"], + capture_output=True, + check=True, + ).stdout.decode().splitlines() + assert log == ["parker: auto add id 1"] + assert subprocess.run( + ["git", "-C", str(local_repo), "remote"], + capture_output=True, + check=True, + ).stdout == b"" + + +def test_allocate_is_idempotent(repo): + first = allocate(repo, PUBKEY) + before = remote_log(repo) + second = allocate(repo, PUBKEY) + assert second["id"] == first["id"] + assert remote_log(repo) == before # no re-registration commit + assert allocate(repo, PUBKEY2)["id"] == 2 + + +def test_allocate_reuses_freed_id(repo): + allocate(repo, PUBKEY) + allocate(repo, PUBKEY2) + subprocess.run( + ["git", "-C", str(repo), "rm", "-q", "--", parker_lib.record_relpath(PUBKEY)], + check=True, + ) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "gc"], check=True) + subprocess.run(["git", "-C", str(repo), "push", "-q"], check=True) + assert allocate(repo, PUBKEY3)["id"] == 1 + + +def test_allocate_exhaustion_creates_nothing(repo): + allocate(repo, PUBKEY, cfg=pool_cfg(SMALL_POOL)) + with pytest.raises(parker_lib.PoolExhausted): + allocate(repo, PUBKEY2, cfg=pool_cfg(SMALL_POOL)) + assert not (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY2)).exists() + assert len(remote_log(repo)) == 2 # init + first allocation + + +def test_allocate_before_create_can_veto(repo): + def veto(_pubkey): + raise parker_lib.RateLimited("nope") + + with pytest.raises(parker_lib.RateLimited): + allocate(repo, PUBKEY, before_create=veto) + assert not (repo / "parker").exists() + assert len(remote_log(repo)) == 1 # nothing was committed + + allocate(repo, PUBKEY, before_create=lambda p: None) # allowed path creates + + +def test_allocate_push_failure_defers_answer_and_recovers(repo): + flag = str(repo.parent / "push.flag") + with pytest.raises(parker_lib.PushPending): + allocate(repo, PUBKEY, run=run_but_fail_on({"push"})) + assert Path(flag).exists() + # committed locally so the id is not handed to a second node... + assert (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY)).exists() + # ...but the other gateway cannot see it yet + assert "parker: auto add id 1" not in remote_log(repo) + + rec = allocate(repo, PUBKEY) # next poll flushes the push, answers the same id + assert rec["id"] == 1 + assert not Path(flag).exists() + assert "parker: auto add id 1" in remote_log(repo) + + +def test_allocate_blocks_new_ids_while_push_pending(repo): + with pytest.raises(parker_lib.PushPending): + allocate(repo, PUBKEY, run=run_but_fail_on({"push"})) + # while the flag exists and the remote is still down, no node is served + with pytest.raises(parker_lib.GitError): + allocate(repo, PUBKEY, run=run_but_fail_on({"push"})) + with pytest.raises(parker_lib.GitError): + allocate(repo, PUBKEY2, run=run_but_fail_on({"push"})) + assert not (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY2)).exists() + + +def test_allocate_serialises_concurrent_same_key(repo): + """Two workers racing on the same fresh key: one commit, both see the id.""" + results = [] + barrier = threading.Barrier(2) + + def call(): + barrier.wait() + results.append(allocate(repo, PUBKEY)) + + threads = [threading.Thread(target=call) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + assert {r["id"] for r in results} == {1} + assert len([line for line in remote_log(repo) if line.startswith("parker:")]) == 1 + + +# --- signing ----------------------------------------------------------------- + + +def test_sign_message_takes_the_sig_line(): + seen = {} + + def run(cmd, **kwargs): + seen["cmd"] = cmd + sigfile = Path(cmd[cmd.index("-x") + 1]) + sigfile.write_text("untrusted comment: signed by key 00\nRVRnT3Sig\n") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + assert parker_lib.sign_message("/nope/secret", b"{}\n", run=run) == "RVRnT3Sig" + assert seen["cmd"][0] == "usign" + assert seen["cmd"][seen["cmd"].index("-x") + 1].endswith(".sig") + + +def test_sign_message_names_the_tool_in_errors(): + def failing(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, b"", b"no key") + + with pytest.raises(parker_lib.SignError, match="signify-openbsd"): + parker_lib.sign_message("/nope", b"x", binary="signify-openbsd", run=failing) + + +def test_sign_message_errors(): + def failing(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, b"", b"no key") + + with pytest.raises(parker_lib.SignError): + parker_lib.sign_message("/nope", b"x", run=failing) + + def no_file(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + with pytest.raises(parker_lib.SignError): + parker_lib.sign_message("/nope", b"x", run=no_file) + + def no_signature_line(cmd, **kwargs): + Path(cmd[cmd.index("-x") + 1]).write_text("untrusted comment: signed by key 00\n") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + with pytest.raises(parker_lib.SignError): + parker_lib.sign_message("/nope", b"x", run=no_signature_line) + + +# --- status / GC input -------------------------------------------------------- + + +def test_status_roundtrip_and_merge(tmp_path): + (tmp_path / parker_lib.STATUS_SUBDIR).mkdir() + (tmp_path / parker_lib.STATUS_SUBDIR / "sn01.tsv").write_text( + parker_lib.format_status({PUBKEY: 20000, PUBKEY2: 19999}) + ) + (tmp_path / parker_lib.STATUS_SUBDIR / "sn02.tsv").write_text( + parker_lib.format_status({PUBKEY: 20001}) + ) + merged = parker_lib.load_status(str(tmp_path)) + assert merged == {PUBKEY: 20001, PUBKEY2: 19999} + + +def test_record_age_days(repo): + allocate(repo, PUBKEY) + relpath = parker_lib.record_relpath(PUBKEY) + age = parker_lib.record_age_days( + subprocess.run, str(repo), relpath, now=time.time() + ) + assert 0 <= age < 1 + assert parker_lib.record_age_days(subprocess.run, str(repo), "nope") is None From 7146513c7f6480a8c3697e28fd76765e9cda883f Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:24 +0000 Subject: [PATCH 09/19] secrets: add the parker usign signing key Fingerprint 3befd26f90d9530e; the nodes verify it against the public key in site.parker.config_pubkey. Re-encrypting the vault rewrites the whole file. --- group_vars/all/secrets.yml | 501 +++++++++++++++++++------------------ 1 file changed, 260 insertions(+), 241 deletions(-) diff --git a/group_vars/all/secrets.yml b/group_vars/all/secrets.yml index 5a188b4..d98ab76 100644 --- a/group_vars/all/secrets.yml +++ b/group_vars/all/secrets.yml @@ -1,242 +1,261 @@ $ANSIBLE_VAULT;1.1;AES256 -30646430326562323935316464303038653532663537373730626231376333643361363931343430 -3466316139626637353261323366323666303962663065320a626663653431326336306261623433 -37613064336162336239313863636465613330636131636162646435343233363834323134626337 -3832643039326165310a366565643262376361303562333830386530633566373362633964656566 -31306430376238326266303838313433396136666263323163346434326631666264653064333962 -66326530626639326335313263373730333534383962633930333736616437396130623830303362 -36653964373837613564636662343564333132636465663333376139346135353132383061353333 -32383237303537633038633662313137626231643162386436646535626536653966616434636465 -35636532626531393439353863653834623930383035303236663133333231653838303135396634 -36323732393932376132636566353936343730383130393633336365323939313738323231656232 -64333531386365313132666165656430343462613232333635393735633435336566363062396235 -37353330626635643666326562303463306531336562383039353035383631336137626330316535 -33333865646230353239366333616266363133366331376130346562326639623065346632613266 -39326566646234363938653664313637363161313833636435303866386138663037303532623139 -64613537326632306265346261646165623834613238336231616235333264313431383334386565 -63326137343939666332356539643831666332653236633239373662663333353838353862373130 -32623731653930303565336663643939633365346336663134616636313737306661653764663135 -39336532393932343032616533616530323133393638393163613666336630383736663234383831 -31373661323363306635323131373830396161346131393038666565643063373437316664623965 -33303363363739616263326661663834356365636430666431643262666164323163333463626335 -32306562633237376666636461653237663738306531646366383033646138303034623266313764 -31346165623639386165386666626233663935306238613938326535383037326331393564346162 -38633835363561663735383266386338303730336166323930303639343437333533313032636162 -30626430396630306263363837346334666133653262346662336337313534316565373131633562 -37636131633036616433656266623733636139306535636664336665323130366366326437306566 -64326462646265626636626261616261653533373531373761386533653536353133373464386335 -32396233333163363763356433363637353137633336623866373166653665666230663933373130 -65623730316633366534376330343736396436306536623934346331613961623438336533653535 -66633935653435306437393361613239343135656132356235613439613635396539626461666664 -31366138616534353530343834633830663464323563636165343264356430633334613438623432 -30383765383432363231636135303262613033343433643261373065666139383234616132633163 -63333335396461633338626166346539656432663164666566313965616134333231656136633165 -33333963623961373532666466396662313937383634636139383761356565313264333337363630 -38356135383562383861333839376135346534643563666535656363393264663433353966626138 -33323939343865616433366133343634643333343062363336663466613635626338373563656239 -30633162326131633636666162353431643332386363363765666230363833306266356139636235 -35623162663565363834623533306637653538366562616330346365653466366530356436343164 -66323032306436663134633336376462656235363933363836306637383463383066613935333066 -34363735306234366133333262633439626532326137663035656336626239663662326234616339 -34633032636336323466363231623365353933303437616434373565623862363362333361633030 -65646330613634363663636566613630383230306536383939646263323962336664663862336438 -63333566636161306136306136336162313663343066386334326630343865666365653134353934 -33343036623361356661316663326162636663363462346134656637656337636637346430386362 -37383632383335303064663438626561653661346235353834613639383366656233616338653338 -65623630653462613039333230393237653966323936643865633464386133323132336130323365 -36626631396233383263373866393563373238373161306439653036393631653336316265623936 -62323331356663343063663664663933356264396465643734393162383962663165326366336339 -62646334336331343465613938316666346433653039363637396638353931383931653038303232 -39393465303834643730666337396633383064613431376234366264323332376164353639653861 -35643166653636376631373736656664343330653034613765353063313363363833643537653166 -31333136306163643062376339613664393838343664353366336364613536313131393364666237 -30373138303131646432663162313237386131316563303133306333643361356138613638306436 -39336433366266366262346230333039303539616662373033386337653862326235323634353764 -64363661343933336666333537653230396232393436653662323961326334656636306330613166 -65653637396634343065396665356164316661366234326562323263396133356665353631613365 -62623932393936376562356165343432316134383033663863383763386535313566383732313863 -38393935643562633166383362366231386636343439633466396638653163636231653330653734 -35313033336337313461363761373263633566666237623235636132303032623163656339303735 -35666463663965376564383138643139616164623034346531363062316634613266393133623638 -62366235393735336231323531306466313437353239656562633833623362343261626663366335 -63363562363931303531653531356339393163393939346337323932626561643735386235306639 -65646164633564333934346666346537646232333730386164343438313933613865396538646534 -35343539616639333861616261396333333737623539326537313937393266383335663565333633 -34626132356366613266366639616139613035633738323735636461316532306566313937396332 -33353063646664303865383238303235613165643762366237653530613030356164386135303662 -34323864653030306636633862626531376134303938333264643831646330313166653932356536 -32333839663531656335656633333765343361323035323230333566383366626466346166363735 -38363565303632323134663561396332663138643931356230343633333833343565343862613063 -39373936663333633532636165353162386361643933393537393539393061393930383331656563 -64396363616536333332333963653834356264386630646261386637616430373332376133663235 -31616334316466666239313639373666346431356637393837313365656165363531363264343032 -65393565613466313834386663613230383532646631366364386139636331353131303338333033 -62393962626166333236326430393631323339343433346163643339303037333962393264313030 -30623130653966363062376633366261646264393535303163353838656334333139363737393261 -66336462383536353036666139616430633361313331303533613666646336383939656663666262 -62303839613630373861653838376137623564666363646362666438336534623962373430613865 -35393338646364363863306334636531623763326137306163626337306336313339633837653033 -63396532373464333261366133333534343766343663653937333830396334396666643236383036 -35326663653066353937353766373065626432363430326534333032386635383962303262666361 -37353237643763363031653239336239616231623036646434383361396330663432326565633938 -64343337363562613435616161646165623261643265353230666338363234326561396538313530 -36663962613534323635326564333336396662633330373239356262636335613364396264633865 -39393537633463336163613066316262303964306164343833386633393139376165346539613939 -62643964326438666634393736663362376265663862663035336565346130653235656133366636 -65336230646633316633376162616365616663393531633161353931653636663631383435363236 -39356333353064316631386161383931303235636133663634633136366462626437396265643632 -32393635383566643563383861363265666362336661623333393831313663303665663239343533 -35656534326466303865353938386461393232346333336666396264343835373435663963303531 -62636638303832623565396236613234616266333633386663636130636332303561333838323639 -64666435636333396537653764643431346231663164646231373237386466663863323134313731 -39623335666438383531626338376666633230656532303233633461623066656138393439323830 -33663563343033323365323632653239363135633266333465626234613461393536313836333031 -31303565333966313363646235613639366163636438386435333337636538386462363664616638 -66343032633533336163383330326463353163386638316364343063643130656234303762613434 -32613663333865656563303830643664393533323031666461376561666261323865326631643062 -30623832396432393565653330313366613864336432666539633130306437353661363061663065 -38376533383264383265306335363361656161356362336461316465653563373535616632363032 -32386661323635653563366266346536623431373664326465626162373431383436666263386136 -61663331666164386262396131373066373564646262356566303466623665663536386134643830 -33326533353737633635333630653031373738656566366539323433373864613033653661646339 -31343735343636363938646138343538303734383939313630666636363337316230373830616437 -32386365613865303334346430623965353531613935303934636330363132643935356336363763 -62343230633134386561343934323630316165346132366666333664393738383038626439303637 -66343336346361393135356465613439343035363239373536306566636632626561653263393236 -64343237376238383164326266626562386336626461336364656633363135336665383462363038 -37353037346166376136386533653265323662653834316439373561613336663033376266366238 -64666466313361616530313133613839643537313363656437353238363836326463623332303234 -65613661613532396230383765306466666464643666303739313834633566646536656337356631 -33333265343066623763383731383463373837643330616463303830323332646237383931366234 -32366534623863383634653838316438336664343333306564386431353161373166656263633365 -32303133353334333530356162643663353136373833663539323830383835336565343735613963 -64623634663966643263343338643639326438623563626638383062626366666335343162666236 -38306261383461346532396531643930313933373836306132613739363964636563643466373031 -63343337633334623032306534366231316137376566643765303630333835666633353334313037 -37623666343461646136623433623438646231343063396639643963653961376263353337653733 -39393461653333616535343738646266376164623136666531393463333937343536666362353963 -36386563343862303663376661376331336163346166306237366139303533386134633563323837 -63653833663539393661643630346461626533373939653038633861373164663566666265653563 -65653565396462633231393830336333633333366366656261316338313166386536333264316564 -66613234646236356332636336393533363466396633383030373636616165393837323039363663 -37336638303361393261383136636365393631393538643636353933386435303035373734316561 -32646466336563333130356262633336343266303131323638633262663263653139313465313166 -36353335303332666235316434393534663230653462386166313831646563623238646239313636 -36343665386630376561343032643938643266663065313366393731386133316539666161643635 -66303839663861633362643831323362396132623939623038613230636463613966666663353835 -36373830653838656465636665386437663234386239303139333039646431666463366333336362 -32346533393164303165316134326565666239623130383637336530306136616461323839663632 -35623539626134383230636261656534356164663563383834316539326165356138613531353264 -33313237383764356331313339353938643265636663613833623265653433336634323439623534 -62363539663430653333656662343432336337633835663233363763616464613430653161303064 -36323364313032363337323566373361613037386634393261636138636261613532623830393035 -31633965306235623863363463383533353938653430396332663064656138646436376330376136 -65643161343330363835313435653664643131376362333363616639663966303830663235396361 -61653334373762636134383062623838613333633135663534363531663266376536363439313739 -65336235643266623437306462613536636535646331373063393139343433333263653531316538 -66303533306335383739346531373462656336396338343962343039623332386364306332373165 -62323836666462613765656632326339393333353034326339396162333130333437356233353130 -36666436306531303630306561366163663938306633626162623933643839383233663839316161 -65663064333061653439383631386139323738306564343563343463323131386531373333306331 -33613532346530306463336435323263646161616364386635333030343666613838316663326131 -33363864346535363765636261333034376464363031303336366331336239623566313962666535 -31323933343732323265643830303562633034613061323966363664333537393239646535316639 -36666437613965333330393266333035633633656365376239376234623636346465663766633039 -62393237306565643035336137636232376333653462366130613062333131383031643034323236 -63633463633235393863663133383632393431616437363161653332323836633932613739623136 -31666433363838656565363232366431656561333037663232336633326265393034313165663965 -66633535303765386266373531363032616236356434336638383139336265383061636265383731 -62656131363633323336323230356162343662353732313263343131346539396362666339623666 -62376530366430313833653739353530323536613639343436383032623463626565653230306236 -30303130313663623832653365393433343163306365633961326433313366326266373434653562 -66653138303064343464663634316630376435386231363766633965633331623332343330646139 -34646631323532646566313061346337316535313263386563363238653139373538626531663938 -33303638663362356233636632636636393837396563383834386531653264363038396635303838 -39333261303531626665373134333764323165396334633162376265636331303839326334663530 -37303630633833633161336238646637346535376333313164343663383765353434373938663965 -35303835316239343861376136323035623966633138383864353936316162376335303630393136 -30646261656639323637323465356135663138623535623538326566333636666335623334303634 -36373034636365373439633939643731346563626163663134383761646532613635393537356632 -66623131313364393138393862303764383939326661386430663130383434336631366236303265 -31386461313861383536623437633238363034643934343537646335613635373334383235383931 -65623536376261616661333262316532613832323865656630363535616132353937343539346136 -36393735646132303530393938636462303065303331636237616134373465323530626539323864 -63323433393136616566623833666134646238343431636361613136393735363931343136666231 -33646265363661623062336434663038613131363135333964323033626631353164313337633264 -38326534303862333030356631646262343161363734646133393034323832353463613265373634 -37373439326666333038306135376665373934316438363033333866623466623362663430643834 -36626335333439616534396634643764626364376539376537366531366434326161373737646130 -63303063383966643932376138623933393138336662373530623764646330616136666163613739 -31663364396133373737323564633565646538366437323733666362303937343163376636633932 -37316665633230373564623066356339363438626339343166666639666134666530636662383131 -62653232613562313137666538343336656163636664363664346433663838303361386362613463 -30363664613861313537306330313563336330313131373034373761316233313837323232636264 -36333933313530326664373138383862616637333366396235313830366262336338376635653734 -33303963333661353530633432656337366131666236383465373538393334363433653734306633 -32623030613634616337323164616364656434633334373465663833393564343535306437643130 -62646132323233626663623437633934633865393066336437313534383030363861633433663236 -65386133646537306537356566313033656336396664373039306661393733306566386434303030 -62633736363036393435643263383236373039373733323134663631303839333664643765333639 -64396430316661323433646465373461373966613065363936616431613831383461346135326463 -65646333656433616464643030393936323239623732666461316238383565343262333332666563 -64663562653766356665363635663263393565363933336436643135663232383739633333636465 -30623236643563376334363833633337313437653662663663646664353539366239646639316534 -38326466313935636337643335336535616237393935623732303261313361646234616536336665 -66336563363161303266363432643233333862393564653535383631613835656166353364373938 -34383361333835633165653064326633646164646634623633386335653363376536336465326131 -39356366363062363761653136316561333461613930336461623262616235616332326365663362 -61326130623562393334343161613338333732393934376163643631323235373661356537633062 -33623734313166346230646561653264666366646239346430663333666136373137376331643831 -39353538313933356431363064386133366637643366363131653434393735633838396163346633 -37343965316133663035633436376133666539343933656339333464613164383330613035653830 -32366630616636633865663632663461303032326666383338666133386335336536636162656638 -35323739396232356237366266313032643364636531376636323039633761636536376536316462 -35613462623162643961343434383963653434396566623161643432656238666435303536343566 -61353265326461346339306261353333336466333464663338656163373266623236626563666232 -33323833353033653165383033623461366461613665303364353230646663653564323963626363 -38323534353137656264363132663063323936313136363930336333383461626538653336353832 -65383861653563356138336634616465356161663062323330636263633137363631303636346561 -38363965376366306562326461653937656363386632643433666166616331623437626239633962 -31353965663835373335666134643766643334623837373538626536633464363631366334343333 -64666433623339313836643430316365623264353766383332386261336339383733653938313539 -62343536353962343432323836313336656432633636326231666539316364663432626535663637 -33616665356435653863393130366637663339653864376165373933656665386565313635303463 -39303834363265353461633761653863303338643335366638633739656265353962353631333639 -36633339396436383537656236303539363061653266653964356662383663623630313938633364 -36353031383962373036303535616337373362373962393862646662393434663030383935646566 -38653736643466616130626564666562316361343564396561373537306439366466306231663463 -65306435323464396432363361323930633936316239353765376633613131356532653966356436 -35633862386466353437376264343834353966353238616332343536616634323834633236343931 -30383239353765333264616366633266646536336663623835353662363930393837333533373737 -38663030333961313763393437313661316466633037333565386362376564623331383037326438 -65613662306230663131336633303031353536376238323832363161633631663832626662326666 -36313939373236306137653736316339336262353036303537376462376439353932616163653533 -32353030663061626263663530636333333261636339666562613532396233613839633632393539 -62636130666530353734343233663765323463303636333561343064313166313534333433633761 -34626537336333383434303632663235373637613635653632383566663131346364623538376230 -63646433333538393530376636323830356635663333643166613861303134656365393039356534 -63623332373862303266373033353132396331636136386530356466663466633662316530643263 -65383763353137346463373839633038393666393239643231306563656430613335643938643539 -66633564643431356233333533363731333864346465396432663662623362633738313662323761 -61656161626461353339343664613739316637326233636364613464356430316138323462633035 -37616166643535636139383831313066646265383465376531363530343639666166653966393031 -33643236616365353335346231646339363030353565333364653835343431663339343539343638 -64663735633435323364396137363135373237323738353337663139653035656666633265623766 -36346235646536353731333435353733663231303730346534653363626262306238646239336437 -37653432333137316639663161313839336530653864363066326435313061343836623066653130 -31383737313238396162326235333935323365333462613039323136646636313235663366333734 -30323131336639313338663461623135363537646566306532343134333065366261653538333261 -36626639353065343331616136386563646330303239383537656363363930336562386237626233 -35633335613738316164613134333733313337383836636132396332653563353136383035653261 -37333135323337656333323464646466663766303833396362633462666337316539383065613361 -30316666373338383239623262623033393130306264626365653265353865336564303830363466 -65633133646632393935636162643238333232396434663137366661303834376134326337333764 -35666632353664323065316638373239353465363134343036643233643362646162353139303630 -32653339333064336635656361323863323264636536363930643664333639663335343931666332 -66363236613039623664633864396363633337346435653937356563633433346562613434383030 -61373033623536613034343261666430623434343032653232393537303264393037333064666361 -38616264633139336463666332613866393832376361356531623335633635356461323733393638 -31616132396636326233623665343661373838613737636234643836333737383564636339653931 -64646533356538363635613037336664386261623061316264636339653636353337616331343061 -66393938386261623664306333323661663431396664626330353738623934376466646663373437 -64633932376138333634383630363162343430303762343863313566636330643866 +32646462336433663335636164303334646635636636666163393966323631613936663934376531 +3730383966376632636364663736613064313461353937320a643362623036383266633830346630 +32376236383031653661306563383434373230393930393236323337326236633430383664333431 +3261633339326662300a313139666265336339363463306335373266383937633539393132663534 +65303566656237313266306638323761326239306534346365383430383035313734613430623933 +64356137646365623937366132383661616363353261636136326133323739383431353538626633 +37666636383662313363633465383066326236373639663063333337353733303162313265383961 +38616661666661316139633238616235626132643762353238373438653337633963336439636538 +36343334393833316439393566633038316162643164373866393937663239393536386434316465 +30626637656434313633633431376230666437346532653536326361633566613131336533303230 +63616232323336333537313138633866333634623432363066653364303162613333373461646162 +65643239313333373364656333386533373561636531313066346133333335313231393138326638 +33636333383238333336666466323231666338613233323632373438653263396434386634346362 +63626363333730356235343735663337313839353438333039633334623435626563333338313733 +66363765653534613865343436366561306465343530666131623835383336626339363339626630 +38363639623931373230616334333263386432653066653530326633336665303736373733313066 +35386135346530363630353839326139336461643336383131393862646231656433643565613131 +33313037663737323930613636623035306234623135323166653931636636623332323636613231 +33376433316336313263313065313964346161623930386430346634376231656463643662356162 +62613230316434346337396563326263616633376139363533303163653266643733313962383033 +35376233376230363963613063643830383161653232613630346365663838346562306634373733 +64613765346132383965626263323931666334376161356564353162373462636561646466373035 +37613439353462326631623430373336636335353435306664353062356332396665303266383430 +61616234373963313835336331636234333737376130643538343761633363666430616435353366 +32396635626135623434323139303135386335383562313038663766616635643535316230363237 +37616334306163383436626163656137643735333334336261366163663364346264316535623939 +31303437656331306565626365393332376239303864306639306366633536393162396338343561 +32636366616137376639303865313734623837396639653833656238656339373761633535303835 +61626363646635663961373838373630323732376535333962353830366564396363363235666335 +31303266313363383963643031666664633739656165326439353063353334663632346235396162 +31616534323666613433343031646430363962303430373735316538366437303333643434396233 +32646239313636313164303538623066633239306132346263393961363134333862303137313831 +64653735333362303737326635663861646430366538333136383738396565363434613832306663 +63626539666233383038356632633936663934363839383437396666666363343063343466326636 +30346661633862316535353133363035313064656133666565623439633135666130316337363034 +35383862653735343939623430313234333361656361383366636536613437613236646139366134 +62343435396466643238646162316365323636643134373962633763383933363438623865346633 +64666230643566626536633965346335366137313365653963623732366338663961303430613563 +64353831326336353664343634333531346132303438626639373033643563326462383965343965 +30393238666539376561326332666338643738633636373235386334346261663332393730313237 +32383263643062616638336530393935333666343266353234386232303961623166313439333536 +35643161613234363366326636663563666131326461363765373966613736353164663437663035 +66336263666563383134313037366533653231313962303033396339376461366662623365323733 +63343733636135373837623537306366613263323238366233386236643336373134356436363533 +65343134383038326537316138643032316538623861303833333761353936346336636639316531 +62663963323334613064623838623461613639393933363334303263633535373061333165363733 +34313534303363326433613731653937646132323866616164646362623464316364356461646666 +62383034633065396362393635323834316162643836343330336461386638383232663538663737 +62653063636463663861623165643036343739373930646265376565643763616434613336613530 +39313031383563383466366261386237666162363738313333653766373262303161323933316634 +38663166336336336134323136353362386361623634366330623934643932376263346136336562 +62363164333166633364313661616435393235333031393964663665333564656630663837633434 +61663566343365306534336363313636353332663364303131633037363766656230666265396462 +34316335366264353562363937386265376164326531653563633531613433366363323966383265 +36383661363136613732326331326331623663653531326365363961616135306332353037613037 +62323538343362663366323437313433323039643464653335633161643334643239363530306461 +37663465363937626561393638323035393836356631343835623833636430346131666630326164 +35663339663435616561363935323031663638336565663238666365626635623064646236333566 +63376462313561663835646130303565363239663232323665326164383739373432396138333466 +65633439636166303432373530633834616636623861303636323637616333613663366436363231 +34323765363634316263366334373334343962393661303864666233376135633266663031613231 +31643263313462383839653339356136356563666264626263633139383230396338343937666130 +61616439616362386333346437313830643930613962303732616633313562643132363932363738 +35623036356630313465633130346139623732623062353164626666306563656337353364343863 +66363461616439366531303932643762656534313765306137653037613161363237366635383337 +61646133376535656132633631306438633539373231393635323035336239363732643763663037 +35383430623734613864623765333965646331346432346166346339336138363533353665316566 +66356430383739373961333162383538646461393835306535376665313734313334303234336663 +31663163393537333239353064646462343437656662373161663961353066373364353636383738 +63623236653866323339333563633830666137636662663637393661376532636366613166623830 +31366566373531656466643133623435363133623832383038663835646138393330316262383131 +62646433323764383061316139353062303932373631643030646363333963366533623764396632 +65383536363830316565383338383061656465313863656433363435626563356136623565616535 +63666135666131393061316633626431313866616130363239653661326139613734613930343265 +66343264393935383639356639346431373035656537313533633930616334333365613063326535 +66646131623261343532656130336161313538393431336332663361363733326133343239353061 +63366363653666646361373563346464613433343861373838396461346435613462643837666233 +30616439303235363332366566653536343934326361613966346239303463326632383137386662 +37303235643736306532656337633232626530306264306334316638636666613565303864343237 +32623834313033303437633233633366663236633961663038386231643332363363373965666239 +35323332623436373139643038663465356432373163613432646163636234346564323437616639 +61643234333435616531306236353463393937633734393461303461303964383563653334353462 +61666265306263393332626139336664616236316164313366653932366461323738393036373434 +64316533386133366234333364636539393330626265366464313739626565663131663166333636 +62623438343730326461326334616162633639646334393730323439326139613061336432643465 +62666231343235393939613534343732306365313738353937643139623862646538356632313731 +61643866633436343562376366303366383735386562613039396636383463366265393936373631 +39323334376133353861646666663437303533353864656133623738353437626539633236663839 +64323334373766356134616331396437333430303737396335356537623235613535613836356439 +32623636653362393763643961663562306665386534356566656265613165313032333532383461 +30643030316333626531313538633262326465323436353864346363653366663562616136356334 +61373930356530366439366137336365376132353464653939376139656462336134623334366632 +61343339353731366137636331646464393462373830666364373666626363393234316333616561 +66343466653332633733303261343965343664626539353063393163663334623234663365386566 +34646334376164383763613265353630323034343265326533646336306237393131393035306531 +34336330343534396566346362623064646535353536386561353033353963366339666464656536 +30336131643930333837336230323733623532343834613431646265323861666336383834333231 +36333235623061346439636262363335646236373539633230646665393465353063653034316130 +33646234356563323039386333313233366339633866663962376463326638313338313966376532 +39376332633762623564313937643239663731353734373966623232316565663236383737366230 +65363363376634316330336664613634336338373139656138363161623262383964366166313332 +30333737393263623735353661653130326566353733636634373035353465643865666439383866 +32336666373435653639633235663730623034356135313561643439323266343435366161333364 +37633866333335633636333536343866386630303137633133663161363430373637333131303065 +35626231326633663531393765343930663731653137386238636437373534383833623533356663 +37376261396365323666646533626332363563656631363933663638396464366231613633616363 +32633865313030363331623432323132613030363834653262663235326136336562656538373630 +66323937396265316335396466353233393539383066636130373661386532613336643834326238 +36316530633831376261316432336536383033323938383961303632653966303037663961333063 +65303433353338643632383737306433363733303933376231313965386432363863353531383436 +39396631663535386230343937366464313766396562303164313636393462353365333731356235 +62306635323065663765613536323063313237326534366634393262316230303430663837336564 +39653465353761613465306665316138356666653664653830613962643364643363386235656335 +66663531346334646265383730616266343638316162373365626133393837303063323534373337 +66616232303630616562386636653530383565396561306130363938613433383563313062636665 +65323263393566656431343932383530346439383733376662613734633233393435643130633830 +65643465363633366136383034323235656339363331303333313337336464653263396231633937 +39376539373563623234346264363230353461323435343064626535316532653432613638346539 +61316264373336346331616535663433636530393236613039396665656462303461653565663736 +34346336346234626632336331316564663161653038666337653261653737653432386563636330 +66326631333664386266383238306234636436373962303539343766306637303734353761646333 +61383266643031366636306535316236613662373534663938383432396530666163333562326235 +64653335663863373364663435333262363861613865316463383139636635386537663364353461 +37653534643937383362656464343364623938616431663864316666316436313430336461316230 +34323835383532626164636431346639356361323462646166323732343362643135323739656533 +64656162396231376263333463323863343663386262323362646665323038363734323835353765 +33666231663466643866643964346233336365613464623064343137636130373962363737396163 +36393036646531343962393166356263633236313737323631386261663734383134343561666538 +64653430373831383932366431306533343666633936646163363731393437323232316564313762 +64313138323461353132313532643333653761613364356639383236613662623364613666633062 +35633134313837316632663037393036323430303031343163306666623433343738343133343035 +61303139643762663931346332666338396334343766313839336136356665636137323631336332 +36353761353064343761616163623538303430303735323536633666396165626566326132626466 +34323839326637363132643739363938323265313666353062363665666335613130613763373262 +39303332396135646639373230346239376131373038666665326533343839363738393663383365 +35366330303536323935333336656637643464323438333065336566656231393337636133373838 +32373836346536656166346336633864636335343535626431383461376430356439386334316432 +63396463396634343239393161383833336565363437643164336133333234383263363361393363 +32396663306266356433333732623730326532633439346131646564333231613766393933366538 +65646166633564323561653964396262326433363735376233366662656332333438623366646166 +63313032333137313066626433323039396564646562326439326632333339663032643031633764 +32316362316135663963373031356638316662656536393730643533616366626631303338623562 +37326131666631366136616566316230316464646535343734623538623362346661313733643163 +63663762326334396431373330613831636439326534383336616132613763623533616266366338 +32623161633663306132633866633766623538623731356630663934613234623164356266623831 +61663361343135306134653365373565376334366633326462313263613364363439343765373133 +39363964316432613961333766643538633435616237663834333934623961303864343339613162 +32336239626437303931343434313437316664396466363833326238666530303664343962323732 +32323130383333653830356162376238623634323831613038326535366433363461633130643661 +38626461623538643566343965643862643661383834613861653238613066653137663733643866 +32306237363736613832353632613439393032343536336231313264646562393532396333383638 +31653733383665326462323233623136393839386266363865376564653234366235663162346564 +30333532643138396365613839373961366162346465343235663930366439313635633361373031 +32333864643135383131653361333331316330313731646263336637313936363835386465616638 +63376432333434646232336166616162326265663935343766386166346263663733346630316463 +37383565373632633636653739343230323636323835623665646132333234386336313131353738 +66646162353932356639643133666433626139656632656431316637636662616133363365653037 +63373733326465326137663362333963393564653465616237653765633665666337333339366466 +32666462376637336564353631373835353063363264623165343364653536636337303665333938 +63343661646164666262326635356233656533343136303565666631626430643132616533313561 +33336232383731336266373364643461343563333961356335633362393230346365333136313662 +61366233313563353335626131326136663132303462643637353837663366343538613861643830 +38656661643665313562376261323135646266633861663464653831323934323361306531613632 +38306336373061323666313534303163366664303133386639613064343534343163303735663234 +38336538326630393037653635626431633838636663653132386664303564363034626261303538 +65316338333131323739636431383266326434636637626635613039656539366131373336653234 +33303730656533366338353864613866613036616361323833353634666336623564616365323437 +38663430346533626237313337383030323439653236386236343634653038363361396665633964 +61326138396631663166356132636366633938666633383638303137393265313565633337633762 +39313735316230306436373333306635616135333561633038323136663833323637393034383831 +39343031656236663232626134356232306462316439376166393030663263336637373237653539 +64376631323234333062363561363365373537333362626263353734653466663061616263653764 +62633832646433393365316636396462663566643934616264666432336634646230393232353736 +36623034323234303062623938363337336130613032646563643636376139373732653333303834 +34306336346638303639636232663337373630623430663633653938396337386532363237663331 +36383864656333346563353737383537663061393530363965373264643034343164613263386234 +31333638316135616163666264336531363361633165353466386236326637373037366430656538 +34303938653138316332333734653565366636326136313136366137363361646232326439343463 +31636239363434366133626335616362653966336564663765643065363639396662376261656562 +31316637316135313838626637326332346238653631333435346266623537376235646438373666 +64346264633361353962313361393664313564386538343064313336326562373731613135353063 +32666637326131383331616231303230383961303732373363656563323661653933303265616263 +39633266316537663837323563333838313431376235383862383435366236663163353130663230 +34303565653435666365623063376337313736616636396434613637663631626330333635653330 +61316662643134376630343330396263383662646231633938623037383634393731376332613663 +36353336376564333934383139373130666266333436343938316263373431653465383264306165 +62633233666264636538623533643033303334383562616338313562316361663562393766626237 +31326538643635613564663566343334323934363661303733383636393031306139623432303632 +31313334613764353966323035366136336639366636366166613865633739396637336137336466 +38333934623563373266366633383461303263646430663163393637323937633163323437323939 +38316531643863343036626538366563373433326433666563356132323765333839393138393938 +64326461323564323463353834613362303031643232336532386363363832306336633339383461 +33306231666637373631396334316135386239363663383466623863343939343565326335343933 +30383436396336323362363533326433303931366134363738396333393138326236633264326536 +31346632666438656333643365356136363061636534396431313432663132376266623334333761 +30323262346365363935623466643130313464663535333465633138656538366330303562346431 +37393936653434363734353530383033613530626139356234613163343831303431316430326336 +33333738623433383530343061316161306663313335346337353532656662346465333165633039 +61333639643931626162663230353637653564646261323263326635363862643966613737336364 +65363431383030636538353031383832323938326431316430343836323362343838333439303762 +66393837626464643230353161663239626130666562613261376632336232343635323430643865 +32636263613637366333343064363262383364646265333763303462393735313237363066373536 +36313332646135636562623265376137636666383562303334303266363762356231636239636538 +30333738313665356438643939626538333862346264396537633266376162636564306563343333 +30376366306161303335613131303265343736346133663361323837303436636563326436383536 +36313864343339353039303466323761306563396138313030616337663836613030383533336561 +66363934313262303538623134306435663433393631396231626630383164636432356532336262 +65343731666537316636333833333739316432303939306337653266353134353332353761646330 +38383937363039316362306537396236333966386338396561386139323965373465316330376166 +39303233313030393534386639346239326261626265643434626338306434613239373438326336 +37363364353262356363616561663864383137616238333130396133313235306534323930383334 +64366261616266303762356335623034666336326564313738613734663561616239346535366566 +64383964376565353730383062333332313535663161613639346638666231393936326236326230 +37363639393539363230623561396531333631313565336236343437303936333932613462613934 +39383465636531653335653166616539303936366263336462616563393562393034643638663039 +34613765373435613466623735633635633839346166646432366633626464323663393730316139 +33383064373135646562353431643435333438656662333266303332323766336432336665636363 +66653739636134366237356438303337326362336532666165633335343733666262666432333735 +66313166666261353934656665323166363661336265656566303037616534646133313535663438 +31336163366534633663656531653131636263613937386234333337306561393535363666666239 +35663361653436666230663830386464396132393834623863643933616462653863386137626364 +31653336333236383665326264346230626634653966353062643230303637346630626332323663 +30313462326434323030326235666633323264343865316234353966333732386131373537326433 +65386166623962316266303962353563343265643731396237356638633632306264346539373366 +65323361663062396461346362636535396661656532373266323463383362393061343637633361 +33313430343738356233623865613666663463373261653864653634373465623839343137346162 +38396432616535336536366638616438623330643637376536663966663438616630313839393138 +39353338616633346532373238643431333365663561646566366465613034653863306631623738 +66333331316265656163663763616361306661386135366634383762383462656131626332366234 +34303766326263363237363832643664343031396536386637363663623761666361613738336466 +62653539653139333439313933363637626431376363613261323230353133306631646464656138 +36623432373533396662356137376636633535353663653539333339626234656539623231636338 +36353465663961376661343161386338323933363430303336626362346639633562653538613264 +31623132316131303562616632303933663131383836323665623639623165396337646236313764 +34396666336536393163363137343563393332666465616163636331643566363830363037656264 +34613364333838343766653061356334393264386130393861363037326633633062646532626566 +66393936336265366232373135386565313636333537336261323530323935626663326336633434 +32383462613131623863356565386463393635333964383165323163633037366663333866646462 +36353730636535386664313463616666613531646366386234303031613531613765336266633733 +61353539306334346239613135633033383662393739656662313535343236393433326362356161 +31383466373834376231333834326462383766373666326232383931333263366234303233333834 +65396130643031396265373238323763386638333336616538346335613565393531356233616565 +62636536653366323336343365323038326165333235356532343161333466373965336631663264 +64643731386235613434633734313461633263323563396532306563313130346162363334336231 +61306463373835636665626633343333653332353562623231616530333433376438316561643564 +34653762343562623838393637356136623737646166636135303631376164643437666132666334 +65313831323332666333636638613036316439343133363239653661636330626232383039613833 +65636562333932326236353831646230393236643966386535613634613735383239633532616165 +64323535376663373533353238316561323564373238323562653363393064363030616663336534 +38303738386337313864623061326435316334373339636566633265393563353836346239303630 +66386438326162353263626338316437383764363831646663626339383732303532653562616134 +36396230386638383061323862306533313863353237643331356266316330623764313266613262 +36303730393063626332303065393563363839663166643831336334363562396563363930323634 +39313736363439613264343864663634613534316538346336656465366239363837323539653266 +36393739613834316532656238626663326536336535636164353361666362316465633931353834 +64626330376135663061613139356333343864626439653764373339623633306566353238626333 +36303732366632616331336466303662343331383035646430666465356632623932656331323239 +62393336613962343663643166663338643763386133393831663361303536613661646633653864 +35636632356163346438386264336535663734353965396461353635346663636138343065383834 +61393134343434326630 From 7237d428d7a27c88daddd03d2d4607cb9a89d1aa Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:24 +0000 Subject: [PATCH 10/19] wgbroker: bring its own nginx vhost and certificate The broker moves from monitor, which runs ff.nginx and ff.certbot, to a host that runs neither. With wgbroker_vhost set the role installs both, answers the ACME challenge over http, obtains the certificate and only then switches to the https vhost - an ssl block pointing at a missing file keeps nginx from starting at all. Empty wgbroker_vhost leaves the role as it was, so monitor does not grow a second vhost. --- host_vars/sn03.yml | 11 +++ playbooks/roles/ff.wgbroker/defaults/main.yml | 7 ++ playbooks/roles/ff.wgbroker/handlers/main.yml | 5 + playbooks/roles/ff.wgbroker/tasks/main.yml | 91 +++++++++++++++++++ .../roles/ff.wgbroker/templates/broker.py | 9 ++ .../templates/wgbroker-vhost-http.conf.j2 | 31 +++++++ .../templates/wgbroker-vhost.conf.j2 | 48 ++++++++++ .../ff.wgbroker/templates/wgbroker.service.j2 | 5 +- 8 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 playbooks/roles/ff.wgbroker/handlers/main.yml create mode 100644 playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 create mode 100644 playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index c3c197c..c51ad87 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -197,3 +197,14 @@ 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" + +# Parker config service. Only a test placement: the broker role runs from +# playbooks/monitoring.yml on `monitor`, and without enabled: true this +# block changes nothing on sn03. usign_secret is the vaulted key content +# the role writes to wgbroker_parker_secret_key. +wgbroker_parker: + usign_secret: "{{ parker_usign_secret }}" + +# 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 diff --git a/playbooks/roles/ff.wgbroker/defaults/main.yml b/playbooks/roles/ff.wgbroker/defaults/main.yml index 406b5ed..f309542 100644 --- a/playbooks/roles/ff.wgbroker/defaults/main.yml +++ b/playbooks/roles/ff.wgbroker/defaults/main.yml @@ -51,3 +51,10 @@ wgbroker_parker_repo_remote: "" # usign, signify insists that a secret key file be named *.sec. wgbroker_parker_secret_key: /home/auto/usign/parker.sec wgbroker_parker_signer: signify-openbsd + +# Public name the broker answers on. When set, the role installs its own +# nginx vhost with a Let's Encrypt certificate and keeps it renewed; empty +# leaves the front to the host's own nginx (so `monitor`, which runs +# ff.nginx/ff.certbot, does not grow a second vhost). +wgbroker_vhost: "" +wgbroker_acme_webroot: /var/www/html diff --git a/playbooks/roles/ff.wgbroker/handlers/main.yml b/playbooks/roles/ff.wgbroker/handlers/main.yml new file mode 100644 index 0000000..0f31453 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded diff --git a/playbooks/roles/ff.wgbroker/tasks/main.yml b/playbooks/roles/ff.wgbroker/tasks/main.yml index 0d54650..c4bcd35 100644 --- a/playbooks/roles/ff.wgbroker/tasks/main.yml +++ b/playbooks/roles/ff.wgbroker/tasks/main.yml @@ -196,3 +196,94 @@ job: /usr/bin/python3 /home/auto/parker_gc.py when: wgbroker_parker.get('enabled', false) | bool and wgbroker_parker.get('gc', {}).get('enabled', false) | bool + +# Public front, only when the role owns the vhost (wgbroker_vhost). Tagged so +# the certificate and the vhost can be brought up before the broker itself. + +- name: Install nginx and certbot + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - nginx + - certbot + - cron + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Create the ACME webroot + ansible.builtin.file: + path: "{{ wgbroker_acme_webroot }}" + state: directory + mode: "0755" + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Check for an existing broker certificate + ansible.builtin.stat: + path: /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem + register: wgbroker_cert + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +# Only until there is a certificate: the https vhost below carries the same +# port 80 block, and two templates on one file means both report changed on +# every run and nginx reloads for nothing. +- name: Install the broker vhost (http, for the certificate) + ansible.builtin.template: + src: wgbroker-vhost-http.conf.j2 + dest: /etc/nginx/sites-available/wgbroker.conf + mode: "0644" + notify: Reload nginx + when: + - wgbroker_vhost | length > 0 + - not wgbroker_cert.stat.exists + tags: [broker_vhost] + +- name: Enable the broker vhost + ansible.builtin.file: + src: /etc/nginx/sites-available/wgbroker.conf + dest: /etc/nginx/sites-enabled/wgbroker.conf + state: link + notify: Reload nginx + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Obtain the broker certificate + ansible.builtin.command: + cmd: >- + certbot certonly --webroot -w {{ wgbroker_acme_webroot }} + -d {{ wgbroker_vhost }} --non-interactive --agree-tos + --email {{ info_mail }} + creates: /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Check for the broker certificate + ansible.builtin.stat: + path: /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem + register: wgbroker_cert + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +# Only with a certificate on disk: an ssl server block that points at a +# missing file keeps nginx from starting at all. +- name: Install the broker vhost (https) + ansible.builtin.template: + src: wgbroker-vhost.conf.j2 + dest: /etc/nginx/sites-available/wgbroker.conf + mode: "0644" + notify: Reload nginx + when: + - wgbroker_vhost | length > 0 + - wgbroker_cert.stat.exists + tags: [broker_vhost] + +- name: Set up certificate renewal + ansible.builtin.cron: + name: certbot-renewal + job: "/bin/bash -lc '/usr/bin/certbot -q renew'" + minute: "0" + hour: "14" + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] diff --git a/playbooks/roles/ff.wgbroker/templates/broker.py b/playbooks/roles/ff.wgbroker/templates/broker.py index 49d19c8..843ef3e 100644 --- a/playbooks/roles/ff.wgbroker/templates/broker.py +++ b/playbooks/roles/ff.wgbroker/templates/broker.py @@ -10,6 +10,7 @@ import parker_lib from flask import Flask, Response, jsonify, request +from werkzeug.middleware.proxy_fix import ProxyFix REPO = "/etc/wireguard/peers-wg" @@ -36,6 +37,14 @@ PARKER_SIGNER = {{ wgbroker_parker_signer | tojson }} app = Flask(__name__) +# nginx is the only front (gunicorn listens on localhost): without the +# header from the vhost every node looks like 127.0.0.1, and the per-source +# rate limit turns into one global bucket. +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) +# Flask's logger inherits WARNING from the root logger, so the one line per +# /config request (who, which version, which mtu) would never reach the +# journal. +app.logger.setLevel("INFO") # TODO(ruairi): Refactor load_config to return Dataclass. WG_PUBKEY_PATTERN = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 new file mode 100644 index 0000000..9512ee3 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 @@ -0,0 +1,31 @@ +# Managed by the ff.wgbroker role. Plain HTTP only, so that certbot can +# answer the ACME challenge before any certificate exists. +server { + listen 80; + listen [::]:80; + server_name {{ wgbroker_vhost }}; + + location /.well-known/acme-challenge/ { + root {{ wgbroker_acme_webroot }}; + } + + # The node-facing endpoints stay on plain HTTP: Gluon's busybox is built + # without TLS, so nodeconfig.sh cannot follow a redirect. The answer is + # signed and the request carries nothing but the node's public key; the + # nonce keeps a captured answer from being replayed. + location /config { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location /api/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 new file mode 100644 index 0000000..590335d --- /dev/null +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 @@ -0,0 +1,48 @@ +# Managed by the ff.wgbroker role. +server { + listen 80; + listen [::]:80; + server_name {{ wgbroker_vhost }}; + + location /.well-known/acme-challenge/ { + root {{ wgbroker_acme_webroot }}; + } + + # The node-facing endpoints stay on plain HTTP: Gluon's busybox is built + # without TLS, so nodeconfig.sh cannot follow a redirect. The answer is + # signed and the request carries nothing but the node's public key; the + # nonce keeps a captured answer from being replayed. + location /config { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location /api/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ wgbroker_vhost }}; + + ssl_certificate /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ wgbroker_vhost }}/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/{{ wgbroker_vhost }}/chain.pem; + ssl_stapling on; + ssl_stapling_verify on; + + location / { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } +} diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 index 4f3e25f..d1539f0 100644 --- a/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 @@ -7,7 +7,10 @@ After=network.target User=auto WorkingDirectory=/home/auto Environment="PYTHONUNBUFFERED=TRUE" -ExecStart=/usr/bin/gunicorn -b [::]:8000 broker:app +# Loopback only: nginx is the front, and a directly reachable worker would +# let anyone hand in their own X-Forwarded-For and step around the per-source +# rate limit. +ExecStart=/usr/bin/gunicorn -b 127.0.0.1:8000 broker:app Restart=always [Install] From 5454c92b406857b8b5fccb05fd93ed66a09c109a Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 12:39:24 +0000 Subject: [PATCH 11/19] broker: run the wgbroker role on sn03 site.conf now sends add_key and /config to 03.wg-node, so the broker has to run there. monitor keeps its instance for images that carry the old name; only sn03 has a parker block, and part of what a node gets back is a prefix, so the parker service stays on one host. --- host_vars/sn03.yml | 9 +++++---- inventory | 6 ++++++ playbooks/broker.yml | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 playbooks/broker.yml diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index c51ad87..cc5fc88 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -198,10 +198,11 @@ yanic_central: "true" # flows with - eth0's public IPv4, whose return path is already routed here parker_pool4: "5.145.135.153/32" -# Parker config service. Only a test placement: the broker role runs from -# playbooks/monitoring.yml on `monitor`, and without enabled: true this -# block changes nothing on sn03. usign_secret is the vaulted key content -# the role writes to wgbroker_parker_secret_key. +# 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. Without enabled: true this block +# changes nothing on sn03. usign_secret is the vaulted key content the role +# writes to wgbroker_parker_secret_key. wgbroker_parker: usign_secret: "{{ parker_usign_secret }}" diff --git a/inventory b/inventory index ff04612..7de86ad 100644 --- a/inventory +++ b/inventory @@ -45,6 +45,12 @@ 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: diff --git a/playbooks/broker.yml b/playbooks/broker.yml new file mode 100644 index 0000000..fd8b4ce --- /dev/null +++ b/playbooks/broker.yml @@ -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] From a4cc1aecf187cfa237107987ec64df6826aa5f47 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 13:17:44 +0000 Subject: [PATCH 12/19] secrets: add the sn03 parker tunnel key Private half of the wg-nodes interface; the public key sits in the concentrators list the broker hands out. --- group_vars/all/secrets.yml | 533 +++++++++++++++++++------------------ 1 file changed, 273 insertions(+), 260 deletions(-) diff --git a/group_vars/all/secrets.yml b/group_vars/all/secrets.yml index d98ab76..25e4af6 100644 --- a/group_vars/all/secrets.yml +++ b/group_vars/all/secrets.yml @@ -1,261 +1,274 @@ $ANSIBLE_VAULT;1.1;AES256 -32646462336433663335636164303334646635636636666163393966323631613936663934376531 -3730383966376632636364663736613064313461353937320a643362623036383266633830346630 -32376236383031653661306563383434373230393930393236323337326236633430383664333431 -3261633339326662300a313139666265336339363463306335373266383937633539393132663534 -65303566656237313266306638323761326239306534346365383430383035313734613430623933 -64356137646365623937366132383661616363353261636136326133323739383431353538626633 -37666636383662313363633465383066326236373639663063333337353733303162313265383961 -38616661666661316139633238616235626132643762353238373438653337633963336439636538 -36343334393833316439393566633038316162643164373866393937663239393536386434316465 -30626637656434313633633431376230666437346532653536326361633566613131336533303230 -63616232323336333537313138633866333634623432363066653364303162613333373461646162 -65643239313333373364656333386533373561636531313066346133333335313231393138326638 -33636333383238333336666466323231666338613233323632373438653263396434386634346362 -63626363333730356235343735663337313839353438333039633334623435626563333338313733 -66363765653534613865343436366561306465343530666131623835383336626339363339626630 -38363639623931373230616334333263386432653066653530326633336665303736373733313066 -35386135346530363630353839326139336461643336383131393862646231656433643565613131 -33313037663737323930613636623035306234623135323166653931636636623332323636613231 -33376433316336313263313065313964346161623930386430346634376231656463643662356162 -62613230316434346337396563326263616633376139363533303163653266643733313962383033 -35376233376230363963613063643830383161653232613630346365663838346562306634373733 -64613765346132383965626263323931666334376161356564353162373462636561646466373035 -37613439353462326631623430373336636335353435306664353062356332396665303266383430 -61616234373963313835336331636234333737376130643538343761633363666430616435353366 -32396635626135623434323139303135386335383562313038663766616635643535316230363237 -37616334306163383436626163656137643735333334336261366163663364346264316535623939 -31303437656331306565626365393332376239303864306639306366633536393162396338343561 -32636366616137376639303865313734623837396639653833656238656339373761633535303835 -61626363646635663961373838373630323732376535333962353830366564396363363235666335 -31303266313363383963643031666664633739656165326439353063353334663632346235396162 -31616534323666613433343031646430363962303430373735316538366437303333643434396233 -32646239313636313164303538623066633239306132346263393961363134333862303137313831 -64653735333362303737326635663861646430366538333136383738396565363434613832306663 -63626539666233383038356632633936663934363839383437396666666363343063343466326636 -30346661633862316535353133363035313064656133666565623439633135666130316337363034 -35383862653735343939623430313234333361656361383366636536613437613236646139366134 -62343435396466643238646162316365323636643134373962633763383933363438623865346633 -64666230643566626536633965346335366137313365653963623732366338663961303430613563 -64353831326336353664343634333531346132303438626639373033643563326462383965343965 -30393238666539376561326332666338643738633636373235386334346261663332393730313237 -32383263643062616638336530393935333666343266353234386232303961623166313439333536 -35643161613234363366326636663563666131326461363765373966613736353164663437663035 -66336263666563383134313037366533653231313962303033396339376461366662623365323733 -63343733636135373837623537306366613263323238366233386236643336373134356436363533 -65343134383038326537316138643032316538623861303833333761353936346336636639316531 -62663963323334613064623838623461613639393933363334303263633535373061333165363733 -34313534303363326433613731653937646132323866616164646362623464316364356461646666 -62383034633065396362393635323834316162643836343330336461386638383232663538663737 -62653063636463663861623165643036343739373930646265376565643763616434613336613530 -39313031383563383466366261386237666162363738313333653766373262303161323933316634 -38663166336336336134323136353362386361623634366330623934643932376263346136336562 -62363164333166633364313661616435393235333031393964663665333564656630663837633434 -61663566343365306534336363313636353332663364303131633037363766656230666265396462 -34316335366264353562363937386265376164326531653563633531613433366363323966383265 -36383661363136613732326331326331623663653531326365363961616135306332353037613037 -62323538343362663366323437313433323039643464653335633161643334643239363530306461 -37663465363937626561393638323035393836356631343835623833636430346131666630326164 -35663339663435616561363935323031663638336565663238666365626635623064646236333566 -63376462313561663835646130303565363239663232323665326164383739373432396138333466 -65633439636166303432373530633834616636623861303636323637616333613663366436363231 -34323765363634316263366334373334343962393661303864666233376135633266663031613231 -31643263313462383839653339356136356563666264626263633139383230396338343937666130 -61616439616362386333346437313830643930613962303732616633313562643132363932363738 -35623036356630313465633130346139623732623062353164626666306563656337353364343863 -66363461616439366531303932643762656534313765306137653037613161363237366635383337 -61646133376535656132633631306438633539373231393635323035336239363732643763663037 -35383430623734613864623765333965646331346432346166346339336138363533353665316566 -66356430383739373961333162383538646461393835306535376665313734313334303234336663 -31663163393537333239353064646462343437656662373161663961353066373364353636383738 -63623236653866323339333563633830666137636662663637393661376532636366613166623830 -31366566373531656466643133623435363133623832383038663835646138393330316262383131 -62646433323764383061316139353062303932373631643030646363333963366533623764396632 -65383536363830316565383338383061656465313863656433363435626563356136623565616535 -63666135666131393061316633626431313866616130363239653661326139613734613930343265 -66343264393935383639356639346431373035656537313533633930616334333365613063326535 -66646131623261343532656130336161313538393431336332663361363733326133343239353061 -63366363653666646361373563346464613433343861373838396461346435613462643837666233 -30616439303235363332366566653536343934326361613966346239303463326632383137386662 -37303235643736306532656337633232626530306264306334316638636666613565303864343237 -32623834313033303437633233633366663236633961663038386231643332363363373965666239 -35323332623436373139643038663465356432373163613432646163636234346564323437616639 -61643234333435616531306236353463393937633734393461303461303964383563653334353462 -61666265306263393332626139336664616236316164313366653932366461323738393036373434 -64316533386133366234333364636539393330626265366464313739626565663131663166333636 -62623438343730326461326334616162633639646334393730323439326139613061336432643465 -62666231343235393939613534343732306365313738353937643139623862646538356632313731 -61643866633436343562376366303366383735386562613039396636383463366265393936373631 -39323334376133353861646666663437303533353864656133623738353437626539633236663839 -64323334373766356134616331396437333430303737396335356537623235613535613836356439 -32623636653362393763643961663562306665386534356566656265613165313032333532383461 -30643030316333626531313538633262326465323436353864346363653366663562616136356334 -61373930356530366439366137336365376132353464653939376139656462336134623334366632 -61343339353731366137636331646464393462373830666364373666626363393234316333616561 -66343466653332633733303261343965343664626539353063393163663334623234663365386566 -34646334376164383763613265353630323034343265326533646336306237393131393035306531 -34336330343534396566346362623064646535353536386561353033353963366339666464656536 -30336131643930333837336230323733623532343834613431646265323861666336383834333231 -36333235623061346439636262363335646236373539633230646665393465353063653034316130 -33646234356563323039386333313233366339633866663962376463326638313338313966376532 -39376332633762623564313937643239663731353734373966623232316565663236383737366230 -65363363376634316330336664613634336338373139656138363161623262383964366166313332 -30333737393263623735353661653130326566353733636634373035353465643865666439383866 -32336666373435653639633235663730623034356135313561643439323266343435366161333364 -37633866333335633636333536343866386630303137633133663161363430373637333131303065 -35626231326633663531393765343930663731653137386238636437373534383833623533356663 -37376261396365323666646533626332363563656631363933663638396464366231613633616363 -32633865313030363331623432323132613030363834653262663235326136336562656538373630 -66323937396265316335396466353233393539383066636130373661386532613336643834326238 -36316530633831376261316432336536383033323938383961303632653966303037663961333063 -65303433353338643632383737306433363733303933376231313965386432363863353531383436 -39396631663535386230343937366464313766396562303164313636393462353365333731356235 -62306635323065663765613536323063313237326534366634393262316230303430663837336564 -39653465353761613465306665316138356666653664653830613962643364643363386235656335 -66663531346334646265383730616266343638316162373365626133393837303063323534373337 -66616232303630616562386636653530383565396561306130363938613433383563313062636665 -65323263393566656431343932383530346439383733376662613734633233393435643130633830 -65643465363633366136383034323235656339363331303333313337336464653263396231633937 -39376539373563623234346264363230353461323435343064626535316532653432613638346539 -61316264373336346331616535663433636530393236613039396665656462303461653565663736 -34346336346234626632336331316564663161653038666337653261653737653432386563636330 -66326631333664386266383238306234636436373962303539343766306637303734353761646333 -61383266643031366636306535316236613662373534663938383432396530666163333562326235 -64653335663863373364663435333262363861613865316463383139636635386537663364353461 -37653534643937383362656464343364623938616431663864316666316436313430336461316230 -34323835383532626164636431346639356361323462646166323732343362643135323739656533 -64656162396231376263333463323863343663386262323362646665323038363734323835353765 -33666231663466643866643964346233336365613464623064343137636130373962363737396163 -36393036646531343962393166356263633236313737323631386261663734383134343561666538 -64653430373831383932366431306533343666633936646163363731393437323232316564313762 -64313138323461353132313532643333653761613364356639383236613662623364613666633062 -35633134313837316632663037393036323430303031343163306666623433343738343133343035 -61303139643762663931346332666338396334343766313839336136356665636137323631336332 -36353761353064343761616163623538303430303735323536633666396165626566326132626466 -34323839326637363132643739363938323265313666353062363665666335613130613763373262 -39303332396135646639373230346239376131373038666665326533343839363738393663383365 -35366330303536323935333336656637643464323438333065336566656231393337636133373838 -32373836346536656166346336633864636335343535626431383461376430356439386334316432 -63396463396634343239393161383833336565363437643164336133333234383263363361393363 -32396663306266356433333732623730326532633439346131646564333231613766393933366538 -65646166633564323561653964396262326433363735376233366662656332333438623366646166 -63313032333137313066626433323039396564646562326439326632333339663032643031633764 -32316362316135663963373031356638316662656536393730643533616366626631303338623562 -37326131666631366136616566316230316464646535343734623538623362346661313733643163 -63663762326334396431373330613831636439326534383336616132613763623533616266366338 -32623161633663306132633866633766623538623731356630663934613234623164356266623831 -61663361343135306134653365373565376334366633326462313263613364363439343765373133 -39363964316432613961333766643538633435616237663834333934623961303864343339613162 -32336239626437303931343434313437316664396466363833326238666530303664343962323732 -32323130383333653830356162376238623634323831613038326535366433363461633130643661 -38626461623538643566343965643862643661383834613861653238613066653137663733643866 -32306237363736613832353632613439393032343536336231313264646562393532396333383638 -31653733383665326462323233623136393839386266363865376564653234366235663162346564 -30333532643138396365613839373961366162346465343235663930366439313635633361373031 -32333864643135383131653361333331316330313731646263336637313936363835386465616638 -63376432333434646232336166616162326265663935343766386166346263663733346630316463 -37383565373632633636653739343230323636323835623665646132333234386336313131353738 -66646162353932356639643133666433626139656632656431316637636662616133363365653037 -63373733326465326137663362333963393564653465616237653765633665666337333339366466 -32666462376637336564353631373835353063363264623165343364653536636337303665333938 -63343661646164666262326635356233656533343136303565666631626430643132616533313561 -33336232383731336266373364643461343563333961356335633362393230346365333136313662 -61366233313563353335626131326136663132303462643637353837663366343538613861643830 -38656661643665313562376261323135646266633861663464653831323934323361306531613632 -38306336373061323666313534303163366664303133386639613064343534343163303735663234 -38336538326630393037653635626431633838636663653132386664303564363034626261303538 -65316338333131323739636431383266326434636637626635613039656539366131373336653234 -33303730656533366338353864613866613036616361323833353634666336623564616365323437 -38663430346533626237313337383030323439653236386236343634653038363361396665633964 -61326138396631663166356132636366633938666633383638303137393265313565633337633762 -39313735316230306436373333306635616135333561633038323136663833323637393034383831 -39343031656236663232626134356232306462316439376166393030663263336637373237653539 -64376631323234333062363561363365373537333362626263353734653466663061616263653764 -62633832646433393365316636396462663566643934616264666432336634646230393232353736 -36623034323234303062623938363337336130613032646563643636376139373732653333303834 -34306336346638303639636232663337373630623430663633653938396337386532363237663331 -36383864656333346563353737383537663061393530363965373264643034343164613263386234 -31333638316135616163666264336531363361633165353466386236326637373037366430656538 -34303938653138316332333734653565366636326136313136366137363361646232326439343463 -31636239363434366133626335616362653966336564663765643065363639396662376261656562 -31316637316135313838626637326332346238653631333435346266623537376235646438373666 -64346264633361353962313361393664313564386538343064313336326562373731613135353063 -32666637326131383331616231303230383961303732373363656563323661653933303265616263 -39633266316537663837323563333838313431376235383862383435366236663163353130663230 -34303565653435666365623063376337313736616636396434613637663631626330333635653330 -61316662643134376630343330396263383662646231633938623037383634393731376332613663 -36353336376564333934383139373130666266333436343938316263373431653465383264306165 -62633233666264636538623533643033303334383562616338313562316361663562393766626237 -31326538643635613564663566343334323934363661303733383636393031306139623432303632 -31313334613764353966323035366136336639366636366166613865633739396637336137336466 -38333934623563373266366633383461303263646430663163393637323937633163323437323939 -38316531643863343036626538366563373433326433666563356132323765333839393138393938 -64326461323564323463353834613362303031643232336532386363363832306336633339383461 -33306231666637373631396334316135386239363663383466623863343939343565326335343933 -30383436396336323362363533326433303931366134363738396333393138326236633264326536 -31346632666438656333643365356136363061636534396431313432663132376266623334333761 -30323262346365363935623466643130313464663535333465633138656538366330303562346431 -37393936653434363734353530383033613530626139356234613163343831303431316430326336 -33333738623433383530343061316161306663313335346337353532656662346465333165633039 -61333639643931626162663230353637653564646261323263326635363862643966613737336364 -65363431383030636538353031383832323938326431316430343836323362343838333439303762 -66393837626464643230353161663239626130666562613261376632336232343635323430643865 -32636263613637366333343064363262383364646265333763303462393735313237363066373536 -36313332646135636562623265376137636666383562303334303266363762356231636239636538 -30333738313665356438643939626538333862346264396537633266376162636564306563343333 -30376366306161303335613131303265343736346133663361323837303436636563326436383536 -36313864343339353039303466323761306563396138313030616337663836613030383533336561 -66363934313262303538623134306435663433393631396231626630383164636432356532336262 -65343731666537316636333833333739316432303939306337653266353134353332353761646330 -38383937363039316362306537396236333966386338396561386139323965373465316330376166 -39303233313030393534386639346239326261626265643434626338306434613239373438326336 -37363364353262356363616561663864383137616238333130396133313235306534323930383334 -64366261616266303762356335623034666336326564313738613734663561616239346535366566 -64383964376565353730383062333332313535663161613639346638666231393936326236326230 -37363639393539363230623561396531333631313565336236343437303936333932613462613934 -39383465636531653335653166616539303936366263336462616563393562393034643638663039 -34613765373435613466623735633635633839346166646432366633626464323663393730316139 -33383064373135646562353431643435333438656662333266303332323766336432336665636363 -66653739636134366237356438303337326362336532666165633335343733666262666432333735 -66313166666261353934656665323166363661336265656566303037616534646133313535663438 -31336163366534633663656531653131636263613937386234333337306561393535363666666239 -35663361653436666230663830386464396132393834623863643933616462653863386137626364 -31653336333236383665326264346230626634653966353062643230303637346630626332323663 -30313462326434323030326235666633323264343865316234353966333732386131373537326433 -65386166623962316266303962353563343265643731396237356638633632306264346539373366 -65323361663062396461346362636535396661656532373266323463383362393061343637633361 -33313430343738356233623865613666663463373261653864653634373465623839343137346162 -38396432616535336536366638616438623330643637376536663966663438616630313839393138 -39353338616633346532373238643431333365663561646566366465613034653863306631623738 -66333331316265656163663763616361306661386135366634383762383462656131626332366234 -34303766326263363237363832643664343031396536386637363663623761666361613738336466 -62653539653139333439313933363637626431376363613261323230353133306631646464656138 -36623432373533396662356137376636633535353663653539333339626234656539623231636338 -36353465663961376661343161386338323933363430303336626362346639633562653538613264 -31623132316131303562616632303933663131383836323665623639623165396337646236313764 -34396666336536393163363137343563393332666465616163636331643566363830363037656264 -34613364333838343766653061356334393264386130393861363037326633633062646532626566 -66393936336265366232373135386565313636333537336261323530323935626663326336633434 -32383462613131623863356565386463393635333964383165323163633037366663333866646462 -36353730636535386664313463616666613531646366386234303031613531613765336266633733 -61353539306334346239613135633033383662393739656662313535343236393433326362356161 -31383466373834376231333834326462383766373666326232383931333263366234303233333834 -65396130643031396265373238323763386638333336616538346335613565393531356233616565 -62636536653366323336343365323038326165333235356532343161333466373965336631663264 -64643731386235613434633734313461633263323563396532306563313130346162363334336231 -61306463373835636665626633343333653332353562623231616530333433376438316561643564 -34653762343562623838393637356136623737646166636135303631376164643437666132666334 -65313831323332666333636638613036316439343133363239653661636330626232383039613833 -65636562333932326236353831646230393236643966386535613634613735383239633532616165 -64323535376663373533353238316561323564373238323562653363393064363030616663336534 -38303738386337313864623061326435316334373339636566633265393563353836346239303630 -66386438326162353263626338316437383764363831646663626339383732303532653562616134 -36396230386638383061323862306533313863353237643331356266316330623764313266613262 -36303730393063626332303065393563363839663166643831336334363562396563363930323634 -39313736363439613264343864663634613534316538346336656465366239363837323539653266 -36393739613834316532656238626663326536336535636164353361666362316465633931353834 -64626330376135663061613139356333343864626439653764373339623633306566353238626333 -36303732366632616331336466303662343331383035646430666465356632623932656331323239 -62393336613962343663643166663338643763386133393831663361303536613661646633653864 -35636632356163346438386264336535663734353965396461353635346663636138343065383834 -61393134343434326630 +64646461333837633439316666613032366537326633613334326335643562623334336161663039 +3161356436333566303437653063353465383436376338650a633765393134626363313136333766 +38366633636538303161653730343866636362366163336234386136613664333833326661363537 +6266613435626530660a626530633833393865373632303738313739633564366463343666643534 +64366136623437636631353763363130316162353232366461333866326663383038353130383062 +30346666626339376333636632346661666264373930336233343033326565663138313330343035 +34666663336362613234613136636461623331323362386133393864323138646339373638353231 +36366665613238383537363133393761333863663936313535343762633430393838646562346330 +37646236333562333236333731386465393064343062633934376237346630366631653530643532 +61356231303437646430646164353832316466616630316533383737323935336338623938613234 +65616561333465633861653433303938393762636636323437373532653064643066353461613937 +63393037613935623963663264626234353562653061316462383364343361646266326634613532 +63356231656234386133356162366337396535663633393931343964656361343562343334353838 +38623130393539303330313931626663363466636462383932323531356166303963303038653235 +62396134363236306537656466653736653030386363623336643031366536326239326134643736 +38613730333239613537383331666631633237666262383063313839643862336330326231323766 +35323230653033653538356132613763626237373432343566363639393138646332343235643766 +63623066336665333532323034363831656533393639633438666237336530663333396463643433 +34363666373461346335613563306561353834396566643632366533343031373536396561313962 +65316331383764323166363739613231366135653135333765663262666132396662306266386439 +30336161343639323833323264636235316431333064633935316264636465616236326434616262 +64323866363735616332633463313438303239316531653135306334393131653939316537643031 +31653331323231663561663665356632613333616230396134316237326562376466363938383332 +64363632646238303734343161643965346464356636616336323539313530326264653130336366 +30613039353166333166633830653965623361303933366330336638613237633662326636366266 +36623035626662316338353337626162326362646637396533653463613935316536356538373661 +38666537646531636436626465663435613636393132363538316239316437623366633366376538 +33636263653661353565333535663934303664633237653863376663306435636537323634303936 +33613832393365616362643463323866636461623031346262363063623762333864316437396663 +35343337643831326233343334306535633637343236373637393030356362353738363738643164 +30643738336631333931646437313261353666306139643862646536666563643035656439653639 +38323161666361353434393931386162646463626662633031663834653631633863653638376165 +64303036393831386230373864643638373766333831333466373335666162323762363961643032 +65376266373465323638326139323330366132356431343430643065346334373836383132653162 +62653430653637613434326562303561633039323666306535376261346135353435643363393462 +61623034313863373064656231613435363564353464306565633936353161393035656266366539 +63326163643534396339353665363738373736323439396236353436613436316530346564666237 +33373034643230306332643363626332306132633665633565343266396531393932383035346430 +36616438346262306134663966333666363639343630653036393731333832313663346638663335 +38646535666536353161323561306333633964336634346630666234613332363966306163393433 +36643462323066663337356637623433656437306261356162363064666331663666656336643133 +34616536356439336636353538653366646261353764353937393366316236343535323439383932 +36373730373966393735326364316237393862313630663830646634363064376131303233386263 +64356366313830346665303632643262303736646136316435383963363861623035303764636238 +38346131393962376165626638353466663961366336363163343430636635623730376262386365 +31313761646663663536336464336536373437336637366139653263303961623035643563633261 +66666637396163376636326431363931643935633938363736326263643162303866313036383234 +65666262343631386339663964616237643739306632356366623564303038303462333366613666 +35666662373635643330656262346538393864356464616231653265316239666364356266643931 +38616330343435656334333439356136666535663464353839626435313032356431653237353330 +36346130343136396265356339653863343035306266363439303230663163333365323862643030 +31656630616564616265616634303534376533353864336563306132623432386138363762306231 +36323865363961356231643962643332646261623432373565383762323735643130346135303063 +36373132643136376266303362343166323562313531393639636438333562323861363436373138 +36343138613665366465613866333663336531353430393730653538666462363739646135633035 +31343766383835626465376639613033653862336139636435303330366266363838613233623637 +61323934363962363138643434383666646565363363306165626330323163343032643230323062 +39383935333436663838373462313666373962393035386138326134376266623765613061346431 +30353438643261363661353763366161326138623965336237623634396366306430323231666638 +30666563623130303438623933313337343632303764356463613931323938333463663463356662 +65613931626264316135623833346262376334346330616566333862373635623833393065336564 +61333730343536303438363564626533663437383266663761333937616337613339393835323262 +61383638383033333866333039313732363039396133373035653763313664393364393031623532 +38363565326432356534383138303235383436383132363237376361373539333462356135623062 +64636338363864343237356432613962616366663832356434323665303737303338376533333561 +64363664383364636165316264326338386263616261643832396466616565313166633532653138 +33623761386539643038653862343637633831316230663334633634633964313466656339653766 +37376135353465346139613864363434613362646339663637303232653131626331633837353533 +64633135373832326466366233353566633636343766346637356365626465616639613634363061 +65366364333434323337376335346437343830386536326438643933316537643863653363623861 +33656536303335366132343537623530373731643863376538316537663334333465343966336462 +34633135313430343436393133343966386232353332653135333434303663656336656139646366 +34353033643165386565616332373963656335353731656665613038613839326136353266346338 +36616231363131323435393834303264643265636262633531393930303262663531326365663565 +33373832326136363265613437336464376137393637613434626236613366656638306265616562 +34386365623130316339653865633437353266623265626333393235636131343864303463663866 +64333638376135353936396566376634383233323135636138366464373461363637386166633833 +37353933396538326239303032306166616366316135326539626364393436363061393663663463 +33633234623831383463326266366365366534313463383137613564633965303530396661353662 +65646261313536323932366161623334393261653564363562316639626632633362666232383566 +62666236373731363239623962653561303732626130633439643534363635636135396535313132 +37616164633030373134633030643336626666636136313366306631363134383764643064393562 +36633766633333366237313836313464336132666334653737623065396561376462373439366665 +32386362376531666465363561346635633236386663396132396233386538326363323739353965 +31323337393434613637393536333262663563316230363365646265323539326666346337623437 +30333130636330663637643230386439346638316663383864663861313630356562353736393366 +39356135313336316135353832343737353839323762366432663232656666663937393130373838 +62626566343330306461313765343234653264393537363933386563386130666462373334383931 +34623935373733313535366137633231613336383132663463336139373030363138613664363234 +38366161636630346533376638653361393764303939303566613761393933303834636464396262 +30643062333235313561306364633866613236396533343431383262633433653639666561303832 +36373834623635633433646435633430643961376162636433333135616330656339336435356661 +36383536363762313366376437633130663039393132633537613464613239326633363564326230 +35383530333662353837343265383930333563383362363861666266653361643636353431643665 +61303938616361323663626466663139383561616163663165393266613537336465393235613863 +33323266343130633636396133393937366135303932663334623139396363386235646238383030 +63306464373231623238366663346335616237336637633366383039393964303331326164353330 +30626632343033393533373535656332353462376562366438623762663966333031626463343830 +39326533396130313964316365316264343731383234656466336634393666376433343964643064 +38396138643162623563336662383030383334366139373733656233323835313833356162363031 +30666339323166666563626637323334626639303437653033306263386532616238326238373764 +39636534613638616431316133313035306233376534346562656561643430336631663736323464 +62363164663665313363303637633964323233393466386463623461303136363331393765613565 +61313733663236323661346438643532386338323464326232653065663266656535386136303733 +32653635633962656533636538363764623763636663323362366430656531626236626432653533 +61623736333630633639313034346634383061613734643164383230396161316433333962656138 +63393862653462396631613265363665656165393633646165656137333464353634376562343337 +35326663376162383333303364653765363164663433616431373462633338373835633538346631 +30316639616566383939623339613566616565653262626435656636343333363762613131313232 +63663466323536643163383666663438323761343533636536373463323265613166396163653562 +33323131656132346564303136653563323064303263303366643766366339636432373135393836 +32643335386630313937316565396561316237366465363235316537356131613765373030653662 +37663461653464316231313361373930363933623337363932376262656662663730653936353166 +65333131373633353734613666623830363664336236643737366130383766373033366465643834 +31653133373731323730316163633964376366303563633265653466613266373233366134663764 +39313835333831303638613464353637303034666461626166316563353963666131323838636230 +31386666626161616264356664663165633835383134666663393835303466383733306530386663 +31626566393430316331316166633264323833363862633962376361666439366138393566626133 +66383762363231346164303162666264353861306464613064633665623837366565386432353366 +39366366633965613439323336616239343138333031623966303136613831356164306335356136 +62383536376362653439626537386462383265646531343663356134303030373131333464373732 +39333233663732373435643732613464333336363835643339613833323533393063346264396236 +33393764333966666336316434616335363530373130323130653862636364633539366239313365 +37643133383636363763323133336330643738386131626331363763356266633932306164646263 +65353530323830346539326633333739393437373534363536623762393665653664336436613539 +39666264323761633637623166306335313465663530653037623937613738373230653830623135 +38663333656432666130396266376535366562613132656361376531353638633536313532626536 +61393031633463663836306162376334363035623633343334633135366265366161616637663663 +30633731653233656364373435373965363537323739616532313266336133343733353461633937 +32356365383131653137333336303764633561353939633836393061383337326634393637626566 +62646432386534326565363462356662636134616263393266396637333337613631343534663634 +61383165623437623234376530653436663833373535633266353432323330653531616361633931 +32383065353265373261343865333262653339373365623361643762373136326264656536663237 +38663034663837373532646433323934373735626265356439376161653137666264393330396566 +36653663326333623938616636623662646563383862313037626636373231343432653532613635 +65353431356265623538643932343564303438616265373131396239633933306566643832626363 +36343239363533313439663862643863663833336363653430366132326462323862653831656534 +61613338373164326166636665326162623631656435666239613931623333613732303431633138 +61346639383466656630646262383761653663643033623132313231393134643132336331636232 +61653163373131613732323133396631306130313464313839313465323564646238646238663163 +34393739386233663864623733383030353166376233313037373161393462643063633334373133 +62336339643737313432653636326537313862663636633264666638613262303534343766633363 +38363030386561626164373637323335353030336366346232633337613730363538613637623334 +34643634376436373438313665323934386536326435636262323232313334373832373336623639 +34356566336466393932353334366661366466336563616532316365303730666530653964646434 +64656232393432396563393938356464316365636266396566353262653237656462623365303936 +39303665653265383662323366383934373031343932373939666431326565663437653061616338 +61313833343762386465656331653638316462393036363235636134646335336139613934626634 +30323564656666303436353431376664326531306261646637373335333533623933373663376537 +36653766343838653131363463663439343330616537323961333366333438363939623434653339 +63666331333364303732376232343666646336356630323635343738306236663238626337343237 +38353439393466343863383965393963396131306466393630303438363665326532343737396133 +37653939396238363463323230356433323139633063643037633439616633306533303761363765 +35386637633962366566343534323838636136663936636233353239346535663237393739313532 +37336234643232646538663832366563653631306533306563356466366661633330333165346437 +65393461373664323538313732336134393638643138376365313064303737303065353434373363 +66653862356135303133343331326561343165303635386336626234356634646464663331663639 +61393063363333646561613533393239343466396439656365643034343665366262663235656235 +36343462613231653331323534353165353263383138346137393934663730303961636631346535 +38653963316638323462643064633664363033303565376438616463653735306231643762323433 +63393432386434383036376536386134663036326366376432393637346661656462356531393964 +37646532303939643930656235633634313136333662396664613239303732653065383063306631 +66386335373733656339656562333530323032653639316134303035613039633331323631386130 +36613035323131613434356438306462366535353639636634323535336462303435636264633239 +63633865323033663763653731383330306364623136323039633139373139396430636161303235 +37313038373935613263323065323433646336356335656161303136653064653239666563303561 +66373365636561396538303164666663303265343233666635623063323462343132633664373561 +30663732366231393238646636326664306239356665333364353434373433303032613938373433 +34636637663831353039316339313032643864656634343731383963346135623662363232373233 +33346666336336306663373732663231343465633063643165373230303137326230393363643434 +63336230356663383536636536363530643762613039666337373964633064646533333864313461 +62626366386635316638346165636265616664303266343330383263333134386165623462636434 +31303838626464313932396438373565346538336239383064383131333865343062646138613366 +35396462323134336230363761313530633132323531636231316535356561663635356436343234 +31323562306435376465316337346234666539376636643435613939363037373866333138633538 +33313363306237343231343139386138343231646635323463316663346338616561623132346631 +34323631323132383930333831313034316133633433613234356435343065316366633663633361 +38613461663637343732323732363065383332646231323739623966386530326134653764366162 +35613165313563666433356235356639386538373966313865386535363031306435623530316265 +32643437396539383234666338623561303663326335323061343562346139343939643039336331 +33383837313739313964653464396638613335353362393531303035323366303337663864323535 +33333863373537323263363735363439386262613736653863313832333261313238653563353537 +34636262643833656563666131356465366433653736633263383538313064646566326130363630 +61323032306435313233623030383362336364633564306332343732326537383934323933663638 +37393563613738613662663432373135663061393363646533383230346234303836353362613964 +37313263643865393533396139626462613535663364633532663838666166613736663766643266 +38386535333964343266306164366431303535366234386232353339343365613732353663656162 +37666637383565333433363433626336393063336565633663326136643638323037366130366333 +38336538383339313564316264343735383532313963663034643161323931353335653938666330 +63396533363330623462303864313462633966336539343761636666313538393431643135613066 +37343465653162323633356333326537393635336230313337666364616138323136323364323465 +39383432646639313031616233643930373963356661633834326331393837643131613631396137 +33613135653338613638653865613464303038383034323363326532343863613866666230386164 +30323032383134643164643533663739333863343262666334643862663030613564346236323235 +35323961613333343633393034383837396361623036313531316431656363356638326262366261 +35333462363166353139373839646665613463396536306531393238346134643534366538326166 +63613661363638663265613430363238393962343062316561663035303666623636326633666630 +33313139656539643031633430363664656238623964623561363838376132333136323539353532 +66653233373532656462373663366635353033393566613636323635653438353234396161613337 +35326534373964306332323637316134333537376138346166646465376135643466396266383534 +66663763366636353231383861316665306461376166383436636162353633646565633065383763 +32306230616439363731353763626130383636626162393463643132373161613161343166383261 +33316238663863626636383239336465633561636163323732616264623862353531613030346631 +32313766346235393535336431303964303066666566343264633432343834366666303362376232 +39636634656639626532633366366632346231373533343562373437626133393331613234303436 +64633935393930653538323762613833636436633336646437626637643564333562356464616536 +31326461643637323030373333363061333634323132336165663335343532613334663138396363 +32643139306338353765653064666265313137363334666330336338633134313239316338633939 +62376239316561303261663732633065623634353232373461613732373132343663323864343732 +36376566333337326664303531363164613734323766653733663831393738353534643362386130 +61633130643433366566666133373834633139656434383038623532376634393535643631326433 +63613131356463353734393033623864336535313164313630373362316665363963656162333461 +38343361643132323738363237626333303033663063633761306161323237333365613238306138 +35663235356361346334343838333066653833653534393865343361323139633364613063336161 +34303364326563383161316264663832646639393931666564613637376636353065613263616161 +38643430623332316334653635376362643062333530656438613361313464386530663631613932 +66396465636430373166306535643037646366623733386236336663326330303536313830656664 +38633731633665663037353039326663393133323438623765653439653236633835636138623730 +32353062643339633032373634363133636266333061643136666536306437383936303733663738 +35663538313466303364633734653864656533386436316339643530393664613638323437383734 +33333039643033366436323633646562363639383939383164376663373064363035646263313235 +63373863303064373036663834316230646530303465343161616230663233353638653530643436 +66333236326338363331316630323166393131623165666236633636643536653761633861623039 +66303762616664393934356631386639633931626335303930356162383832666235366161366230 +38373464656135616639636165313237353161643234643338353330333334383838653736376661 +65353139343232663265316162393832383231393436616462396665396635373661393636366131 +65313231376164653834623431663135396162343832343131663738303337656464333332356262 +61323937663433356631633630353031633563373165363136353436613039383963666434303661 +62353961303363363534316466623334663530626231306266376265333861353033663764616361 +64306131313738613438303636323635303463323766613665356564383466626162323636363733 +39356162356363326363383539333865613736353232376539366333646434393962646163633437 +61623535306661363335633432376330316230383565333939356634323437613233346130303262 +66663733653466343530623961336234626434663932373464313339306130366336353964613238 +64393564333863386134646362303565316130333032393137383535626230323932613231376162 +37383037646565623839333530666365323934383333653238353664333538303064353133656464 +32663931363366316363626564613134633736656531333135373864313931393864646230376330 +30386265633533333664303162346639303934396263646230346466373737666437383066396435 +65613639373064613161346534366137313663346364393232623939633364663261326464666163 +32366563353063626530393363313661363030653437333336393938376366316666616634636261 +36313363373030366539386462323430313331323038643532666163636231643331323062393861 +32303733646132633361316538343938656134333066333030313830663566383930373733623461 +30613030366330343630646261393365333135313233623866646132306339653035313863663238 +30333730633865393461333138386664633838366532316637626138326432353236323030336235 +63656331383836323266663564383432316639323837326531303532643030653738323139376361 +36376563353262313531613961353330623731376539386366363662376266383463353339383333 +62353039353437303436333937386630336539396262366462636663363633356665376666393962 +63396238613035333937323762613965376135633630633435303836613166653432376563336338 +39633161366665386339663932346561636439613832646265393139353634386434323066633034 +38646163303033366537343238303637386362323238643635326332383662303138626631373339 +62646139653364636534656636396137336133366262643066636364623439373763636538323232 +64353362623530626433313433333164663063306532393465396537366265653161613536376364 +37623739303732643635643236386631353534353839643865376638346464623233323761663130 +65326330323561386333633166323164336634393136643539666336393532636635343239646566 +31346530383433346366303331363032363066383933383866316534633230313034383863336234 +36333333363936373762663335343832616237386562616533626332333665643437303739666663 +61336535346130633464646464396265323431623836343635343533396361343064313063326631 +62396437323961323363613631636537386538383162666632363438663262333739653637633237 +34346462303137313064393738333737346132323238313238323165363262306531383930653938 +30303438373739343561326638383563396264336337343032323833303233393439326639393635 +35643830616439656364323333376463383065623466633661383961663662333064343737633733 +30346465323664656433386662336633396237373566393166343533386461326437663966396130 +37333635613962383931666435313933386136336564633038393464656534663136643636666634 +65373535636333333534386266336131633162396432356565333865653263383166623032363235 +34633562313232653766363437353133313937393164346566376361346636303233653264656637 +37383433313164383863396337363961653637323763643538306436306433323938636637613461 +62316430636465363135363331313632306537323530623163353034313161366366313832653734 +32353764346564386530343561333161326430623431333830316131303166313563633630316135 +32393833383736336665643131636539626362303931643464363563613431366635336530373930 +66626334643735313966376364643065346235666530643532383038393136333334373639316630 +64666265663338306565613431323433626431343233393439613631323734393635393565393633 +33353438363936626239356334333238393561613035616465333834623738343265666663396434 +36613232623165646265356161616261633338623532313738623462356263353231303964396432 +6235 From 1731905628af5afb31b7eb0af75df91f13dad58c Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 13:17:45 +0000 Subject: [PATCH 13/19] parker: wire sn03 up as concentrator and config broker The role now runs before dns64 and jool so the guard can name its interface, and it derives node prefixes from the same pool the broker allocates out of. Handshake liveness is published into the registry for the broker-side collector. sn03 gets its tunnel address from the reserved id-0 block and the full config the broker answers with: community client IPv4, xlat on, both concentrators, rate limit, and a log-only garbage collector for the first cycle. --- group_vars/parker_concentrators/main.yml | 8 ++++ host_vars/sn03.yml | 47 ++++++++++++++++++++++-- playbooks/parker.yml | 7 ++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/group_vars/parker_concentrators/main.yml b/group_vars/parker_concentrators/main.yml index 10bf80e..4de6c48 100644 --- a/group_vars/parker_concentrators/main.yml +++ b/group_vars/parker_concentrators/main.yml @@ -13,6 +13,14 @@ # 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 + # 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. diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index cc5fc88..5b7377d 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -198,13 +198,54 @@ yanic_central: "true" # 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. Without enabled: true this block -# changes nothing on sn03. usign_secret is the vaulted key content the role -# writes to wgbroker_parker_secret_key. +# 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). diff --git a/playbooks/parker.yml b/playbooks/parker.yml index dc1ca70..5b3ec59 100644 --- a/playbooks/parker.yml +++ b/playbooks/parker.yml @@ -66,9 +66,10 @@ ansible.builtin.import_tasks: "include/etckeeper_pre.yml" tags: [always] roles: - # The pair that has never run: DNS64 answers AAAA from A for clients in - # the parker pool, NAT64 translates the resulting 64:ff9b::/96 traffic to - # IPv4. ff.parker_concentrator joins them once a node registers. + # The interface first, so the NAT64 guard can name it; then the pair that + # translates: DNS64 answers AAAA from A for clients in the parker pool, + # NAT64 translates the resulting 64:ff9b::/96 traffic to IPv4. + - { role: ff.parker_concentrator, tags: parker, become: true } - { role: ff.dns64, tags: dns64, become: true } - { role: ff.jool, tags: jool, become: true } post_tasks: From 05b8affec108672a2f7656750fdc35106c66c754 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Wed, 11 Nov 2026 13:29:52 +0000 Subject: [PATCH 14/19] parker_concentrator: only ever touch the delegated routes Two ways the reconciler deleted routes it should have kept. The add branch never added the new prefixes to the expected set, so one pass with a new peer emptied table 42 of every /64 for a minute. And current_routes() counted every route through the interface, including the /128 bird installs and restores, so each pass deleted it and watched it come back. Both are fixed at the source: the expected set gets the added prefixes, and only /64s count as this gateway's routes. parker_lib exposes DELEGATION_BITS for that. --- playbooks/files/parker_lib.py | 15 ++++++++------- .../templates/autoupdate_parker.py.j2 | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/playbooks/files/parker_lib.py b/playbooks/files/parker_lib.py index 706a705..64fec9b 100644 --- a/playbooks/files/parker_lib.py +++ b/playbooks/files/parker_lib.py @@ -45,8 +45,9 @@ STATUS_SUBDIR = "parker-status" # A delegated unit is one /64; every id owns range6 and the xlat sibling, -# so it consumes two of them. -_DELEGATION_BITS = 64 +# so it consumes two of them. Public because the concentrator's reconciler +# needs it to tell its own routes from bird's in the shared table. +DELEGATION_BITS = 64 class ParkerError(Exception): @@ -177,15 +178,15 @@ def load_records(repo_dir): def _pool_network(prefix6): net = ip_network(prefix6) - if net.prefixlen > _DELEGATION_BITS: - raise ValueError(f"{prefix6}: pool is smaller than one /{_DELEGATION_BITS}") + if net.prefixlen > DELEGATION_BITS: + raise ValueError(f"{prefix6}: pool is smaller than one /{DELEGATION_BITS}") return net def id_capacity(prefix6): """Number of ids the pool holds; ids 1..capacity-1 are allocatable, 0 is infra.""" net = _pool_network(prefix6) - return net.num_addresses // (2 ** (_DELEGATION_BITS + 1)) + return net.num_addresses // (2 ** (DELEGATION_BITS + 1)) def id_to_prefixes(prefix6, node_id): @@ -196,10 +197,10 @@ def id_to_prefixes(prefix6, node_id): config change, not a migration of records. """ net = _pool_network(prefix6) - capacity = net.num_addresses // (2 ** _DELEGATION_BITS) + capacity = net.num_addresses // (2 ** DELEGATION_BITS) if node_id < 1 or 2 * node_id + 1 >= capacity: raise PoolExhausted(f"id {node_id} does not fit into {prefix6}") - step = 2**_DELEGATION_BITS + step = 2**DELEGATION_BITS base = int(net.network_address) + 2 * node_id * step return f"{IPv6Address(base)}/64", f"{IPv6Address(base + step)}/64" diff --git a/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 b/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 index 7d82762..45353e4 100644 --- a/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 +++ b/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 @@ -67,10 +67,23 @@ def prefixes_of(record): def current_routes(): + """Routes in the table that belong to a node: the delegated /64s. + + bird writes its own routes into the same table (the interface's /128 among + them) and puts back what is deleted, so anything that is not a /64 is not + ours to touch. + """ out = subprocess.check_output( ["ip", "-6", "route", "show", "table", str(ROUTE_TABLE), "dev", IFACE] ).decode() - return {line.split()[0] for line in out.splitlines() if line} + routes = set() + for line in out.splitlines(): + parts = line.split() + if not parts or "/" not in parts[0]: + continue + if int(parts[0].split("/")[1]) == parker_lib.DELEGATION_BITS: + routes.add(parts[0]) + return routes def sync_peers(records): @@ -87,6 +100,9 @@ def sync_peers(records): record = records[key] prefixes = prefixes_of(record) wg.set(IFACE, peer={"public_key": key, "allowed_ips": prefixes}) + # Without this the route set below is empty on the pass that adds + # a peer, and every existing route would be deleted for a minute. + expected.update(prefixes) print(f"parker: added peer {key} id {record['id']} {prefixes}") for key in sorted(iface_keys & repo_keys): # Re-assert: the id (and with it the prefixes) of a record is From f6ff4c8ec3b65adb206de52730a12f41adb0a69a Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 12 Nov 2026 07:47:43 +0000 Subject: [PATCH 15/19] ff.backbone: give static protocols a channel BIRD 2 only accepts import/export inside a channel block, so the static protocol the template rendered was a syntax error ("unexpected IMPORT"). Pick the channel the way the kernel block does: is_ipv6, IPv6 by default. bb-a's uplink_hostroute is an IPv4 route and says so. --- host_vars/bb-a.yml | 1 + playbooks/roles/ff.backbone/templates/bird2.conf.j2 | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/host_vars/bb-a.yml b/host_vars/bb-a.yml index 3e1d304..6d65d5a 100644 --- a/host_vars/bb-a.yml +++ b/host_vars/bb-a.yml @@ -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: diff --git a/playbooks/roles/ff.backbone/templates/bird2.conf.j2 b/playbooks/roles/ff.backbone/templates/bird2.conf.j2 index 6ca5270..a83ace8 100644 --- a/playbooks/roles/ff.backbone/templates/bird2.conf.j2 +++ b/playbooks/roles/ff.backbone/templates/bird2.conf.j2 @@ -52,7 +52,7 @@ protocol kernel { {% for route in bird.staticroutes | default([]) %} protocol static {{ route.name }} { - import all; + {% if not route.is_ipv6 is defined or route.is_ipv6 %}ipv6 {% else %}ipv4 {% endif %}{ import all; }; {% for rule in route.rules %} {{ rule }}; {% endfor %} From 3a81a570187640435b054b4146a65d9a20ee6a98 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 12 Nov 2026 07:47:43 +0000 Subject: [PATCH 16/19] sn03: originate the parker pool as one /56 FFRL routes our /56s but nothing beyond, and the pool only existed as per-node /64s, so nothing announced it and replies to pool addresses died at FFRL's tunnel ends. Originate the aggregate on sn03 like the bat /56: OSPF carries it to both backbones, which announce it via is_aachen_3006(). A static on the backbones would keep announcing it when a backbone loses its adjacency to sn03; this way that backbone withdraws it. --- host_vars/sn03.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index 5b7377d..696daaf 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -149,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 From d63581149a35edbb55d0303df3767d17052dfaa3 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 12 Nov 2026 09:06:23 +0000 Subject: [PATCH 17/19] parker_lib: ask parker nodes for respondd and validate the answer A parker node is not part of a batman domain and the monitor records the concentrator as its address, so nothing polls it for statistics. The concentrator can: one unicast request over the tunnel reaches the same respondd a Gluon node runs. Add the module list, port, node_address() and parse_respondd(). The answer is raw-deflated JSON; decoding it here only keeps junk out of the monitor's log and names the node that stayed silent, the payload itself is forwarded unchanged. node_address() also replaces the inline ip_network()[1] in build_config so the address rule lives in one place. --- playbooks/files/parker_lib.py | 53 ++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/playbooks/files/parker_lib.py b/playbooks/files/parker_lib.py index 64fec9b..62a204d 100644 --- a/playbooks/files/parker_lib.py +++ b/playbooks/files/parker_lib.py @@ -30,16 +30,19 @@ """ import fcntl +import json import os import re import subprocess import tempfile import threading import time +import zlib from ipaddress import IPv6Address, ip_network PUBKEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") NONCE_RE = re.compile(r"^[0-9a-f]{32}$") +NODE_ID_RE = re.compile(r"^[0-9a-f]{12}$") RECORDS_SUBDIR = "parker" STATUS_SUBDIR = "parker-status" @@ -234,7 +237,7 @@ def build_config(pool_cfg, pubkey, node_id, nonce, now=None): per-link computation. """ range6, xlat_range6 = id_to_prefixes(pool_cfg["prefix6"], node_id) - address6 = str(ip_network(range6)[1]) + address6 = node_address(range6) conf = { "nonce": nonce, @@ -262,6 +265,54 @@ def build_config(pool_cfg, pubkey, node_id, nonce, now=None): return conf +# --------------------------------------------------------------------------- +# respondd + + +# The module list yanic asks for on a bat interface; a parker node answers the +# same request over its tunnel. +RESPOND_QUERY = b"GET nodeinfo statistics neighbours" +RESPOND_PORT = 1001 + + +def node_address(range6): + """The node's own address inside its delegated /64 - always ::1.""" + return str(ip_network(range6)[1]) + + +def respondd_node_id(response): + """The 12 hex digit node id from a decoded answer, or None. + + yanic accepts an answer only when every module that is present carries the + same id, so a single module is enough to name the node. + """ + for module in ("nodeinfo", "neighbours", "statistics"): + section = response.get(module) + if isinstance(section, dict): + node_id = section.get("node_id") + if isinstance(node_id, str) and NODE_ID_RE.match(node_id): + return node_id + return None + + +def parse_respondd(payload): + """Decode one respondd answer: raw deflate, JSON object, valid node id. + + The node answers in exactly the format yanic's own collector produces, so + the concentrator forwards the payload unchanged. Decoding here only keeps + junk out of the monitor's log and tells us which node stayed silent. + """ + try: + response = json.loads(zlib.decompress(payload, -zlib.MAX_WBITS)) + except (zlib.error, ValueError) as exc: + raise ParkerError(f"not a deflated JSON answer: {exc}") from None + if not isinstance(response, dict): + raise ParkerError("answer is not a JSON object") + if respondd_node_id(response) is None: + raise ParkerError("answer carries no 12 digit node id") + return response + + # --------------------------------------------------------------------------- # subprocess plumbing From 82e1a626e6a5785b4e275258f6ccfe5280790348 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 12 Nov 2026 09:06:24 +0000 Subject: [PATCH 18/19] tests: cover the respondd helpers Pin the answer format instead of the code: raw deflate only, a JSON object only, and a 12 hex digit node id that may come from any of the three modules. A node id of the wrong length or with non-hex characters must be rejected, otherwise the monitor drops the answer without a trace. --- tests/test_parker_lib.py | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_parker_lib.py b/tests/test_parker_lib.py index f09cb6a..116da03 100644 --- a/tests/test_parker_lib.py +++ b/tests/test_parker_lib.py @@ -236,6 +236,76 @@ def test_build_config_xlat_kill_switch(): assert "xlat_range6" not in parker_lib.build_config(cfg, PUBKEY, 1, NONCE) +# --- respondd ---------------------------------------------------------------- + + +def deflate(payload): + """Raw deflate, the way respondd and yanic's respondd connection pack.""" + import zlib + + comp = zlib.compressobj(9, zlib.DEFLATED, -15) + return comp.compress(payload) + comp.flush() + + +def nodeinfo(node_id="ea5981962ae4"): + return {"nodeinfo": {"node_id": node_id, "hostname": "ffac-parker"}} + + +def test_node_address_is_the_first_host(): + assert parker_lib.node_address("2a03:2260:3006:8018::/64") == \ + "2a03:2260:3006:8018::1" + # id 0 is the concentrator's own /63, ids start at 1 + assert parker_lib.node_address("2a03:2260:3006:8000::/64") == \ + "2a03:2260:3006:8000::1" + + +def test_respondd_query_asks_for_the_three_modules(): + assert parker_lib.RESPOND_QUERY.split()[0] == b"GET" + assert set(parker_lib.RESPOND_QUERY.split()[1:]) == { + b"nodeinfo", b"statistics", b"neighbours" + } + assert parker_lib.RESPOND_PORT == 1001 + + +def test_parse_respondd_accepts_a_node_answer(): + import json + + answer = nodeinfo() + answer["statistics"] = {"node_id": "ea5981962ae4", "clients": {"total": 3}} + response = parker_lib.parse_respondd(deflate(json.dumps(answer).encode())) + assert response["statistics"]["clients"]["total"] == 3 + assert parker_lib.respondd_node_id(response) == "ea5981962ae4" + + +def test_parse_respondd_rejects_plain_json(): + # an answer that arrives uncompressed is foreign to both yanic and us + with pytest.raises(parker_lib.ParkerError, match="deflated"): + parker_lib.parse_respondd(b'{"nodeinfo":{"node_id":"ea5981962ae4"}}') + + +def test_parse_respondd_rejects_a_missing_or_broken_node_id(): + import json + + for answer in ({"statistics": {"clients": 1}}, + nodeinfo(node_id="ea5981962ae"), + nodeinfo(node_id="ZZZZZZZZZZZZ")): + with pytest.raises(parker_lib.ParkerError, match="node id"): + parker_lib.parse_respondd(deflate(json.dumps(answer).encode())) + + +def test_parse_respondd_rejects_a_non_object(): + with pytest.raises(parker_lib.ParkerError, match="JSON object"): + parker_lib.parse_respondd(deflate(b"[1, 2, 3]")) + + +def test_respondd_node_id_survives_a_missing_module(): + # yanic drops the modules whose id disagrees, so one present module names + # the node + answer = {"neighbours": {"node_id": "ea5981962ae4"}} + assert parker_lib.respondd_node_id(answer) == "ea5981962ae4" + assert parker_lib.respondd_node_id({}) is None + + # --- rate limiting ----------------------------------------------------------- From 6f7c659622b06ca9695490892ff179bfa25e9301 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 12 Nov 2026 09:06:25 +0000 Subject: [PATCH 19/19] parker: forward node data to the monitor's yanic The monitor can neither multicast into the parker pool nor reach a node directly: it records the concentrator as the node's address and never asks the node itself. Every parked node therefore stayed invisible on the map. The concentrator now asks each node in the registry for nodeinfo, statistics and neighbours on port 1001, checks the answer and forwards it untouched to the monitor's central yanic listener. That is the same deframed format the monitor's own respondd connection uses for bat nodes, so it parses the push like a local answer; a lost request costs one minute of freshness, not a node marked offline. Off by default and switched on for parker_concentrators, with the cron job removed again when the flag goes back to false. --- group_vars/parker_concentrators/main.yml | 8 ++ .../ff.parker_concentrator/defaults/main.yml | 10 ++ .../ff.parker_concentrator/tasks/main.yml | 25 ++++ .../templates/respondd_parker.py.j2 | 133 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 diff --git a/group_vars/parker_concentrators/main.yml b/group_vars/parker_concentrators/main.yml index 4de6c48..68fd208 100644 --- a/group_vars/parker_concentrators/main.yml +++ b/group_vars/parker_concentrators/main.yml @@ -21,6 +21,14 @@ parker_prefix6: "{{ parker_pool6 }}" # 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. diff --git a/playbooks/roles/ff.parker_concentrator/defaults/main.yml b/playbooks/roles/ff.parker_concentrator/defaults/main.yml index 7bd1120..daf5a4c 100644 --- a/playbooks/roles/ff.parker_concentrator/defaults/main.yml +++ b/playbooks/roles/ff.parker_concentrator/defaults/main.yml @@ -26,3 +26,13 @@ parker_route_table: "main" # Publish handshake liveness into the registry so the broker-side garbage # collector can tell dead nodes from alive ones. parker_gc_publish: false + +# Hand each node's respondd answer to the monitoring yanic. The monitor cannot +# poll a parker node (no batman, and it records this host as the node's +# address), so the concentrator asks over the tunnel and forwards the answer +# untouched to the monitor's central yanic listener. +parker_respondd_push: false +parker_respondd_monitor: "monitor.{{ dns_searchdomains }}" +parker_respondd_port: 11001 +# Seconds a pass waits for answers before forwarding what arrived. +parker_respondd_timeout: 5 diff --git a/playbooks/roles/ff.parker_concentrator/tasks/main.yml b/playbooks/roles/ff.parker_concentrator/tasks/main.yml index 4eb78db..3e4b25f 100644 --- a/playbooks/roles/ff.parker_concentrator/tasks/main.yml +++ b/playbooks/roles/ff.parker_concentrator/tasks/main.yml @@ -93,3 +93,28 @@ # Cron would mail whatever the script prints; the journal is where we look # for what a pass did. job: /usr/local/sbin/autoupdate_parker.py 2>&1 | logger -t autoupdate_parker + +- name: Install parker respondd collector + ansible.builtin.template: + src: respondd_parker.py.j2 + dest: /usr/local/sbin/respondd_parker.py + mode: "0755" + owner: root + group: root + +- name: Install crontab for the parker respondd collector + ansible.builtin.cron: + name: "push parker node data to the monitor" + user: root + minute: "*/1" + job: /usr/local/sbin/respondd_parker.py 2>&1 | logger -t parker_respondd + when: parker_respondd_push | bool + +- name: Remove the crontab when pushing is off + # A concentrator that was switched back off keeps no stale job that would + # keep feeding a monitor it no longer belongs to. + ansible.builtin.cron: + name: "push parker node data to the monitor" + user: root + state: absent + when: not (parker_respondd_push | bool) diff --git a/playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 b/playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 new file mode 100644 index 0000000..c68b6e5 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Forward parker node data to the monitor's yanic. + +A parker node is not part of a batman domain: its address sits in the parker +pool behind wg-nodes, so neither this host's yanic nor the monitor's can reach +it with a multicast request - and the monitor records this host as the node's +address, so it never polls the node itself either. + +What does work is one unicast request over the tunnel: the node runs the same +respondd as a Gluon node and answers on port 1001 with nodeinfo, statistics and +neighbours. The answer is already raw-deflated JSON, the format yanic's own +respondd database connection sends, so it is handed to the monitor unchanged; +the monitor parses it like any local answer. + +Runs from cron every minute. A lost request costs one minute of freshness, not +a marked-offline node: the monitor's offline_after is five minutes. +""" + +import socket +import sys +import time + +sys.path.insert(0, "/usr/local/lib/parker") + +import parker_lib # noqa: E402 + +REPO = {{ parker_repo | tojson }} +PREFIX6 = "{{ parker_prefix6 }}" +MONITOR_HOST = "{{ parker_respondd_monitor }}" +MONITOR_PORT = {{ parker_respondd_port | default(11001) | int }} +TIMEOUT = {{ parker_respondd_timeout | default(5) | int }} + + +def targets(records): + """address -> record for every record that fits into the pool.""" + found = {} + for record in records.values(): + try: + range6, _xlat = parker_lib.id_to_prefixes(PREFIX6, record["id"]) + except parker_lib.PoolExhausted: + print(f"parker-respondd: id {record['id']} outside {PREFIX6}, skipping") + continue + found[parker_lib.node_address(range6)] = record + return found + + +def ask(addresses): + """Send the module list to every node, collect answers until TIMEOUT.""" + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + sock.settimeout(0.2) + answers = {} + try: + for address in addresses: + sock.sendto( + parker_lib.RESPOND_QUERY, (address, parker_lib.RESPOND_PORT) + ) + deadline = time.monotonic() + TIMEOUT + while len(answers) < len(addresses) and time.monotonic() < deadline: + try: + payload, source = sock.recvfrom(65535) + except socket.timeout: + continue + address = source[0] + try: + parker_lib.parse_respondd(payload) + except parker_lib.ParkerError as exc: + print(f"parker-respondd: {address}: {exc}") + continue + if address not in addresses: + print(f"parker-respondd: {address}: answer nobody asked for") + continue + answers[address] = payload + finally: + sock.close() + return answers + + +def forward(payloads): + """Hand every answer to the monitor unchanged.""" + info = socket.getaddrinfo( + MONITOR_HOST, MONITOR_PORT, socket.AF_INET6, socket.SOCK_DGRAM + ) + family, socktype, proto, _canon, sockaddr = info[0] + sock = socket.socket(family, socktype, proto) + try: + for payload in payloads: + sock.sendto(payload, sockaddr) + finally: + sock.close() + + +def main(): + try: + records = parker_lib.load_records(REPO) + except parker_lib.RecordConflict as exc: + # Same stance as the reconciler: report a broken registry instead of + # guessing around it, and send nothing this pass. + print(f"parker-respondd: refusing to read {REPO}: {exc}") + return 1 + + wanted = targets(records) + if not wanted: + return 0 + + try: + answers = ask(list(wanted)) + except OSError as exc: + print(f"parker-respondd: cannot query the nodes: {exc}") + return 1 + + for address in (address for address in wanted if address not in answers): + print(f"parker-respondd: no answer from {address} (id {wanted[address]['id']})") + + if answers: + try: + forward(list(answers.values())) + except OSError as exc: + # A monitor outage is not a reason to mail every minute; the + # monitor ages the nodes out on its own. + print( + f"parker-respondd: cannot reach {MONITOR_HOST}:{MONITOR_PORT}: {exc}" + ) + return 1 + + print( + f"parker-respondd: {len(wanted)} nodes, {len(answers)} answered, " + f"{len(answers)} forwarded to {MONITOR_HOST}:{MONITOR_PORT}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())