Skip to content
 
 

Repository files navigation

Dynamic Prefix Operator

Tests Build Latest Release Artifact Hub

A Kubernetes operator that manages dynamic IPv6 prefix delegation for bare-metal and home/SOHO Kubernetes clusters.

The Problem

You can host services from as many IPv6 addresses as you want — until you can't.

IPv6 promises virtually unlimited addresses. With a /48 or /56 prefix, you could theoretically assign unique global addresses to every service, pod, and device in your infrastructure. No more NAT, no more port conflicts, just direct end-to-end connectivity.

Then reality hits.

The Dynamic Prefix Problem

Many residential and SOHO ISPs assign IPv6 prefixes dynamically. These prefixes change:

  • Daily or weekly for "privacy" reasons
  • After router reboots
  • After DHCPv6 lease expiration
  • Randomly, because ISPs gonna ISP

When your prefix changes from 2001:db8:1234::/64 to 2001:db8:5678::/64, everything breaks:

  • LoadBalancer IPs become unreachable (bare-metal IPAM pools are static)
  • DNS records point to stale addresses
  • Firewall rules reference invalid CIDRs
  • Network policies stop matching traffic

The "solution" many resort to? NAT66 — taking the beautiful end-to-end transparency of IPv6 and bolting the same ugly NAT architecture that made IPv4 a nightmare.

Why This Matters for Kubernetes

Kubernetes on bare-metal or at home/SOHO is increasingly popular:

  • Talos Linux makes cluster management trivial
  • Cilium provides powerful networking without cloud dependencies
  • ArgoCD enables GitOps for home infrastructure

But all of this assumes stable IP addressing. Cloud providers give you static IPs. Your home ISP gives you a prefix that changes every time the wind blows.

The Solution

Dynamic Prefix Operator bridges this gap by:

  1. Monitoring prefix changes via Router Advertisement observation
  2. Calculating address ranges from the received prefix automatically
  3. Updating supported pool backends (Cilium, MetalLB, Calico) when prefixes change
  4. Managing graceful transitions to minimize service disruption

Quick Start

1. Install the operator

# Using the Helm repository
helm repo add dynamic-prefix-operator https://pkizzle.github.io/dynamic-prefix-operator
helm repo update
helm install dynamic-prefix-operator dynamic-prefix-operator/dynamic-prefix-operator

# Or using Helm OCI
helm install dynamic-prefix-operator \
  oci://ghcr.io/pkizzle/dynamic-prefix-operator/helm/dynamic-prefix-operator

# Or using kubectl
kubectl apply -f https://github.com/pkizzle/dynamic-prefix-operator/releases/latest/download/install.yaml

Operator HA

Leader election is supported for the operator itself, but the default deployment still uses a single replica to keep the footprint small. Scale the operator to at least two replicas if you want a warm standby that can take over when the leader pod exits.

Non-leader replicas intentionally still serve health probes and metrics while they wait for the lease. Controllers and prefix receivers only become active on the elected leader.

2. Create a DynamicPrefix with Address Ranges

The recommended approach for home/SOHO: reserve a portion of your /64 that your router won't hand out via DHCPv6/SLAAC.

apiVersion: dynamic-prefix.io/v1alpha1
kind: DynamicPrefix
metadata:
  name: home-ipv6
spec:
  acquisition:
    routerAdvertisement:
      interface: eth0
      enabled: true

  # Reserve ::f000:0:0:0 through ::ffff:ffff:ffff:ffff for Kubernetes services
  # Configure your router to NOT assign addresses in this range via SLAAC/DHCPv6
  addressRanges:
    - name: loadbalancers
      start: "::f000:0:0:0"
      end: "::ffff:ffff:ffff:ffff"

3. Create a supported pool that references it

