Three defects enabled the 2026-08-16 Gitea blackhole (bug-wdgjpz3a00gd):
1. Orphaned allocation GC missing: ungraceful eviction (TaintManagerEviction)
never calls CNI DEL, so the old node keeps advertising the pod's public
/128 via BGP. Older allocation wins BGP path selection; live pod's node
yields → blackhole.
Fix: after the pod informer syncs at startup, sweep all committed
allocations via orphanedCommitted(). Any allocation whose owner pod is
absent from the node (or whose UID mismatches, indicating name reuse) is
torn down, removed from the store, and released from IPAM. A 60 s
periodic GC goroutine provides the same sweep while the agent runs.
2. renderBird outside-aggregate IP loop lacked pod liveness check: stale
committed allocations caused BIRD to keep advertising the /128 even in
steady state between GC ticks.
Fix: before adding an outside-aggregate primary IP to the BIRD export,
verify the pod is still in the node-scoped informer cache with a matching
UID. Orphans are skipped silently; the GC cleans them on the next tick.
3. birdc startup race: the agent's first Render() fires before BIRD has
bound /run/flock/bird.ctl, so the configure call silently fails with
"Unable to connect" and the initial routes are never advertised. A
container-only flock-agent restart (BIRD left running) avoids the race;
a full pod restart re-hits it.
Fix: reload() now retries up to 20 × 500 ms on socket-absent and
"Unable to connect" conditions. Any other birdc failure (syntax error,
etc.) is not retried.
Fixes bug-wdgjpz3a00gd
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When flock.fritzlab.net/addresses provides a v6 or v4, the IP becomes
the pod's primary IP for that family — bound to eth0, default route off
it, on-link host route via setHostRoute, and a per-pod /128 or /32 in
BGP. IPAM no longer allocates a private IP alongside it. The pod ends up
with exactly the operator-supplied addresses on eth0 (plus any extras
beyond the first-of-family, which keep the pre-existing layered
behavior).
This is the fix the original addresses-annotation work missed: bug #1
allocated a private IP next to the public one (so VPN-routed clients
could land on the private path on Plex). Promoting addresses-supplied
IPs into the IPAM-style routing slot keeps the public IP as the only
primary IP visible from outside.
Three pieces:
- annotations.go: reject pods whose addresses/anycast IP family is
disabled (ipv6/ipv4 annotation or NodeConfig default). Both annotation
types rely on the family being enabled for return-path routing.
- handlers.go: peel first v6 + first v4 from Addresses into res.IP6/IP4;
suppress IPAM for those families; skip IPAM call entirely if both
families are addresses-supplied.
- anycast_linux.go: extend renderBird to advertise any IPAM IP that's
outside the node's BGP aggregate as a per-pod /32 or /128. This is
what makes 142.202.202.166 reachable when host004's pod CIDR is
172.25.214.0/24 — the addresses-promoted IP isn't covered by the
aggregate.
Tests: 7 new annotation tests covering the conflict cases (ipv4=false +
addresses-v4, NodeConfig default + addresses-v4, etc.) plus 5 unit tests
for the splitAddressesPrimary helper.
README updated with the addresses-replaces-IPAM behavior, the
addresses-vs-anycast comparison, the conflict rule, and a Plex-style
example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kubernetes limits pod.status.podIPs to one IPv4 + one IPv6 per pod.
Additional IPs in the CNI result are silently dropped by kubelet, making
the resultFromAllocation change in 4a60c00 a no-op. Revert it and add
a comment documenting the constraint so the intent is clear.
Addresses IPs remain fully functional: bound to eth0, advertised via
BGP, visible inside the pod — just not reflected in pod status.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
resultFromAllocation now appends Addresses entries to the CNI result so
they appear in pod.status.podIPs. Kubernetes and workloads that inspect
pod metadata (e.g. Plex remote-access detection) see the public IPs
alongside the IPAM-allocated ones.
Anycast IPs are intentionally excluded — they're shared across replicas
and must not appear as per-pod IPs in Kubernetes.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Like anycast, addresses IPs are advertised via BGP (/128+/32) and get
host routes via the AnycastReconciler. The sole difference: they are
assigned to pod eth0 instead of lo, so workloads that inspect their
primary interface (e.g. Plex remote-access detection) see the public IP
directly.
- annotations.go: annAddresses const, Addresses []net.IP in ParsedAnnotations
- state.go: Addresses []string persisted in allocations.json
- anycast.go: resolveAnycastTargets processes Anycast+Addresses together
- netns_linux.go: configurePodSide assigns Addresses to eth0
- netns_stub.go: mirror Addresses field for non-Linux builds
- handlers.go: thread Addresses through ADD path
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The `pod` field hashed pod.Name, which differs per replica because of
the ReplicaSet pod-template-hash + 5-char random suffix. With
namespace,pod,image, all replicas of the same Deployment got distinct
hextets even though they were the same workload.
Replace `pod` with `app` — a stable workload identifier derived from
the controller chain:
- Deployment → ReplicaSet → Pod: strip the pod-template-hash suffix
from the RS name (`traefik-789df685f` → `traefik`).
- StatefulSet/DaemonSet/Job → Pod: use controller name as-is.
- Bare pod: pod name.
Image now comes from pod.Spec.Containers[0].Image (the spec'd
reference). 64-hex-char values are treated as sha256 digests and
parsed as before; everything else (image:tag, short SHA) is FNV-1a-64'd
as a string. This makes `traefik:v3.5` deterministic across replicas
without needing the runtime-resolved digest.
Net effect: namespace,app,image yields identical hextets across all
replicas of the same Deployment except the trailing random N nibble.
embed.Values.Pod → App; AllocRequest.Pod kept for log context only,
new App and Image fields drive the embed call. handlers.go computes
both via deriveAppName + podImageRef helpers.
Tests: 7 new TestDeriveAppName_* cases (Deploy/STS/DS/bare/RS-without-
hash/non-controller-owner) + TestPodImageRef. Existing fuzz seeds
updated for the new keyword.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add flock.fritzlab.net/ip-algo as a node-wide default via NodeConfig
metadata.annotations. Pod-level annotation still wins. Empty, missing,
or invalid input at either level falls through to the next; invalid
values warn-log via the agent's slog. Both unset → fully random IID
(unchanged baseline).
ParseAnnotations no longer touches ip-algo; ResolveIPAlgo handles the
full precedence chain, called from PodHandler.Add with the cached
NodeConfig's annotations and the agent logger.
Tests: 9 new TestResolveIPAlgo_* cases covering pod-wins, all
fall-through paths, both-absent, nil node map, whitespace, and
duplicate-as-invalid. Fuzz target rebuilt without ip-algo input space
(now exercised by ResolveIPAlgo unit tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
CNI ADD now adds anycast IPs to the pod's lo interface (NOT eth0 — design
doc rationale: avoid NDP/ARP DAD conflicts when N replicas share an IP).
Allocation persists the anycast list.
AnycastReconciler:
desired = { ip → flock<8hex> } from
committed allocations × pod.Status.PodReady=True
diff against advertised, install/remove host /128 (v6) or /32 (v4)
re-render bird.conf with the active set
Triggers: 2s tick, AfterCommit (per ADD/DEL), Pod informer Ready
transitions (PodCache.OnReadyChange callback).
The bird template already supported Anycast6/Anycast4 via the export
filter — this turn finally drives those slices from runtime.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>