NodeConfig defaults + code-quality pass + fuzz tests + README

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) <noreply@anthropic.com>
This commit is contained in:
Donavan Fritz
2026-04-25 09:25:45 -05:00
parent 677aec2a42
commit 71e584cf96
17 changed files with 1583 additions and 100 deletions
+326 -13
View File
@@ -1,22 +1,335 @@
# flock # 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) ## Table of contents
- `cmd/flock-agent` — DaemonSet binary
- `pkg/api/v1alpha1``NodeConfig` CRD types - [How it works](#how-it-works)
- `pkg/cni` — CNI plugin internals + RPC client - [Requirements](#requirements)
- `pkg/agent` — agent server, IPAM, state file, anycast, NetworkPolicy - [Quickstart](#quickstart)
- `pkg/embed``ip-algo` IID embedding (pure) - [NodeConfig CRD](#nodeconfig-crd)
- `pkg/routing/{bird,ospf}` — routing backends - [Pod annotations](#pod-annotations)
- `deploy/` — CRDs, RBAC, DaemonSet manifests - [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 `<anycast-ip> via <pod-eth0-ip> dev <veth>`
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 (`6451265534`,
`42000000004294967294`) 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 <node-name> 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 ## License
Apache 2.0. Apache 2.0 — see [LICENSE](LICENSE).
@@ -20,6 +20,9 @@ spec:
openAPIV3Schema: openAPIV3Schema:
type: object type: object
required: [spec] 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: properties:
spec: spec:
type: object type: object
@@ -35,6 +38,25 @@ spec:
items: items:
type: string type: string
description: IPv4 CIDR owned and aggregate-advertised by this node. 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: bgp:
type: object type: object
required: [asn, peers] required: [asn, peers]
@@ -70,3 +92,9 @@ spec:
- name: CIDR4 - name: CIDR4
type: string type: string
jsonPath: .spec.cidr4 jsonPath: .spec.cidr4
- name: DefV6
type: boolean
jsonPath: .spec.defaults.ipv6
- name: DefV4
type: boolean
jsonPath: .spec.defaults.ipv4
+28
View File
@@ -20,6 +20,9 @@ spec:
openAPIV3Schema: openAPIV3Schema:
type: object type: object
required: [spec] 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: properties:
spec: spec:
type: object type: object
@@ -35,6 +38,25 @@ spec:
items: items:
type: string type: string
description: IPv4 CIDR owned and aggregate-advertised by this node. 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: bgp:
type: object type: object
required: [asn, peers] required: [asn, peers]
@@ -70,6 +92,12 @@ spec:
- name: CIDR4 - name: CIDR4
type: string type: string
jsonPath: .spec.cidr4 jsonPath: .spec.cidr4
- name: DefV6
type: boolean
jsonPath: .spec.defaults.ipv6
- name: DefV4
type: boolean
jsonPath: .spec.defaults.ipv4
--- ---
apiVersion: v1 apiVersion: v1
kind: ServiceAccount kind: ServiceAccount
+175 -46
View File
@@ -5,77 +5,153 @@ import (
"net" "net"
"strings" "strings"
flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1"
"code.fritzlab.net/fritzlab/flock/pkg/embed" "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/" const annotationPrefix = "flock.fritzlab.net/"
// ParsedAnnotations is the typed view of a Pod's flock annotations. // Recognised annotation keys (without the prefix).
type ParsedAnnotations struct { const (
WantV6 bool annIPv6 = "ipv6"
WantV4 bool annIPv4 = "ipv4"
CIDR6 []*net.IPNet annCIDR6 = "cidr6"
CIDR4 []*net.IPNet annCIDR4 = "cidr4"
IPAlgo []embed.Field annIPAlgo = "ip-algo"
Anycast []net.IP 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) // BuiltinFamilyDefaults returns flock's hard-coded fallback: IPv6 only.
// and validates the post-merge combination. // This is the policy applied when no NodeConfig override is in effect.
func ParseAnnotations(in map[string]string) (*ParsedAnnotations, error) { //
out := &ParsedAnnotations{WantV6: true, WantV4: false} // 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 { // FamilyDefaultsFromNodeConfig resolves the effective per-node defaults,
switch strings.ToLower(strings.TrimSpace(v)) { // falling back to BuiltinFamilyDefaults for any field the NodeConfig leaves
case "true": // unset. A nil NodeConfig (or nil Spec.Defaults) returns the built-in
out.WantV6 = true // baseline unchanged.
case "false": func FamilyDefaultsFromNodeConfig(nc *flockv1alpha1.NodeConfig) FamilyDefaults {
out.WantV6 = false out := BuiltinFamilyDefaults()
default: if nc == nil || nc.Spec.Defaults == nil {
return nil, fmt.Errorf("annotation ipv6=%q: must be true or false", v) return out
}
} }
if v, ok := in[annotationPrefix+"ipv4"]; ok { if nc.Spec.Defaults.IPv6 != nil {
switch strings.ToLower(strings.TrimSpace(v)) { out.WantV6 = *nc.Spec.Defaults.IPv6
case "true": }
out.WantV4 = true if nc.Spec.Defaults.IPv4 != nil {
case "false": out.WantV4 = *nc.Spec.Defaults.IPv4
out.WantV4 = false }
default: return out
return nil, fmt.Errorf("annotation ipv4=%q: must be true or false", v) }
// 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 { 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 { if v, ok := in[annotationPrefix+annCIDR6]; ok {
nets, err := parseCIDRList(v) nets, err := parseCIDRList(v, familyV6)
if err != nil { if err != nil {
return nil, fmt.Errorf("annotation cidr6: %w", err) return nil, fmt.Errorf("annotation %s: %w", annCIDR6, err)
} }
out.CIDR6 = nets out.CIDR6 = nets
} }
if v, ok := in[annotationPrefix+"cidr4"]; ok { if v, ok := in[annotationPrefix+annCIDR4]; ok {
nets, err := parseCIDRList(v) nets, err := parseCIDRList(v, familyV4)
if err != nil { if err != nil {
return nil, fmt.Errorf("annotation cidr4: %w", err) return nil, fmt.Errorf("annotation %s: %w", annCIDR4, err)
} }
out.CIDR4 = nets out.CIDR4 = nets
} }
if v, ok := in[annotationPrefix+"ip-algo"]; ok { if v, ok := in[annotationPrefix+annIPAlgo]; ok {
fields, err := parseIPAlgo(v) fields, err := parseIPAlgo(v)
if err != nil { if err != nil {
return nil, fmt.Errorf("annotation ip-algo: %w", err) return nil, fmt.Errorf("annotation %s: %w", annIPAlgo, err)
} }
out.IPAlgo = fields out.IPAlgo = fields
} }
if v, ok := in[annotationPrefix+"anycast"]; ok { if v, ok := in[annotationPrefix+annAnycast]; ok {
ips, err := parseIPList(v) ips, err := parseIPList(v)
if err != nil { if err != nil {
return nil, fmt.Errorf("annotation anycast: %w", err) return nil, fmt.Errorf("annotation %s: %w", annAnycast, err)
} }
out.Anycast = ips out.Anycast = ips
} }
@@ -83,7 +159,39 @@ func ParseAnnotations(in map[string]string) (*ParsedAnnotations, error) {
return out, nil 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 var out []*net.IPNet
for _, part := range strings.Split(s, ",") { for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
@@ -94,6 +202,17 @@ func parseCIDRList(s string) ([]*net.IPNet, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err) 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) out = append(out, n)
} }
if len(out) == 0 { if len(out) == 0 {
@@ -102,6 +221,9 @@ func parseCIDRList(s string) ([]*net.IPNet, error) {
return out, nil 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) { func parseIPList(s string) ([]net.IP, error) {
var out []net.IP var out []net.IP
for _, part := range strings.Split(s, ",") { for _, part := range strings.Split(s, ",") {
@@ -121,6 +243,9 @@ func parseIPList(s string) ([]net.IP, error) {
return out, nil 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) { func parseIPAlgo(s string) ([]embed.Field, error) {
var out []embed.Field var out []embed.Field
for _, part := range strings.Split(s, ",") { for _, part := range strings.Split(s, ",") {
@@ -128,11 +253,11 @@ func parseIPAlgo(s string) ([]embed.Field, error) {
switch part { switch part {
case "": case "":
continue continue
case "namespace": case string(embed.FieldNamespace):
out = append(out, embed.FieldNamespace) out = append(out, embed.FieldNamespace)
case "pod": case string(embed.FieldPod):
out = append(out, embed.FieldPod) out = append(out, embed.FieldPod)
case "image": case string(embed.FieldImage):
out = append(out, embed.FieldImage) out = append(out, embed.FieldImage)
default: default:
return nil, fmt.Errorf("unknown ip-algo field %q (allowed: namespace, pod, image)", part) 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 return out, nil
} }
// CNIArgs parses the K=V;K=V CNI_ARGS string for the kubelet keys we care // CNIArgs is the typed view of the K=V;K=V CNI_ARGS string passed by kubelet.
// about. Other keys are ignored. // We only keep the fields the agent uses; unknown keys are ignored.
type CNIArgs struct { type CNIArgs struct {
PodNamespace string PodNamespace string
PodName string PodName string
@@ -153,6 +278,10 @@ type CNIArgs struct {
InfraID string 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 { func ParseCNIArgs(s string) CNIArgs {
var a CNIArgs var a CNIArgs
for _, kv := range strings.Split(s, ";") { for _, kv := range strings.Split(s, ";") {
+156
View File
@@ -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)
}
+172 -7
View File
@@ -3,11 +3,68 @@ package agent
import ( import (
"testing" "testing"
flockv1alpha1 "code.fritzlab.net/fritzlab/flock/pkg/api/v1alpha1"
"code.fritzlab.net/fritzlab/flock/pkg/embed" "code.fritzlab.net/fritzlab/flock/pkg/embed"
) )
func TestParseAnnotations_Defaults(t *testing.T) { // boolPtr returns a pointer to b — convenient for the *bool pointer fields
a, err := ParseAnnotations(nil) // 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 { if err != nil {
t.Fatal(err) 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{ a, err := ParseAnnotations(map[string]string{
annotationPrefix + "ipv4": "true", annotationPrefix + "ipv4": "true",
}) }, BuiltinFamilyDefaults())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -31,15 +114,49 @@ func TestParseAnnotations_DualStack(t *testing.T) {
func TestParseAnnotations_NoFamily(t *testing.T) { func TestParseAnnotations_NoFamily(t *testing.T) {
if _, err := ParseAnnotations(map[string]string{ if _, err := ParseAnnotations(map[string]string{
annotationPrefix + "ipv6": "false", annotationPrefix + "ipv6": "false",
}); err == nil { }, BuiltinFamilyDefaults()); err == nil {
t.Fatalf("expected error: ipv6=false ipv4=false") 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) { func TestParseAnnotations_IPAlgo(t *testing.T) {
a, err := ParseAnnotations(map[string]string{ a, err := ParseAnnotations(map[string]string{
annotationPrefix + "ip-algo": "namespace,pod,image", annotationPrefix + "ip-algo": "namespace,pod,image",
}) }, BuiltinFamilyDefaults())
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestParseAnnotations_CIDR(t *testing.T) {
a, err := ParseAnnotations(map[string]string{ a, err := ParseAnnotations(map[string]string{
annotationPrefix + "cidr6": "2602:817:3000:f001::/64, 2602:817:3000:f002::/64", annotationPrefix + "cidr6": "2602:817:3000:f001::/64, 2602:817:3000:f002::/64",
}) }, BuiltinFamilyDefaults())
if err != nil { if err != nil {
t.Fatal(err) 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) { func TestParseCNIArgs(t *testing.T) {
args := ParseCNIArgs("IgnoreUnknown=1;K8S_POD_NAMESPACE=mail;K8S_POD_NAME=stalwart-0;K8S_POD_INFRA_CONTAINER_ID=abc123") 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" { if args.PodNamespace != "mail" || args.PodName != "stalwart-0" || args.InfraID != "abc123" {
t.Fatalf("ParseCNIArgs got %+v", args) 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)
}
}
+22
View File
@@ -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
+2 -1
View File
@@ -49,7 +49,8 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re
return nil, fmt.Errorf("lookup pod: %w", err) 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 { if err != nil {
return nil, fmt.Errorf("parse annotations: %w", err) return nil, fmt.Errorf("parse annotations: %w", err)
} }
+63
View File
@@ -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)
}
}
})
}
+43 -20
View File
@@ -62,13 +62,15 @@ func (cryptoRand) PickIndex(n int) int {
} }
// AllocRequest describes a pending allocation. Values come from Pod metadata // 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 { type AllocRequest struct {
ContainerID string ContainerID string
Namespace string Namespace string
Pod string Pod string
// WantV6 / WantV4 come from the ipv6 / ipv4 annotations (defaults in // WantV6 / WantV4 are the post-merge address family selection (pod
// design doc: ipv6=true, ipv4=false). // annotation > NodeConfig.Spec.Defaults > built-in baseline). At least
// one MUST be true; Allocate rejects the request otherwise.
WantV6 bool WantV6 bool
WantV4 bool WantV4 bool
// AnnCIDR6 / AnnCIDR4 come from the cidr6 / cidr4 annotations. Empty // 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 // 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. // 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) { func (i *IPAM) randomV6(cidr *net.IPNet) (net.IP, error) {
ones, bits := cidr.Mask.Size() ones, bits := cidr.Mask.Size()
if bits != 128 { if bits != 128 {
return nil, fmt.Errorf("cidr %s is not IPv6", cidr) 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()) copy(out, cidr.IP.To16())
hostBits := 128 - ones rnd := make([]byte, net.IPv6len)
rnd := make([]byte, 16)
i.randSrc.FillIID(rnd) i.randSrc.FillIID(rnd)
// Merge rnd into out where mask bit is 0. for b := 0; b < net.IPv6len; b++ {
for b := 0; b < 16; b++ {
// Host bits start at bit index `ones`, byte `b`.
byteStart := b * 8 byteStart := b * 8
byteEnd := byteStart + 8 byteEnd := byteStart + 8
if byteEnd <= ones { switch {
continue // entirely network case byteEnd <= ones:
} // Entirely inside the network prefix — leave untouched.
if byteStart >= ones {
out[b] = rnd[b] // entirely host
continue 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 return out, nil
} }
@@ -360,15 +364,34 @@ func toStringSlice(ns []*net.IPNet) []string {
return out 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 { func canonical(ip net.IP) string {
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil { if v4 := ip.To4(); v4 != nil {
return v4.String() 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 { func ipToU32(ip net.IP) uint32 {
v4 := ip.To4() v4 := ip.To4()
if v4 == nil {
return 0
}
return uint32(v4[0])<<24 | uint32(v4[1])<<16 | uint32(v4[2])<<8 | uint32(v4[3]) return uint32(v4[0])<<24 | uint32(v4[1])<<16 | uint32(v4[2])<<8 | uint32(v4[3])
} }
+169
View File
@@ -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]})
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Package agent owns the in-process flock-agent runtime: IPAM, netns, state, // This file implements the durable per-node allocation file at
// anycast, and NetworkPolicy. This file implements the durable per-node // /var/lib/flock/allocations.json. The package-level doc lives in doc.go.
// allocation file at /var/lib/flock/allocations.json.
package agent package agent
import ( import (
+60 -4
View File
@@ -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 package v1alpha1
import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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 // The agent reads this on startup and via informer for live updates. There is
// no controller and no auto-allocation — purely declarative input. // 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 { type NodeConfigSpec struct {
// CIDR6 is the set of IPv6 CIDRs this node owns and advertises as BGP // 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"` CIDR6 []string `json:"cidr6,omitempty"`
// CIDR4 is the set of IPv4 CIDRs this node owns and advertises as BGP // 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"` CIDR4 []string `json:"cidr4,omitempty"`
// BGP configures the BGP sessions this node establishes upstream. // BGP configures the BGP sessions this node establishes upstream.
BGP BGPSpec `json:"bgp"` 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 { 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"` Peers []BGPPeer `json:"peers"`
} }
// BGPPeer is a single upstream BGP neighbor.
type BGPPeer struct { 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"` 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- // NodeConfig is the Schema for the nodeconfigs API. NodeConfigs are cluster-
+103
View File
@@ -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
}
+129 -6
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"net" "net"
"sort" "sort"
"strings"
"text/template" "text/template"
) )
@@ -118,28 +119,150 @@ protocol bgp upstream4_{{$i}} {
{{end}}{{end}}` {{end}}{{end}}`
// Render produces the bird.conf text. // 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) { func Render(in NodeBGP) (string, error) {
if in.RouterID == "" { 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 { 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 if err := validateLocalSource(in.LocalV6, "v6"); err != nil {
// changes (avoids needless birdc reloads). 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) in = normalize(in)
t, err := template.New("bird").Parse(tpl) t, err := template.New("bird").Parse(tpl)
if err != nil { if err != nil {
return "", err return "", fmt.Errorf("bird template parse: %w", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := t.Execute(&buf, in); err != nil { if err := t.Execute(&buf, in); err != nil {
return "", err return "", fmt.Errorf("bird template execute: %w", err)
} }
return buf.String(), nil 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 { func normalize(in NodeBGP) NodeBGP {
cp := in cp := in
cp.CIDR6 = sortedUnique(in.CIDR6) cp.CIDR6 = sortedUnique(in.CIDR6)
+93
View File
@@ -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)
}
})
}
@@ -0,0 +1,11 @@
go test fuzz v1
string("0")
uint32(65101)
string("0")
uint32(1)
string("")
string("")
string("")
string("}")
string("")
string("")