--- # LIVE — reconciled by the non-gating `apps-vms` Flux Kustomization (see namespace.yaml header). # # ============================================================================================ # L4 EXPOSURE (the hard part) — why the shared HTTPS Gateway is NOT enough for UniFi. # ============================================================================================ # The Gateway API `shared` Gateway is an L7 HTTPS terminator (host-routed :443 -> backend). It # CANNOT express: raw TCP on non-HTTP ports (8080 inform, 6789), UDP at all (3478 STUN, 10001 # discovery), or the device-facing protocols that never speak HTTP. UniFi device ADOPTION rides # exactly those L4 ports. So the controller is exposed as a type=LoadBalancer Service whose VIP # is pulled from the CiliumLoadBalancerIPPool `default-pool` (dev 192.168.1.53-.60 — the # CILIUM_LB_POOL_START/STOP cluster-vars knobs) and ARP-announced on the flat LAN by the existing # CiliumL2AnnouncementPolicy `default-l2` — the SAME L2/LB-IPAM # machinery the Gateway ingress VIP already uses (platform-config/cilium/*). No new Cilium config # is required; these Services just consume the pool. # # ONE VIP, TWO SERVICES: a LoadBalancer Service is single-protocol-friendly, so TCP and UDP are # split into two Services that SHARE ONE IP via Cilium's `lbipam.cilium.io/sharing-key`. The IP is # PINNED (lbipam.cilium.io/ips) so devices have a STABLE inform/STUN target that never moves. # (Alternative: one mixed-protocol Service — needs the MixedProtocolLBService feature, GA on # k3s v1.31 — but the shared-key split is the lower-risk, Cilium-native pattern.) # # selector kubevirt.io/vm=unifi -> the virt-launcher pod; masquerade forwards the declared ports # into the guest. # # ------------------------------------------------------------------------------------------- # externalTrafficPolicy: Cluster — CORRECTED 2026-09-14 after an 8-DAY LAN OUTAGE. # ------------------------------------------------------------------------------------------- # This file used to say `Local`, justified as "preserves the LAN client source IP and keeps the # VIP pinned to the node running the VM (also what L2 announcement does)". BOTH HALVES OF THAT # WERE FALSE ON THIS CLUSTER, and the second one took the UniFi controller off the LAN entirely. # # 1) L2 ANNOUNCEMENT DOES NOT FOLLOW THE BACKEND. Cilium's L2 announcer elects one lease holder # per Service among the nodes matching the CiliumL2AnnouncementPolicy `default-l2` — which # matches ALL linux nodes — and it does NOT filter that set by which nodes hold a backend. # MEASURED on prod 2026-09-14: the unifi VM ran on k3s-prod-1 while BOTH # `cilium-l2announce-unifi-unifi-l4-tcp` and `-udp` were held by k3s-prod-2. With `Local`, an # announcing node that holds no backend has nowhere to send the packet and answers ICMP port # unreachable. 192.168.2.21:8443, :8080 and :11443 were dead from the LAN for up to 8 days # (the leases were 8 days old); the operator's controller UI and device inform path were down. # It does NOT self-heal: the lease holder keeps RENEWING after the backend moves, so only # deleting the Lease forces a re-election — and that re-election is random, so it can pick a # backend-less node again. See apps/wave-0/pihole-dns.yaml, which hit this first and is the # reference implementation this file now follows. # # 2) `Local` WAS NEVER PRESERVING THE CLIENT IP HERE ANYWAY — this is a KubeVirt VM. The # interface in 30-virtualmachine.yaml is `masquerade: {}`, which is KubeVirt's NAT binding: it # SNATs everything from the virt-launcher pod into the guest over a private link, so the UniFi # controller has always seen the pod-side masquerade gateway as the source, never the LAN # client. There is a SECOND translation below the Service, and it already destroyed the very # thing `Local` was being kept for. So the client-IP cost of this change is ZERO for UniFi: # it is giving up an address the guest could not see in the first place. # # Nothing UniFi depends on breaks: device identity in the adoption/inform flow is the device # MAC (and the device reports its own IP inside the inform payload), not the packet source, # and guest-portal authorization runs on the gateway appliance, not on this controller. # # `Cluster` makes EVERY node able to serve the VIP by forwarding to wherever the VM runs, which # is what makes this survive a reschedule and a rebuild. # # NO CHANGE IS NEEDED IN 40-networkpolicy.yaml, AND THAT WAS VERIFIED RATHER THAN ASSUMED. # Under `Cluster` the forwarded second hop is sourced from the ingress node and carries Cilium's # reserved `remote-node` identity. pihole-dns.yaml has to name `remote-node` explicitly in its # CiliumNetworkPolicy — but that is because its policy deliberately omits `cluster`. # unifi-allow-l4-ingress admits `cluster`, and Cilium's `cluster` entity SUBSUMES `remote-node` # (along with host/init/unmanaged). Confirmed on prod 2026-09-14 by dumping the endpoint's # datapath policy map (`cilium-dbg bpf policy get`) while the CNP was in its unmodified state: # `reserved:remote-node` was already an Allow on the L4 ports. Adding `remote-node` to that rule # would be a no-op, so it is deliberately NOT added here — do not "fix" it. # # NO loadBalancerSourceRanges HERE, DELIBERATELY — and this is where this file departs from # pihole-dns.yaml. PiHole added `loadBalancerSourceRanges: [${LAN_CIDR}]` because it was giving # up a restriction it actually had and genuinely needed to be LAN-only. These Services never had # a source restriction at all (unifi-allow-l4-ingress admits `world`), so `Cluster` removes no # protection and there is nothing to compensate for. Adding a LAN-only filter during an outage # fix would be a NEW restriction that could silently break a remote site's device informing over # WAN — trading this outage for a subtler one. If UniFi should be LAN-only, that is its own # change, made deliberately and tested against the device fleet. # # VIP: ${UNIFI_L4_VIP} (cluster-vars, R-ENV-02; dev 192.168.1.54, confirmed free, inside # default-pool and reserved in tofu — the Gateway already holds the pool's first IP, dev .53). # Substituted by the `apps-vms` Flux Kustomization, which carries postBuild.substituteFrom for # exactly this token; every shell $${...} in 30-virtualmachine.yaml's cloud-init is escaped as $${...} # accordingly. A new environment MUST set UNIFI_L4_VIP to a free address in ITS pool. # # REUSABLE PATTERN (the deferred `minecraft` :25565/L4 problem is the same shape): expose an # L4-only workload as a Cilium LoadBalancer on a pinned, L2-announced VIP with a per-app sharing # key; publish its A record from the Service via external-dns. minecraft = one TCP port; unifi is # the multi-port TCP+UDP generalization. apiVersion: v1 kind: Service metadata: name: unifi-l4-tcp namespace: unifi labels: app.kubernetes.io/name: unifi annotations: lbipam.cilium.io/sharing-key: "unifi" lbipam.cilium.io/ips: "${UNIFI_L4_VIP}" # external-dns (source: service) publishes A unifi.home.keithjackson.dev -> the pinned VIP. # This is the browser + device target; UI is https://unifi.home.keithjackson.dev:11443. external-dns.alpha.kubernetes.io/hostname: "unifi.${BASE_DOMAIN}" # DNS-ONLY, NEVER PROXIED (2026-11-08). external-dns runs with --cloudflare-proxied globally (the # gateway names point at the Cloudflare Tunnel); Cloudflare's proxy carries HTTP(S) only, so an # orange-clouded A for these L4 ports would be broken. Required by the L0 check # internal-services-publish-no-public-dns (9b) on every `service`-source publisher. external-dns.alpha.kubernetes.io/cloudflare-proxied: "false" spec: type: LoadBalancer externalTrafficPolicy: Cluster selector: kubevirt.io/vm: unifi ports: - { name: inform, port: 8080, targetPort: 8080, protocol: TCP } - { name: ui, port: 11443, targetPort: 11443, protocol: TCP } - { name: ui-legacy, port: 8443, targetPort: 8443, protocol: TCP } - { name: portal-http, port: 8880, targetPort: 8880, protocol: TCP } - { name: portal-https, port: 8843, targetPort: 8843, protocol: TCP } - { name: throughput, port: 6789, targetPort: 6789, protocol: TCP } --- apiVersion: v1 kind: Service metadata: name: unifi-l4-udp namespace: unifi labels: app.kubernetes.io/name: unifi annotations: lbipam.cilium.io/sharing-key: "unifi" # same key + same IP => shares the VIP with -tcp lbipam.cilium.io/ips: "${UNIFI_L4_VIP}" spec: type: LoadBalancer externalTrafficPolicy: Cluster selector: kubevirt.io/vm: unifi ports: - { name: stun, port: 3478, targetPort: 3478, protocol: UDP } - { name: discovery, port: 10001, targetPort: 10001, protocol: UDP }