From 71e584cf960d7145d2e534b2a55c108d67ffffe3 Mon Sep 17 00:00:00 2001 From: Donavan Fritz Date: Sat, 25 Apr 2026 09:25:45 -0500 Subject: [PATCH] NodeConfig defaults + code-quality pass + fuzz tests + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeConfig.Spec.Defaults adds per-node IPv6/IPv4 family defaults that pod annotations can override; built-in baseline (v6=true, v4=false) still applies when the field is omitted. bird.Render now validates every operator-supplied value (peer addresses, CIDRs, anycast IPs, source addresses) before templating — fuzz found a peer address containing `}` produced unbalanced braces in bird.conf. Failing input preserved as a regression seed. Fuzz targets added for ParseAnnotations, ParseCNIArgs, HostIfaceName, canonical, IPAM allocate sequences, embed.Embed, and bird.Render. Hardened canonical/ipToU32 against nil and non-IPv4 inputs. README rewritten for outside readers — quickstart, NodeConfig + annotation reference with worked examples, anycast use cases, comparison vs Calico and Cilium, requirements, limitations. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- README.md | 339 +++++++++++++++++- .../crds/flock.fritzlab.net_nodeconfigs.yaml | 28 ++ deploy/install.yaml | 28 ++ pkg/agent/annotations.go | 221 +++++++++--- pkg/agent/annotations_fuzz_test.go | 156 ++++++++ pkg/agent/annotations_test.go | 179 ++++++++- pkg/agent/doc.go | 22 ++ pkg/agent/handlers.go | 3 +- pkg/agent/hostiface_test.go | 63 ++++ pkg/agent/ipam.go | 63 ++-- pkg/agent/ipam_fuzz_test.go | 169 +++++++++ pkg/agent/state.go | 6 +- pkg/api/v1alpha1/nodeconfig_types.go | 64 +++- pkg/embed/suffix_fuzz_test.go | 103 ++++++ pkg/routing/bird/config.go | 135 ++++++- pkg/routing/bird/config_fuzz_test.go | 93 +++++ .../testdata/fuzz/FuzzRender/568393b92f99b2ad | 11 + 17 files changed, 1583 insertions(+), 100 deletions(-) create mode 100644 pkg/agent/annotations_fuzz_test.go create mode 100644 pkg/agent/doc.go create mode 100644 pkg/agent/hostiface_test.go create mode 100644 pkg/agent/ipam_fuzz_test.go create mode 100644 pkg/embed/suffix_fuzz_test.go create mode 100644 pkg/routing/bird/config_fuzz_test.go create mode 100644 pkg/routing/bird/testdata/fuzz/FuzzRender/568393b92f99b2ad diff --git a/README.md b/README.md index 078d2e0..da3991e 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,335 @@ # flock -Kubernetes CNI for sjc001. Per-pod IPv4 opt-in, IID embedding, Ready-gated anycast via BGP. +A small, opinionated Kubernetes CNI built around three ideas: -Design doc: `k8s-manager/dfritz-cni.md` (in the operator's k8s-manager repo). +1. **IPv6-first.** Every pod gets a globally routable IPv6 address. IPv4 is + per-pod opt-in for legacy clients. +2. **No tunnels, no NAT.** Pod addresses are the real packets on the wire. + Each node speaks BGP to its upstream router and advertises its own + per-node prefix. The pod network is just the LAN, plus host routes. +3. **Anycast as a primitive.** A pod can request an anycast address via + an annotation; flock binds it on the pod's loopback and advertises a + `/128` (or `/32`) over BGP, but only while the pod is `Ready`. Multiple + replicas advertise the same address from different nodes for ECMP load + balancing without a separate Service or external LB. -Status: M1 scaffold. Not functional. See milestones table in the design doc. +flock is built for clusters where every node already speaks BGP to one +or more upstream routers. It deliberately leaves out features you'd +expect from a general-purpose CNI — overlays, IPsec/Wireguard, IPAM +coordination across nodes, kube-proxy integration — so the moving parts +that remain are easy to reason about. -## Layout +> **Status:** alpha. CRD shape and annotation keys may still change. -- `cmd/flock` — CNI plugin binary (kubelet-invoked) -- `cmd/flock-agent` — DaemonSet binary -- `pkg/api/v1alpha1` — `NodeConfig` CRD types -- `pkg/cni` — CNI plugin internals + RPC client -- `pkg/agent` — agent server, IPAM, state file, anycast, NetworkPolicy -- `pkg/embed` — `ip-algo` IID embedding (pure) -- `pkg/routing/{bird,ospf}` — routing backends -- `deploy/` — CRDs, RBAC, DaemonSet manifests +## Table of contents + +- [How it works](#how-it-works) +- [Requirements](#requirements) +- [Quickstart](#quickstart) +- [NodeConfig CRD](#nodeconfig-crd) +- [Pod annotations](#pod-annotations) +- [Use cases](#use-cases) +- [Comparison vs Calico / Cilium](#comparison-vs-calico--cilium) +- [Limitations and non-goals](#limitations-and-non-goals) +- [Building and testing](#building-and-testing) +- [License](#license) + +## How it works + +Each node runs a single `flock-agent` DaemonSet pod with three containers: + +- a privileged init container (`flock-installer`) that drops the CNI + plugin binary into `/opt/cni/bin/flock` and writes + `/etc/cni/net.d/01-flock.conflist`, +- the agent itself, which owns IPAM, programs veth pairs, and tracks + pod readiness, and +- a [BIRD2](https://bird.network.cz/) sidecar that the agent re-renders + and reloads when the per-node config or the active anycast set changes. + +Each node has a `NodeConfig` CR (cluster-scoped, name = node name) that +declares its IPv6 and IPv4 prefixes, its local BGP ASN, and its upstream +peers. The agent reads the CR via a dynamic informer. + +When kubelet runs the CNI plugin on `ADD`, the plugin opens a unix-socket +RPC to the agent. The agent allocates an address from the per-node +CIDRs, creates a veth pair, configures the pod side, persists the +allocation to `/var/lib/flock/allocations.json`, and returns the result. +There is no controller loop and no IPAM coordination across nodes — each +node owns a non-overlapping CIDR and allocates locally. + +For anycast, the agent installs ` via dev ` +host routes on the node and adds the anycast IP to BIRD's BGP export +filter. When a pod loses readiness, the agent withdraws the route from +both the kernel and BGP within one reconcile cycle (sub-second). + +### Packet path + +`pod.eth0` (a veth) ↔ host-side veth (with `addrgenmode none`, +`fe80::1/64`, proxy-ARP for the v4 default-via) ↔ host kernel ↔ uplink +NIC ↔ upstream router. No conntrack, no SNAT, no encapsulation. + +For IPv6 the host side of every veth carries the deterministic link-local +gateway `fe80::1`, so every pod can use a fixed default route. For IPv4 +the host side answers ARP for `169.254.1.1`, providing the same fixed +default route in v4. + +## Requirements + +- Linux nodes. flock has not been tested on, and does not target, + Windows nodes. +- Kubernetes ≥ 1.27. +- An upstream router (or pair) that accepts a BGP session from each + node. flock has been tested with Cisco IOS-XE, Arista EOS, and FRR + acting as the upstream; anything that speaks standard eBGP should work. +- Globally routable (or at least datacentre-routable) IPv6 prefix + delegated to the cluster, sliced into a per-node /64. IPv4 is + optional but supported. +- Each node must have a unique local ASN. Private ASNs (`64512–65534`, + `4200000000–4294967294`) are typical. + +## Quickstart + +```sh +# 1. Install CRD + RBAC + DaemonSet (single bundled manifest): +kubectl apply -f deploy/install.yaml + +# 2. Label the node(s) you want flock to manage: +kubectl label node flock.fritzlab.net/agent= + +# 3. Apply a NodeConfig CR for that node (see "NodeConfig CRD" below): +kubectl apply -f my-nodeconfig.yaml + +# 4. Verify the agent is up: +kubectl -n kube-system get pod -l app=flock-agent -o wide +kubectl -n kube-system exec -it ds/flock-agent -c bird -- \ + birdc -s /run/flock/bird.ctl show protocols +``` + +The DaemonSet is gated by the `flock.fritzlab.net/agent` node label, so +unlabelled nodes continue to use whatever CNI was installed before. This +lets you migrate node-by-node — start with one node, prove it works, then +proceed. + +## NodeConfig CRD + +A `NodeConfig` is the only operator-supplied input. One per node, name +matches the node name. Example: + +```yaml +apiVersion: flock.fritzlab.net/v1alpha1 +kind: NodeConfig +metadata: + name: node-a +spec: + cidr6: + - 2001:db8:f001::/64 # Pods on this node get addresses from here. + cidr4: + - 192.0.2.0/24 # IPv4 pool, used only when a pod opts in. + defaults: + ipv6: true # Optional. Built-in baseline if omitted. + ipv4: false # Optional. Built-in baseline if omitted. + bgp: + asn: 65101 # This node's local ASN. + peers: + - address: 2001:db8::1 # Upstream router (IPv6 session). + asn: 65000 + - address: 192.0.2.1 # Same router, IPv4 session. + asn: 65000 +``` + +### `spec.defaults` + +`spec.defaults` controls which address families a pod *gets by default* +on this node — i.e. when the pod has no explicit `flock.fritzlab.net/ipv6` +or `flock.fritzlab.net/ipv4` annotation. Pod annotations always override. +If you omit `spec.defaults` (or any individual field inside it) flock +falls back to its built-in baseline of **IPv6 on, IPv4 off**. + +| Goal | `spec.defaults` | +|---------------------------|----------------------------------------| +| IPv6-only (the default) | omit, or `{ ipv6: true, ipv4: false }`| +| Dual-stack by default | `{ ipv6: true, ipv4: true }` | +| IPv4-only (legacy node) | `{ ipv6: false, ipv4: true }` | + +A NodeConfig that resolves to "neither family" is rejected at allocation +time, so misconfiguring both to false will surface as an error on the +first `CNI ADD`. + +### `spec.bgp` + +Each `peer` becomes one BGP session. The agent picks a node-local source +address on the same subnet as the peer; if there isn't one, BIRD uses +its default. Multi-homing (multiple peers per family — or per upstream +router pair) is allowed. + +## Pod annotations + +All annotations live under `flock.fritzlab.net/`. Every annotation is +optional; leave them off to inherit the per-node defaults. + +| Annotation | Type | Purpose | +|-------------------------------------|--------|-----------------------------------------------------------------------------------------------| +| `flock.fritzlab.net/ipv6` | bool | Override `spec.defaults.ipv6` for this pod (`true`/`false`). | +| `flock.fritzlab.net/ipv4` | bool | Override `spec.defaults.ipv4` for this pod (`true`/`false`). | +| `flock.fritzlab.net/cidr6` | CIDRs | Restrict IPv6 allocation to a sub-range of the node's `cidr6`. Comma-separated. | +| `flock.fritzlab.net/cidr4` | CIDRs | Restrict IPv4 allocation to a sub-range of the node's `cidr4`. Comma-separated. | +| `flock.fritzlab.net/ip-algo` | list | Embed identity into the IPv6 IID. Subset of `namespace,pod,image`, in order, comma-separated. | +| `flock.fritzlab.net/anycast` | IPs | Bind these IPs on the pod's `lo`; advertise via BGP while pod is `Ready`. Mixed v6+v4 ok. | + +Bool values must be the literal strings `"true"` or `"false"` +(case-insensitive, surrounding whitespace tolerated). Other values — +`1`, `0`, `yes`, `no` — are rejected so a typo can't silently flip +behaviour. + +### Example pods + +Default IPv6-only — no annotations needed: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: minimal +``` + +Dual-stack on a node whose default is IPv6-only: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: legacy-client + annotations: + flock.fritzlab.net/ipv4: "true" +``` + +Operator-friendly addressing — `fnv(namespace) | fnv(pod) | random` +packed into the host bits, so a pod's identity is recognisable from +its IP in `kubectl get pods -o wide`: + +```yaml +metadata: + annotations: + flock.fritzlab.net/ip-algo: "namespace,pod" +``` + +Anycast service — three replicas, each advertising the same v6+v4 +anycast pair from the node it lands on. The upstream router does ECMP +across the active set: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dns +spec: + replicas: 3 + template: + metadata: + annotations: + flock.fritzlab.net/ipv4: "true" + flock.fritzlab.net/anycast: "2001:db8:a::53, 192.0.2.53" + spec: + containers: + - name: coredns + image: coredns/coredns + readinessProbe: + httpGet: { path: /ready, port: 8181 } + periodSeconds: 1 + failureThreshold: 1 +``` + +## Use cases + +**Highly-available DNS.** Run N CoreDNS replicas, each annotated with +the same `anycast` IP. Point client `/etc/resolv.conf` at the anycast +address. Each replica advertises a `/128` from its own node; the +upstream router does ECMP. Lose a pod, traffic fails over within a +probe cycle. + +**Replacing a kube-proxy `ClusterIP`.** Headless Service plus an anycast +IP gives you a single stable address with load-balancing across pods, +without the DNAT-pinning that makes long-lived TCP keepalive connections +stick to one backend forever. ECMP routes each new flow independently. + +**Per-pod public IPv6.** Because every pod has a globally routable IPv6 +address and the cluster does no NAT, a pod's `eth0` IP is reachable from +the rest of the internet (subject to your firewall). Useful for things +like outgoing SMTP, where you want a stable from-address per pod, or for +peer-to-peer protocols that don't tolerate NAT. + +**Fast pod identification in `kubectl`.** With +`flock.fritzlab.net/ip-algo: namespace,pod` the IPv6 host bits encode +the pod's namespace+name, so you can recognise a pod from its IP without +a lookup. Reverse-DNS via a wildcard zone makes those IPs human-readable +too. + +**Static-IP migration.** Annotation-driven address allocation means you +can ask for a specific sub-CIDR (`cidr6: 2001:db8:f001::ab00/120`) for +services that previously needed pinned IPs (mail server, ingress +controller). When the static-IP requirement goes away, drop the +annotation and the pod gets a normal allocation. + +## Comparison vs Calico / Cilium + +| | flock | Calico | Cilium | +|--------------------------|-----------------------------|------------------------------|------------------------------| +| Default address family | IPv6 | IPv4 | dual | +| BGP | yes (BIRD) | yes | optional | +| Overlay (VXLAN/IPIP) | never | optional | yes (geneve) or native | +| NAT in datapath | never | masquerade by default | masquerade by default | +| Anycast pod addressing | first-class | manual | optional, via service mesh | +| eBPF datapath | no | optional | yes | +| NetworkPolicy | not yet | yes (Felix) | yes (eBPF) | +| Cluster size target | small (< 100 nodes) | thousands | thousands | +| Operational surface area | low (1 DaemonSet, 1 CRD) | medium | high | +| Production-ready | alpha | yes | yes | + +flock is not trying to compete with Calico or Cilium. The right answer +for most clusters is one of those two — flock exists for clusters where +every node already speaks BGP, the operator wants to think in IPv6-first +terms, and per-pod anycast is something they actually want to use rather +than work around. + +## Limitations and non-goals + +- No NetworkPolicy enforcement yet (planned). +- No NAT, no masquerade, no SNAT-egress. If your pods need to reach a + legacy IPv4-only destination, give them an IPv4 address explicitly. +- No multi-cluster, no peering across clusters. +- Linux-only datapath. +- IPAM is per-node — there's no global allocator and no IP mobility. + When a pod moves to a different node it gets a new address. +- The agent is privileged. It mounts `/var/run/netns`, configures veth + pairs, manages kernel routes, and holds `CAP_NET_ADMIN`. This is + inherent to being a CNI; reducing privilege further is not a goal. +- If BIRD dies but the agent stays up, pods on that node stop being + reachable from off-node. The DaemonSet liveness probes catch this. + +## Building and testing + +```sh +# Unit tests + fuzz seed corpora (fast, ~1s): +go test ./... + +# Targeted fuzz pass: +go test -run NEVERMATCH -fuzz=FuzzParseAnnotations -fuzztime=30s ./pkg/agent +go test -run NEVERMATCH -fuzz=FuzzRender -fuzztime=30s ./pkg/routing/bird +go test -run NEVERMATCH -fuzz=FuzzEmbed -fuzztime=30s ./pkg/embed +go test -run NEVERMATCH -fuzz=FuzzIPAM_Allocate -fuzztime=30s ./pkg/agent + +# Build the container image (used by the DaemonSet): +docker build -t flock:dev . +``` + +The fuzz tests are also run as plain unit tests via their seed corpora, +so every `go test ./...` exercises the discovered edge cases as +regressions. + +`pkg/agent` has Linux-only files (`*_linux.go`) for netlink and netns +work; the macOS/Windows build pulls in stubs from `*_stub.go` so tests +run cleanly on developer laptops. ## License -Apache 2.0. +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml b/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml index 9da50a3..c25da15 100644 --- a/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml +++ b/deploy/crds/flock.fritzlab.net_nodeconfigs.yaml @@ -20,6 +20,9 @@ spec: openAPIV3Schema: type: object required: [spec] + description: | + NodeConfig is the per-node operator-supplied configuration for the + flock CNI agent. Its name MUST equal the Kubernetes node name. properties: spec: type: object @@ -35,6 +38,25 @@ spec: items: type: string description: IPv4 CIDR owned and aggregate-advertised by this node. + defaults: + type: object + description: | + Per-node baseline for which address families a pod receives + when its own annotations don't specify. Pod annotations + flock.fritzlab.net/ipv6 and flock.fritzlab.net/ipv4 always + override these defaults. Built-in fallback (when this block + or any field is omitted) is IPv6=true, IPv4=false. + properties: + ipv6: + type: boolean + description: | + Default IPv6 inclusion for pods on this node. Omit to + inherit the built-in baseline (true). + ipv4: + type: boolean + description: | + Default IPv4 inclusion for pods on this node. Omit to + inherit the built-in baseline (false). bgp: type: object required: [asn, peers] @@ -70,3 +92,9 @@ spec: - name: CIDR4 type: string jsonPath: .spec.cidr4 + - name: DefV6 + type: boolean + jsonPath: .spec.defaults.ipv6 + - name: DefV4 + type: boolean + jsonPath: .spec.defaults.ipv4 diff --git a/deploy/install.yaml b/deploy/install.yaml index 7fc587a..9b3f708 100644 --- a/deploy/install.yaml +++ b/deploy/install.yaml @@ -20,6 +20,9 @@ spec: openAPIV3Schema: type: object required: [spec] + description: | + NodeConfig is the per-node operator-supplied configuration for the + flock CNI agent. Its name MUST equal the Kubernetes node name. properties: spec: type: object @@ -35,6 +38,25 @@ spec: items: type: string description: IPv4 CIDR owned and aggregate-advertised by this node. + defaults: + type: object + description: | + Per-node baseline for which address families a pod receives + when its own annotations don't specify. Pod annotations + flock.fritzlab.net/ipv6 and flock.fritzlab.net/ipv4 always + override these defaults. Built-in fallback (when this block + or any field is omitted) is IPv6=true, IPv4=false. + properties: + ipv6: + type: boolean + description: | + Default IPv6 inclusion for pods on this node. Omit to + inherit the built-in baseline (true). + ipv4: + type: boolean + description: | + Default IPv4 inclusion for pods on this node. Omit to + inherit the built-in baseline (false). bgp: type: object required: [asn, peers] @@ -70,6 +92,12 @@ spec: - name: CIDR4 type: string jsonPath: .spec.cidr4 + - name: DefV6 + type: boolean + jsonPath: .spec.defaults.ipv6 + - name: DefV4 + type: boolean + jsonPath: .spec.defaults.ipv4 --- apiVersion: v1 kind: ServiceAccount diff --git a/pkg/agent/annotations.go b/pkg/agent/annotations.go index b99abda..277e965 100644 --- a/pkg/agent/annotations.go +++ b/pkg/agent/annotations.go @@ -5,77 +5,153 @@ import ( "net" "strings" + flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1" "code.fritzlab.net/fritzlab/flock/pkg/embed" ) +// annotationPrefix is the namespace under which all flock pod annotations +// live. Anything not starting with this prefix is ignored by the parser. const annotationPrefix = "flock.fritzlab.net/" -// ParsedAnnotations is the typed view of a Pod's flock annotations. -type ParsedAnnotations struct { - WantV6 bool - WantV4 bool - CIDR6 []*net.IPNet - CIDR4 []*net.IPNet - IPAlgo []embed.Field - Anycast []net.IP +// Recognised annotation keys (without the prefix). +const ( + annIPv6 = "ipv6" + annIPv4 = "ipv4" + annCIDR6 = "cidr6" + annCIDR4 = "cidr4" + annIPAlgo = "ip-algo" + annAnycast = "anycast" +) + +// FamilyDefaults is the per-call baseline for whether a pod receives an IPv6 +// and/or IPv4 address. It is the merge of: +// +// 1. flock's built-in baseline (IPv6=true, IPv4=false), then +// 2. any NodeConfig.Spec.Defaults override the operator has applied to +// the local node. +// +// Pod-level `flock.fritzlab.net/ipv{6,4}` annotations override this baseline. +// +// Use FamilyDefaultsFromNodeConfig to compute a value from a NodeConfig, +// or BuiltinFamilyDefaults() if no NodeConfig is in scope. +type FamilyDefaults struct { + // WantV6 is the default-on value for IPv6 inclusion when the pod has no + // explicit ipv6 annotation. + WantV6 bool + // WantV4 is the default-on value for IPv4 inclusion when the pod has no + // explicit ipv4 annotation. + WantV4 bool } -// ParseAnnotations applies the design-doc defaults (ipv6=true, ipv4=false) -// and validates the post-merge combination. -func ParseAnnotations(in map[string]string) (*ParsedAnnotations, error) { - out := &ParsedAnnotations{WantV6: true, WantV4: false} +// BuiltinFamilyDefaults returns flock's hard-coded fallback: IPv6 only. +// This is the policy applied when no NodeConfig override is in effect. +// +// We define it as a function rather than a var so callers can't mutate the +// shared baseline at runtime. +func BuiltinFamilyDefaults() FamilyDefaults { + return FamilyDefaults{WantV6: true, WantV4: false} +} - if v, ok := in[annotationPrefix+"ipv6"]; ok { - switch strings.ToLower(strings.TrimSpace(v)) { - case "true": - out.WantV6 = true - case "false": - out.WantV6 = false - default: - return nil, fmt.Errorf("annotation ipv6=%q: must be true or false", v) - } +// FamilyDefaultsFromNodeConfig resolves the effective per-node defaults, +// falling back to BuiltinFamilyDefaults for any field the NodeConfig leaves +// unset. A nil NodeConfig (or nil Spec.Defaults) returns the built-in +// baseline unchanged. +func FamilyDefaultsFromNodeConfig(nc *flockv1alpha1.NodeConfig) FamilyDefaults { + out := BuiltinFamilyDefaults() + if nc == nil || nc.Spec.Defaults == nil { + return out } - if v, ok := in[annotationPrefix+"ipv4"]; ok { - switch strings.ToLower(strings.TrimSpace(v)) { - case "true": - out.WantV4 = true - case "false": - out.WantV4 = false - default: - return nil, fmt.Errorf("annotation ipv4=%q: must be true or false", v) + if nc.Spec.Defaults.IPv6 != nil { + out.WantV6 = *nc.Spec.Defaults.IPv6 + } + if nc.Spec.Defaults.IPv4 != nil { + out.WantV4 = *nc.Spec.Defaults.IPv4 + } + return out +} + +// ParsedAnnotations is the typed view of a pod's flock annotations after the +// node-level defaults have been merged in. All slices are non-nil only when +// the corresponding annotation was present and parsed cleanly. +type ParsedAnnotations struct { + // WantV6 is true when the pod should receive an IPv6 address. + WantV6 bool + // WantV4 is true when the pod should receive an IPv4 address. + WantV4 bool + // CIDR6 narrows IPv6 allocation to specific operator-approved sub-ranges + // of the node's CIDR6 set. nil/empty means "use any node CIDR6". + CIDR6 []*net.IPNet + // CIDR4 narrows IPv4 allocation. nil/empty means "use any node CIDR4". + CIDR4 []*net.IPNet + // IPAlgo is the ordered list of identity fields used to build the IID. + // nil/empty means "random IID". + IPAlgo []embed.Field + // Anycast is the set of anycast IPs to bind on the pod's loopback. + // nil/empty means "no anycast". + Anycast []net.IP +} + +// ParseAnnotations applies the supplied per-node defaults and validates the +// post-merge combination. It is pure — it does not consult NodeConfig or any +// global state — so it is safe to call from tests and fuzz targets. +// +// Annotation precedence: pod annotation > FamilyDefaults > built-in baseline. +// Callers compute FamilyDefaults via FamilyDefaultsFromNodeConfig and pass it +// in. +// +// Errors: +// - any unknown ipv6/ipv4 value (must be "true" or "false", case-insensitive) +// - any malformed cidr6/cidr4/anycast/ip-algo value +// - the post-merge combination resolves to neither IPv6 nor IPv4 (a pod +// must have at least one address) +func ParseAnnotations(in map[string]string, defaults FamilyDefaults) (*ParsedAnnotations, error) { + out := &ParsedAnnotations{WantV6: defaults.WantV6, WantV4: defaults.WantV4} + + if v, ok := in[annotationPrefix+annIPv6]; ok { + b, err := parseBoolAnnotation(annIPv6, v) + if err != nil { + return nil, err } + out.WantV6 = b + } + if v, ok := in[annotationPrefix+annIPv4]; ok { + b, err := parseBoolAnnotation(annIPv4, v) + if err != nil { + return nil, err + } + out.WantV4 = b } if !out.WantV6 && !out.WantV4 { - return nil, fmt.Errorf("ipv6=false requires ipv4=true (pod must have at least one address)") + return nil, fmt.Errorf("annotations + defaults resolve to no address family (need at least one of ipv6/ipv4)") } - if v, ok := in[annotationPrefix+"cidr6"]; ok { - nets, err := parseCIDRList(v) + if v, ok := in[annotationPrefix+annCIDR6]; ok { + nets, err := parseCIDRList(v, familyV6) if err != nil { - return nil, fmt.Errorf("annotation cidr6: %w", err) + return nil, fmt.Errorf("annotation %s: %w", annCIDR6, err) } out.CIDR6 = nets } - if v, ok := in[annotationPrefix+"cidr4"]; ok { - nets, err := parseCIDRList(v) + if v, ok := in[annotationPrefix+annCIDR4]; ok { + nets, err := parseCIDRList(v, familyV4) if err != nil { - return nil, fmt.Errorf("annotation cidr4: %w", err) + return nil, fmt.Errorf("annotation %s: %w", annCIDR4, err) } out.CIDR4 = nets } - if v, ok := in[annotationPrefix+"ip-algo"]; ok { + if v, ok := in[annotationPrefix+annIPAlgo]; ok { fields, err := parseIPAlgo(v) if err != nil { - return nil, fmt.Errorf("annotation ip-algo: %w", err) + return nil, fmt.Errorf("annotation %s: %w", annIPAlgo, err) } out.IPAlgo = fields } - if v, ok := in[annotationPrefix+"anycast"]; ok { + if v, ok := in[annotationPrefix+annAnycast]; ok { ips, err := parseIPList(v) if err != nil { - return nil, fmt.Errorf("annotation anycast: %w", err) + return nil, fmt.Errorf("annotation %s: %w", annAnycast, err) } out.Anycast = ips } @@ -83,7 +159,39 @@ func ParseAnnotations(in map[string]string) (*ParsedAnnotations, error) { return out, nil } -func parseCIDRList(s string) ([]*net.IPNet, error) { +// parseBoolAnnotation accepts only "true" or "false" (case-insensitive, +// surrounding whitespace tolerated). All other values — including "1", "0", +// "yes", "no" — are rejected so operator typos are caught loudly rather +// than silently producing the "false" default. +func parseBoolAnnotation(key, v string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("annotation %s=%q: must be \"true\" or \"false\"", key, v) + } +} + +// addressFamily distinguishes IPv6 vs IPv4 in places where the parser must +// validate the family of supplied CIDRs. +type addressFamily int + +const ( + familyAny addressFamily = iota + familyV6 + familyV4 +) + +// parseCIDRList parses a comma-separated CIDR list. Whitespace around items +// is trimmed; empty items are silently dropped. The list must contain at +// least one entry post-trim. +// +// If `want` is familyV6 or familyV4 each entry's family is checked and a +// mismatch is reported, so an `flock.fritzlab.net/cidr6` annotation cannot +// silently slip a v4 prefix into the v6 allocator. +func parseCIDRList(s string, want addressFamily) ([]*net.IPNet, error) { var out []*net.IPNet for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) @@ -94,6 +202,17 @@ func parseCIDRList(s string) ([]*net.IPNet, error) { if err != nil { return nil, fmt.Errorf("invalid CIDR %q: %w", part, err) } + isV4 := n.IP.To4() != nil + switch want { + case familyV6: + if isV4 { + return nil, fmt.Errorf("CIDR %q is IPv4, expected IPv6", part) + } + case familyV4: + if !isV4 { + return nil, fmt.Errorf("CIDR %q is IPv6, expected IPv4", part) + } + } out = append(out, n) } if len(out) == 0 { @@ -102,6 +221,9 @@ func parseCIDRList(s string) ([]*net.IPNet, error) { return out, nil } +// parseIPList parses a comma-separated literal-IP list. Same trim/empty +// semantics as parseCIDRList. Mixed v4 and v6 entries are allowed (anycast +// pods can advertise both families together). func parseIPList(s string) ([]net.IP, error) { var out []net.IP for _, part := range strings.Split(s, ",") { @@ -121,6 +243,9 @@ func parseIPList(s string) ([]net.IP, error) { return out, nil } +// parseIPAlgo parses the ip-algo annotation. Each comma-separated token must +// match one of: namespace, pod, image. Empty tokens are dropped; unknown +// tokens are reported. func parseIPAlgo(s string) ([]embed.Field, error) { var out []embed.Field for _, part := range strings.Split(s, ",") { @@ -128,11 +253,11 @@ func parseIPAlgo(s string) ([]embed.Field, error) { switch part { case "": continue - case "namespace": + case string(embed.FieldNamespace): out = append(out, embed.FieldNamespace) - case "pod": + case string(embed.FieldPod): out = append(out, embed.FieldPod) - case "image": + case string(embed.FieldImage): out = append(out, embed.FieldImage) default: return nil, fmt.Errorf("unknown ip-algo field %q (allowed: namespace, pod, image)", part) @@ -144,8 +269,8 @@ func parseIPAlgo(s string) ([]embed.Field, error) { return out, nil } -// CNIArgs parses the K=V;K=V CNI_ARGS string for the kubelet keys we care -// about. Other keys are ignored. +// CNIArgs is the typed view of the K=V;K=V CNI_ARGS string passed by kubelet. +// We only keep the fields the agent uses; unknown keys are ignored. type CNIArgs struct { PodNamespace string PodName string @@ -153,6 +278,10 @@ type CNIArgs struct { InfraID string } +// ParseCNIArgs is permissive by design — kubelet versions and runtime +// shims pass varying sets of keys. Malformed entries are skipped silently +// rather than failing the whole ADD; required-key validation is the +// caller's responsibility. func ParseCNIArgs(s string) CNIArgs { var a CNIArgs for _, kv := range strings.Split(s, ";") { diff --git a/pkg/agent/annotations_fuzz_test.go b/pkg/agent/annotations_fuzz_test.go new file mode 100644 index 0000000..2385d73 --- /dev/null +++ b/pkg/agent/annotations_fuzz_test.go @@ -0,0 +1,156 @@ +package agent + +import ( + "testing" +) + +// FuzzParseAnnotations explores the joint space of {ipv6, ipv4, cidr6, cidr4, +// ip-algo, anycast} annotations with random byte strings. Every recognised +// key is exercised by deriving a deterministic input map from the fuzzed +// bytes; this gives the fuzzer reach into all parser branches at once. +// +// Properties checked: +// +// 1. The parser never panics on any input. +// 2. On nil-error return, the result satisfies the design-doc invariant +// that at least one of WantV6 / WantV4 is true (a pod always has at +// least one address). +// 3. Anycast IPs and IPAlgo fields are non-nil/empty only when the +// annotation was supplied; never spontaneously populated. +// +// Seed corpus covers known edge cases the spec must handle. +func FuzzParseAnnotations(f *testing.F) { + // Seeds: each entry is six strings — the literal raw values for the + // six parsed keys. Empty string for "key absent". + type seed struct { + ipv6, ipv4, cidr6, cidr4, ipAlgo, anycast string + } + seeds := []seed{ + {}, + {ipv4: "true"}, + {ipv6: "false", ipv4: "true"}, + {ipv6: "TRUE"}, + {ipv6: " true "}, + {ipv6: "yes"}, // invalid → expect error + {ipv4: "1"}, // invalid + {cidr6: ""}, // invalid (empty after split) + {cidr6: ","}, // invalid (empty after trim) + {cidr6: "2602:817:3000:f001::/64"}, // valid single + {cidr6: "2602:817:3000:f001::/64,"}, // trailing comma + {cidr6: " 2602:817:3000:f001::/64 "}, // surrounding whitespace + {cidr6: "2602:817:3000:f001::/64, 2602:817:3000:f002::/64"}, + {cidr6: "10.0.0.0/8"}, // family mismatch + {cidr4: "172.25.210.0/24"}, // valid + {cidr4: "172.25.210.0/24,172.25.211.0/24"}, // multiple + {cidr4: "2602:817::/32"}, // family mismatch + {ipAlgo: "namespace,pod,image"}, + {ipAlgo: "namespace, pod , image"}, // whitespace + {ipAlgo: "namespace,unknown"}, // invalid + {ipAlgo: ""}, // invalid (empty) + {ipAlgo: ","}, // invalid + {anycast: "2602:817:3000:ac::1"}, + {anycast: "2602:817:3000:ac::1, 172.25.255.1"}, + {anycast: "::1"}, // loopback (allowed at parse time) + {anycast: "fe80::1"}, // link-local (allowed at parse time) + {anycast: "::ffff:10.0.0.1"}, // v4-mapped v6 + {anycast: "0.0.0.0"}, // unspecified + {anycast: "definitely-not-an-ip"}, // invalid + {anycast: ""}, // invalid + // Embedded NUL bytes + {ipv4: "true\x00"}, + {cidr6: "2602:817:3000:f001::/64\x00"}, + {anycast: "\x00\x00"}, + // Unicode + {ipv4: "trüe"}, + {ipAlgo: "námespace"}, + // Very long + {cidr6: longString("2602:817:3000:f001::/64,", 4096)}, + } + for _, s := range seeds { + f.Add(s.ipv6, s.ipv4, s.cidr6, s.cidr4, s.ipAlgo, s.anycast) + } + + f.Fuzz(func(t *testing.T, ipv6, ipv4, cidr6, cidr4, ipAlgo, anycast string) { + in := map[string]string{} + // Treat empty as "key absent" so the seed table matches the run-time + // shape; Kubernetes annotations cannot have a nil value but they CAN + // be missing entirely. Empty-string-with-key is also a real case + // (operator typo); add a separate seed below to cover it. + if ipv6 != "" { + in[annotationPrefix+annIPv6] = ipv6 + } + if ipv4 != "" { + in[annotationPrefix+annIPv4] = ipv4 + } + if cidr6 != "" { + in[annotationPrefix+annCIDR6] = cidr6 + } + if cidr4 != "" { + in[annotationPrefix+annCIDR4] = cidr4 + } + if ipAlgo != "" { + in[annotationPrefix+annIPAlgo] = ipAlgo + } + if anycast != "" { + in[annotationPrefix+annAnycast] = anycast + } + + got, err := ParseAnnotations(in, BuiltinFamilyDefaults()) + if err != nil { + return // any error is acceptable; we only require no panic + } + // Property: at least one family must be selected. + if !got.WantV6 && !got.WantV4 { + t.Fatalf("parser accepted but produced no family: in=%#v", in) + } + // Property: optional fields populated only when their key was set. + if _, hasAlgo := in[annotationPrefix+annIPAlgo]; !hasAlgo && len(got.IPAlgo) != 0 { + t.Fatalf("IPAlgo populated without annotation") + } + if _, hasAny := in[annotationPrefix+annAnycast]; !hasAny && len(got.Anycast) != 0 { + t.Fatalf("Anycast populated without annotation") + } + if _, hasC6 := in[annotationPrefix+annCIDR6]; !hasC6 && len(got.CIDR6) != 0 { + t.Fatalf("CIDR6 populated without annotation") + } + if _, hasC4 := in[annotationPrefix+annCIDR4]; !hasC4 && len(got.CIDR4) != 0 { + t.Fatalf("CIDR4 populated without annotation") + } + }) +} + +// FuzzParseCNIArgs requires the parser to never panic on adversarial inputs. +// The parser is permissive by spec — it returns a CNIArgs with whatever it +// could extract — so the only invariant is "doesn't crash". +func FuzzParseCNIArgs(f *testing.F) { + f.Add("") + f.Add("=") + f.Add(";") + f.Add(";=;=;") + f.Add("K8S_POD_NAMESPACE=ns;K8S_POD_NAME=p") + f.Add("K8S_POD_NAMESPACE=ns;K8S_POD_NAME=p;K8S_POD_UID=abc;K8S_POD_INFRA_CONTAINER_ID=def") + f.Add("=value-only") + f.Add("key-only=") + f.Add("\x00\x00\x00") + f.Add("K8S_POD_NAMESPACE=\xff\xfe\xfd") + f.Add("K8S_POD_NAME=value;K8S_POD_NAME=other") // duplicate keys: last wins + // Long input + f.Add(longString("K8S_POD_NAME=x;", 4096)) + + f.Fuzz(func(t *testing.T, in string) { + _ = ParseCNIArgs(in) + }) +} + +// longString returns s repeated to total >= n bytes, useful for piling up +// realistic-looking but oversized inputs. +func longString(s string, n int) string { + if len(s) == 0 { + return "" + } + var b []byte + for len(b) < n { + b = append(b, s...) + } + return string(b) +} diff --git a/pkg/agent/annotations_test.go b/pkg/agent/annotations_test.go index 7564ea3..16a3c66 100644 --- a/pkg/agent/annotations_test.go +++ b/pkg/agent/annotations_test.go @@ -3,11 +3,68 @@ package agent import ( "testing" + flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1" "code.fritzlab.net/fritzlab/flock/pkg/embed" ) -func TestParseAnnotations_Defaults(t *testing.T) { - a, err := ParseAnnotations(nil) +// boolPtr returns a pointer to b — convenient for the *bool pointer fields +// in FamilyDefaults where nil means "unset". +func boolPtr(b bool) *bool { return &b } + +func TestBuiltinFamilyDefaults(t *testing.T) { + d := BuiltinFamilyDefaults() + if !d.WantV6 || d.WantV4 { + t.Fatalf("built-in defaults wrong: v6=%v v4=%v (want true/false)", d.WantV6, d.WantV4) + } +} + +func TestFamilyDefaultsFromNodeConfig_NilNodeConfig(t *testing.T) { + d := FamilyDefaultsFromNodeConfig(nil) + if d != BuiltinFamilyDefaults() { + t.Fatalf("nil NodeConfig should yield built-in defaults; got %+v", d) + } +} + +func TestFamilyDefaultsFromNodeConfig_NilDefaults(t *testing.T) { + nc := &flockv1alpha1.NodeConfig{} + d := FamilyDefaultsFromNodeConfig(nc) + if d != BuiltinFamilyDefaults() { + t.Fatalf("missing Defaults should yield built-in; got %+v", d) + } +} + +func TestFamilyDefaultsFromNodeConfig_PartialOverride(t *testing.T) { + nc := &flockv1alpha1.NodeConfig{ + Spec: flockv1alpha1.NodeConfigSpec{ + Defaults: &flockv1alpha1.FamilyDefaults{ + IPv4: boolPtr(true), + }, + }, + } + d := FamilyDefaultsFromNodeConfig(nc) + // IPv6 was unset → keeps built-in true; IPv4 was set → flipped on. + if !d.WantV6 || !d.WantV4 { + t.Fatalf("partial override wrong: %+v (want v6=true, v4=true)", d) + } +} + +func TestFamilyDefaultsFromNodeConfig_FullOverride(t *testing.T) { + nc := &flockv1alpha1.NodeConfig{ + Spec: flockv1alpha1.NodeConfigSpec{ + Defaults: &flockv1alpha1.FamilyDefaults{ + IPv6: boolPtr(false), + IPv4: boolPtr(true), + }, + }, + } + d := FamilyDefaultsFromNodeConfig(nc) + if d.WantV6 || !d.WantV4 { + t.Fatalf("full override wrong: %+v (want v6=false, v4=true)", d) + } +} + +func TestParseAnnotations_BuiltinDefaults(t *testing.T) { + a, err := ParseAnnotations(nil, BuiltinFamilyDefaults()) if err != nil { t.Fatal(err) } @@ -16,10 +73,36 @@ func TestParseAnnotations_Defaults(t *testing.T) { } } -func TestParseAnnotations_DualStack(t *testing.T) { +func TestParseAnnotations_NodeDefaultsApplied(t *testing.T) { + // Node config says "IPv4 is on by default for this node". + d := FamilyDefaults{WantV6: true, WantV4: true} + a, err := ParseAnnotations(nil, d) + if err != nil { + t.Fatal(err) + } + if !a.WantV6 || !a.WantV4 { + t.Fatalf("node defaults not applied: %+v", a) + } +} + +func TestParseAnnotations_AnnotationOverridesNodeDefault(t *testing.T) { + // Node says dual-stack by default; pod opts out of v4 explicitly. + d := FamilyDefaults{WantV6: true, WantV4: true} + a, err := ParseAnnotations(map[string]string{ + annotationPrefix + "ipv4": "false", + }, d) + if err != nil { + t.Fatal(err) + } + if !a.WantV6 || a.WantV4 { + t.Fatalf("annotation override failed: %+v", a) + } +} + +func TestParseAnnotations_DualStackViaAnnotation(t *testing.T) { a, err := ParseAnnotations(map[string]string{ annotationPrefix + "ipv4": "true", - }) + }, BuiltinFamilyDefaults()) if err != nil { t.Fatal(err) } @@ -31,15 +114,49 @@ func TestParseAnnotations_DualStack(t *testing.T) { func TestParseAnnotations_NoFamily(t *testing.T) { if _, err := ParseAnnotations(map[string]string{ annotationPrefix + "ipv6": "false", - }); err == nil { + }, BuiltinFamilyDefaults()); err == nil { t.Fatalf("expected error: ipv6=false ipv4=false") } } +func TestParseAnnotations_NoFamily_NodeDefaultsAlsoOff(t *testing.T) { + // Pathological NodeConfig that disables both families. Even with no pod + // annotation we must reject — otherwise a pod gets an empty allocation. + d := FamilyDefaults{WantV6: false, WantV4: false} + if _, err := ParseAnnotations(nil, d); err == nil { + t.Fatalf("expected error when both defaults are false") + } +} + +func TestParseAnnotations_BoolStrictness(t *testing.T) { + // Common misuses that should be rejected so typos don't silently flip + // behaviour to the implicit-false default. + bad := []string{"1", "0", "yes", "no", "TrueFalse", " "} + for _, v := range bad { + _, err := ParseAnnotations(map[string]string{ + annotationPrefix + "ipv4": v, + }, BuiltinFamilyDefaults()) + if err == nil { + t.Errorf("expected error for ipv4=%q", v) + } + } +} + +func TestParseAnnotations_BoolCaseInsensitive(t *testing.T) { + for _, v := range []string{"TRUE", "True", " true ", "FALSE", "False"} { + _, err := ParseAnnotations(map[string]string{ + annotationPrefix + "ipv4": v, + }, BuiltinFamilyDefaults()) + if err != nil { + t.Errorf("expected ipv4=%q to parse cleanly: %v", v, err) + } + } +} + func TestParseAnnotations_IPAlgo(t *testing.T) { a, err := ParseAnnotations(map[string]string{ annotationPrefix + "ip-algo": "namespace,pod,image", - }) + }, BuiltinFamilyDefaults()) if err != nil { t.Fatal(err) } @@ -54,10 +171,18 @@ func TestParseAnnotations_IPAlgo(t *testing.T) { } } +func TestParseAnnotations_IPAlgo_Unknown(t *testing.T) { + if _, err := ParseAnnotations(map[string]string{ + annotationPrefix + "ip-algo": "namespace,foo", + }, BuiltinFamilyDefaults()); err == nil { + t.Fatalf("expected unknown-field error") + } +} + func TestParseAnnotations_CIDR(t *testing.T) { a, err := ParseAnnotations(map[string]string{ annotationPrefix + "cidr6": "2602:817:3000:f001::/64, 2602:817:3000:f002::/64", - }) + }, BuiltinFamilyDefaults()) if err != nil { t.Fatal(err) } @@ -66,9 +191,49 @@ func TestParseAnnotations_CIDR(t *testing.T) { } } +func TestParseAnnotations_CIDR_FamilyMismatch(t *testing.T) { + // v4 prefix in a cidr6 annotation must not silently slip through. + if _, err := ParseAnnotations(map[string]string{ + annotationPrefix + "cidr6": "10.0.0.0/8", + }, BuiltinFamilyDefaults()); err == nil { + t.Fatalf("expected family mismatch error") + } + if _, err := ParseAnnotations(map[string]string{ + annotationPrefix + "cidr4": "2602:817::/32", + }, BuiltinFamilyDefaults()); err == nil { + t.Fatalf("expected family mismatch error") + } +} + +func TestParseAnnotations_Anycast_Mixed(t *testing.T) { + // Anycast accepts both families together — typical for a service that + // advertises one v6 and one v4 anycast IP. + a, err := ParseAnnotations(map[string]string{ + annotationPrefix + "anycast": "2602:817:3000:ac::1, 172.25.255.1", + }, BuiltinFamilyDefaults()) + if err != nil { + t.Fatal(err) + } + if len(a.Anycast) != 2 { + t.Fatalf("anycast len=%d", len(a.Anycast)) + } +} + func TestParseCNIArgs(t *testing.T) { args := ParseCNIArgs("IgnoreUnknown=1;K8S_POD_NAMESPACE=mail;K8S_POD_NAME=stalwart-0;K8S_POD_INFRA_CONTAINER_ID=abc123") if args.PodNamespace != "mail" || args.PodName != "stalwart-0" || args.InfraID != "abc123" { t.Fatalf("ParseCNIArgs got %+v", args) } } + +func TestParseCNIArgs_EmptyAndMalformed(t *testing.T) { + // Permissive: malformed entries are skipped, never crash. + a := ParseCNIArgs("") + if a.PodName != "" { + t.Fatalf("empty input should yield empty CNIArgs, got %+v", a) + } + a = ParseCNIArgs(";;K8S_POD_NAMESPACE=ns;noequalshere;=novalue;K8S_POD_NAME=p") + if a.PodNamespace != "ns" || a.PodName != "p" { + t.Fatalf("permissive parse failed: %+v", a) + } +} diff --git a/pkg/agent/doc.go b/pkg/agent/doc.go new file mode 100644 index 0000000..ffc29b5 --- /dev/null +++ b/pkg/agent/doc.go @@ -0,0 +1,22 @@ +// Package agent owns the in-process flock-agent runtime. The agent is a +// single Linux DaemonSet pod per node and holds: +// +// - the durable per-node allocation file at /var/lib/flock/allocations.json +// (see Store in state.go), +// - an in-memory IPAM seeded from NodeConfig CIDRs and reconciled against +// the allocation file at startup (see ipam.go), +// - dynamic informers watching the per-node NodeConfig CR (nodeconfig.go) +// and the local-node Pod set (podinfo.go), +// - an RPC server speaking to the lightweight CNI plugin binary +// (cmd/flock and pkg/cni), so kubelet's CNI invocations are answered by +// a long-lived process rather than spinning up a fresh binary per ADD, +// - the BirdManager that renders bird.conf and triggers `birdc reload` +// on changes (bird.go), and +// - the AnycastReconciler that programs per-pod /128 and /32 host routes +// gated on Pod readiness (anycast_linux.go). +// +// The package is split between platform-specific files (anycast_linux.go, +// netns_linux.go, runtime_linux.go) and stub files used on non-Linux build +// hosts so the rest of the package — IPAM, parsing, store, RPC plumbing — +// stays unit-testable on macOS and Windows CI. +package agent diff --git a/pkg/agent/handlers.go b/pkg/agent/handlers.go index 5e0ff8b..eff35b9 100644 --- a/pkg/agent/handlers.go +++ b/pkg/agent/handlers.go @@ -49,7 +49,8 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re return nil, fmt.Errorf("lookup pod: %w", err) } - parsed, err := ParseAnnotations(pod.Annotations) + defaults := FamilyDefaultsFromNodeConfig(h.NodeConfig.Load()) + parsed, err := ParseAnnotations(pod.Annotations, defaults) if err != nil { return nil, fmt.Errorf("parse annotations: %w", err) } diff --git a/pkg/agent/hostiface_test.go b/pkg/agent/hostiface_test.go new file mode 100644 index 0000000..ee85b8b --- /dev/null +++ b/pkg/agent/hostiface_test.go @@ -0,0 +1,63 @@ +package agent + +import ( + "strings" + "testing" +) + +func TestHostIfaceName_Format(t *testing.T) { + got := HostIfaceName("0123456789abcdef0123456789abcdef") + if !strings.HasPrefix(got, "flock") || len(got) != len("flock")+8 { + t.Fatalf("HostIfaceName=%q (want flock + 8 hex)", got) + } +} + +func TestHostIfaceName_Determinism(t *testing.T) { + a := HostIfaceName("container-xyz") + b := HostIfaceName("container-xyz") + if a != b { + t.Fatalf("not deterministic: %s vs %s", a, b) + } +} + +func TestHostIfaceName_DifferentInputs(t *testing.T) { + a := HostIfaceName("a") + b := HostIfaceName("b") + if a == b { + t.Fatalf("collision on trivial inputs") + } +} + +// FuzzHostIfaceName ensures the host interface name generator never produces +// an output longer than IFNAMSIZ-1 (15 chars on Linux) and never panics. +// The name format is "flock" + 8 hex chars = 13 chars, always. +func FuzzHostIfaceName(f *testing.F) { + f.Add("") + f.Add("a") + f.Add("/var/run/netns/abc") + f.Add("0123456789abcdef0123456789abcdef") + f.Add(longString("x", 64*1024)) // very long containerID + f.Add("\x00\x00\x00") + f.Add("ünïcødé/контейнер") + + f.Fuzz(func(t *testing.T, id string) { + got := HostIfaceName(id) + // Linux IFNAMSIZ is 16 (15 chars + NUL); ours must fit comfortably. + if len(got) > 15 { + t.Fatalf("HostIfaceName(%q)=%q exceeds 15 chars", id, got) + } + if !strings.HasPrefix(got, "flock") { + t.Fatalf("HostIfaceName(%q)=%q missing prefix", id, got) + } + // Suffix must be lowercase hex (8 chars). + suffix := got[len("flock"):] + if len(suffix) != 8 { + t.Fatalf("HostIfaceName(%q) suffix len=%d", id, len(suffix)) + } + for _, c := range suffix { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Fatalf("HostIfaceName(%q)=%q has non-hex suffix", id, got) + } + } + }) +} diff --git a/pkg/agent/ipam.go b/pkg/agent/ipam.go index 2ade078..9fca2dd 100644 --- a/pkg/agent/ipam.go +++ b/pkg/agent/ipam.go @@ -62,13 +62,15 @@ func (cryptoRand) PickIndex(n int) int { } // AllocRequest describes a pending allocation. Values come from Pod metadata -// + annotations at CNI ADD time. +// + annotations at CNI ADD time, with per-node FamilyDefaults already merged +// in (see ParseAnnotations). type AllocRequest struct { ContainerID string Namespace string Pod string - // WantV6 / WantV4 come from the ipv6 / ipv4 annotations (defaults in - // design doc: ipv6=true, ipv4=false). + // WantV6 / WantV4 are the post-merge address family selection (pod + // annotation > NodeConfig.Spec.Defaults > built-in baseline). At least + // one MUST be true; Allocate rejects the request otherwise. WantV6 bool WantV4 bool // AnnCIDR6 / AnnCIDR4 come from the cidr6 / cidr4 annotations. Empty @@ -224,34 +226,36 @@ func (i *IPAM) allocV6(cidr *net.IPNet, req AllocRequest) (net.IP, error) { // randomV6 picks a random /128 inside cidr. The network prefix bits are // preserved from cidr.IP; the host bits are filled from the random source. +// +// Implementation: walk the 16 IPv6 bytes once. For each byte we ask whether +// it's entirely inside the network mask (skip), entirely inside the host +// portion (overwrite with random), or split (combine bits from both). func (i *IPAM) randomV6(cidr *net.IPNet) (net.IP, error) { ones, bits := cidr.Mask.Size() if bits != 128 { return nil, fmt.Errorf("cidr %s is not IPv6", cidr) } - out := make(net.IP, 16) + out := make(net.IP, net.IPv6len) copy(out, cidr.IP.To16()) - hostBits := 128 - ones - rnd := make([]byte, 16) + rnd := make([]byte, net.IPv6len) i.randSrc.FillIID(rnd) - // Merge rnd into out where mask bit is 0. - for b := 0; b < 16; b++ { - // Host bits start at bit index `ones`, byte `b`. + for b := 0; b < net.IPv6len; b++ { byteStart := b * 8 byteEnd := byteStart + 8 - if byteEnd <= ones { - continue // entirely network - } - if byteStart >= ones { - out[b] = rnd[b] // entirely host + switch { + case byteEnd <= ones: + // Entirely inside the network prefix — leave untouched. continue + case byteStart >= ones: + // Entirely inside the host portion — fully randomise. + out[b] = rnd[b] + default: + // Split byte: top (ones-byteStart) bits are network, rest host. + networkBits := ones - byteStart + hostMask := byte(0xFF) >> uint(networkBits) + out[b] = (out[b] & ^hostMask) | (rnd[b] & hostMask) } - // Split byte: top (ones-byteStart) bits are network, rest is host. - networkBits := ones - byteStart - hostMask := byte(0xFF) >> uint(networkBits) - out[b] = (out[b] & ^hostMask) | (rnd[b] & hostMask) } - _ = hostBits return out, nil } @@ -360,15 +364,34 @@ func toStringSlice(ns []*net.IPNet) []string { return out } +// canonical returns the textual form of ip in its native family, so the same +// host address is always represented identically regardless of whether it +// arrived as a 4-byte slice, a 16-byte v4-in-v6 slice, or a string-parsed +// net.IP. Used as the key for the in-use map. +// +// Returns "" for nil input — callers MUST treat the returned key as opaque +// and never use the empty string as a sentinel. func canonical(ip net.IP) string { + if ip == nil { + return "" + } if v4 := ip.To4(); v4 != nil { return v4.String() } - return ip.To16().String() + if v16 := ip.To16(); v16 != nil { + return v16.String() + } + return "" } +// ipToU32 reads a 4-byte IPv4 net.IP into a uint32. The caller is expected +// to have already validated that ip is an IPv4 address; mis-use returns 0 +// rather than panicking. func ipToU32(ip net.IP) uint32 { v4 := ip.To4() + if v4 == nil { + return 0 + } return uint32(v4[0])<<24 | uint32(v4[1])<<16 | uint32(v4[2])<<8 | uint32(v4[3]) } diff --git a/pkg/agent/ipam_fuzz_test.go b/pkg/agent/ipam_fuzz_test.go new file mode 100644 index 0000000..a0a7890 --- /dev/null +++ b/pkg/agent/ipam_fuzz_test.go @@ -0,0 +1,169 @@ +package agent + +import ( + "net" + "testing" +) + +// FuzzIPAM_Allocate runs randomly-driven Allocate/Release sequences against +// a /120 IPv6 + /28 IPv4 IPAM so the fuzzer can hit address exhaustion. +// +// Properties checked: +// +// 1. Allocate never panics regardless of the action stream. +// 2. The set of in-use addresses never contains an address that has been +// released without a subsequent successful Allocate. +// 3. A successful v6 allocation always yields an address inside the +// configured /120, and a successful v4 always inside the configured /28. +// 4. ipToU32(canonical(allocated v4)) round-trips, and likewise that no +// v4 allocation lands on .0 (network) or .15 (broadcast) of the /28. +// +// The fuzzed bytes are interpreted as an opcode stream: +// - bytes[i] & 0x03 selects the action: 0=alloc-v6, 1=alloc-v4, +// 2=alloc-dual, 3=release-most-recent. +// - bytes[i]>>2 is fed into the deterministic random source so different +// fuzzed bytes drive different IID/index choices. +func FuzzIPAM_Allocate(f *testing.F) { + f.Add([]byte{0, 0, 0, 0}) + f.Add([]byte{1, 1, 1, 1}) + f.Add([]byte{2, 2, 2, 2}) + f.Add([]byte{0, 1, 2, 3}) + f.Add([]byte(longString("\x00\x01\x02\x03", 256))) + + f.Fuzz(func(t *testing.T, ops []byte) { + ipam, err := NewIPAM( + []string{"2001:db8::/120"}, // 256 host slots; 16 bytes of fuzzed nibbles + []string{"10.0.0.0/28"}, // 14 usable hosts (.2..14) + ) + if err != nil { + t.Fatal(err) + } + // Deterministic source: replay nibbles cycled from `ops`. + fr := &fakeRand{ + nibbles: append([]byte{}, ops...), + iids: [][]byte{ + // 16 bytes of "host portion" — only the last byte matters + // for a /120 prefix. + makeIID(ops, 0), + makeIID(ops, 1), + makeIID(ops, 2), + makeIID(ops, 3), + }, + } + if len(fr.nibbles) == 0 { + fr.nibbles = []byte{0} + } + ipam.randSrc = fr + + net6 := mustNet(t, "2001:db8::/120") + net4 := mustNet(t, "10.0.0.0/28") + + var live []AllocResult + seen := map[string]struct{}{} + + for idx, op := range ops { + req := AllocRequest{ContainerID: idStr(idx)} + switch op & 0x03 { + case 0: + req.WantV6 = true + case 1: + req.WantV4 = true + case 2: + req.WantV6, req.WantV4 = true, true + case 3: + if len(live) == 0 { + continue + } + rel := live[len(live)-1] + live = live[:len(live)-1] + ipam.Release(rel.IP6, rel.IP4) + delete(seen, canonical(rel.IP6)) + delete(seen, canonical(rel.IP4)) + continue + } + + res, err := ipam.Allocate(req) + if err != nil { + continue // exhaustion is acceptable + } + + if req.WantV6 { + if res.IP6 == nil { + t.Fatalf("requested v6 but got nil") + } + if !net6.Contains(res.IP6) { + t.Fatalf("v6 %s outside /120", res.IP6) + } + if _, dup := seen[canonical(res.IP6)]; dup { + t.Fatalf("v6 %s duplicated", res.IP6) + } + seen[canonical(res.IP6)] = struct{}{} + } + if req.WantV4 { + if res.IP4 == nil { + t.Fatalf("requested v4 but got nil") + } + if !net4.Contains(res.IP4) { + t.Fatalf("v4 %s outside /28", res.IP4) + } + v4 := res.IP4.To4() + if v4 == nil { + t.Fatalf("v4 result not 4-byte: %s", res.IP4) + } + // Skip .0 (network) and .15 (broadcast). The allocator + // should also skip .1 (gateway) by convention. + last := v4[3] + if last == 0 || last == 1 || last == 15 { + t.Fatalf("v4 %s in reserved range", res.IP4) + } + if _, dup := seen[canonical(res.IP4)]; dup { + t.Fatalf("v4 %s duplicated", res.IP4) + } + seen[canonical(res.IP4)] = struct{}{} + } + live = append(live, res) + } + }) +} + +// FuzzCanonical asserts that canonical never panics and is idempotent. +func FuzzCanonical(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{1, 2, 3, 4}) + f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + f.Add([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 10, 0, 0, 1}) // v4-mapped v6 + f.Add([]byte{0xff}) + + f.Fuzz(func(t *testing.T, b []byte) { + ip := net.IP(b) + s1 := canonical(ip) + // Idempotent: re-canonicalising the parsed form yields the same + // string for any non-empty result. + if s1 != "" { + parsed := net.ParseIP(s1) + if parsed == nil { + t.Fatalf("canonical(%v)=%q is not parseable as IP", b, s1) + } + if got := canonical(parsed); got != s1 { + t.Fatalf("not idempotent: %q -> %q", s1, got) + } + } + }) +} + +func makeIID(seed []byte, salt byte) []byte { + out := make([]byte, net.IPv6len) + for i := range out { + if i < len(seed) { + out[i] = seed[i] ^ salt + } else { + out[i] = salt + } + } + return out +} + +func idStr(i int) string { + const hex = "0123456789abcdef" + return string([]byte{'c', '-', hex[(i>>4)&0xF], hex[i&0xF]}) +} diff --git a/pkg/agent/state.go b/pkg/agent/state.go index f02ebc6..67b143a 100644 --- a/pkg/agent/state.go +++ b/pkg/agent/state.go @@ -1,6 +1,6 @@ -// Package agent owns the in-process flock-agent runtime: IPAM, netns, state, -// anycast, and NetworkPolicy. This file implements the durable per-node -// allocation file at /var/lib/flock/allocations.json. +// This file implements the durable per-node allocation file at +// /var/lib/flock/allocations.json. The package-level doc lives in doc.go. + package agent import ( diff --git a/pkg/api/v1alpha1/nodeconfig_types.go b/pkg/api/v1alpha1/nodeconfig_types.go index 9112090..78ae70b 100644 --- a/pkg/api/v1alpha1/nodeconfig_types.go +++ b/pkg/api/v1alpha1/nodeconfig_types.go @@ -1,3 +1,8 @@ +// Package v1alpha1 contains the operator-facing API types for flock. +// +// Stability: alpha. The shape of these types may change in incompatible ways +// between minor releases. CRDs are versioned and the agent reads only its +// pinned version. package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -6,27 +11,78 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" // // The agent reads this on startup and via informer for live updates. There is // no controller and no auto-allocation — purely declarative input. +// +// A NodeConfig's name MUST equal the Kubernetes node name it configures +// (NodeConfigs are cluster-scoped). The agent ignores all NodeConfigs whose +// name does not match its own node. type NodeConfigSpec struct { // CIDR6 is the set of IPv6 CIDRs this node owns and advertises as BGP - // aggregates. Pod IPv6 addresses are allocated from these. + // aggregates. Pod IPv6 addresses are allocated from these. May be empty + // only if Defaults disables IPv6 for every pod on this node. CIDR6 []string `json:"cidr6,omitempty"` // CIDR4 is the set of IPv4 CIDRs this node owns and advertises as BGP - // aggregates. Pod IPv4 addresses are allocated from these. + // aggregates. Pod IPv4 addresses are allocated from these. May be empty + // when no pod on this node ever opts into IPv4. CIDR4 []string `json:"cidr4,omitempty"` // BGP configures the BGP sessions this node establishes upstream. BGP BGPSpec `json:"bgp"` + + // Defaults sets the per-node baseline for which address families a pod + // receives when its own annotations don't say. Pod-level + // `flock.fritzlab.net/ipv6` and `flock.fritzlab.net/ipv4` annotations + // always override these defaults. + // + // When a field is unset (nil), the agent falls back to its built-in + // baseline of IPv6=true, IPv4=false. When the whole Defaults block is + // nil, both built-in defaults apply. + // + // Typical uses: + // - dual-stack node: Defaults: { ipv6: true, ipv4: true } + // - IPv4-only node: Defaults: { ipv6: false, ipv4: true } + // - default (omit Defaults entirely): IPv6-only. + // + // Validation: at least one of IPv6 or IPv4 must end up true after merging + // (annotations + defaults + built-in baseline). The agent rejects pods + // that resolve to neither. + Defaults *FamilyDefaults `json:"defaults,omitempty"` } +// FamilyDefaults is the per-node default for which address families a pod +// receives when its annotations don't specify. Each field is a pointer so +// "unset" is distinguishable from explicit "false". +type FamilyDefaults struct { + // IPv6 is the default value for the `flock.fritzlab.net/ipv6` annotation. + // nil → fall back to the built-in baseline (true). + IPv6 *bool `json:"ipv6,omitempty"` + + // IPv4 is the default value for the `flock.fritzlab.net/ipv4` annotation. + // nil → fall back to the built-in baseline (false). + IPv4 *bool `json:"ipv4,omitempty"` +} + +// BGPSpec describes this node's BGP speaker configuration. Each upstream peer +// becomes one BGP session in the rendered bird.conf. type BGPSpec struct { - ASN uint32 `json:"asn"` + // ASN is this node's local autonomous system number. flock uses private + // ASNs in the 64512-65534 range by convention but accepts any value. + ASN uint32 `json:"asn"` + + // Peers is the set of upstream BGP neighbors. At least one is required + // for BGP advertisement to function. Multiple peers of the same family + // are allowed (multi-homing). Peers []BGPPeer `json:"peers"` } +// BGPPeer is a single upstream BGP neighbor. type BGPPeer struct { + // Address is the peer's IP. May be IPv4 or IPv6. The agent picks an + // appropriate local source address on the same subnet. Address string `json:"address"` - ASN uint32 `json:"asn"` + + // ASN is the peer's remote ASN. + ASN uint32 `json:"asn"` } // NodeConfig is the Schema for the nodeconfigs API. NodeConfigs are cluster- diff --git a/pkg/embed/suffix_fuzz_test.go b/pkg/embed/suffix_fuzz_test.go new file mode 100644 index 0000000..3f3e9f3 --- /dev/null +++ b/pkg/embed/suffix_fuzz_test.go @@ -0,0 +1,103 @@ +package embed + +import ( + "net" + "testing" +) + +// FuzzEmbed verifies that Embed never panics and that any successful return +// keeps the output address inside the requested network. +func FuzzEmbed(f *testing.F) { + type seed struct { + prefix string + fields string // comma-separated, mapped below to []Field + ns, pod string + image string + fallback string + nNibble byte + } + for _, s := range []seed{ + {"2602:817:3000:f001::/64", "namespace,pod,image", "mail", "stalwart-0", "", "ctr", 0xe}, + {"2001:db8::/64", "namespace", "ns", "p", "", "", 0}, + {"2001:db8::/96", "pod", "", "podname", "", "ctr", 0xf}, + {"2001:db8::/48", "namespace,pod", "ns", "p", "", "ctr", 0x1}, + {"2001:db8::/120", "namespace", "n", "p", "", "ctr", 0x0}, // 8 host nibbles + {"2001:db8::/124", "namespace", "n", "p", "", "ctr", 0x0}, // 4 host nibbles + {"2001:db8::/127", "namespace", "n", "p", "", "ctr", 0x0}, // not nibble-aligned + {"2001:db8::/63", "namespace", "n", "p", "", "ctr", 0x0}, // not nibble-aligned + {"2001:db8::/64", "namespace,pod,image", "", "", "sha256:abcdef0123456789aabbccddeeff00112233445566778899aabbccddeeff0011", "", 0xa}, + {"2001:db8::/64", "namespace,pod,image", "", "", "", "ctr", 0xa}, + {"2001:db8::/64", "namespace", "🦆", "🐧", "", "", 0}, + {"2001:db8::/64", "namespace", "ns\x00\x00", "p", "", "", 0}, + } { + f.Add(s.prefix, s.fields, s.ns, s.pod, s.image, s.fallback, s.nNibble) + } + + f.Fuzz(func(t *testing.T, prefix, fieldsStr, ns, pod, image, fallback string, nNibble byte) { + _, network, err := net.ParseCIDR(prefix) + if err != nil { + return + } + fields, ok := decodeFields(fieldsStr) + if !ok { + return + } + got, err := Embed(network, fields, Values{ + Namespace: ns, + Pod: pod, + Image: image, + ImageFallback: fallback, + }, nNibble) + if err != nil { + return + } + if !network.Contains(got) { + t.Fatalf("Embed(%s, %v) = %s, outside network", prefix, fields, got) + } + // Property: low nibble of last byte equals nNibble & 0x0F. + if want := nNibble & 0x0F; got[len(got)-1]&0x0F != want { + t.Fatalf("low nibble = %x, want %x", got[len(got)-1]&0x0F, want) + } + }) +} + +func decodeFields(s string) ([]Field, bool) { + if s == "" { + return nil, false + } + var out []Field + cur := []byte{} + flush := func() bool { + if len(cur) == 0 { + return true + } + switch string(cur) { + case string(FieldNamespace): + out = append(out, FieldNamespace) + case string(FieldPod): + out = append(out, FieldPod) + case string(FieldImage): + out = append(out, FieldImage) + default: + return false + } + cur = cur[:0] + return true + } + for i := 0; i < len(s); i++ { + if s[i] == ',' { + if !flush() { + return nil, false + } + continue + } + cur = append(cur, s[i]) + } + if !flush() { + return nil, false + } + if len(out) == 0 { + return nil, false + } + return out, true +} diff --git a/pkg/routing/bird/config.go b/pkg/routing/bird/config.go index 2eb9a4d..57f4033 100644 --- a/pkg/routing/bird/config.go +++ b/pkg/routing/bird/config.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "sort" + "strings" "text/template" ) @@ -118,28 +119,150 @@ protocol bgp upstream4_{{$i}} { {{end}}{{end}}` // Render produces the bird.conf text. +// +// The output is deterministic: the same NodeBGP input always produces the +// same string. CIDR lists, anycast lists, and peer lists are sorted before +// templating so that the only way the rendered config changes is when +// semantically meaningful inputs change. This stability matters because +// BirdManager compares Render output against the last-written config to +// avoid superfluous birdc reloads. +// +// Render validates every operator-supplied value that flows into the +// templated output (peer addresses, CIDRs, anycast IPs, source addresses) +// so a malformed NodeConfig or annotation cannot produce a malformed +// bird.conf — even one that BIRD would later reject. func Render(in NodeBGP) (string, error) { if in.RouterID == "" { - return "", fmt.Errorf("RouterID is required") + return "", fmt.Errorf("bird render: RouterID is required") + } + if net.ParseIP(in.RouterID) == nil { + return "", fmt.Errorf("bird render: RouterID %q is not a valid IP", in.RouterID) } if in.LocalASN == 0 { - return "", fmt.Errorf("LocalASN is required") + return "", fmt.Errorf("bird render: LocalASN is required") } - // Stable order — important so config changes only when something real - // changes (avoids needless birdc reloads). + if err := validateLocalSource(in.LocalV6, "v6"); err != nil { + return "", err + } + if err := validateLocalSource(in.LocalV4, "v4"); err != nil { + return "", err + } + for i, p := range in.Peers { + if err := validatePeer(p); err != nil { + return "", fmt.Errorf("bird render: peer[%d]: %w", i, err) + } + } + if err := validateCIDRs(in.CIDR6, "v6"); err != nil { + return "", fmt.Errorf("bird render: cidr6: %w", err) + } + if err := validateCIDRs(in.CIDR4, "v4"); err != nil { + return "", fmt.Errorf("bird render: cidr4: %w", err) + } + if err := validateAnycastIPs(in.Anycast6, "v6"); err != nil { + return "", fmt.Errorf("bird render: anycast6: %w", err) + } + if err := validateAnycastIPs(in.Anycast4, "v4"); err != nil { + return "", fmt.Errorf("bird render: anycast4: %w", err) + } + in = normalize(in) t, err := template.New("bird").Parse(tpl) if err != nil { - return "", err + return "", fmt.Errorf("bird template parse: %w", err) } var buf bytes.Buffer if err := t.Execute(&buf, in); err != nil { - return "", err + return "", fmt.Errorf("bird template execute: %w", err) } return buf.String(), nil } +// validatePeer checks that a peer entry has a parseable IP whose family +// matches its declared Family field, and a non-zero ASN. +func validatePeer(p Peer) error { + if p.ASN == 0 { + return fmt.Errorf("ASN must be non-zero") + } + ip := net.ParseIP(p.Address) + if ip == nil { + return fmt.Errorf("address %q is not a valid IP", p.Address) + } + isV4 := ip.To4() != nil + switch p.Family { + case "v6": + if isV4 { + return fmt.Errorf("address %q is IPv4 but Family is v6", p.Address) + } + case "v4": + if !isV4 { + return fmt.Errorf("address %q is IPv6 but Family is v4", p.Address) + } + default: + return fmt.Errorf("Family %q must be v6 or v4", p.Family) + } + return nil +} + +// validateCIDRs parses each entry as a CIDR and rejects family mismatches. +// fam must be "v6" or "v4". +func validateCIDRs(cidrs []string, fam string) error { + for _, c := range cidrs { + _, n, err := net.ParseCIDR(c) + if err != nil { + return fmt.Errorf("invalid CIDR %q: %w", c, err) + } + isV4 := n.IP.To4() != nil + if fam == "v6" && isV4 { + return fmt.Errorf("CIDR %q is IPv4, expected IPv6", c) + } + if fam == "v4" && !isV4 { + return fmt.Errorf("CIDR %q is IPv6, expected IPv4", c) + } + } + return nil +} + +// validateAnycastIPs parses each entry as a literal IP (no prefix) and rejects +// family mismatches. +func validateAnycastIPs(ips []string, fam string) error { + for _, s := range ips { + ip := net.ParseIP(s) + if ip == nil { + return fmt.Errorf("invalid IP %q", s) + } + isV4 := ip.To4() != nil + if fam == "v6" && isV4 { + return fmt.Errorf("IP %q is IPv4, expected IPv6", s) + } + if fam == "v4" && !isV4 { + return fmt.Errorf("IP %q is IPv6, expected IPv4", s) + } + } + return nil +} + +// validateLocalSource validates an optional LocalV6/LocalV4 source address. +// Empty is allowed (BIRD picks its own); non-empty must be a parseable IP of +// the matching family. +func validateLocalSource(s, fam string) error { + if s == "" { + return nil + } + ip := net.ParseIP(s) + if ip == nil { + return fmt.Errorf("bird render: Local%s %q is not a valid IP", strings.ToUpper(fam), s) + } + isV4 := ip.To4() != nil + if fam == "v6" && isV4 { + return fmt.Errorf("bird render: LocalV6 %q is IPv4", s) + } + if fam == "v4" && !isV4 { + return fmt.Errorf("bird render: LocalV4 %q is IPv6", s) + } + return nil +} + func normalize(in NodeBGP) NodeBGP { cp := in cp.CIDR6 = sortedUnique(in.CIDR6) diff --git a/pkg/routing/bird/config_fuzz_test.go b/pkg/routing/bird/config_fuzz_test.go new file mode 100644 index 0000000..8701011 --- /dev/null +++ b/pkg/routing/bird/config_fuzz_test.go @@ -0,0 +1,93 @@ +package bird + +import ( + "strings" + "testing" +) + +// FuzzRender drives the bird template with a wide range of inputs and +// confirms two safety properties: +// +// 1. Render never panics. +// 2. On nil-error return, the output is deterministic (calling Render +// twice with the same input yields byte-identical output) and contains +// no unbalanced braces (a smoke test for malformed template branches). +func FuzzRender(f *testing.F) { + type seed struct { + routerID string + asn uint32 + peerAddr string + peerASN uint32 + cidr6 string + cidr4 string + anycast6 string + anycast4 string + localV6 string + localV4 string + } + seeds := []seed{ + {routerID: "10.0.0.1", asn: 65101, peerAddr: "2001:db8::1", peerASN: 65000, cidr6: "2001:db8:f001::/64"}, + {routerID: "172.25.25.101", asn: 65101, peerAddr: "172.25.25.1", peerASN: 65000, cidr4: "172.25.210.0/24"}, + {routerID: "10.0.0.1", asn: 65101, peerAddr: "2001:db8::1", peerASN: 65000, cidr6: "2001:db8:f001::/64", anycast6: "2001:db8:a::1"}, + {routerID: "10.0.0.1", asn: 65101, peerAddr: "10.0.0.2", peerASN: 65000, cidr4: "10.0.0.0/24", anycast4: "10.255.0.1"}, + {routerID: "10.0.0.1", asn: 65101}, // no peer, no cidrs + {routerID: "", asn: 65101, peerAddr: "10.0.0.2", peerASN: 1}, // empty routerID → expect error + {routerID: "10.0.0.1", asn: 0, peerAddr: "10.0.0.2", peerASN: 1}, // zero ASN → expect error + // Backtick-bearing inputs to defend the template against accidental + // closure of the raw-string literal. + {routerID: "10.0.0.1`", asn: 65101}, + // Newlines and template-meta in user-supplied addresses + {routerID: "10.0.0.1", asn: 65101, peerAddr: "2001:db8::1\n{{kaboom}}", peerASN: 65000, cidr6: "2001:db8:f001::/64"}, + } + for _, s := range seeds { + f.Add(s.routerID, s.asn, s.peerAddr, s.peerASN, s.cidr6, s.cidr4, s.anycast6, s.anycast4, s.localV6, s.localV4) + } + + f.Fuzz(func(t *testing.T, routerID string, asn uint32, peerAddr string, peerASN uint32, cidr6, cidr4, anycast6, anycast4, localV6, localV4 string) { + in := NodeBGP{ + RouterID: routerID, + LocalASN: asn, + LocalV6: localV6, + LocalV4: localV4, + } + // Add the peer in whichever family it belongs to, if any. FamilyOf + // returns "" for non-IPs; that test exercises the "skip unknown + // family" branch in the bird agent code path. + if fam := FamilyOf(peerAddr); fam != "" { + in.Peers = []Peer{{Family: fam, Address: peerAddr, ASN: peerASN}} + } + if cidr6 != "" { + in.CIDR6 = []string{cidr6} + } + if cidr4 != "" { + in.CIDR4 = []string{cidr4} + } + if anycast6 != "" { + in.Anycast6 = []string{anycast6} + } + if anycast4 != "" { + in.Anycast4 = []string{anycast4} + } + + out, err := Render(in) + if err != nil { + return + } + + // Determinism. + out2, err := Render(in) + if err != nil { + t.Fatalf("Render became flaky: first ok, second %v", err) + } + if out != out2 { + t.Fatalf("Render not deterministic on identical input") + } + + // Smoke test for balanced braces. The template uses `{` and `}` + // as BIRD's block delimiters; if our template engine ever + // produced an unbalanced output we'd catch it here. + if got := strings.Count(out, "{") - strings.Count(out, "}"); got != 0 { + t.Fatalf("unbalanced braces: %d", got) + } + }) +} diff --git a/pkg/routing/bird/testdata/fuzz/FuzzRender/568393b92f99b2ad b/pkg/routing/bird/testdata/fuzz/FuzzRender/568393b92f99b2ad new file mode 100644 index 0000000..e4398d8 --- /dev/null +++ b/pkg/routing/bird/testdata/fuzz/FuzzRender/568393b92f99b2ad @@ -0,0 +1,11 @@ +go test fuzz v1 +string("0") +uint32(65101) +string("0") +uint32(1) +string("") +string("") +string("") +string("}") +string("") +string("")