feat/ansible-inventory #1
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/ansible-inventory"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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>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>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>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>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>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>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>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_01GeUYgtrM9B7arXgGErD1GgThe 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`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