Use the same dynamic-prefix.io/* annotations on whichever pool backend you run.

Cilium LB-IPAM

apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: ipv6-lb-pool
  annotations:
    dynamic-prefix.io/name: home-ipv6
    dynamic-prefix.io/address-range: loadbalancers
spec:
  blocks: []  # Operator manages this

MetalLB

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: ipv6-lb-pool
  namespace: metallb-system
  annotations:
    dynamic-prefix.io/name: home-ipv6
    dynamic-prefix.io/address-range: loadbalancers
spec:
  addresses: []  # Operator manages this

Calico

Calico IPPool.spec.cidr can only hold one exact CIDR, so use subnet mode or an address range that aligns exactly to one CIDR.

apiVersion: projectcalico.org/v3
kind: IPPool
metadata:
  name: ipv6-lb-pool
  annotations:
    dynamic-prefix.io/name: home-ipv6
    dynamic-prefix.io/subnet: loadbalancers
spec:
  cidr: 2001:db8::/64  # Operator replaces this with the calculated CIDR
  allowedUses:
    - LoadBalancer

4. Watch the operator populate the pool

kubectl get ciliumloadbalancerippool ipv6-lb-pool -o yaml
# spec.blocks now contains the actual address range from your prefix:
# - start: "2001:db8:1234:0:f000::"
#   stop: "2001:db8:1234:0:ffff:ffff:ffff:ffff"

For MetalLB, inspect spec.addresses; for Calico, inspect spec.cidr.

When your prefix changes, the operator automatically updates all annotated pools.

Architecture

                         Upstream Router / ISP
                                  │
                                  │ Router Advertisement or DHCPv6-PD
                                  ▼
┌────────────────────────────────────────────────────────────────────────────┐
│                         Dynamic Prefix Operator                            │
│                                                                            │
│  ┌─────────────────────┐        ┌──────────────────────────────────────┐   │
│  │ Prefix Receivers    │        │ DynamicPrefix Controller             │   │
│  │ • RA monitor        │───────▶│ • Manages receiver lifecycle         │   │
│  │ • DHCPv6-PD client  │        │ • Calculates ranges/subnets          │   │
│  └─────────────────────┘        │ • Updates status/history/conditions  │   │
│                                 └──────────────────┬───────────────────┘   │
│                                                    │ status.currentPrefix  │
│                                                    ▼                       │
│  ┌─────────────────────┐        ┌──────────────────────────────────────┐   │
│  │ Pool Backend        │◀───────│ PoolSync Controller                  │   │
│  │ Discovery           │        │ • Watches annotated backend pools     │   │
│  └─────────────────────┘        │ • Preserves unmanaged entries         │   │
│                                 │ • Keeps current + historical blocks   │   │
│                                 └──────────────────┬───────────────────┘   │
│                                                    ▼                       │
│                         ┌──────────────────────────────────────────────┐   │
│                         │ Supported pool backends                      │   │
│                         │ • CiliumLoadBalancerIPPool / CIDRGroup       │   │
│                         │ • MetalLB IPAddressPool                      │   │
│                         │ • Calico IPPool                              │   │
│                         └──────────────────────────────────────────────┘   │
│                                                                            │
│  ┌─────────────────────────────┐      ┌────────────────────────────────┐   │
│  │ ServiceSync Controller      │      │ BGPSync Controller             │   │
│  │ • HA mode multi-IP Services │      │ • CiliumBGPAdvertisement sync  │   │
│  │ • DNS target management     │      │ • Subnet mode route adverts    │   │
│  └─────────────────────────────┘      └────────────────────────────────┘   │
│                                                                            │
│  Emits Kubernetes events and Prometheus metrics for prefix/pool activity.   │
└────────────────────────────────────────────────────────────────────────────┘

Address Range Mode (Recommended)

For most home/SOHO setups, you receive a /64 prefix from your ISP. The operator lets you reserve a portion of that /64 for Kubernetes services.

How it works:

  1. Configure your router to NOT hand out addresses in a specific range (e.g., ::f000:0:0:0 to ::ffff:ffff:ffff:ffff)
  2. Tell the operator about this reserved range
  3. The operator monitors RAs for prefix changes and updates your annotated pool backend with the full addresses

Advantages:

  • Works with standard /64 allocations
  • No BGP required
  • Simple router configuration (just exclude a range from DHCPv6/SLAAC)
spec:
  addressRanges:
    - name: loadbalancers
      start: "::f000:0:0:0"        # Lower bound suffix
      end: "::ffff:ffff:ffff:ffff"  # Upper bound suffix

Graceful Prefix Transitions

When your ISP changes your prefix, the operator supports two transition modes to minimize service disruption:

Simple Mode (Default)

Keeps multiple address blocks in pools during transitions. Services retain their old IPs until the historical blocks are removed.

spec:
  transition:
    mode: simple           # Default
    maxPrefixHistory: 2    # Keep 2 previous prefixes in pool blocks

How it works:

  1. Prefix changes from A → B
  2. Pool now has blocks for both prefix A and B
  3. Existing services keep their prefix-A IPs
  4. New services get prefix-B IPs
  5. After another prefix change (B → C), oldest block (A) is dropped

HA Mode (High Availability)

For zero-downtime transitions, HA mode manages both LoadBalancer IPs and DNS targeting:

spec:
  transition:
    mode: ha
    maxPrefixHistory: 2

How it works:

  1. Prefix changes from A → B
  2. Service gets both IPs via lbipam.cilium.io/ips annotation
  3. DNS points to new IP only via external-dns.kubernetes.io/target

Which target annotation? The operator writes the target under every key listed in --external-dns-target-annotation-keys (chart: config.serviceSync.externalDNSTargetAnnotationKeys). The default writes both external-dns.alpha.kubernetes.io/target and external-dns.kubernetes.io/target, because external-dns v0.22 changed its own default prefix with no fallback and each version simply ignores the key it does not read. Narrow the list to the single key your external-dns reads once you are no longer migrating: the operator then releases the other from the Services it owns, preserving any entry it did not write itself. The examples below show one key for brevity.

  1. Old connections continue working (both IPs active on Service)
  2. New clients connect to new IP via DNS

Static suffix annotation (recommended for dual-stack):

Instead of relying on the Service's dynamically assigned IP to infer the host part, you can declare a static suffix. The operator combines it with each prefix to produce deterministic IPs:

# HA Mode with static suffix (preferred for dual-stack):
apiVersion: v1
kind: Service
metadata:
  name: my-service
  annotations:
    dynamic-prefix.io/name: home-ipv6
    dynamic-prefix.io/suffix: "::ffff:0:1"          # Static host part
    lbipam.cilium.io/ips: "198.51.100.10"           # IPv4 only — operator adds IPv6
    external-dns.kubernetes.io/target: "example.com"  # Hostname for IPv4 NAT
spec:
  type: LoadBalancer

After reconciliation, the annotations become:

# HA Mode result on Service:
annotations:
  lbipam.cilium.io/ips: "198.51.100.10,2001:db8:new::ffff:0:2,2001:db8:old::ffff:0:2"
  external-dns.kubernetes.io/target: "example.com,2001:db8:new::ffff:0:2"

The operator preserves all non-managed entries in both annotations:

  • lbipam.cilium.io/ips: IPv4 addresses and static IPv6 are preserved; managed IPv6 (current + historical) is appended
  • external-dns.kubernetes.io/target: Hostnames, IPv4 addresses, and static IPv6 are preserved; only the current IPv6 is appended (DNS should point to the new prefix)
# HA Mode result without suffix (dynamically assigned — inferred from Cilium-assigned IP):
annotations:
  lbipam.cilium.io/ips: "2001:db8:new::1,2001:db8:old::1"     # Both IPs active
  external-dns.kubernetes.io/target: "2001:db8:new::1"   # DNS → new only

DNS Spec Limitation: The operator preserves hostnames in the target annotation, but external-dns will discard them when both a hostname (CNAME) and IP addresses (A/AAAA) are present. Per RFC 1034, CNAME records cannot coexist with other record types on the same DNS name. External-DNS logs this as a conflict and keeps only the A/AAAA records. For dual-stack NAT setups (IPv4 via hostname, IPv6 via direct addresses), use a separate tool like ddns-updater to manage the A record, and configure external-dns with --managed-record-types=AAAA to manage only IPv6.

Zone-apex records are not managed by external-dns under --txt-suffix: external-dns derives the name of its ownership TXT record by splitting the first label off the record name, so for a zone apex the registry record lands outside the zone (example.com → example-<suffix>.com). External-dns then never adopts the apex record, and it is left untouched however often the prefix rotates — silently, since subdomains in the same zone are managed correctly and nothing reports an error. This is a limitation of the external-dns TXT registry, not of the target annotation this operator writes: the operator's contract ends at handing external-dns the right target, so it deliberately does not grow DNS-provider credentials and clients to work around it. If you need an apex AAAA that follows the prefix, point a dedicated updater at it (many DDNS clients can combine a detected prefix with a fixed interface identifier), or use a --txt-prefix ending in a dot so the registry record stays inside the zone.

Annotations for HA Mode Services

Annotation Description
dynamic-prefix.io/name Name of the DynamicPrefix CR (required)
dynamic-prefix.io/suffix Static IPv6 suffix (e.g., ::ffff:0:2). Preferred for dual-stack setups — operator calculates full IPv6 from prefix + suffix
dynamic-prefix.io/service-address-range Which address range for IP calculation (legacy mode)
dynamic-prefix.io/skip-external-dns-update Set to "true" to prevent the operator from managing the external-dns.kubernetes.io/target annotation on this Service. lbipam.cilium.io/ips is still managed normally

Supported Annotations

Add these annotations to supported pool resources to have them managed by the operator:

Annotation Description
dynamic-prefix.io/name Name of the DynamicPrefix CR to reference
dynamic-prefix.io/address-range Name of the address range to use
dynamic-prefix.io/subnet Name of the subnet to use

Add these annotations to LoadBalancer Services for HA mode:

Annotation Description
dynamic-prefix.io/name Name of the DynamicPrefix CR (required)
dynamic-prefix.io/suffix Static IPv6 suffix — operator manages IPv6, preserves IPv4
dynamic-prefix.io/service-address-range Address range for dynamically assigned IP offset calculation
dynamic-prefix.io/service-subnet Subnet for dynamically assigned IP offset calculation
dynamic-prefix.io/skip-external-dns-update Set to "true" to skip external-dns target management for this Service
dynamic-prefix.io/skip-l2-nudge Set to "true" to disable the L2 announcer nudge for this Service
dynamic-prefix.io/force-l2-nudge Set to "true" to apply the L2 announcer nudge even when version detection concluded this Cilium no longer needs it. skip-l2-nudge wins if both are set

Cilium L2 announcer nudge

On a Cilium release carrying the L2 announcer bug described in docs/cilium-l2announcer-bug-report.md, an address added to an existing Service is assigned by LB-IPAM and programmed into the datapath but never announced, so it silently fails to answer ARP/NDP. Every prefix rotation would otherwise leave each managed Service unreachable at its new address until something unrelated happened to touch it.

The announcer rebuilds a Service's addresses only when a Service, policy, node or lease event reaches it — never when a frontend appears. The operator therefore writes dynamic-prefix.io/l2-announce-nudge once the new address is actually present in status.loadBalancer.ingress, which supplies the missing event. The value is a fingerprint of the assigned address set, so the write happens once per change rather than on every reconcile.

This is automatic — you should not normally need to configure it. The operator reads the tag of the Cilium agent DaemonSet's image (k8s-app=cilium) and nudges only on a release that predates the fix. Upgrade Cilium and the nudge stops on its own, within five minutes and without touching a single Service.

Upstream status. Cilium fixed this in PR #47579 (merged 2026-07-29), which makes the announcer re-evaluate services on frontend changes. The fix is in v1.21.0-pre.0 and on the v1.20 branch, but not in v1.20.0 — it merged roughly 2½ hours after that release was built. The operator treats v1.20.1 and newer as fixed; a single threshold covers the 1.21 line too, since 1.21.0-pre.0 sorts above 1.20.1.

Every uncertain case nudges. The two directions are not symmetric: a wrong "already fixed" silently stops announcing rotated addresses — and that failure looks perfectly healthy from every other angle, since pool, annotation, Service status and datapath frontends are all correct — whereas a wrong "still broken" costs one annotation write per change to a Service's address set. So a tag is only believed when all of the following hold, and anything else nudges:

  • the image repository names Cilium (a sidecar's tag never decides the verdict);
  • the tag is a complete MAJOR.MINOR.PATCH version — a date-stamped nightly (cilium:20260810) or a bare build number is rejected rather than read as an enormous version that clears the threshold;
  • it is read from the container Cilium's own chart names cilium-agent;
  • exactly one DaemonSet is identifiable as the agent (a second one outside kube-system is ignored; two inside it are ambiguous);
  • the DaemonSet rollout has finished — during an upgrade the un-upgraded nodes still run the buggy announcer, so the template alone is not evidence.

A missing DaemonSet, a digest-only pin, an unreadable tag and absent RBAC all fall back to nudging too. The verdict is re-checked every five minutes, so upgrading Cilium underneath a running operator is picked up without a restart, and each change of verdict is logged at info level.

Two per-Service escape hatches cover the cases detection gets wrong:

  • dynamic-prefix.io/skip-l2-nudge: "true" suppresses the nudge regardless of the detected version — for a cluster not using Cilium L2 announcements at all.
  • dynamic-prefix.io/force-l2-nudge: "true" applies it regardless — the recovery when a fork or repackaging reports a version that makes detection stand down too early. skip wins if both are set.

This requires read-only list on apps/daemonsets, which the chart grants. Denying it is safe: the version simply cannot be determined and the operator nudges unconditionally.

Annotations written by the operator

These are ownership records, written and read by the operator. Do not set them by hand. Each one lists exactly what the operator put into the neighbouring field on its last pass, so the next pass can tell its own entries apart from yours without guessing from the address shape.

Annotation Written on Records
dynamic-prefix.io/managed-ips Services The addresses last written to lbipam.cilium.io/ips
dynamic-prefix.io/managed-targets Services The address last written to external-dns.kubernetes.io/target
dynamic-prefix.io/managed-blocks Cilium pools The blocks last written to spec.blocks
dynamic-prefix.io/managed-cidrs CIDR groups The CIDRs last written to spec.externalCIDRs
dynamic-prefix.io/managed-addresses MetalLB pools The entries last written to spec.addresses
dynamic-prefix.io/last-sync Both Timestamp of the last change the operator made
dynamic-prefix.io/l2-announce-nudge Services Fingerprint of the assigned addresses the L2 announcer nudge last forced Cilium to re-read

Deleting one is safe but not free: the operator falls back to matching against the prefixes currently in status, which cannot recognise an entry whose prefix has already aged out of the history window. It will then preserve that entry forever rather than replacing it. The record is rewritten on the next reconcile.

Removing dynamic-prefix.io/name releases the object: on the next reconcile the operator removes the entries named in its records, deletes the records, and stops managing it. Entries it never recorded are left untouched.

Restricting which prefixes are accepted

A delegated prefix is global unicast by definition, and by default anything outside 2000::/3 is rejected:

spec:
  acquisition:
    prefixFilter:
      requireGlobalUnicast: true   # default

This matters because a link usually advertises more than one prefix. If a Router Advertisement arrives carrying no global prefix — during upstream renegotiation, or on a link where a unique-local prefix is advertised alongside — accepting the unique-local one looks exactly like a delegation change: every derived address moves into a range that is not routable off-link, and the real prefix ages out of status.history as though it had been retired.

A rejected prefix is not destructive. status.currentPrefix keeps the last good value, the resource reports PrefixAcquired=False with reason PrefixRejected, and a warning event explains why. Set the field to false only when the prefix being tracked is deliberately not global unicast.

What an advertisement has to satisfy

Router Advertisements are validated as RFC 4861 §6.1.2 requires before anything in them is believed: the source address must be link-local, and the hop limit must be 255. The second is what makes an RA unforgeable from off-link — a router that forwards a packet must decrement the hop limit, so 255 on arrival proves the packet originated on this link. Advertisements failing either check are counted and dropped.

On their own, these checks do not make RA-based delegation a trusted channel. Every host on the segment satisfies both, so anyone with access to the link can send a conforming advertisement and move the prefix. That is inherent to taking delegation from Router Advertisements, and the same exposure every SLAAC host on the link has. What the checks do is restore the floor a conforming NDP implementation provides and rule out senders that are not on the link at all.

Naming the routers you believe

On a link you do not control, say which routers may be believed:

spec:
  acquisition:
    routerAdvertisement:
      interface: eth0
      # The router's link-local address on this link, which is what an
      # advertisement's source address carries. Anything else is dropped.
      trustedRouters:
        - fe80::1
    prefixFilter:
      # What a plausible delegation looks like here. Applies to DHCPv6-PD too:
      # a server handing back something far larger is taken just as much on
      # faith as a router advertising it.
      minPrefixLength: 48
      maxPrefixLength: 64

Advertisements from anywhere else, and prefixes outside the bounds, are dropped, counted in dynamic_prefix_rejected_router_advertisements_total{interface,reason}, and reported on the resource as a RouterAdvertisementsRejected warning at most once every five minutes. A rising count is the outward sign of something on the link advertising when it should not be.

There are three ways to handle an untrusted link, and they combine:

  1. Name the routers, as above. Useful where the switch cannot filter.
  2. Run RA Guard on the switch (RFC 6105), which drops rogue advertisements before they reach any host. Better where it is available, because it protects everything on the segment rather than only this operator.
  3. Use DHCPv6-PD instead, which does not take delegation from advertisements at all. This is the right answer behind switch-side RA Guard, since the advertisements would not reach the operator anyway. See docs/prefix-acquisition-modes.md.

Supported Resources

  • CiliumLoadBalancerIPPool — for Cilium LB-IPAM (spec.blocks with start/stop)
  • CiliumCIDRGroup — for network policies (spec.externalCIDRs)
  • MetalLB IPAddressPool — for MetalLB LoadBalancer pools (spec.addresses with CIDR or start-end entries)
  • Calico IPPool — for Calico LoadBalancer IPAM (spec.cidr; address ranges must align to one exact CIDR)
  • kube-vip pool ConfigMap — for the kube-vip cloud provider (one cidr-* or range-* key; opt-in, see below)

Backend Notes

Backend Resource Address range mode Subnet mode Notes
Cilium CiliumLoadBalancerIPPool Precise start/stop blocks CIDR blocks Preserves unmanaged IPv4/static IPv6 blocks
Cilium CiliumCIDRGroup Approximate containing CIDR CIDR entries Intended for network policy CIDR groups
MetalLB IPAddressPool Precise start-end entries CIDR entries L2Advertisement/BGPAdvertisement remain user-managed
Calico IPPool Exact CIDR-aligned ranges only spec.cidr Requires Calico LoadBalancer IPAM and, for BGP, user-managed BGPConfiguration
kube-vip pool ConfigMap range-* keys take start-end; cidr-* keys need CIDR-aligned ranges CIDR or range entries Off by default. Set kubevip.enabled=true (or pass --kubevip-configmap=<ns>/<name>) and annotate the ConfigMap with dynamic-prefix.io/kubevip-key

kube-vip

The kube-vip cloud provider keeps every pool in one ConfigMap, conventionally kube-system/kubevip, keyed by name. Enabling the backend grants the operator write access to ConfigMaps in that namespace, which nothing else it does needs, so it is off by default and its RBAC is a namespaced Role rather than part of the cluster-wide grant.

# values.yaml
kubevip:
  enabled: true
  configMap:
    namespace: kube-system
    name: kubevip
apiVersion: v1
kind: ConfigMap
metadata:
  name: kubevip
  namespace: kube-system
  annotations:
    dynamic-prefix.io/name: home-ipv6
    # Which key to manage. Required: one ConfigMap holds every pool in the
    # cluster, and cidr- and range- keys are allocated from differently.
    dynamic-prefix.io/kubevip-key: cidr-global
data:
  # The operator maintains its own entries here and leaves everything else --
  # including the whole IPv4 half of a dual-stack pool -- alone.
  cidr-global: 192.168.1.220/29

For HA mode on a kube-vip cluster, mark the Service so the operator writes kube-vip.io/loadbalancerIPs rather than Cilium's annotation:

metadata:
  annotations:
    dynamic-prefix.io/name: home-ipv6
    dynamic-prefix.io/lb-provider: kube-vip

Services without that annotation keep using lbipam.cilium.io/ips, so nothing changes for existing installs. During a rotation the annotation carries the current address and the historical ones; check that your kube-vip version announces all of them before relying on the drain window, or use simple mode, which is unaffected.

Configuration Reference

DynamicPrefix Spec

apiVersion: dynamic-prefix.io/v1alpha1
kind: DynamicPrefix
metadata:
  name: home-ipv6
spec:
  # How to receive the prefix. At least one method is required; configuring
  # both runs DHCPv6-PD as primary with Router Advertisements as fallback.
  acquisition:
    # Act as a DHCPv6-PD client. The only method that works behind switch-side
    # RA Guard, and the one to prefer where the upstream offers it.
    dhcpv6pd:
      interface: eth0
      requestedPrefixLength: 56   # hint to the server, 48-64

    routerAdvertisement:
      interface: eth0    # Interface to monitor for RAs
      enabled: true
      # Optional: believe only these routers, by link-local source address.
      trustedRouters:
        - fe80::1

    # Optional, applies to every acquisition method above.
    prefixFilter:
      requireGlobalUnicast: true  # reject anything outside 2000::/3
      minPrefixLength: 48         # reject implausibly large delegations
      maxPrefixLength: 64         # reject implausibly small ones

  # Address ranges within the /64 (recommended for home/SOHO)
  addressRanges:
    - name: loadbalancers
      start: "::f000:0:0:0"
      end: "::ffff:ffff:ffff:ffff"

  # Transition settings
  transition:
    mode: simple            # "simple" (default) or "ha" for high availability
    maxPrefixHistory: 2     # Number of historical prefixes to retain in pool blocks

Status

status:
  currentPrefix: "2001:db8:1234::/64"
  prefixSource: "router-advertisement"

  addressRanges:
    - name: loadbalancers
      start: "2001:db8:1234:0:f000::"
      end: "2001:db8:1234:0:ffff:ffff:ffff:ffff"

  conditions:
    - type: PrefixAcquired
      status: "True"
    - type: PoolsSynced
      status: "True"

Requirements

  • Kubernetes 1.28+
  • At least one supported pool backend: Cilium, MetalLB or Calico CRDs, or the kube-vip cloud provider's pool ConfigMap
  • hostNetwork: true for the operator pod. Both acquisition methods read the uplink directly: advertisements arrive on the host's interfaces, and the DHCPv6-PD client sources from the host interface's link-local address. This is the chart default.
  • NET_RAW, for the raw ICMPv6 socket Router Advertisement monitoring reads
  • NET_BIND_SERVICE, for the UDP 546 bind DHCPv6-PD needs. Dropping all capabilities takes this from root too, so it has to be added back explicitly; without it the client fails with bind: permission denied and the resource reports PrefixAcquired=False with reason AcquisitionFailed.

Prefix Change Behavior

When your ISP changes your prefix:

  1. Detection: The RA receiver detects the new prefix within seconds
  2. Status Update: DynamicPrefix status is updated with new prefix and calculated ranges
  3. Pool Sync: All annotated backend pools are updated with both old and new blocks where the backend supports it
  4. Service Sync (HA mode): Services get both IPs, DNS target updated with current IPv6 (preserving hostnames/IPv4)
  5. DNS Update: external-dns updates records based on Service IPs or target override

Simple Mode (Default)

  • Pools contain multiple blocks (current + historical prefixes)
  • Existing Services keep their old IPs until pool blocks are pruned
  • New Services get IPs from the current prefix block

HA Mode

  • Services are updated with all active IPs (old + new)
  • DNS target annotation ensures new clients get the new IP
  • Old connections continue working until they naturally close
  • Zero-downtime for properly configured setups
  • Non-managed entries preserved in both annotations — hostnames, IPv4, and static IPv6 are never disturbed

Recommendations:

  • Use short DNS TTLs (60-300s) so clients get new IPs quickly
  • Use HA mode if you need zero-downtime during prefix transitions
  • Ensure your applications handle reconnection gracefully
  • Monitor the PrefixAcquired condition for alerting

Current limitation: The operator updates DynamicPrefix status, pool backends, HA Service IP annotations, DNS target annotations, and Cilium BGP advertisements. It does not automatically restart arbitrary workloads after a prefix change. If an application caches external addresses, source addresses, or resolver state in-process, handle restarts with your deployment tooling (for example, a rollout controller or GitOps automation) until workload restart orchestration is added.

Observability

The operator exports controller-runtime metrics plus dynamic-prefix specific Prometheus series:

Metric Description
dynamic_prefix_received_total Prefixes acquired, labeled by DynamicPrefix name and source
dynamic_prefix_changes_total Prefix changes after an initial prefix was active
dynamic_prefix_lease_expiry_seconds Current lease expiry as a Unix timestamp, or 0 when unknown
dynamic_prefix_pools_synced Successful pool sync state labeled by backend, DynamicPrefix, and pool
dynamic_prefix_receiver_healthy 1 while the receiver's last acquisition attempt succeeded, 0 while acquisition or renewal is failing
dynamic_prefix_rejected_router_advertisements_total Advertisements dropped by validation, labeled by interface and reason

It also emits Kubernetes events for prefix acquisition, prefix changes, transition history pruning, receiver failures, and pool updates.

Roadmap

  • Core operator framework (kubebuilder)
  • Router Advertisement monitoring
  • Address range mode (within /64)
  • Cilium LB-IPAM integration
  • Cilium CIDRGroup integration
  • Graceful prefix transitions (simple mode)
  • HA mode with multi-IP Services and DNS targeting
  • Suffix annotation for declarative dual-stack IP management
  • Dual-stack IP preservation (IPv4 + static IPv6 untouched)
  • Subnet mode with BGP (carve /64s from larger prefix)
  • DHCPv6-PD client (act as PD client)
  • Calico IPPool backend
  • MetalLB IPAddressPool backend
  • Optional workload restart orchestration for applications that require restart after prefix change

Contributing

Contributions are welcome! See CONTRIBUTING.md for guidelines.

License

Apache License 2.0. See LICENSE for details.

Acknowledgments

About

IPv6 Dynamic Prefix Operator updating Kubernetes resources like IP pools when prefixes changes.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages