Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5 4d948a50f4 feat(bird): iBGP mesh over pod0, routes into table 48 (2/3) (#576)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Nothing reads table 48 yet. The DaemonSet fills it so the routing rules in 3/3 land on a table that is already correct, and so a mistake here shows up as an empty table rather than as broken pod traffic.

Each node advertises only its own pod CIDR and learns the other two. The own-CIDR filter keys on ifname rather than the prefix, because Cilium's routes for the OTHER nodes are protocol kernel as well — local is cilium_host, learned is mgmt0 — and ifname is set for the gatewayless IPv6 device route too, which is what FRR's route-maps could not match. learn all rather than learn, since plain learn skips routes marked proto kernel.

Only BGP-learned routes are exported to table 48; the local CIDR stays in main, where cilium_host already serves it. direct is explicit because iBGP defaults to multihop and would make the gateway recursive. strict bind keeps the listener off mgmt0 and ingress0 — the option FRR lacks, since its --listenon implies --no_kernel.

Per-node config is one ConfigMap key per node, copied by an init container from spec.nodeName. The addresses duplicate the pod0 statics in kubernetes/talos/patches/k8s-{1,2,3}.yaml; reading them off the interface instead would need iproute2 in the image, which Alpine does not put in busybox.

Verified: kubectl kustomize builds, and bird -p parses the rendered config inside the 2.17.2 image with exit 0. A deliberately broken copy exits non-zero with a syntax error, so the check distinguishes the two. Peering, route exchange and the table-48 writes are runtime behaviour and are proven by the rollout, not by this.

Refs #576

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:56:19 +02:00
3 changed files with 214 additions and 0 deletions
@@ -0,0 +1,209 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: bird-config
namespace: kube-system
data:
bird.conf: |
# Per-node addresses, written by the init container from bird-nodes.
include "/etc/bird/node.conf";
router id from "pod0";
log stderr all;
# Mirrors of the kernel main table, filtered down to this node's own pod
# CIDR. master4/master6 are implicit and hold what BGP exchanges.
ipv4 table kmain4;
ipv6 table kmain6;
protocol device { scan time 10; }
# Cilium's routes for the OTHER nodes' pod CIDRs are protocol kernel too,
# so the prefix alone does not identify the local one. The outgoing
# interface does: local is cilium_host, learned is mgmt0. ifname is set for
# the gatewayless IPv6 device route as well.
filter own_pod4 {
if ! (net ~ [ 10.244.0.0/16{17,32} ]) then reject;
if ifname !~ "cilium_host" then reject;
accept;
}
filter own_pod6 {
if ! (net ~ [ fd10:244::/48{49,128} ]) then reject;
if ifname !~ "cilium_host" then reject;
accept;
}
# learn all, not learn: plain learn skips routes marked proto kernel, which
# is what Cilium's are.
protocol kernel klearn4 {
learn all;
scan time 10;
ipv4 { table kmain4; import filter own_pod4; export none; };
}
protocol kernel klearn6 {
learn all;
scan time 10;
ipv6 { table kmain6; import filter own_pod6; export none; };
}
protocol pipe own4 { table kmain4; peer table master4; export all; import none; }
protocol pipe own6 { table kmain6; peer table master6; export all; import none; }
# Only BGP-learned routes reach table 48. The local CIDR stays out of it —
# it is already reachable via cilium_host in main.
filter to_t48 {
if source != RTS_BGP then reject;
accept;
}
protocol kernel k48v4 {
kernel table 48;
metric 0;
ipv4 { table master4; import none; export filter to_t48; };
}
protocol kernel k48v6 {
kernel table 48;
metric 0;
ipv6 { table master6; import none; export filter to_t48; };
}
# iBGP full mesh over pod0. 64514 keeps clear of Cilium's 64513, which
# peers with the UDM from ingress0 and is unrelated to this.
#
# direct is explicit because iBGP defaults to multihop, which makes the
# gateway recursive — BIRD would try to resolve the peer in the pod RIB.
# strict bind keeps the listener off mgmt0 and ingress0.
template bgp pod4 {
local as 64514;
source address MY4;
direct;
strict bind;
ipv4 {
next hop self;
import filter { if net ~ [ 10.244.0.0/16{17,32} ] then accept; reject; };
export filter { if net ~ [ 10.244.0.0/16{17,32} ] then accept; reject; };
};
}
template bgp pod6 {
local as 64514;
source address MY6;
direct;
strict bind;
ipv6 {
next hop self;
import filter { if net ~ [ fd10:244::/48{49,128} ] then accept; reject; };
export filter { if net ~ [ fd10:244::/48{49,128} ] then accept; reject; };
};
}
protocol bgp p1v4 from pod4 { neighbor P1_4 as 64514; }
protocol bgp p2v4 from pod4 { neighbor P2_4 as 64514; }
protocol bgp p1v6 from pod6 { neighbor P1_6 as 64514; }
protocol bgp p2v6 from pod6 { neighbor P2_6 as 64514; }
---
apiVersion: v1
kind: ConfigMap
metadata:
name: bird-nodes
namespace: kube-system
data:
# One key per node, selected by name. The addresses are the pod0 statics from
# kubernetes/talos/patches/k8s-{1,2,3}.yaml; a node added there needs a key
# here and a peer line in the other two.
k8s-1: |
define MY4 = 10.2.48.11;
define MY6 = fd02:48::11;
define P1_4 = 10.2.48.12;
define P1_6 = fd02:48::12;
define P2_4 = 10.2.48.13;
define P2_6 = fd02:48::13;
k8s-2: |
define MY4 = 10.2.48.12;
define MY6 = fd02:48::12;
define P1_4 = 10.2.48.11;
define P1_6 = fd02:48::11;
define P2_4 = 10.2.48.13;
define P2_6 = fd02:48::13;
k8s-3: |
define MY4 = 10.2.48.13;
define MY6 = fd02:48::13;
define P1_4 = 10.2.48.11;
define P1_6 = fd02:48::11;
define P2_4 = 10.2.48.12;
define P2_6 = fd02:48::12;
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: bird
namespace: kube-system
labels:
tier: node
app: bird
spec:
selector:
matchLabels:
app: bird
updateStrategy:
type: RollingUpdate
template:
metadata:
labels:
tier: node
app: bird
spec:
# Carries pod east-west once the routing rules land, so a node without it
# falls back to VLAN 40 rather than losing connectivity.
priorityClassName: system-node-critical
# Writes host routes and peers on host addresses.
hostNetwork: true
# Every node here is a control plane node, so this is not optional.
tolerations:
- operator: Exists
effect: NoSchedule
initContainers:
# bird.conf is identical everywhere; node.conf is this node's key.
- name: render-config
image: gitea.renner.uno/gitea_admin/bird:2026-09-16
command: ["/bin/sh", "-c"]
args:
- cp /config/bird.conf /etc/bird/bird.conf && cp /nodes/"$NODE_NAME" /etc/bird/node.conf
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: config
mountPath: /config
- name: nodes
mountPath: /nodes
- name: etc-bird
mountPath: /etc/bird
containers:
- name: bird
image: gitea.renner.uno/gitea_admin/bird:2026-09-16
resources:
requests:
cpu: "50m"
memory: "32Mi"
limits:
cpu: "200m"
memory: "128Mi"
securityContext:
capabilities:
drop: ["ALL"]
# NET_ADMIN writes the routes; NET_BIND_SERVICE binds 179.
add: ["NET_ADMIN", "NET_BIND_SERVICE"]
volumeMounts:
- name: etc-bird
mountPath: /etc/bird
volumes:
- name: config
configMap:
name: bird-config
- name: nodes
configMap:
name: bird-nodes
- name: etc-bird
emptyDir: {}
@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- daemonset.yaml
@@ -7,6 +7,7 @@ resources:
- cluster-settings/
- cilium/
- multus/
- bird/
- cert-manager/
- snapshot-controller/
- metrics-server/