feat/ansible-inventory #1

Merged
thystips merged 75 commits from feat/ansible-inventory into main 2026-07-31 22:42:01 +02:00
Owner
No description provided.
Adopt uv for the repo's Python tooling: pyproject.toml + uv.lock at the
monorepo root (package = false, groups ansible/dns/lint so each CI job
installs only what it needs). Resolves to ansible-core 2.21.2 on Python
3.13 — the workstation, the CI and the future AWX execution environment
now run the exact same versions, instead of whatever ansible the system
happens to ship (Debian's 2.16 here).

.ci/lint.sh drives everything through `uv sync --frozen` + `uv run`, and
keeps a documented fallback to PATH tools when uv is missing.

Two checks were also silently broken:

  - tool detection used `uv run -- command -v <tool>`; `command` is a
    shell builtin, not an executable, so every check was skipped and the
    script exited green without verifying anything. Probe .venv/bin
    instead.
  - Ansible commands ran from the repo root, where ansible/ansible.cfg is
    not picked up. Run them from ansible/ so inventory_plugins and
    enable_plugins actually apply.

yamllint therefore ran for the first time; fix the indentation warning it
reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the dn42_topology inventory plugin: it reads topology/ and produces
the hosts, the groups and the computed variables, so no address or node
list is ever duplicated into ansible/.

Groups come from node *fields*, never from the hostname (dn42_nodes,
site_<site>, provider_<provider>, role_border, role_rr), so changing a
node's hosting provider cannot turn a group name into a lie. Variables
are prefixed dn42_ and documented as a table in ansible/README.md — that
table is the contract the roles will consume. ansible_host is the
out-of-band management FQDN: configuration has to stay possible while the
overlay is down.

The WireGuard mesh is computed rather than enumerated. This needs a
stable per-node `id` (new field, never recycled) and wg_port_base, from
which both ends derive the same values without coordination:

  - mesh interface towards P = wgm-<P>   (wgp- stays for eBGP peers)
  - listen port towards P    = wg_port_base + P.id, so P reaches me on
                               wg_port_base + my id
  - link addresses           = fe80::<id> — link-local only, no global
                               address spent on the mesh
  - endpoint prefers IPv6; null (the home site, no routed IPv4) simply
    means the far end initiates.

The plugin also validates the topology and exits non-zero on a duplicate
or missing id, an unknown site, a duplicate loopback, an anycast node
that does not exist, or an interface name over IFNAMSIZ. `ansible-
inventory --list` is therefore a topology validator, and it runs in CI.

topology/ is trusted, Jinja-free and Vault-free content, so load it with
yaml.safe_load: Ansible's DataLoader (core >= 2.19) tags every string
__ansible_unsafe, which makes the inventory JSON unusable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rename fr-gra1 to fr-rbx1 (and the planned fr-gra2 to fr-rbx2) and fix
the site location. Nothing is provisioned yet, so the rename is free.

Keep id 1 on the node: it is the same router at a different location, not
a recycled identifier — the WireGuard ports and link-local addresses
derived from it are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment (and the message of 69ace8e) claimed Ansible's DataLoader was
what tagged every string __ansible_unsafe in the inventory JSON, and that
yaml.safe_load avoided it. Measured: 272 occurrences either way.

The tagging comes from how ansible-core >= 2.19 serialises the variables
of an inventory plugin, and it is harmless — those strings are used
normally, they are simply never templated, which is what we want for
topology data.

yaml.safe_load stays, for the reason that actually holds: topology/ is
trusted, Vault-free, Jinja-free data, so the DataLoader buys us nothing
and our own error message is clearer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The target is four core routers — two in Roubaix (OVH/Proxmox) and two at
home (CloudStack) — and as.yml already reserved exactly four IPv4
loopbacks for them. Only two were declared. Add fr-rbx2 (id 3) and
fr-bod2 (id 4); ids 1 and 2 keep their nodes.

The mesh goes from 1 to 6 tunnels with no code and no configuration
change: it is computed from the node list. Nodes whose public key is not
known yet are simply skipped, so the four can be declared now and
deployed one at a time.

Two consequences worth stating rather than discovering later:

  - fr-bod1 and fr-bod2 share a link, an ISP and a roof. That is
    hypervisor redundancy, not site redundancy; geographic resilience
    still needs a third-party VPS, which is not decided.
  - two nodes behind the same home IPv4 cannot both port-forward the
    same port, which is harmless only because the home endpoint is IPv6
    only. Recorded next to fr-bod2 so it is not rediscovered.

fr-bod2 now names the second home router, so the lab MikroTik cannot take
that name: it will be lab1. That settles a naming question left open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bring the nodes to a working L3 state without Bird: VRF, loopbacks and
the WireGuard mesh, all driven by the computed inventory. This is the
floor the Babel/RTT work needs — a reroute experiment is unreadable if
VRF, MTU or enslavement problems are still in play.

base installs the routing packages and provisions the CZ.NIC APT
repository (bird3 suite, key served already dearmored). Its sysctl
drop-in carries three decisions that are easy to get wrong:

  - *_l3mdev_accept stays at 0, so a socket on the global table (SSH,
    exporters) never answers traffic arriving through the dn42 VRF. That
    isolation is the whole point of the out-of-band management plane.
  - accept_ra=2 on the management interface only. Turning on IPv6
    forwarding stops RA acceptance, which would strand a node whose
    default route comes from an RA — plausible on the CloudStack side.
  - rp_filter=0, because DN42 multi-homing makes asymmetric routing
    normal.

network templates systemd-networkd units: the VRF (table from
topology/), a dummy carrying the loopbacks, and one WireGuard interface
per neighbour built entirely from dn42_mesh_peers. AllowedIPs is
0.0.0.0/0 + ::/0, which is safe here because RouteTable defaults to
false: networkd installs no route from them, so Bird stays the only
thing deciding what crosses a tunnel. RequiredForOnline=no keeps
wait-online from stalling boot on a tunnel whose peer is asleep.

The private key comes from Vault (host_vars, inline). The role derives
the public key from what it just deployed and fails if it differs from
topology/, because the other nodes encrypt towards the published one and
the symptom of a mismatch — silent handshakes — does not point at its
cause. Mesh interfaces dropped from topology/ get their files and their
links removed, so a deleted node does not survive on the machine.

Verified: ansible-lint at the production profile, and every template
rendered against real inventory data for a neighbour with an endpoint,
one without, and a node still missing its key.

Not yet runnable end to end: no host_vars/<node>/vault.yml exists, since
generating real keys is the operator's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two entangled changes — they touch the same lines, so separating them
would mean splitting hunks rather than commits.

DNS is now two services rather than one. Knot (authoritative) and Unbound
(recursive) run on different routers, one of each per site, so losing a
site leaves both services standing without running everything everywhere:
dns_auth on fr-rbx1 and fr-bod1, dns_rec on fr-rbx2 and fr-bod2. They get
separate anycast addresses (.252 and .253, ::a53 and ::53) — sharing one
would publish an open resolver and make any diagnosis ambiguous.

anycast.yml no longer lists nodes: it declares the `role` that serves the
service, and the nodes carrying that role are derived. Otherwise "who
serves what" lives in two files, which eventually disagree. The inventory
now refuses to build when a service is carried by no node at all, so an
address cannot exist in topology/ with nothing announcing it.

gen-node-keys.sh generates a node's key pair, files the private half into
host_vars/<node>/vault.yml encrypted inline, and prints the public half
for topology/. It refuses to overwrite an existing vault.yml: rerunning
it would break every tunnel towards that node.

Second change, the project convention from now on: everything inside code
files is English — names, variables and comments — including YAML data
files, Jinja templates (hence the text landing in deployed files), shell
scripts and the messages the code raises. Conversation and the Markdown
READMEs stay in French.

Verified: inventory resolves role_dns_auth and role_dns_rec to the right
pairs and hands each node its own anycast service; ansible-lint clean at
the production profile; templates re-rendered against real inventory
data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four key pairs generated: the private half encrypted inline with Vault in
host_vars/<node>/vault.yml, the public half published in
topology/nodes.yml. The mesh can now be templated for every neighbour —
until now every peer was skipped for want of a public key.

Two bugs found while doing it, both invisible to the checks we had:

  - the scripts documented ansible/vault-pass but never used it; they
    relied on ANSIBLE_VAULT_PASSWORD_FILE being exported by hand. They
    now pick the file up themselves. It stays out of ansible.cfg on
    purpose: pointing at a file that does not exist makes every ansible
    run fail, CI included.
  - stdout_callback = yaml resolved to a community.general plugin removed
    in v12, so any real playbook run died on startup. --syntax-check does
    not load callbacks, which is why nothing caught it. Replaced by the
    built-in default callback with result_format = yaml.

Verified by replaying the network role's own guard against the four
nodes: the vault decrypts and each derived public key matches the one
published in topology/.

Note for later: host_vars/ is resolved next to the playbook, so these
files are only picked up by playbooks living in ansible/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mix a WireGuard pre-shared key into every internal mesh handshake. X25519
alone falls to a quantum computer, so traffic captured today could be
decrypted years from now; a symmetric key mixed into Noise_IKpsk2 does
not. We own both ends of all six links, so this costs nothing but the
secrets themselves.

A PSK belongs to a PAIR, not to a node, so it cannot live in host_vars.
It goes into group_vars/all/wg_psk.yml, indexed by the sorted pair — the
same `psk_key` the inventory computes for each neighbour, so both ends
read one entry without having to agree on anything. gen-mesh-psk.sh
creates only the missing pairs; regenerating one in place would leave the
two ends disagreeing and the tunnel silent.

A PSK present on one side only makes the handshake fail outright, and the
symptom does not name the cause, so the role asserts and lists the
missing pairs rather than quietly skipping them. Orphan PSK files are
removed along with the interfaces they belonged to.

This changes nothing we advertise in BGP: DN42's encryption scale stops
at (64511, 34), "encrypted with a safe VPN solution with PFS", which
WireGuard already earns without a PSK. There is no post-quantum
community — checked against the wiki, not from memory.

Verified: the six pairs resolve to the same index and the same key
fingerprint from both ends, and the rendered netdev carries
PresharedKeyFile.

Note: the render harness now loads the role's real defaults. It had been
carrying a hand-copied subset, which silently rendered the PSK block as
absent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OpenTofu stack for the four routers: bpg/proxmox for the OVH site,
cloudstack/cloudstack for the home one. The node list is read from
topology/ with yamldecode and split by the site's provider, never by node
name, so rehoming a node does not mean editing this code.

The OVH answer changes a premise the READMEs had wrong. A failover IPv4
and its virtual MAC are allocated in the customer panel before the VM
exists, so endpoint.v4 and vm.mac are INPUTS that tofu consumes to write
the VM's network configuration — not values read back afterwards. With
the WireGuard keys already generated, the whole topology is now known
before the first apply: tofu apply then ansible-playbook yields a node
that comes up already meshed.

Two consequences that the code has to respect:

  - a failover address is alone in its /32, so its gateway is off-subnet
    and the default route must be declared on-link. Proxmox's generated
    ip_config cannot express that, hence cloud-init network config pushed
    as a snippet.
  - without the virtual MAC the traffic is dropped upstream whatever the
    VM believes. Missing vm.mac, endpoint.v4 or infra.v4_gateway fail the
    plan through preconditions rather than producing a silent machine.
    Preconditions, not a check block — check only warns.

On CloudStack the address comes from the network offering, so there it
genuinely is an output, exposed for copying back into nodes.yml.

State lives on the MinIO S3 backend with native locking, following the
pattern already in use elsewhere; no secret in the repo, credentials come
from a named AWS profile. The bucket and profile do not exist yet, so
tofu init will fail until they are created.

.terraform.lock.hcl was being git-ignored, which defeats the point of a
lock file: it is now committed, and the Proxmox provider is pinned on the
resolved minor since it is still 0.x.

Verified: tofu fmt, init -backend=false and validate all pass, and the
CI step now runs them (validation needs no credentials).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Credentials now live in terraform.tfvars, git-ignored, with a documented
terraform.tfvars.example committed alongside. TF_VAR_* still works and
still wins, which keeps CI able to inject values without a file.

Nothing describing the nodes goes in there: that stays in topology/. The
file only says how to reach the hypervisors and who may log into the
machines.

Also add a CI step that fails when git actually tracks a *.tfvars,
vault-pass, *.key or *.pem. A .gitignore only protects files that are not
tracked yet — once one has been committed it keeps being tracked in
silence, which is exactly the case worth catching. Verified by staging a
fake tfvars: the check fails with exit 1 and names the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anticipating LAN integration at Roubaix: besides the public interface, a
node can carry two more legs. They are attached only once addressed, so
nothing changes until topology/ says otherwise — today both are null and
the inventory reports no leg at all.

What matters is not public versus private, it is which routing table each
leg lands in, because that decides whether Bird sees the interface,
whether its prefix is announced, and whether it opens a leak path:

  - services: enslaved in the dn42 VRF, addressed from the site's
    services_prefix — which is exactly what that prefix was reserved for
    — and announced by Bird. Babel never runs there: the hosts on that
    segment are servers, and an IGP facing them would trust what they
    say.
  - private: stays in the global table, Bird never sees it. This is what
    makes the planned nftables catch-all on DN42 prefixes load-bearing
    rather than theoretical.

Both sit on vmbr1 but on different VLANs. Separate broadcast domains are
the requirement — sharing one segment would let a host on the LAN reach
both tables and bridge them.

cloud-init keeps configuring the public interface only: the node has to
be reachable before Ansible exists, and letting netplan and
systemd-networkd both own an interface ends badly. The legs are attached
by tofu and addressed by the network role.

MAC and interface name are derived from the node id, from one constant
declared in as.yml, so tofu and systemd-networkd agree without exchanging
anything. Matching on MAC, not on name: names follow PCI order, which a
template change can reshuffle silently. Renaming to lan-svc and lan-priv
gives Bird and nftables something stable to refer to.

Also fixes a stale site_ovh reference left by the rbx/bdx rename.

Verified: tofu validate, ansible-lint at production profile, and both
templates rendered against a sample addressed leg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither hypervisor had a Debian 13 template, so tofu now builds both from
the same upstream cloud image: proxmox_download_file plus a VM marked as
a template on one side, cloudstack_template on the other. The starting
point of a rebuild belongs in the repo rather than in whatever happened
to exist on a host, and sharing one image keeps the two sites from
drifting onto different bases. The template_id and template fields in
sites.yml are gone with it — the VMs clone what tofu built.

CloudStack gains what the site actually needs:

  - resources go into the DN42 project rather than the account root;
  - two networks, matching Roubaix in intent but not in count. Here the
    public one carries tunnels, internet and infrastructure access, so
    there is no third global-table-only segment;
  - the second NIC is a cloudstack_nic rather than nicnetworklist,
    because it is the only form that lets the MAC be set — and the
    network role identifies the interface by MAC, identically on both
    sites;
  - a custom constrained offering, with cores and memory passed at deploy
    time from details{}, so sizing stays in topology/ instead of being
    frozen into a CloudStack object. root_disk_size finally consumes
    vm.disk_gb, which nothing read before.

Renames for symmetry: infra.bridge is now public_bridge, and CloudStack's
network is public_network alongside the services_network field added for
the DN42 leg.

Verified against the provider schemas rather than from memory: project,
details, root_disk_size and cloudstack_nic.mac_address all exist, and
proxmox_virtual_environment_download_file is deprecated in favour of
proxmox_download_file. tofu validate is clean, with no deprecation
warnings left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing read it, and since tofu builds the base image itself the per-node
field could only ever contradict the template actually cloned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fr-rbx1 and fr-rbx2 now carry their failover IPv4, an address from the
routed /64 and the virtual MAC bound to them, so tofu can write their
network configuration and the mesh can reach them.

Four of the six tunnels resolve an endpoint from at least one end.
fr-bod1 to fr-bod2 stays silent for now: both are null, so neither can
initiate. It resolves once CloudStack assigns their addresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bird 3 configured for everything internal: Babel over the mesh, BFD under
it, and an iBGP full mesh between loopbacks. eBGP towards DN42 peers, the
import policy that goes with it and RPKI belong to their own roles and
are deliberately absent.

Babel carries the loopbacks and nothing else, with the RTT metric that
justified choosing Bird over FRR in the first place: `type tunnel` makes
cost follow measured latency instead of hop count, which is what a
WireGuard mesh needs. `source != RTS_BGP` keeps the DN42 table out of the
IGP — redistributing it would strip communities and AS_PATH and invite
loops.

iBGP carries that table instead, one MP-BGP session per pair: IPv6
native, IPv4 over an extended next hop, next hop self at the edge, and no
ROA check since the routes were validated where they entered.

Interfaces are listed one by one from the inventory rather than matched
by wildcard, so a neighbour removed from topology/ stops being spoken to
instead of lingering behind a pattern that still matches. Aggregates are
originated on every border node — a single originator would be a single
point of announcement.

Config is validated by Bird itself before it replaces the live file, and
applied with reload rather than restart, so a one-line change does not
tear down every session.

Not verified locally: there is no Bird binary on this machine, so the
syntax has never been parsed. Two things to watch on the POC, flagged in
the config itself:

  - the mesh links carry no IPv4 address, so Babel can only install the
    IPv4 loopbacks over an IPv6 next hop (RFC 9229). If it cannot, those
    loopbacks go unreachable inside the AS while everything else looks
    fine.
  - `rtt cost` and friends are stated explicitly rather than left to the
    tunnel defaults, so an upstream change cannot flatten the metric
    silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One ruleset, not two. A VRF is not a network namespace, so "global
policy" and "VRF policy" are the same firewall telling the two planes
apart by interface — hence an explicit set of DN42 interfaces built from
the inventory rather than a wildcard, so an interface that left topology/
stops being trusted.

The guard the design has been promising since the review is now real: any
DN42 prefix leaving or entering through an interface that is not part of
DN42 is dropped, in forward and in output. If a WireGuard interface comes
up before it is enslaved in the VRF, or the private leg is misconfigured,
that becomes a visible failure instead of a leak nobody notices.

ICMP is never filtered, in either family: dropping packet-too-big is how
a network acquires a PMTUD black hole, where connections open and then
hang on the first large packet. MSS clamping covers the case where
someone upstream filters it anyway. SSH stays on the global table,
rate-limited, and left open by source until real prefixes are known — a
wrong value there locks the node out permanently.

Also add scripts/render-check.py, wired into CI. Ansible only renders a
template once it has a host to talk to, so a Jinja mistake surfaces at
deploy time against a live router; this renders every role template for
every node offline with StrictUndefined, and hands the firewall ruleset
to `nft -c` inside a throwaway network namespace, which is where the
CAP_NET_ADMIN it needs comes from.

It earned its keep immediately by catching that ansible-inventory --list
wraps strings in __ansible_unsafe where --host does not, which the
harness now unwraps. Bird is left out of it: no Bird binary exists on a
workstation, and the role already has Bird validate its own config before
replacing the live one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wireguard_peers builds one tunnel per external peer — VRF-enslaved,
link-local only, optional pre-shared key — and removes the tunnel of a
peer dropped from host_vars, so a finished peering stops being accepted
instead of lingering. The interface name is derived from the peer name,
and the role refuses one that would exceed the kernel's 15 characters.

The bird role gains the eBGP sessions and the DN42 import and export
policy, ported from the previous repository rather than rewritten:
prefix-length bounds per range, ROA check, and the latency, bandwidth and
crypto communities, each keeping the worse of the two values on
propagation because a path is only as good as its weakest hop. Export
announces the aggregates and never a more specific of our own space,
which would pull traffic away from the aggregate everywhere.

Two things worth stating plainly. Extended next hop is mandatory rather
than opportunistic: with no IPv4 on a link there is no other way to carry
v4 routes, and that is the price of a /27. And the ROA filter fails OPEN
— roa_check returns UNKNOWN when the tables are empty, which DN42 policy
accepts, so the freshness of what fills them is a monitoring concern, not
a detail. The rpki role owns the include; bird ships an empty one so the
config is valid before it has ever run.

dn42_peers moved from the role's defaults to group_vars/all: it is host
data, not a role setting, which ansible-lint pointed out and which also
happens to be what makes it visible to every role that needs it.

render-check now exercises the peering templates with a fixture peer.
Without one they render to nothing until the first real peering, and a
mistake in them would surface while someone else is waiting on the other
end of the tunnel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each node clones the DN42 registry and builds its own ROA table, so
filtering never depends on one machine or on a third party's feed staying
up. A systemd timer refreshes it every six hours with a randomised delay,
because every node pulling the registry on the same minute is both rude
and a good way to look like an outage.

The failure mode is what shaped this. DN42 policy accepts ROA_UNKNOWN, so
an empty or truncated table does not break routing — it silently stops
rejecting invalid announcements, which is the worst kind of failure:
everything keeps working and the filter is gone. The generator therefore
refuses to write a table below a floor of entries and leaves the previous
one in place, and writes atomically so Bird never reads a half-written
file. A stale table beats no table, and both deserve an alert.

The pull runs over the management plane, never over DN42: this has to
keep working when the overlay is precisely what is broken.

Verified against a fixture registry: several origins on one object yield
one route each, a missing max-length falls back to the prefix length, and
the floor refuses the write while leaving the previous table untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Knot rather than NSD, and for a reason that survived being challenged:
online DNSSEC signing with automatic key rollover. NSD is smaller and
simpler, but it signs offline, which turns every rollover into local
scripting — the exact place zones get broken. The "same vendor as
Unbound" argument for NSD does not apply here, since authoritative and
recursive deliberately run on different nodes.

Also corrects a claim this repo was carrying: the CZ.NIC repository
configured for Bird does NOT provide Knot. Each of their projects has its
own repository path, and Debian 13 ships a current Knot 3.4 in main
anyway, so no third-party repository is involved.

Anycast needs no health checker. The address exists only while the unit
runs — added on start, removed on stop by a systemd drop-in — and Bird
announces what is on the loopback. The service being up and the route
being announced are then the same fact, which is the only arrangement
where they cannot disagree.

The zone is derived from topology/: nameservers are the nodes carrying
the role, records come from the node loopbacks and anycast.yml. Nothing
is typed by hand, so adding a node cannot leave the zone describing a
network that no longer exists. Knot owns the serial once it signs.

Note the one step no daemon can take: the DS record has to be published
in the DN42 registry by hand, and until it is the zone is signed but
unvalidated.

render-check now knows which roles are scoped to a group — it caught that
rendering dns_auth on a resolver fails on data that node legitimately
does not have — and passes groups and hostvars, which zone templates need
to describe the whole network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unbound stays, and the challenge did not dent it: the DN42 requirement is
a handful of stub zones and one trust anchor, which it expresses in a few
lines. Knot Resolver's Lua engine answers a question we do not have, and
its v6 adds a manager daemon; PowerDNS Recursor is built for a scale we
will never reach. The "same vendor as Knot" argument is worth exactly as
little as the "same vendor as Unbound" one I rejected for NSD, since the
two services run on different nodes.

The real finding was not about the software. A single process cannot
serve both routing tables: with udp_l3mdev_accept at 0 — what keeps
management out of the overlay — a socket on the global table ignores
traffic arriving through a VRF, and `ip vrf exec` binds every socket of a
process to the VRF, outgoing ones included. So a resolver facing DN42
cannot reach anything outside it.

Hence two instances and one narrow crossing. The VRF instance serves the
anycast addresses, stubs the DN42 zones straight to the delegation
servers, and forwards the rest to the global instance, which is the only
half able to reach the internal resolvers. The crossing is a veth pair
carrying a /31 outside DN42 space — outside deliberately, since the
firewall's own catch-all would drop it otherwise — and nftables opens it
for port 53 between those two addresses and nothing else. A visible,
countable object rather than a default route quietly punched through the
isolation. No NAT and no route leak: the crossing never carries anything
but one local hop.

Clearnet is answered only for our prefixes plus a list left to extend,
through an Unbound view; the DN42 zones stay open to all of DN42, with
rate limiting, because an anycast resolver reachable by a whole network
is an amplifier waiting to happen. Queries go to the internal resolvers
rather than a third party.

Anycast tracking is the same pattern as the authoritative side: the
addresses live and die with the unit, so Bird withdraws the route on its
own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Proxmox VMID and the CloudStack template UUID, which is what the
preconditions were waiting for. Both hypervisors now have a plan.

First plan is clean: 8 resources, four VMs' worth of cloud-init snippets
and instances, cloning template 9000 full onto scsi0 and the CloudStack
UUID into the DN42 project with the custom offering sizing passed at
deploy time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first registration never downloaded: CloudStack reported "Failed to
download ... due to redirection, response code: 302". Its secondary
storage VM does not follow HTTP redirects, and cloud.debian.org answers
with one. Internet access from the SSVM was never the problem — it
reached the server and stopped at the redirect.

Re-registered against the mirror the redirect pointed at, which serves
the file directly. Downloaded and ready, 3 GiB. The name also loses a
typo it was carrying ("Debain").

A template's URL cannot be edited after registration, so this is a new
object with a new UUID rather than a correction of the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zone and project were still names while the networks, offering and
template were already UUIDs. This applies the rule the tofu README states:
a CloudStack name is only unique within a project, so it resolves
silently to something else the day one is duplicated — the kind of
mistake that builds a VM in the wrong place without erroring.

The readable name stays as a trailing comment, since a UUID alone tells a
reader nothing.

UUIDs taken from the API rather than transcribed: note that a plan does
not validate them, it passes them through as strings, so a wrong one
would only surface at apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The management names are what Ansible connects to (`ansible_host` =
`<node>.<domain_mgmt>`) and what cloud-init writes as the VM's FQDN, so they
carry the public endpoints — this zone must work precisely when DN42 does not.

`dn42.thystips.cloud` is a zone of its own, never the parent `thystips.cloud`:
OctoDNS deletes whatever a zone holds and its configuration ignores, and the
parent holds mail and web records that have nothing to do with DN42.

The node records are generated from topology/ and committed, rather than
resolved at sync time by a custom source: a DNS change then shows up as a diff
in review, and the whole OctoDNS toolchain keeps working on plain YAML. What is
not derivable (the apex NS) lives in dns/manual/ and is merged as a second
source, where a collision raises rather than silently overriding.

CI runs the generator's --check instead of octodns-validate, whose manager
instantiates every provider — targets included — and would need the Google
Cloud credentials. Offline, it checks more: staleness against topology/, both
sources' records, and their collisions.

The DN42 zone itself stays out of here: it is a signed zone file shipped into
Knot by the dns_auth role. One producer per zone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Ports, public keys and link-local addresses are carried over byte for byte:
the far side holds the same values, so changing any of them would turn the
rebuild into five separate conversations.

All four ex-fr-r42-0 peerings land on fr-rbx1 rather than being spread over the
two Roubaix borders, because each peer has our endpoint IP in their own
configuration and fr-rbx1 is the node inheriting 46.105.208.125. Rebalancing
towards fr-rbx2 costs one agreement per peer and should be a decision, not a
side effect.

Importing real peers surfaced a gap in the schema: dn724 does not do extended
next hop, while the eBGP template declared the IPv4 channel unconditionally.
Added `ipv4: false`, which omits the channel instead of announcing a capability
the far side cannot use — otherwise we take v4 routes whose next hop neither end
can reach.

Prefix limits raised from the illustrative 1000 to 10000: the DN42 table is
around 1500 prefixes per family, so a full-transit peer would have restarted the
session as the network grew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Reading the live node showed the recovered configuration did not describe a
working network: only tech9 and dn724 had a tunnel and an established session,
while kioubit and darkpoint were Idle with no WireGuard interface at all
despite being configured in the old repository. A session towards burble's
route collector was running and appeared in no peers.yml — it rides DN42
itself rather than a tunnel, so nothing in the files hinted at it.

Configuration does not prove a session. Rather than carry four peerings of
which two are dead and one is unknown, the peerings are re-established
deliberately. What existed is recorded in ansible/legacy-peerings.md so the
keys, ports and link-local addresses are there if one is resumed.

The two schema fixes the import surfaced are kept: they hold regardless of
which peers come back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Both halves failed on the first run, for unrelated reasons.

The CloudStack offering is fully customized, so it fixes neither cores nor
memory nor clock: all three have to be passed at deploy time. Only two were,
and CloudStack answered "Invalid CPU speed value" rather than defaulting
anything. cpuSpeed sits in sites.yml next to the offering it belongs to — it
has no meaning at Roubaix — and stays below the slowest host in the zone so
the figure itself never constrains placement.

Proxmox failed on an assumption stated in a comment and untrue: snippets
cannot be uploaded through the API, the provider writes them over SSH. Since
the failover address forces the network configuration to be a snippet, the
Roubaix nodes cannot be built without SSH credentials, which the API token
does not cover. The provider now takes an agent by default, or an unencrypted
key for unattended runs.

The two CloudStack nodes are created; their IPv6 endpoints are recorded in
topology/. They had to be read from the API, not from an output: the provider
exposes no IPv6 anywhere, not even on the data source's nic block. The address
is EUI-64 from a MAC the provider cannot pin, so a rebuild changes it — both
noted where someone would look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Gen8 tops out at 1800 MHz per core on cs0, and a WireGuard router is bound by
single-core throughput — encryption on one tunnel does not spread across cores.
Gen9 (cs2, cs3) starts at 2300 MHz.

The allocator happened to place both nodes on Gen9 already; pinning turns that
into a guarantee rather than luck. Cluster and not host: the point is the
faster hardware, not defeating placement, and pinning a host would tie a node
to a single machine.

cpuSpeed follows, from 1500 to 2300 — the slowest core the pinned cluster can
provide, so the reservation describes what a vCPU actually gets.

Note this only bites at deploy time: CloudStack reads cluster_id when placing a
VM, so applying it to the two existing instances records it in state without
moving anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
The nodes were unreachable because "DN42 interco" ran the "Shared External
Gateway" offering, which serves UserData over ConfigDrive and nothing else: no
DHCP, so the address CloudStack allocated never reached the guest.

Neither half could be fixed in place. An offering's networkrate is immutable —
updateNetworkOffering takes name, displaytext, availability, state and little
else — and CloudStack refuses to change the offering of a SHARED network at
all: updateNetwork and the purpose-built migrateNetwork both answer "Isolated
networks only". So the network was recreated on a new offering, "Shared 1G",
a copy of DefaultSharedNetworkOffering at 1000 Mbit/s instead of 200, with
Dhcp and Dns through a virtual router like every network here that works.

Same VLAN, ranges, gateways and resolvers as before. The nodes had to be
rebuilt anyway for the Gen9 pinning and the CPU clock, so nothing was lost.

Both answer on IPv4 and IPv6 now, and their endpoints are read back from the
guest rather than from the API — the segment carries two advertised prefixes,
and only one of them is the one CloudStack declares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
thystips.cloud already belongs to the ATNET dns repository, which reconciles it
to Google Cloud DNS on a push to main. This directory was set up to publish a
delegated dn42.thystips.cloud from here, with its own Google provider and
service account — a second writer on a zone that already had one, which is the
drift this monorepo exists to prevent. One producer per zone: the clearnet
plane there, thystips.dn42 in the dns_auth role, neither here.

What remains is the guarantee the two agree. ansible_host is
<node>.<domain_mgmt>, so an endpoint changed in topology/ and forgotten in the
zone breaks every playbook at once rather than degrading. check-records.py
compares them and fails on any divergence; it passes with a note when the other
repository is not alongside, so a runner holding only this one stays green.

Dropping the Google provider takes octodns out of the toolchain entirely —
PyYAML, already there for Ansible, is enough to read a zone file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Enabling IPv6 forwarding does not merely stop the acceptance of router
advertisements — it flushes the routes already learnt from them. The
CloudStack nodes hold their default IPv6 route that way, with a lifetime of
about 27 minutes, and their management name resolves to AAAA only: Ansible
reaches them over exactly the route that would disappear. The play would have
hung mid-run on a node it could no longer reach, and the route would only have
come back at the next advertisement.

accept_ra was already in the drop-in, but after the forwarding settings, so the
kernel dropped the route and the setting merely allowed it back later. Declared
first, there is no reason to drop it at all. Roubaix was never at risk — its
default route is static, from the cloud-init snippet.

Verified on both Roubaix nodes: applied, idempotent on a second run, default
route intact, and the CZ.NIC repository offers bird3 3.3.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Roubaix is addressed entirely by the cloud-init snippet, so accepting
advertisements there bought nothing and left a path open: vmbr0 is a shared
public L2, where a neighbour advertising itself as a router would have been
believed and could have pulled our traffic through it. RAs are now refused on
all interfaces by default — a DN42 interface has no business learning anything
from one — and ICMPv6 redirects with them.

Which posture applies is a property of the site, not a guess: sites.yml gains
connectivity.mgmt_addressing, and the inventory plugin refuses to build on an
unknown value. Getting it wrong on a node whose default route IS an
advertisement takes that node off the network, so it fails loudly at inventory
time rather than quietly at apply time.

Bordeaux stays on `ra` for now: v4 comes from the virtual router's DHCP and v6
from SLAAC there.

Verified on both Roubaix nodes: accept_ra 0 on all/default/eth0, redirects off,
static default route intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Bordeaux was left to the platform: v4 from the virtual router's DHCP, v6 from
SLAAC. That cost twice. The WireGuard endpoint was an EUI-64 address derived
from a MAC CloudStack reallocates, so every rebuild moved it and DNS had to be
rewritten. And the default route being an advertisement, router advertisements
could not be refused — on a segment where anything can claim to be a router.

Both sites are now declared. The addresses sit outside the pools CloudStack
allocates from (.1-200 and ::2-::ffff), so its IPAM can never hand the same
address to another VM — a conflict nothing in this repository could otherwise
prevent. They follow the node id, like the mesh ports and link-local addresses.

The netplan is carried in the user-data rather than applied by a role: a
rebuilt node must be reachable at the address DNS already publishes, before
Ansible exists. First boot still comes up on DHCP, because that is how
cloud-init's CloudStack datasource finds the metadata server — the static
configuration is what we do once the user-data has arrived.

Verified on all four nodes: forwarding on, accept_ra 0 everywhere, redirects
off, static default route intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
The account cloud-init creates is `ansible`, but nothing told Ansible so: the
playbook fell back to the operator's local login and every run needed a -u.
The failure that produces reads as "Permission denied (publickey)", which sends
you looking at keys and known_hosts rather than at the user name.

It now lives in topology/as.yml, the one place tofu and Ansible both read. The
tofu variable disappears rather than being kept as a second default, since two
defaults are exactly how the values drift apart. The inventory refuses to build
without it: a node nobody can log into is not a useful inventory entry.

Verified: `tofu plan` reports no change — the rendered user-data is identical.
`network` and `nftables` can cut a node off. Being able to run a single role on
a single node is what makes it reasonable to try them in production at all, and
it is how the mesh was actually brought up here: rbx2 first, then rbx1 to open
the first tunnel, then the two home nodes.

Also fixes a `when:` on a bare string, which ansible-core 2.19 refuses outright.
A peer has one listen_port whichever family it reaches us over, but the two home
nodes share a single public IPv4, where a UDP port maps to exactly one of them.
The per-node split follows from that, and IPv6 reuses it so the numbers match —
otherwise a peering works over v4 and fails over v6, and the symptom points at
Bird rather than at the firewall.

Sized for the peerings actually expected rather than padded: the range is not what
keeps us safe, WireGuard drops anything without a valid handshake and never
answers. The IPv4 forwards exist on the router but stay disabled until a v4
peering actually turns up.

Getting the port wrong fails silently: the peer's packets reach the other node,
or nothing at all.
The input chain derived its WireGuard ports from the internal mesh alone, so the
first external peering would have been dropped by the node itself — after the home
router had dutifully forwarded it. The symptom is a tunnel that never handshakes
with nothing logged anywhere, which sends you looking at keys and endpoints.

Read from dn42_peers, like the mesh ports are read from the inventory: a peer
declared in the repo opens its own port, and a peer removed closes it.
LLMNR listened on 5355, TCP and UDP, v4 and v6, on every interface — including the
public one at Roubaix, where it answered link-local name queries to the whole
internet. It exists to find printers on an office LAN; a core router resolves
through the resolvers cloud-init hands it and through nothing else. The firewall
already dropped the port, but a service that should not run is better stopped than
masked.

MulticastDNS is off by the compiled default and is pinned anyway, so a future
change of that default cannot quietly open 5353 the way LLMNR held 5355.

A drop-in rather than an edit of resolved.conf, so an upgrade cannot take the
setting back. DNSStubListener is deliberately left alone: resolv.conf points at
127.0.0.53 and turning it off would leave the node unable to resolve at all.
First run of the role against real nodes, and `bird -p -c` earned its keep
immediately: a `prefix` constant is not a legal set element, so `net ~ [ OWNNETv4+ ]`
never parsed. The sets are now the constants. The ROA include also had to move ahead
of the validation, which parses every include and so needs them on disk already.

Two failures only a running network could show:

`tcp_l3mdev_accept = 0` refused every iBGP session arriving through the VRF, because
Bird's own listening socket sits on the wildcard address in the global table. It reads
as "Connection refused" from a neighbour that pings perfectly. The knob cannot be what
isolates management from the overlay — it takes the routing daemon down with it — so
the boundary moves into the ruleset, where SSH is refused on the DN42 interfaces
explicitly. That rule goes ABOVE the SSH accepts: nftables takes the first match, and
those accept from ::/0.

Verified on all four nodes: 12 of 12 iBGP sessions Established, Babel metrics tracking
RTT (96 for the 1 ms link, 99-100 for the 14 ms ones), IPv4 loopbacks carried over IPv6
next hops, SSH unreachable from inside the VRF and reachable from management.
systemd-networkd deletes the local IPv6 route of a /128 address on a VRF-enslaved
interface every time the interface is reconfigured — systemd#16049, still open and
labelled a kernel bug. The reporter's setup is ours exactly: a VRF, several WireGuard
interfaces, and a dummy carrying a /128 v6 and a /32 v4. The v4 route survives, which
is why the symptom is v6-only, and a clean boot is fine, which is why it only appears
once this role has run a second time.

Without the route the node stops answering for its own loopback: the packet arrives on
a mesh interface and is forwarded straight back out lo-dn42. Established sessions keep
running, so nothing looks wrong until the next reconnection, and then iBGP says
"Connection refused" from a neighbour that pings.

Metric 1 rather than the default 1024: Bird re-exports the same prefix at 32 through
its direct protocol, and at 1024 the local route exists without ever winning — the
packet is forwarded exactly as if it were missing. 0 would be the kernel's own value
but networkd reads it as "unset".

Verified on fr-rbx2: survives repeated reconfigure and reload, and a full reboot, where
it simply sits alongside the kernel's own copy.
`tcp_l3mdev_accept` has to be 1 for Bird's listening socket to accept iBGP arriving
through the VRF. That one knob makes EVERY wildcard-bound socket on the machine
reachable from DN42, so a blanket `iifname @dn42_ifaces accept` quietly hands each of
them to the network — sshd today, whatever exporter gets installed on [::]:9100 next.

The input chain now lists what the overlay is entitled to: BGP, BFD, Babel, and DNS on
this node's anycast addresses, read from the inventory so a node that carries no service
opens no port for one. Everything else falls through to the drop policy.

Measured on fr-rbx2 with a real listener bound to [::]:9100: unreachable from inside the
VRF, as is SSH, while port 179 answers. Before this it would have replied.

Confirmed the same session that the two failures were independent: with the loopback
route fixed, tcp_l3mdev_accept=0 still refuses port 179 through the VRF. The firewall
change was not compensating for the routing bug.
The firewall policy only existed as rules, and the reasoning behind the awkward parts
lived in commit messages. This session produced two decisions that are impossible to
re-derive from reading nftables.conf: why SSH is dropped for the overlay ABOVE the SSH
accepts rather than below, and why DN42 ingress is enumerated service by service instead
of waved through. Both follow from tcp_l3mdev_accept having to be 1 for Bird.

The matrix names source TYPES rather than addresses — addresses live in topology/ and
move, and a type is what a rule actually expresses. It covers the home router too: the
Bordeaux nodes have no public address of their own, so that box is part of the policy
whether or not it is part of the repo.

CLAUDE.md makes the pairing a rule: a rule without its matrix line, or the reverse, is
an inconsistency to fix rather than a documentation detail.

Checked line by line against the ruleset live on fr-rbx2.
The design assumed BFD would give sub-second detection on the mesh. It cannot: `bfd`
inside a Babel interface block is a syntax error, checked against the 3.3.1 parser
rather than assumed, and no spelling of it is accepted. The BFD protocol was declared
and never asked for a single session, so the promise had been silently unmet since the
role was written.

Detection now rests on Babel's hellos, at 1 s instead of the 4 s default — six tunnels
at one hello per second is free next to the hole traffic falls into meanwhile. Measured
end to end by removing a peer from WireGuard on the far side, which is a silent death
with the interface still up: the route moves to the alternate path in about 6 seconds.
The neighbour entry lingers to 17 s, but the reroute does not wait for it.

BFD stays declared, for the external peerings — single hop, dedicated tunnel, no IGP
underneath — where Bird's BGP does accept `bfd on`. It is deliberately not wired to
iBGP: those sessions ride loopbacks over Babel and must survive a reroute, and Bird
already drops a multihop session when its neighbour stops being reachable.

Also closes the hole that let this reach production: the fragments were written without
validation, and bird.conf was only checked when bird.conf itself changed, so a broken
babel.conf was found by the reload handler instead of by the play.
The role generated ROA from a local clone of the DN42 registry. That no longer
works: git.dn42.dev answers 401 to an anonymous clone, so every node would need
a registry credential just to filter. Replaced by StayRTR fed from the published
DN42 ROA feed, one cache per node, and Bird talking to it over RTR.

What is given up is real — this is one operator's generator, and a wrong file
would be a wrong filter everywhere at once. What bounds it is that DN42 policy
only rejects ROA_INVALID, plus StayRTR's staleness check on the feed's `valid`
field and a 24 h expiry on Bird's side, so losing the cache does not empty the
tables at the moment nobody is watching.

Two things worth keeping in the record:

- StayRTR binds to :8282 and :9847 by default, i.e. the wildcard, i.e. reachable
  from the overlay through tcp_l3mdev_accept. Both are pinned to 127.0.0.1, and
  the flow matrix now says why a service that stays local takes no firewall rule.
- The RTR version is pinned to 1. Bird 3.3.1 opens with version 2, StayRTR 0.6.2
  tops out at 1, and the downgrade is unreliable: deployed unpinned on four
  identical nodes, two settled on version 0 and two stayed in Transport-Error
  with empty ROA tables — an open filter that looks like a working one.

Deployed on all four: session Established at version 1, 2651 v4 and 2712 v6
ROA, Babel and iBGP untouched, second run at changed=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
The cache was bound to 127.0.0.1, so it served exactly one client. Binding it
to the dn42 loopback instead does not work — a socket in the default L3 domain
cannot bind an address carried by a VRF-enslaved interface, the kernel answers
EADDRNOTAVAIL, and the one way around it (`ip vrf exec`) would drag the HTTPS
fetch of the ROA feed into the VRF and make filtering depend on DN42.

So StayRTR listens on the wildcard and nftables carries the policy, which is
what this ruleset already does for every other overlay-facing service. Only our
own nodes' loopbacks are let in; the management side stays closed on the drop
policy. Bird keeps talking to 127.0.0.1, so a node still validates against its
own cache without the overlay being up.

Verified from fr-rbx1 over DN42: the peer's 8282 answers when the source is the
loopback. It does NOT answer without an explicit source, and that is a separate
problem worth stating — in the VRF the kernel picks the outgoing interface's
link-local as the source address for locally originated traffic, so `ping` and
friends never reach DN42 unbound. Bird is unaffected because its protocols set
`local` explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Every node now opens an RTR session to each of the other three caches over DN42,
on top of its own on loopback. All four feed the same two tables, so a ROA that
several of them carry is stored several times — `show route count` reports about
four times the feed, which is expected and costs a few MB.

These sessions go through the VRF and state their source address. Without
`local address`, the kernel picks the outgoing tunnel's link-local for traffic
originated on the node and the far end drops it, correctly: it matches no node.

What this covers is a StayRTR dead on one machine. It does NOT cover a bad feed —
all four fetch the same file from the same place — so the local session stays the
primary one and none of this replaces watching it.

Verified on fr-bod1 by stopping the local cache and disabling rpki_local: the
tables fell from 10604 to 7953 v4, exactly three sources' worth, with our own
prefix still present, and came back to 10604 on restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
The role had never run. Five things it got wrong, each found by deploying it:

- Knot's config parser accepts a flow sequence only on ONE line. Split across
  two, `listen:` was rejected with "(parser failed)" and no hint as to why.
- The zone file was headed by `#` comments from the `comment` filter every other
  template uses. In a zone file the comment character is `;`, so Knot read three
  owner names and refused to load.
- `ip addr` in ExecStartPost inherits `User=knot` from Debian's unit and got
  RTNETLINK "Operation not permitted", which surfaced as a unit that would not
  start, several lines away from the cause. Fixed with the `+` prefix.
- knotd ran in the default L3 domain while its anycast addresses live on
  lo-dn42, inside the VRF. It bound anyway — Knot enables non-local binding on
  its own — and then never saw a query: measured, the request arrives on the
  tunnel and no reply is ever emitted. `udp_l3mdev_accept` does not cover this,
  it only helps wildcard-bound sockets. Now started through `ip vrf exec`.
- Knot resynchronises the zone file after every change, so a signature refresh
  would bump the serial in a file Ansible owns and the next run would "restore"
  it and reload for nothing. `zonefile-sync: -1` leaves the journal in charge.

Also: identity, version and NSID all default to the FQDN hostname, which on
these nodes is the MANAGEMENT name. Answering the overlay with it advertises
where the out-of-band plane lives. Identity and version are now off; NSID says
the short node name, because an anycast address is worth attributing.

Verified from the two resolver nodes over DN42: NOERROR, correct SOA, DNSSEC
signed, IPv4 answered over the extended next hop. Anycast steers to the local
site (fr-rbx2 to fr-rbx1, fr-bod2 to fr-bod1), and stopping Knot on fr-bod1
moved fr-bod2 to fr-rbx1 within seconds with no health checker anywhere.

kdig comes along in the base role: a DNS service on an anycast address inside a
VRF cannot be diagnosed from a node with no resolver client, and kdig takes the
source address the VRF needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Each node was signing thystips.dn42 with keys it generated itself — DS 15288
on fr-rbx1, DS 21523 on fr-bod1. On a shared anycast address that is broken by
construction: a validating resolver can take the DNSKEY RRset from one node and
an RRSIG from the other, and fail. Nothing was serving it yet, the zone is not
delegated, so this cost nothing to fix now.

Both nodes now hold the same KSK and ZSK, from Vault, and each signs its own
copy. One DNSKEY RRset, one DS, no primary and no signer to lose.

RFC 8901 multi-signer was prototyped on these nodes first and does work: each
node keeping its private key and publishing the others' public ones through
`keymgr import-pub`, which produced identical four-key RRsets. It was not kept.
The RFC exists for several INDEPENDENT PROVIDERS; we are one operator whose
machines already share a Vault, so the property it buys is one we already have.
What it would have cost is a two-phase ordered rollover — publish everywhere,
then start signing — which is an outage waiting for the day nobody remembers
the order. A slave added later needs none of it either: a secondary transfers
and serves, it does not sign.

The keys are declared rather than generated: group_vars carries their ids, which
are hashes of the PUBLIC keys and therefore not secret, and the role imports what
is missing and deletes what is not declared. `manual: on` keeps Knot from rolling
on its own schedule and signing with something the other node does not publish.

Two things the deployment taught, both worth the comments they carry:

- `serial-modulo: R/256` passes `knotc conf-check` and then kills knotd with
  SIGFPE — the bound the manual allows overflows a byte, M becomes 0, and the
  modulo divides by zero. 255 and 16 are fine on 3.4.6. The option itself stays:
  Knot documents it for exactly our case, distinct serial sequences so a future
  secondary cannot see the serial go backwards when it switches master.
- keymgr drops to the knot user before opening a PEM, so key material handed to
  it from a root-only path fails with an error blaming the key format.

Verified: both nodes hold exactly tags 39120/43161, identical DNSKEY RRsets, and
answer queries signed by the same ZSK from either site. Second run at changed=0.

Still to do by hand: publish the DS in the DN42 registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
The registry is the network's public record of what we hold and how to reach us,
and nothing made it agree with what we run. It did not: the delegation of
thystips.dn42 still named `ns.thystips.dn42`, a nameserver of the previous
infrastructure that the current zone does not define at all.

Six objects are now generated from topology/ and committed, so a change is a
reviewable diff before it becomes a pull request upstream: aut-num, route,
route6, inetnum, inet6num and the dns delegation with the zone's DS. `person`
and `mntner` are deliberately left alone — identity and authentication material,
which nothing here determines.

Two checks in the CI, mirroring what dns/ already does: registry/data must still
be what topology/ implies, and it must match what DN42 publishes. The second
skips itself when no registry checkout sits alongside.

The registry is NOT a submodule and the README says why: a dozen commits a day
makes a pinned commit stale by design, and stale registry data is a filter
rejecting a legitimate route. 85 MB against this repository's 4, and
git.dn42.dev refuses an anonymous clone, so a submodule would break git clone
for CI. It lives next door, like ATNET/dns.

Writing the delegation down exposed a contradiction the repository had been
carrying quietly: the zone declares its NS records on the authoritative nodes'
loopbacks, while Knot listened only on the anycast addresses and the firewall
only opened 53 towards those. Measured before the fix — a query to ns1's address
timed out. Publishing that delegation would have made the zone lame the moment a
resolver tried to validate it.

Knot now listens on the loopback as well as the anycast address, and nftables
opens 53 towards it on nodes carrying dns_auth (flow matrix line 15). The
delegation therefore hands a resolver two independent targets rather than one
anycast address written twice. Verified: all four addresses answer NOERROR.

max-length is set to the prefix length on both route objects. The export filter
announces the aggregate and never a more specific of our own space, so a longer
prefix coming from us would be a leak — and a leak deserves to be ROA_INVALID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
check-published.py read the registry checkout's working tree. `sync.sh` copies
our objects into exactly that tree to prepare the pull request, so the check
compared them to themselves and went green the moment a change was staged —
which is precisely when it must not. Caught by running the two in sequence.

It now reads through `git show`, from upstream/master when that remote exists,
falling back to origin/master then HEAD, and prints which ref and which date it
compared against so a stale answer cannot pass for a current one.

Also adds an assertion in the dns_auth role: the DS declared in topology/ must
match the one the zone's signing key actually gives. A DS that no longer matches
is worse than no DS — every validating resolver gets SERVFAIL for the whole zone
and nothing on the node looks wrong. `dn42_registry` is exposed by the inventory
plugin for it.

Verified against the real registry (upstream/master at 2026-07-28): the drift is
reported with our objects already synced into the working tree, and the DS
assertion passes on both authoritative nodes.

Note: lint stays red until the registry pull request is merged. That is the check
working, not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
In this VRF the kernel only ever considers the OUTGOING interface's addresses
when it picks a source. Measured on fr-rbx1: a global address added to another
interface of the VRF is never chosen, the same address on the outgoing one is
chosen at once — with use_oif_addrs_only at 0 throughout. The mesh tunnels carry
nothing but a link-local, so anything originated on a node without stating its
own source left as fe80::<id> towards a global destination and died there.

Bird's own protocols were unaffected because each states `local`, which is why
iBGP and Babel worked perfectly while `ping`, `dig` and anything else did not.
That is also why it stayed invisible: the routing was healthy, the node just
could not talk to its own network. It would have taken out the recursive
resolver's outbound queries the day dns_rec was deployed.

krt_prefsrc fixes it where the routes are installed, once, rather than asking
every future daemon to remember. Bird 3 accepts it on IPv6, which the manual
still describes as IPv4-only.

Two things this cost, both now encoded:

- The kernel refuses a preferred source on an UNREACHABLE route and answers
  "Netlink: Invalid argument". Setting it unconditionally silently dropped the
  originated aggregate out of table 42 while Bird retried every 20 s forever.
  Guarded with `if dest = RTD_UNICAST`.
- krt_prefsrc only reaches routes Bird actually reinstalls. On a node already
  running, `birdc configure` and `reload filters` both leave existing routes
  untouched — verified, neither worked. Only a full resync of the kernel
  protocol does, so the role looks for routes without a source and resyncs just
  then: it briefly withdraws Bird's routes, which is not something to do on
  every run, but is worth it when the alternative is a node that cannot reach
  its own network.

Verified on all four: aggregate back in the table, no netlink error, Babel 3/3,
iBGP 3/3, 16 RTR sessions Established, and a ping to the anycast address with no
explicit source now answers from every node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Kioubit (AS4242423914) on fr-rbx1, over WireGuard with a pre-shared key, MP-BGP
and extended next hop. The session is Established and imports 1158 IPv4 and 1187
IPv6 routes; we export exactly two, the aggregates, which is what the export
filter is for. The import filter rejected 172.23.125.160/27 from AS4242423582 on
its ROA check within seconds of coming up — the rpki work paying for itself.

Two defects this exposed, neither of which the session itself revealed:

- `@dn42_ifaces` was built from the mesh tunnels and never included the external
  peer ones. That does not break BGP — we open the session ourselves, so the
  answers come back as `established` — it breaks everything else in silence: the
  output guard drops what the node originates towards DN42 through the tunnel,
  and the forward guards drop the transit that is the entire point. Measured: no
  reply from any DN42 address before the fix, 0% loss after. Flow matrix says so
  now, in the same commit.
- A link-local BGP neighbour needs its interface named, and `<addr>%<iface>`
  parses only if the interface is a Bird SYMBOL: bare, the lexer stops at the
  first hyphen, and every peer interface has one since the prefix is `wgp-`;
  double-quoted it becomes TEXT, which is not accepted there. Replaced by the
  dedicated `interface "..."` option, which says plainly what it means.

The link communities are measured rather than guessed: 4.1 ms to their endpoint
from this node puts the latency at 2 on the DN42 scale. Bandwidth is 24 where
the previous infrastructure claimed 23, because that figure came from a 15 Mbit
rate limit that no longer exists.

This unblocks dns_rec: the DN42 delegation servers now answer, verified with a
query to 172.20.129.1 returning the dn42. SOA. The three other nodes see the
table over iBGP and reach DN42 through fr-rbx1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Prompted by https://dn42.eu/howto/networksettings, which we were not following on
four counts — each measured on a live node before changing anything:

- rp_filter was 2 on every real interface. `all` and `default` were 0, but the
  effective value is the MAX of the two, and systemd's 50-default.conf sets `*`
  to 2. DN42 routing is asymmetric by design, so this was quietly dropping
  legitimate return paths. Now set per interface in the .network files, because
  these interfaces are created after boot and a sysctl glob never sees them.
- accept_local was 0. Without it a router drops transit whose SOURCE is an
  address it holds locally — which is what an anycast address is on every node
  answering for it, and we have two.
- MSS was clamped on forward only. DN42 asks for output too, and the reason is
  anycast: a "packet too big" comes back to whichever instance the network
  steers it to, not necessarily the one that sent the large packet, so the
  sender never learns. Clamping at the source removes the need to learn.
- `ct state invalid drop` was in the forward chain. Same asymmetry: conntrack
  sees one direction of plenty of legitimate transit and calls it invalid.
  Removed there, kept on input where conntrack does see both directions.

The sysctl drop-in moves from 50 to 60, so that sorting after the distribution's
own defaults is a rule rather than an accident of the alphabet.

The VRF also needs the `local` table lookup off priority 0, as the kernel
documentation says — without it a process inside the VRF addressing anything the
machine holds locally is short-circuited to `lo`, which is what made the
resolver's crossing dead in one direction. Getting there took two outages on
fr-bod2, both recovered through the QEMU guest agent, and the lesson is in the
templates:

- `ManageForeignRoutingPolicyRules` defaults to YES, so networkd deletes any
  rule it did not create. Our unit added the replacement and removed rule 0;
  the next reconfigure swept the replacement away and left no local lookup at
  all, in either family. The rule is now declared in the VRF's .network file so
  networkd owns and recreates it, and a drop-in stops networkd touching what it
  did not create — including the kernel's IPv6 rule 0, which it had also
  deleted, taking out a management plane that is IPv6-only.
- The unit no longer adds anything. It removes rule 0 only once it can SEE the
  replacement, and gives up otherwise: a node that keeps rule 0 is merely
  suboptimal, a node with neither is unreachable.
- IPv4 only. The crossing is IPv4 and IPv6 gains nothing here.

Verified across a reboot on fr-bod2: rule at 32765 reinstalled by networkd, IPv6
rule 0 intact, rp_filter 0 and accept_local 1 on the tunnels, Babel 3/3, iBGP
3/3, 4 RTR sessions, 1206 routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
First deployment of the role, on fr-rbx2. Four things it needed:

- `dn42.` IS signed, from a root of its own that the global chain of trust knows
  nothing about. Told nothing, unbound tries to build a path from the root to a
  TLD the root has never heard of and SERVFAILs the whole of DN42. The anchor is
  pinned from the registry's own `data/dns/dn42` ds-rdata — and pinning means a
  DN42 key rollover breaks us, so registry/scripts/check-anchors.py compares the
  two on every lint and turns that into a red check instead of an outage.
- `ip-freebind: yes`. The anycast addresses are added by the unit AFTER the
  daemon starts, so that they never outlive the service; unbound refuses to bind
  an address it cannot find, where Knot enables non-local binding on its own.
- The crossing view is no longer conditional on there being an upstream. The
  crossing exists whenever this role runs, and what the far end does with a
  question is its own business.
- With no upstream the global instance now RECURSES rather than emitting a
  forward-zone with no address in it. That is the better default anyway:
  forwarding hands every question our users ask to a third party.

The defaults' justification for two instances was also wrong — it argued from
`udp_l3mdev_accept` being 0, which it has not been since the bird work. The real
reason is the outgoing side: `ip vrf exec` binds every socket of a process,
upstream queries included, so one resolver cannot reach both the DN42 delegation
servers and the internet.

Verified from fr-rbx1 over DN42: burble.dn42 resolves with the AD flag set, so
DNSSEC validation against the DN42 anchor works, and example.com comes back
NOERROR across the crossing from a full recursion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Three defects on the path a node takes the FIRST time it gets these fixes —
fr-bod2 hid all three, because it had already been repaired by hand.

The foreign-rule policy was written as a file early but applied by an
end-of-play handler, so the `networkctl reload` that installs our .network
files ran BEFORE it — under the default policy, the one that deletes rules it
did not create. That is precisely the reload that cost fr-bod2 its IPv6 rule 0
and its only management plane. The drop-in now goes first and is flushed on the
spot, before any interface is touched.

The unit that removes rule 0 was started before networkd had installed the
replacement. On a node that has neither, it would burn its 30-second grace
period and fail the play. It now runs after the networking handlers.

And `networkctl reconfigure` drops every address a managed link's .network does
not declare — which is every anycast address, since the DNS roles add them from
an ExecStartPost. The service kept answering on an address nobody could route
to: unbound binds with freebind, so nothing looked wrong locally. networkd has
ManageForeignRoutes and ManageForeignRoutingPolicyRules but no equivalent for
addresses, so the role puts them back itself — only for units still running, so
a dead daemon stays withdrawn and the failover still works.

Deployed on fr-rbx2, fr-rbx1 and fr-bod1, no outage; all four nodes now carry
the same rules and sysctls, 4/4 RTR, 3/3 iBGP, ~1195 v6 / ~1169 v4 routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
`loop` over an empty list runs zero times, so the accumulator that derives the
peer interface names stayed undefined on every node without a peer — three of
four — and the next task failed on it. It went unnoticed because the peering
was deployed with --limit on the one node that has one.

Skipping the role there would have been the wrong fix: it is also what deletes
the tunnel of a peer removed from the inventory, and a node losing its last
peer is exactly the case that leaves an empty list behind.

The full playbook now converges on all four nodes with nothing changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Nedifinita (AS4242420454, Dunkerque) on fr-rbx2 — deliberately not fr-rbx1,
which already terminates Kioubit. Two peerings on one node means a single
reboot takes all external connectivity with it; split across both Roubaix
nodes, iBGP carries whichever survives.

The MTU is why this needed a schema change. Their form suggests 1320 and they
are right: the path to their endpoint carries 1420, not 1500 — their v6 transit
is itself tunnelled, `ping -6 -M do -s 1452` fails and the kernel caches
pmtu 1420. WireGuard costs 80 bytes over IPv6, so 1340 is the ceiling. Our mesh
value of 1420 would have put 1500-byte UDP on a 1420 path: the handshake and BGP
keepalives sail through while everything large is silently dropped, which is the
kind of fault that surfaces a week later. The role had one global MTU, so peers
now take an optional one, and the schema says to measure it rather than copy it.

Of their two endpoints, route64 measured 9.08 ms with mdev 0.23 against 10.97 ms
for the other — which jittered to 30 ms from fr-rbx1. Latency community 3, one
worse than Kioubit despite both being northern France, because of that tunnel.

nftables needed nothing: the ruleset derives peer ports from dn42_peers, and the
flow matrix already covers both flows (row 7 for the WireGuard port, row 11 for
BFD). BFD itself is NOT enabled yet — they advertise it, but our bfd protocol
only lists mesh interfaces today. Bringing the peering up first means that if
BFD misbehaves, we know which change did it.

Session Established, ~1240 prefixes imported per family, both aggregates
exported, and 603 v4 / 567 v6 networks now prefer this path over Kioubit.
Verified end to end: traceroute leaves through their router and crosses three
hops of their network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
The BFD protocol listed the MESH interfaces and had done since it was written,
while its own comment explained at length that BFD is for external peerings and
deliberately not for the mesh. `birdc show bfd sessions` was empty the whole
time, and that is not a coincidence: an interface block only sets parameters,
sessions get created on request, and nothing requested any — Babel has no BFD
hook and iBGP must not use one. So the mesh blocks are gone and peerings opt in
per session, in host_vars.

Timers are slower than the mesh values they replace. An external peering crosses
the internet and the far side is somebody else's router: 100 ms x 5 turns one
scheduling hiccup over there into a withdrawn table over here. 300 ms x 4 is
1.2 s against a 240 s hold timer — two orders of magnitude, and the third is not
worth a flap.

Enabling it on a live session turned out to be safe, and now measured rather
than inferred: Nedifinita's session kept its uptime across the reconfigure while
its BFD session sat in Down. Bird's docs only say "advisory" and speak of a
detected failure, which presupposes an established session; that ambiguity was
the reason to watch instead of assume.

It stays on although the session is DOWN — they advertise BFD but do not answer
it. Verified from both sides of the question: nothing inbound on 3784 before we
enabled it, well-formed BFDv1 every second with no reply after. One packet per
second buys a session that comes up by itself the day they turn it on.

Firewall, same commit as the matrix it disagreed with: BFD was accepted from any
DN42 interface while row 11 said `peer`. Nothing inside the AS asks for a BFD
session, so a mesh tunnel could reach a service no protocol here would answer.
Now @dn42_peer_ifaces, and no rule at all on a node with no peer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Mofu Networks (AS4242422670) on fr-rbx1. Their node is PASSIVE — it dials us and
has no listening port — which decides two things on its own. Our side declares
no endpoint for them, and this peering cannot live on a bod node: inbound UDP is
filtered at the home site, so a peer that initiates towards us would never
arrive there.

We published our IPv4 endpoint as a literal, against the habit of using a name,
because a dual-stack name would have let them pick IPv6 and IPv6 is the wrong
path here: 12.7 ms with a 1400-byte path MTU, versus 1.82 ms and a full 1500
over IPv4, both ends being OVH. Their v6 sits in the same MoeDove-sponsored
tunnelled transit that made Nedifinita's second endpoint jitter to 30 ms. Link
latency community 1 — the best band on the network — where the v6 path would
have earned 3.

So this is the peering where the role default is right: 1420 over an IPv4 path
that carries 1500, WireGuard costing 60 bytes rather than 80. The `mtu: null` is
written out rather than omitted, because "measured, and the default happens to
fit" is a different statement from "nobody looked".

No BFD: 20 s of tcpdump on udp/3784 over the established tunnel caught nothing,
and unlike Nedifinita they do not advertise it, so there is no reason to send a
probe every second at a peer who never asked.

They had already submitted when we deployed, so the tunnel came up immediately —
their endpoint shows their IPv4, confirming the family pinning worked. Session
Established, ~1300 prefixes imported per family, both aggregates exported, full
1420-byte packets crossing end to end. Kioubit kept its uptime throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
(64511, 2x) means bw >= 10^(x-2) Mbit. Reading it as a range is how a link ends
up one band too high: 24 is "at least 100 Mbit", not "10 to 100".

Written down because the bod nodes were about to be misdescribed. Their site has
a 6 Gbit uplink, which says nothing useful — the hypervisor caps VM networking at
1 Gbit, and a 1 Gbit wire does not carry 1 Gbit of payload once WireGuard has
taken its cut. So they get 24, the same as the Roubaix links, and the 6 Gbit
figure never enters the config.

The filters keep the worst value along a path, so an inflated community does not
mislead anyone but us: it attracts transit we then serve badly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
FLIPFLAP Network (AS4242420263) on fr-bod1, against their Paris node. Until now
both aggregates were announced from Roubaix only: an OVH outage removed the whole
AS from DN42 while the Bordeaux nodes sat there alive, reaching the network over
the mesh and unable to let anyone reach us. Site diversity, not extra prefixes,
is the point — and both aggregates are now exported from Bordeaux too.

Paris rather than the nearest name on the map: their Rennes node measured 25.4 ms
from here against 10.1 ms for Paris, worse even than their Amsterdam one.
Latency community 3. Path MTU to Paris carries a full 1500, so the role default
of 1420 is right and no override is written.

WE dial THEM, and that is why this needed no rule on the home router: the
outbound flow creates the conntrack entry and their packets return as
established. Their auto-peering took our endpoint too, so they may dial us as
well — the only case that would want UDP 20200 opened, and robustness rather
than a prerequisite.

Which corrects the flow matrix in the same commit. It claimed the whole peer port
range was accepted inbound at the home site; measured, five UDP packets from
fr-rbx1 to fr-bod1's public IPv6 on 20200 produced nothing on the node, so the
block is upstream of it. The policy written down now is the one actually wanted:
one peer's port, opened when that peer needs it, never a range in advance.

Also honest now about BFD: the matrix said it had no session because no peering
existed. Four peerings exist and it still has none — our side is verified end to
end, but Nedifinita advertises BFD without serving it and the other three do not
advertise it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
Yuyuko Network (AS4242421117) on fr-rbx2, against their Paris node. 4.81 ms with
mdev 0.13 and a path carrying a full 1500 — latency community 2, the best band we
have on any peering, matched only by Mofu's IPv4.

Their fr02 was rejected on measurement, and that is the reusable part: 13.96 ms
from Roubaix, a 288 ms average with 1-second peaks from Bordeaux, and a path MTU
under 1420. It sits in 2a13:a5c3:42c3::/48 — the same MoeDove-sponsored tunnelled
transit as Nedifinita's second endpoint and Mofu's IPv6. Three different peers,
three times that prefix is the slow option, and none of them say so; Yuyuko's page
only mentions "tunnels use Cloudflare WARP". Worth measuring both families and
looking at the prefix before believing any endpoint.

First peering where BOTH ends can dial: their portal let us choose the port on
their side too, so it matches ours, and the tunnel recovers whichever end
restarted. Mofu only ever dials us, Nedifinita only ever answers us.

Session Established, ~1110 prefixes imported per family, both aggregates exported,
569 v4 and 502 v6 networks already preferring this path, full 1420-byte packets
crossing end to end. Nedifinita kept its uptime throughout.

Five peerings now, spread two on each Roubaix node and one at Bordeaux. No BFD
anywhere still: probed here too, nothing inbound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
It takes no external peering, so its ports and its share of the peer allocation
range stay free for that. Written down because nothing else in the tree explains
why one of four nodes has no peer, and the next person adding one would have
picked the empty node.

Notes the consequence while it is fresh: Bordeaux's external presence — the whole
point of the 2026-07-30 peering, our aggregates reaching DN42 from somewhere that
is not OVH — now rests on one session on one node. A second peering on fr-bod1 is
worth more than a first one anywhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJsQ1L5FMENRHaCXTPnb68
RoutedBits (AS4242420207) on fr-bod1, chosen by measurement rather than by the map.
Site diversity stops resting on a single session on a single node: fr-bod1 now has two,
and fr-bod2 stays reserved for the site router interconnection.

Two things worth keeping from how it was picked.

The peerfinder was run over BOTH families, and that changed the answer. AFNOOI, the
fallback candidate left over from yesterday, measures 31.6 ms over IPv6 against 13.4 ms
over IPv4 — their v6 is tunnelled, the same pattern as Mofu. A single-family probe would
have ranked it near the bottom or near the top depending on which family was probed. Most
of the Paris nodes that answered at all answer only over IPv6, which suits fr-bod1 since
its endpoint is v6.

And the peerfinder DIRECTORY is not the registry. It lists "RoutedBits" under
AS4242422010, which the registry gives as ONVOX-AS — a different AS of the same operator.
The peering ASN is AS4242420207, as-name ROUTEDBITS, confirmed against the registry before
being written here. Taking the directory at face value would have produced a session stuck
in Connect with nothing in the logs to explain it.

Measured after convergence: 1244 v4 / 1224 v6 routes imported, and RoutedBits is already
the preferred path for 761 v4 and 743 v6 prefixes against 113 / 97 for flipflap — a richer
link than the 9.256 ms alone suggested. Path MTU is a full 1500, so the 1420 default holds
on both sides. Six eBGP sessions now, 3/3 iBGP and 4/4 RTR on every node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
iEdon-Net (AS4242422189) on fr-rbx1, third external session there. Established fifteen
seconds after the request — their portal configures the far side immediately, unlike the
five-minute cron at flipflap.

Placed by measurement across all three eligible nodes: 4.761 ms here, 4.810 from fr-rbx2,
10.141 from fr-bod1, so Roubaix rather than Bordeaux. Between the two Roubaix nodes the
difference is noise; fr-rbx1 wins on the thinner argument that fr-rbx2 already carries the
recursive resolver. mdev 0.075 is the lowest of any link we have, and under 7.39 ms is
band 2 — our best, alongside Yuyuko.

THEIR ENDPOINT IS ANYCAST, which no previous peer had. The same name is announced from
several of their POPs, so the tunnel lands wherever their routing puts us rather than at a
fixed machine: today 2001:bc8:711:1229::/... in Paris. Nothing on our side would change if
that moved, which is precisely why the latency community needs re-measuring if the session
ever starts behaving like a longer path. Recorded in the peer comment.

Their portal splits the link addressing into three switches — v4 address, IPv6 ULA,
link-local — and only the link-local is on, so the link carries no address and the v4
routes cross by extended next hop, as everywhere else. They advertise 100 Mbit on the node,
which makes 24 the honest value for the link rather than only for our end of it.

The ASN was checked against the registry before being written (IEDON-NET-AS, IEDON-MNT).
The peerfinder directory agreed this time — which is the reason to check rather than to
trust, since it agrees most of the time.

Seven eBGP sessions now: 3 on fr-rbx1, 2 on fr-rbx2, 2 on fr-bod1. 3/3 iBGP and 4/4 RTR on
every node, 1333 v4 / 1264 v6 networks identical across all four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
c77e9d7 repeated their portal's "Anycast Node" wording as fact. Checked afterwards, and it
does not hold for the WireGuard endpoint:

  - the name resolves to the SAME address from OVH Roubaix and from Free Bordeaux, so it
    is not GeoDNS either;
  - traceroutes from both sites converge on the same last hops (2001:bc8:400::35, then
    2001:bc8:410:1021::1 and ::1a), so both reach one machine through one access router;
  - the 4.7 ms / 10.1 ms spread between our two sites is what one fixed machine in Paris
    looks like — an anycast endpoint would have let Bordeaux land somewhere nearer.

It could not have been otherwise. The address belongs to Scaleway (AS12876, 2001:bc8::/33)
and is aggregated inside their allocation; a cloud tenant has no way to get a slice of it
announced from another site. BGP anycast needs one's OWN addresses announced from several
places, which is exactly what a public cloud does not hand out. Their label most likely
refers to DN42-side anycast services on the node — the way our own resolver address lives
on two nodes at once.

Nothing changes operationally: same endpoint, same session. What changes is that the
latency community is a property of a fixed machine and stays true until they move it,
instead of something their routing could silently invalidate. The earlier comment told a
future reader to distrust a measurement that is in fact stable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
fr-bod2 was kept free of external peerings for this, and until today that reserve
bought nothing: the site router had no session and the node no external role. Both
bod nodes now reflect the DN42 table to `ghost` over the services LAN, and the house
reaches the network natively.

The site router is declared under `sites.bdx.site_router`, NOT in nodes.yml. A node of
that file gets a mesh tunnel to every other node, a tofu resource and an Ansible host;
this one is preexisting hardware the repository does not configure. It is a property
of the site, like its gateway.

It stays in AS4242421607 as a route-reflector client rather than taking an AS of its
own: nothing to register, and no extra hop in an AS_PATH the whole network carries. It
cannot be a plain full-mesh member — that mesh runs loopback to loopback and relies on
Babel, which the router deliberately does not speak. Both reflectors share one cluster
id (RFC 4456), so the house keeps DN42 when either node reboots. Verified: `birdc
disable rrc_ghost` on fr-bod2 leaves the 1334 v4 routes active.

`next hop self` on a reflected session is not the standard behaviour and is required
here: the reflected routes point at tunnels the site router has no path to.

The import filter is the part that matters. The router also routes the house, so its
table holds the site's public v6 space and its RFC1918; nothing in iBGP would otherwise
stop it making US the origin of those prefixes in DN42. `is_site_router_net()` accepts
only what is more specific than our aggregates, never the aggregates themselves.

BGP on the services LAN is split out of the general rule, hence flow-matrix.md changing
with the ruleset. Every other DN42 interface is a point-to-point tunnel, where "which
interface" already answers "which peer"; a shared segment carrying servers answers
nothing, so 179 there is pinned to the router's address and lan-svc is excluded from
the catch-all — otherwise it would have accepted BGP from any host on the segment.

Also fixes a bug the deployment exposed: a `.link` file is applied by UDEV when the
device APPEARS, not by networkd. A NIC attached to a running VM keeps its kernel name
indefinitely — the file is on disk, `networkctl reload` reports success, and the
interface the rest of the configuration names simply does not exist. Measured on both
nodes: ens8 stayed ens8, unmanaged. The role now replays the add event, and only for a
leg that is not already correctly named.

Measured rather than assumed: RouterOS 7.23.2 really does install IPv4 routes over an
IPv6 next hop — 1334 v4 active, 172.20.187.224/27 via fd1d:cc09:1366:2000::4. The
fallback of spending 8 addresses of the /27 on the segment is therefore moot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
Three segments behind ghost enter DN42: VLAN 42 as a services segment reachable
from the network, VLANs 40 and 60 with outbound access only. Delegated prefixes
rather than NAT66, and the reasoning is in topology/site-router.md because the
question was a fair one — the house already NATs v6 towards its ISP.

The short version: we NAT66 on the WAN because the provider's prefix is neither
stable nor owned, and fd1d:cc09:1366::/48 is both. There is no scarcity to
manage either — 4096 /64s in the site's /52, four spent. And "reachable outbound
only" is not an addressing property: the prefix has to be announced either way
for a TCP reply to find its way home, so what forbids inbound is a forward rule.
The same forward chain the house already trusts against the whole of IPv6.

IPv4 inverts that reasoning exactly, which is why VLAN 42 gets a real subnet and
the other two get nothing: 172.20.187.224/27 is 32 addresses for the entire AS.
172.20.187.232/29 leaves 6 usable, and the reserve drops from 24 to 16. A
segment that only needs to reach out is v6-only, or src-nat'd on the router the
day a v4-only service is actually missed.

Numbering follows the house, not this repository: the 4th hextet is `2` plus the
VLAN id in decimal digits, because that is already how the public /48 is laid
out. One exception worth reading slowly — …:2042::/64 is VLAN 42, NOT VLAN 2042.
The interco has four digits, does not fit, and keeps the first /64 of the /52.

dn42_export_filter had a real bug, found by looking and confirmed in production.
The guard forbidding the announcement of a more-specific of our own space sat
INSIDE a condition on the route source (RTS_STATIC, RTS_DEVICE). It covered the
aggregates and the anycast /128s on lo-dn42, and let through everything learned
by BGP — which is exactly what a site router announces. Measured: the four
prefixes were being exported to routedbits and flipflap within minutes of the
first announcement; 0 on all five peerings after deploying the fix, with both
aggregates still going out normally.

The flow matrix changes in the same commit, as it must, even though the policy
it gained lives on a router this repository does not configure. It also records
why those segments MUST be numbered inside DN42 space: outside it, our own
nodes' "the overlay does not enter from elsewhere" guard would drop them.

The inventory plugin validates the new declarations — inside the site's
services_prefix, non-overlapping, and any IPv4 reserved in as.yml. Nothing
consumes this data; it is here because it is the only place that says which
prefixes are spent, exactly like the /27 reservations.

Measured on the way, and both were unknowns rather than deductions:

  - RouterOS originates a v4 prefix over a v6 session. Installing an ENH route
    and emitting one are different things; 172.20.187.232/29 reaches both nodes
    with bgp_next_hop fd1d:cc09:1366:2000::ffff.
  - The IPv4 raw chain has the same defect as its v6 counterpart, fixed on
    30/07: it ends with `drop the rest` and its accepts are keyed on
    Internal|VPN|WAN. v6 crossed in 14 ms while v4 lost 100%, with the route
    correctly installed on the sending node.

Two leftovers of the old infrastructure, on ghost: an /ip/firewall/nat rule
src-nat'ing all of Internal to 172.20.187.253 — the resolver anycast, again —
and an empty output.redistribute on both BGP connections, which meant no
announcement would have left regardless of the filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
On ghost: VLAN 242 becomes `vlan-wan42` and VLAN 2042 becomes `vlan-interco42`.
242 carries the underlay — WireGuard endpoints and node management, in the global
table — and 2042 carries the iBGP transit inside the dn42 VRF. VLAN 42 keeps
`vlan-dn42`, the segment where machines actually live in DN42.

The rename is not cosmetic and not free: `interco42` CHANGED REFERENT. It named
242, it now names 2042. Every document, comment or memory written before today
that says "interco42" means the other segment, which is why this commit rewrites
them rather than only renaming the interfaces — flow-matrix.md said "VLAN interco"
throughout to mean 242, the single most confusing thing to leave behind.

The order is imposed by the collision: free the name before reassigning it.
RouterOS follows its own references on `set name=` — verified rather than
assumed: 3 interface-list memberships, 6 firewall rules and every address moved
with the interfaces, 0 rule marked invalid, the 3 BGP sessions never dropped
(1266 v6 and 1334 v4 routes still active, both control pings still passing).

The symmetric naming debt on this side is recorded, not paid: services_vlan,
services_network, lan.services, lan-svc and services_prefix all designate 2042,
which is interco. The word "services" is therefore already taken by something
that is not the services segment. Fixing it touches tofu, the inventory plugin
and three roles — and renaming a Linux interface costs rather more than a
`set name=` on RouterOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
The end-to-end test that was still owed is done, from a machine on VLAN 60 that
picked up its DN42 address by SLAAC with no intervention. It matters because it
is the only test where source selection is performed by the host's own stack
rather than forced on a command line:

    ip -6 route get fd1d:cc09:1366::1     -> src fd1d:cc09:1366:2060:…
    ip -6 route get 2606:4700:4700::1111  -> src 2a0c:b641:111:60:…

RFC 6724 sorts it out by rule 6 (matching label) rather than by longest prefix:
fc00::/7 carries label 13 in the default policy table, distinct from ::/0, and
rule 6 applies before rule 8. Nothing configured on the host. DNS over the
anycast resolver resolves both an external DN42 name and our own zone.

The inbound direction is where the previous commit was wrong, and the docs said
more than the rules did. TCP 22, 445 and 9999 from fr-rbx1 to that host all time
out silently, as intended — but ICMPv6 gets through, because the router's
`defconf: accept ICMPv6 after RAW` carries no interface constraint at all and
sits ahead of every list-based rule. So DN42 can ping hosts on VLANs 40 and 60.

Kept rather than fixed, deliberately. That same rule already exposes those hosts
to ICMPv6 from the whole internet, so DN42 adds ~1500 participants to an existing
surface rather than a new class of risk; and RFC 4890 warns against blanket
ICMPv6 filtering — dropping packet-too-big builds exactly the PMTUD black hole
that line 5 of the input table exists to avoid. Closing it would mean a targeted
drop on echo-request alone, which is written down should the trade-off change.

Documented in both places, because a matrix that claims a total drop where the
rules allow ping is worse than no matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
The site router was the one router of the AS with no name. It now has one, on
both families, signed and served by both authoritative servers.

Which address carries the name is declared in sites.yml, not derived, because a
router holds one address per segment it serves and picking among them is a
decision. The nodes are published on their loopback — "stable as long as the
machine lives"; this router has none, so the closest equivalent is its address on
the interco, which exists exactly as long as the interconnection does, which is
exactly as long as the name is worth resolving. The IPv4 comes from VLAN 42
because it is the only IPv4 it carries in DN42, the interco having none.

NOT the router-id, and this is the trap the validation now refuses: it looks like
an address, it is reserved in as.yml like one, and it is carried on no interface
at all. Verified rather than reasoned — 172.20.187.229 answers 100% loss from
another node while both published addresses answer 0%.

Verified after deploying: resolves through the anycast resolver in both families,
identical from both authoritative servers, and the answer validates (ad flag,
RRSIG present).

One thing this does NOT change, and it predates this commit: no client here uses
the DN42 resolver as its system resolver, so `.dn42` names only resolve when the
query is aimed at fd1d:cc09:1366:1::53 explicitly. The nodes themselves resolve
through systemd-resolved against the clearnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
Publishing ghost.thystips.dn42 was worth nothing while no client asked a resolver
that knows DN42. The house's two Knot Resolver 6.4 instances now forward the
subtree, so the name works without `dig @`.

Both already carried a `dn42.` forward pointing at 172.22.108.53. It can never
have worked: that is a DN42 IPv4 address and neither machine holds a DN42 IPv4 to
reach it — SERVFAIL on dns2, and NXDOMAIN on dns1 which had no forward at all.
Another leftover of the old infrastructure, in the same family as the ones found
on ghost.

dns2 forwards to our anycast over IPv6, which is not a preference: it holds a
DN42 address only through the VLAN 40 delegation and no DN42 IPv4 at all, so
172.20.187.253 is unreachable from it. That is the direct, intended consequence
of giving no IPv4 to a segment that only reaches out. dns1 sits at another site
that is not in DN42, so it chains through dns2 — the only arrangement that does
not require that site's router to join DN42 too.

`dnssec: false` on the subtree is required, not a concession: dn42. is signed
from a root the ICANN chain has never heard of, so a validating resolver told
nothing builds no path and SERVFAILs the whole namespace. The alternative — pinning
the two DN42 DS records on each house resolver — would put them out of reach of
registry/scripts/check-anchors.py, so a DN42 KSK rollover would break the house
in silence instead of turning a lint red. Validating once, upstream, on the
resolver that already pins them, is the better trade.

That only holds if validation actually happens up there, which means the house
resolver must not send CD=1. Verified on the wire rather than assumed, by
capturing the query as it lands on the node: flags 0x0100, so CD=0 and DO=0.
Unbound validates normally and a bogus answer becomes SERVFAIL. The honest cost
is recorded too: the client gets no `ad` flag on DN42 answers, so it trusts the
path to the anycast rather than checking for itself.

Still missing, and now visibly absurd: the NODES do not resolve .dn42 — they go
through systemd-resolved to the clearnet. Only the house has the path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
`validate-my-dns.py ATNET-MNT` reported thystips.dn42 as clean on all four
nameservers and every reverse lookup of our own space as REFUSED — eight WARNs,
one per nameserver per zone. The delegation was real: the `nserver:` lines of our
inetnum and inet6num point 6.6.3.1.9.0.c.c.d.1.d.f.ip6.arpa and
224/27.187.20.172.in-addr.arpa at ns1/ns2, and Knot served neither. A delegated
zone nobody answers is REFUSED for the whole network, not just for us.

Both are now served and signed, and the RFC 2317 chain works end to end: -x
172.20.187.253 follows 253.187.20.172.in-addr.arpa CNAME into our zone and comes
back resolver.thystips.dn42. That chain is what makes the slash-bearing zone name
correct rather than a typo — an IPv4 prefix longer than a /24 has no zone of its
own, so DN42 delegates the 2317 form and CNAMEs every address into it. The slash
is part of the label, which is why the FILE cannot be named after the zone.

Nothing is typed: _reverse_zones in the inventory plugin derives both names from
the prefixes and both record sets from topology/. Only declared addresses get a
PTR — the house segments carry SLAAC hosts nobody enumerates, and inventing them
would publish names for things that may not answer.

Then Knot 3.4.6 got in the way, and the detour was worth it. `keymgr import-pem`
SEGFAULTS instead of refusing, in three distinct cases: a SEC1 PEM, a PEM whose
last line has no terminator, and — the one that bit — a key already held in this
node's KASP as the active key of a loaded, signing zone. Four hypotheses ruled
out by measurement before that one stood: a freshly generated key imports into
two zones in a row with identical arguments and exits 0, with the same PKCS#8
encoding, the same P-256 curve, a trailing newline, and a byte-identical ASN.1
structure.

That path is the DISASTER RECOVERY path. It only runs when a zone has no keys, so
it had not run since the day the keys were first installed and its being broken
was invisible. Secondary zones now use `keymgr share`, which points a zone at a
key the KASP already holds — the mechanism Knot documents for this, and one key
object rather than three copies of one secret.

Two consequences worth their comments in the role: a zone whose keys are missing
never LOADS ("no keys for signing"), and zone-sign on an unloaded zone answers
"invalid parameter" and changes nothing — so the handler now reloads before
signing, or a node comes back with the right keys and still SERVFAILs. And every
segfault leaks an LMDB reader into the KASP until MDB_READERS_FULL locks keymgr
out entirely; that one cost a wrong root cause before it was spotted.

Each zone gets its own DS from the shared KSK — the digest covers the owner name.
They live in registry.reverse_ds keyed by FAMILY, not by zone name, because a
reverse zone has no domain object to carry a DS: it rides on the inetnum and
inet6num, which is exactly where generate.py now puts it.

check-published.py is red until the DN42 registry merges the two ds-rdata lines,
which is what it is there to say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GeUYgtrM9B7arXgGErD1Gg
thystips deleted branch feat/ansible-inventory 2026-07-31 22:42:01 +02:00
thystips referenced this pull request from a commit 2026-07-31 22:42:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ThysTips_dn42/infra-dn42!1
No description provided.