feat(netbird-ansible): better subnet routers (#217)

This commit is contained in:
Antoine Lecompte
2026-06-26 19:27:28 +00:00
committed by GitHub
parent 00421f33ec
commit e039026cbe
7 changed files with 113 additions and 57 deletions
+24 -20
View File
@@ -26,9 +26,12 @@ The inventory is **TF-generated** at run time (see "Generated inventory").
`tf/shared/modules/identity` (see "Generated inventory" below).
3. **security** — nftables firewall, SSH hardening (no password auth,
`PermitRootLogin prohibit-password`), unattended-upgrades.
4. **networkd** — systemd-networkd VLAN sub-interfaces on the 25G fabric NIC.
**Gated on `mgmt_networkd_enabled` (default false)** — see the 25G caveat
below.
4. **networkd** — the host L3 that lets these nodes *forward* the NetBird-routed
site subnets: the OOB 1G NIC on `10.40.5.0/24` (the switch vme) + the 25G
fabric VLAN sub-interfaces (public/private/api). Enabled by default
(`mgmt_networkd_enabled: true`); only the OOB + fabric NICs are matched, so the
primary public NIC is untouched. The OOB path is reliable; the 25G VLANs are
written but stay carrier-down until that link is up — see the 25G caveat below.
5. **netbird** — install NetBird, `netbird up` with the `mgmt` setup key, enable
IP forwarding (the mgmt nodes are the NetBird route peers for the site subnets;
the routed network itself is declared in TF).
@@ -104,27 +107,28 @@ automatically. This needs the control node on the overlay too — the CI
For the very first run after a reinstall, pass `-e mgmt_bootstrap=true` to force
the public IP (skips any stale peer entry for the host).
## 25G fabric caveat
## Host L3 / routing & the 25G caveat
The 25G fabric link (Intel E810, "ice" driver) is **currently physically
unreliable**. The `networkd` role that configures its VLAN sub-interfaces is
gated off by default (`mgmt_networkd_enabled: false`), so a normal `site.yml`
run is a no-op for networking. Once the fabric links:
These nodes are the NetBird route peers for the site, so they need L3 paths to
the routed subnets in order to **forward** to them. The `networkd` role
(enabled by default) configures, per the TF-rendered host_vars:
1. Confirm the NIC name on each host with `ip link` (prior name:
`enp33s0f0np0`) and correct `mgmt_fabric_nic` in the host_vars if it
differs.
2. Set `mgmt_networkd_enabled: true` (e.g. `--extra-vars` or group_vars).
| Path | Network | mgmt-1 | mgmt-2 | NIC |
|------|---------|--------|--------|-----|
| OOB management (switch vme) | `10.40.5.0/24` | `10.40.5.50` | `10.40.5.51` | `enp37s0` (1G) |
| VLAN 120 (cluster public) | `10.40.20.0/23` | `10.40.20.2` | `10.40.20.3` | `enp33s0f0np0` (25G) |
| VLAN 122 (cluster private) | `10.40.22.0/23` | `10.40.22.2` | `10.40.22.3` | `enp33s0f0np0` (25G) |
| VLAN 10 (api) | `10.40.10.0/24` | `10.40.10.2` | `10.40.10.3` | `enp33s0f0np0` (25G) |
VLAN layout (gateways are `.1` on the leaf IRB):
The **OOB path is 1G and reliable** — that's what carries the switch traffic.
The **25G fabric link (Intel E810, "ice") is currently physically unreliable**;
its VLAN sub-interfaces are written but stay carrier-down (`RequiredForOnline=no`,
so they never block boot) until the link is up — harmless until then.
| VLAN | Network | mgmt-1 | mgmt-2 |
|------|---------|--------|--------|
| 20 (cluster public) | `10.40.20.0/23` | `10.40.20.2` | `10.40.20.3` |
| 22 (cluster private) | `10.40.22.0/23` | `10.40.22.2` | `10.40.22.3` |
The primary public NIC keeps Hetzner's DHCP default — this tree does not touch
it.
After a reinstall, confirm both NIC names with `ip link` and correct
`oob_nic` / `fabric_nic` in `tf/deployment/prod/htz-fsn1/mgmt-hosts.yaml` if they
differ (predictable names can change). Only these NICs are matched; the primary
public NIC keeps Hetzner's DHCP default — this tree does not touch it.
## Setup
+14 -8
View File
@@ -1,14 +1,20 @@
---
# systemd-networkd VLAN sub-interfaces on the 25G fabric NIC.
# systemd-networkd L3 so the node forwards the NetBird-routed site subnets:
# - OOB 1G NIC on the management LAN (mgmt_oob: 10.40.5.0/24 — switch vme)
# - tagged VLAN sub-interfaces on the 25G fabric NIC (mgmt_fabric_vlans)
#
# Prerequisites:
# - systemd-networkd present (Debian 13 base)
# - mgmt_fabric_nic, mgmt_fabric_vlans defined in host_vars
# Prerequisites (from host_vars, TF-rendered): mgmt_oob, mgmt_fabric_nic,
# mgmt_fabric_vlans. networkd only Matches these NICs — the primary public NIC
# (Hetzner's default) is never matched, so it stays under its own management.
#
# Master toggle. False by default because the 25G fabric link is currently
# unreliable — flip to true (and confirm mgmt_fabric_nic via `ip link`) once
# the fabric is up.
mgmt_networkd_enabled: false
# Enabled by default: the OOB path (1G, reliable) is what lets the node reach the
# switches. The 25G VLANs are written too but stay carrier-down until that link
# is up (RequiredForOnline=no, so they never block) — harmless until then.
# NICs are verified per host in mgmt-hosts.yaml; correct there if `ip link` differs.
mgmt_networkd_enabled: true
# OOB management interface { nic, address } — empty disables just the OOB part.
mgmt_oob: {}
# --- Paths ---
mgmt_networkd_config_dir: /etc/systemd/network
+21 -2
View File
@@ -1,7 +1,26 @@
---
# Write systemd-networkd config files for the 25G fabric VLANs.
# Safe to re-run.
# Write systemd-networkd config for the OOB management NIC + the 25G fabric
# VLANs, and make sure networkd is running. Safe to re-run. Only the NICs matched
# below are managed; the primary public NIC is left to its own manager.
- name: Ensure systemd-networkd is enabled and running
ansible.builtin.systemd:
name: systemd-networkd
enabled: true
state: started
# ── OOB management LAN (1G; the switch vme lives here) ──────────────────────
- name: Deploy OOB management .network
ansible.builtin.template:
src: oob.network.j2
dest: "{{ mgmt_networkd_config_dir }}/10-{{ mgmt_oob.nic }}.network"
owner: root
group: root
mode: '0644'
when: mgmt_oob.nic is defined
notify: Reload systemd for networkd
# ── 25G fabric VLAN sub-interfaces ──────────────────────────────────────────
- name: Deploy fabric parent .network (declares VLANs, no L3 of its own)
ansible.builtin.template:
src: fabric.network.j2
+7 -11
View File
@@ -1,16 +1,12 @@
---
# systemd-networkd VLAN sub-interfaces on the 25G fabric NIC.
# systemd-networkd L3 so the node forwards the NetBird-routed site subnets:
# - OOB 1G NIC on the management LAN (mgmt_oob: 10.40.5.0/24 — switch vme)
# - tagged VLAN sub-interfaces on the 25G fabric NIC (public/private/api)
#
# NOTE: applies once the 25G fabric links — the 25G link is currently
# physically unreliable (see notes). Gated on mgmt_networkd_enabled
# (default: false) so a normal site.yml run is a no-op until the fabric
# is up and the operator opts in.
#
# 1. write parent .network (DHCP off on fabric, declares VLANs)
# 2. write per-VLAN .netdev + .network (static addresses)
# 3. reload networkd
#
# The primary public NIC keeps Hetzner's DHCP default — untouched here.
# Gated on mgmt_networkd_enabled (default true). The OOB path is 1G/reliable;
# the 25G VLANs are written but stay carrier-down (RequiredForOnline=no) until
# that link is up — harmless until then. Only the OOB + fabric NICs are Matched,
# so the primary public NIC (Hetzner's default) is left untouched.
- name: Deploy networkd configs
ansible.builtin.import_tasks: deploy.yml
@@ -0,0 +1,16 @@
# {{ ansible_managed }}
# OOB management LAN: {{ mgmt_oob.nic }} -> {{ mgmt_oob.address }}.
# The switch vme (e.g. 10.40.5.115/125) lives on this L2. No gateway here — the
# default route stays on the primary public NIC; this is a directly-attached
# subnet. IP forwarding (enabled by the netbird role) lets this node route the
# NetBird overlay's traffic to the switches.
[Match]
Name={{ mgmt_oob.nic }}
[Network]
Address={{ mgmt_oob.address }}
IPv6AcceptRA=no
[Link]
RequiredForOnline=no
+17 -9
View File
@@ -2,13 +2,19 @@
# BOTH the reprovision stack (mgmt.tf, uses server_number) and the ansible
# inventory render (tf/render/ansible-mgmt, uses everything else).
#
# site_id — drives addressing (must match the stack's var.site_id).
# cluster_id — the cluster whose public/private VLANs these hosts sit on.
# host_index — host's offset within each VLAN /23 (gateway is .1 on the leaf);
# .2/.3 here -> 10.40.20.2/.3 (public), 10.40.22.2/.3 (private).
# fabric_nic — 25G NIC carrying the tagged VLAN sub-interfaces (verify after
# reprovision; predictable name may differ on fresh Debian 13).
# subnet_router — advertises the mgmt /24 over Tailscale (exactly one host).
# site_id — drives addressing (must match the stack's var.site_id).
# cluster_id — the cluster whose public/private VLANs these hosts sit on.
# host_index — host's offset within each fabric VLAN (gateway is .1 on the leaf);
# .2/.3 here -> 10.40.20.2/.3 (public), 10.40.22.2/.3 (private),
# 10.40.10.2/.3 (api).
# fabric_nic — 25G NIC carrying the tagged cluster/api VLAN sub-interfaces.
# oob_nic — 1G NIC on the OOB management LAN (10.40.5.0/24, the switch vme).
# oob_host — host octet on the OOB LAN (mgmt-1 .50, mgmt-2 .51).
#
# These hosts are the NetBird route peers for the site (see prod/htz-fsn1/netbird);
# the L3 paths configured from this roster (ansible networkd role) are what let
# them actually forward to the routed subnets. VERIFY both NIC names with
# `ip link` after a reinstall — predictable names can differ on fresh Debian 13.
site_id: 40
cluster_id: 1
@@ -18,10 +24,12 @@ hosts:
public_ip: 178.63.124.40
host_index: 2
fabric_nic: enp33s0f0np0
subnet_router: true
oob_nic: enp37s0
oob_host: 50
htz-fsn-mgmt-2:
server_number: 3008209
public_ip: 178.63.124.41
host_index: 3
fabric_nic: enp33s0f0np0
subnet_router: false
oob_nic: enp37s0
oob_host: 51
+14 -7
View File
@@ -1,7 +1,7 @@
# Render the ansible/mgmt inventory for a site from Terraform's sources of truth:
# • fabric-addressing — VLAN ids + per-host VLAN addresses
# • identity — server login users (+ keys + sudo)
# • mgmt-hosts.yaml — the host roster (IPs, NIC, host index, subnet router)
# • mgmt-hosts.yaml — the host roster (IPs, NICs, host index, OOB octet)
#
# Lightweight by design (local provider only, no backend/secrets), so it can run
# just-in-time in the ansible CI job (and locally) via `mise run mgmt:ansible`.
@@ -14,6 +14,8 @@ locals {
pub_mask = split("/", module.addressing.public_cidr)[1]
priv_mask = split("/", module.addressing.private_cidr)[1]
api_mask = split("/", module.addressing.api_cidr)[1]
mgmt_mask = split("/", module.addressing.mgmt_cidr)[1]
header = "# GENERATED by `mise run mgmt:render-inventory` (tf/render/ansible-mgmt).\n# Do not edit — edit the Terraform sources (mgmt-hosts.yaml, fabric-addressing, identity).\n"
}
@@ -42,20 +44,25 @@ resource "local_file" "hosts" {
})}"
}
# Per-host: 25G NIC + the tagged VLAN sub-interfaces (ids + addresses from the
# addressing module) + the subnet-router's advertised route.
# Per-host L3 so the node can forward the NetBird-routed site subnets:
# • OOB 1G NIC on the management LAN (10.40.5.0/24 — the switch vme)
# • tagged VLAN sub-interfaces on the 25G NIC (public/private/api)
# Addresses/ids come from the addressing module; bootstrap is the public IP
# (site.yml reconnects over NetBird once the host joins).
resource "local_file" "host_vars" {
for_each = local.hosts
filename = "${local.inv_dir}/host_vars/${each.key}.yml"
content = "${local.header}${yamlencode({
# Bootstrap address — site.yml reconnects over NetBird once the host joins.
# (NetBird routes the site subnets via the mgmt peer group; that's declared in
# tf/deployment/prod/<site>/netbird, so there's no per-host advertise flag.)
mgmt_public_ip = each.value.public_ip
mgmt_public_ip = each.value.public_ip
mgmt_oob = {
nic = each.value.oob_nic
address = "${cidrhost(module.addressing.mgmt_cidr, each.value.oob_host)}/${local.mgmt_mask}"
}
mgmt_fabric_nic = each.value.fabric_nic
mgmt_fabric_vlans = [
{ id = module.addressing.public_vlan_id, address = "${cidrhost(module.addressing.public_cidr, each.value.host_index)}/${local.pub_mask}" },
{ id = module.addressing.private_vlan_id, address = "${cidrhost(module.addressing.private_cidr, each.value.host_index)}/${local.priv_mask}" },
{ id = module.addressing.api_vlan_id, address = "${cidrhost(module.addressing.api_cidr, each.value.host_index)}/${local.api_mask}" },
]
})}"
}