From 2daa2a21f3bb378036d5d9a2011afb3e96b5b962 Mon Sep 17 00:00:00 2001 From: Donavan Fritz Date: Tue, 28 Apr 2026 17:50:49 -0500 Subject: [PATCH] agent: add flock.fritzlab.net/addresses annotation (eth0 static IPs) 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) --- pkg/agent/annotations.go | 28 ++++++++++++++++++++++------ pkg/agent/anycast.go | 6 ++++-- pkg/agent/handlers.go | 2 ++ pkg/agent/netns_linux.go | 22 ++++++++++++++++++++++ pkg/agent/netns_stub.go | 1 + pkg/agent/state.go | 1 + 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/pkg/agent/annotations.go b/pkg/agent/annotations.go index 062c1f1..344ab34 100644 --- a/pkg/agent/annotations.go +++ b/pkg/agent/annotations.go @@ -16,12 +16,13 @@ const annotationPrefix = "flock.fritzlab.net/" // Recognised annotation keys (without the prefix). const ( - annIPv6 = "ipv6" - annIPv4 = "ipv4" - annCIDR6 = "cidr6" - annCIDR4 = "cidr4" - annIPAlgo = "ip-algo" - annAnycast = "anycast" + annIPv6 = "ipv6" + annIPv4 = "ipv4" + annCIDR6 = "cidr6" + annCIDR4 = "cidr4" + annIPAlgo = "ip-algo" + annAnycast = "anycast" + annAddresses = "addresses" ) // FamilyDefaults is the per-call baseline for whether a pod receives an IPv6 @@ -91,6 +92,13 @@ type ParsedAnnotations struct { // Anycast is the set of anycast IPs to bind on the pod's loopback. // nil/empty means "no anycast". Anycast []net.IP + // Addresses is the set of additional IPs to bind directly on the pod's + // eth0. BGP advertisement (/128+/32) is identical to Anycast; the only + // difference is that these IPs land on the primary interface instead of + // lo. Use this when the workload needs the IP directly visible on eth0 + // (e.g. Plex, which inspects its own interfaces for remote-access setup). + // nil/empty means "no extra addresses". + Addresses []net.IP } // ParseAnnotations applies the supplied per-node defaults and validates the @@ -150,6 +158,14 @@ func ParseAnnotations(in map[string]string, defaults FamilyDefaults) (*ParsedAnn out.Anycast = ips } + if v, ok := in[annotationPrefix+annAddresses]; ok { + ips, err := parseIPList(v) + if err != nil { + return nil, fmt.Errorf("annotation %s: %w", annAddresses, err) + } + out.Addresses = ips + } + return out, nil } diff --git a/pkg/agent/anycast.go b/pkg/agent/anycast.go index 759d761..35b9b67 100644 --- a/pkg/agent/anycast.go +++ b/pkg/agent/anycast.go @@ -64,7 +64,7 @@ func resolveAnycastTargets( } out := map[string]anycastTarget{} for _, a := range allocations { - if a.State != StateCommitted || len(a.Anycast) == 0 { + if a.State != StateCommitted || (len(a.Anycast) == 0 && len(a.Addresses) == 0) { continue } if !isReady(a.Namespace, a.PodName) { @@ -73,7 +73,9 @@ func resolveAnycastTargets( host := HostIfaceName(a.ContainerID) via6 := net.ParseIP(a.IP6) via4 := net.ParseIP(a.IP4) - for _, ipStr := range a.Anycast { + // Anycast (lo-bound) and Addresses (eth0-bound) are advertised + // identically: /128 or /32 host route on the host, BGP via BIRD. + for _, ipStr := range append(a.Anycast, a.Addresses...) { ip := net.ParseIP(ipStr) if ip == nil { continue diff --git a/pkg/agent/handlers.go b/pkg/agent/handlers.go index 619a6be..d768308 100644 --- a/pkg/agent/handlers.go +++ b/pkg/agent/handlers.go @@ -167,6 +167,7 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re IP6: ipString(res.IP6), IP4: ipString(res.IP4), Anycast: anycastStrings(parsed.Anycast), + Addresses: anycastStrings(parsed.Addresses), State: StatePending, AllocatedAt: time.Now().UTC(), } @@ -183,6 +184,7 @@ func (h *PodHandler) Add(ctx context.Context, req flockcni.Request) (*current.Re IP6: res.IP6, IP4: res.IP4, Anycast: parsed.Anycast, + Addresses: parsed.Addresses, } if err := h.SetupFunc(setup); err != nil { // Roll forward: leave pending entry in place so startup GC can clean diff --git a/pkg/agent/netns_linux.go b/pkg/agent/netns_linux.go index 5f40608..c1d93ee 100644 --- a/pkg/agent/netns_linux.go +++ b/pkg/agent/netns_linux.go @@ -25,6 +25,11 @@ type SetupRequest struct { // Host /128 and /32 routes are NOT installed here — that happens once // the pod becomes Ready, see AnycastReconciler. Anycast []net.IP + // Addresses are additional IPs to bind directly on pod eth0 (NOT lo). + // BGP advertisement is handled identically to Anycast by the + // AnycastReconciler. Use when the workload needs the IP on its primary + // interface (e.g. Plex remote-access detection). + Addresses []net.IP } // LinkLocalGW is the deterministic IPv6 LL gateway placed on every host @@ -269,6 +274,23 @@ func configurePodSide(req SetupRequest) error { } } + // Addresses: assign directly to pod eth0. Host routing and BGP + // advertisement are handled identically to Anycast by the + // AnycastReconciler (host route via pod-eth0-ip, /128+/32 in BIRD). + for _, ip := range req.Addresses { + var mask net.IPMask + if ip.To4() != nil { + mask = net.CIDRMask(32, 32) + ip = ip.To4() + } else { + mask = net.CIDRMask(128, 128) + } + a := &netlink.Addr{IPNet: &net.IPNet{IP: ip, Mask: mask}, Scope: int(netlink.SCOPE_UNIVERSE)} + if err := netlink.AddrAdd(eth0, a); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("pod eth0 address %s: %w", ip, err) + } + } + return nil }) } diff --git a/pkg/agent/netns_stub.go b/pkg/agent/netns_stub.go index da75770..b4087d6 100644 --- a/pkg/agent/netns_stub.go +++ b/pkg/agent/netns_stub.go @@ -16,6 +16,7 @@ type SetupRequest struct { IP6 net.IP IP4 net.IP Anycast []net.IP + Addresses []net.IP } // Setup is unimplemented on non-Linux platforms; the agent only runs in diff --git a/pkg/agent/state.go b/pkg/agent/state.go index 67b143a..09347c0 100644 --- a/pkg/agent/state.go +++ b/pkg/agent/state.go @@ -33,6 +33,7 @@ type Allocation struct { IP6 string `json:"ip6,omitempty"` IP4 string `json:"ip4,omitempty"` Anycast []string `json:"anycast,omitempty"` + Addresses []string `json:"addresses,omitempty"` State AllocationState `json:"state"` AllocatedAt time.Time `json:"allocated_at"` }