# ServiceDefinition: pihole # Source of truth: spec docs/13-service-catalog.md, row 6. # internal | egress internet | config on Longhorn (DR-critical config) | auth forward-auth. # Also a platform DNS dependency (docs/09). Validates against schemas/servicedefinition.schema.json. name: pihole kind: container source: image: pihole/pihole exposure: internal # LAN-only admin UI (docs/13 row 6; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) storage: - name: config size: 2Gi class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03) dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02) # ------------------------------------------------------------------------------------------------ # LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to. # ------------------------------------------------------------------------------------------------ # coredns-custom answers the ${BASE_DOMAIN} zone for PODS ONLY and says so in its own header ("LAN # clients keep their own answers (PiHole split-horizon)"). Until now PiHole served no DNS at all — # its Service was ClusterIP :80/:4180, no port 53, no LoadBalancer — so the LAN half did not exist # and LAN clients depended on hand-maintained per-client dnsmasq files. One went stale after the dev # decommission and cost the operator sonarr.${BASE_DOMAIN}. Upstream cannot cover for it either: the # SOHO router that owns this LAN strips RFC1918 answers for public domains as rebind protection and # returns NOERROR / ANSWER 0. apps/wave-0/pihole-dns.yaml exposes udp/53 + tcp/53 on # ${PIHOLE_DNS_VIP}; these env vars are what make the ANSWERS declarative. # # WHY env AND NOT A RECONCILER: the repo's level-triggered CronJob reconcilers # (platform/keycloak/client-secret-reconciler.yaml, the CNPG grant reconciler in # apps/wave-0/nextcloud-db.yaml) exist because their state has NO declarative entry point — a # Keycloak client secret lives in Keycloak's own database and a Postgres GRANT lives in the # catalog, and nothing in a manifest can express either. PiHole v6 is not that case. Every FTL # setting has an `FTLCONF_
_` environment variable, and a value supplied that way is # ENV-FORCED: FTL logs it as "forced through environment", applies it on every start ahead of # /etc/pihole/pihole.toml, and marks it read-only in the admin UI so a human cannot drift it. That # is strictly better than a reconciler — it is enforced at t=0 on a cold start rather than at the # next cron tick, it cannot be defeated by the UI, and it carries no extra RBAC, image or Job. A # CronJob here would be ceremony around a knob the app already exposes. (Verified on the live prod # image, PiHole Core v6.4.3 / FTL v6.7, 2026-09-14.) # # THE PVC IS NOT THE MECHANISM AND MUST NOT BE. The `config` volume below mounts at /data/config; # PiHole's actual state directory is /etc/pihole, which is EPHEMERAL in this Deployment. Anything # configured through the UI is lost on the next reschedule — which is exactly what happened to the # upstream servers: the live pod's pihole.toml carried a hand-set `upstreams` marked "### CHANGED" # that no manifest declared, so a restart would have left PiHole with upstreams = [] and no # recursion at all. Declaring upstreams here closes that too: PiHole must stay a usable GENERAL # resolver, not only authoritative for this zone, or pointing LAN clients at it breaks the internet. env: # Default is LOCAL, which answers only clients on the same subnet as the listening interface. The # pod's interface is on the CLUSTER pod CIDR, so every LAN client would be refused. ALL is # required for a LoadBalancer-fronted resolver; what keeps PiHole from being an OPEN resolver is # the exposure, not this setting — `loadBalancerSourceRanges: [${LAN_CIDR}]` on the DNS Services, # enforced by Cilium in the datapath before load-balancing, plus a CiliumNetworkPolicy that admits # nothing but udp/53 and tcp/53. See apps/wave-0/pihole-dns.yaml. - name: FTLCONF_dns_listeningMode value: "ALL" # Recursion for everything that is NOT this zone. Public resolvers on purpose, NOT the SOHO # router at the LAN gateway: that router is the box whose rebind protection strips RFC1918 # answers, and forwarding to a device that may forward back here invites a loop. - name: FTLCONF_dns_upstreams value: "8.8.8.8;8.8.4.4" # Per-client query rate limit OFF (default: 1000 queries per 60s, then REFUSED). It has to be off # because of how the DNS Services are exposed: apps/wave-0/pihole-dns.yaml uses # externalTrafficPolicy Cluster — it must, or LAN resolution breaks whenever Cilium's L2 # announcement lease and the pihole pod land on different nodes, which is most of the time — and # Cilium runs in SNAT mode here, so a query forwarded between nodes reaches PiHole carrying the # INGRESS NODE's address instead of the client's. Every LAN device therefore counts against ONE # apparent client's budget, and a normal evening of browsing from a handful of machines would trip # a limit meant to catch one misbehaving host. Leaving it at the default turns the whole LAN's DNS # off for 60 seconds at a time, intermittently, which is the worst possible failure shape. The # limit becomes meaningful again — and should be restored — if Cilium is ever switched to # `loadBalancer.mode: dsr`, which preserves the client address. - name: FTLCONF_dns_rateLimit_count value: "0" # THE SPLIT-HORIZON ANSWERS. A dnsmasq `address=//` entry answers that domain AND # every subdomain of it, and dnsmasq resolves overlapping entries by LONGEST MATCHING DOMAIN — # so the first line is a wildcard for the whole zone and each later line is a specific override. # PRECEDENCE IS THEREFORE STRUCTURAL, not order-dependent: `unifi.${BASE_DOMAIN}` matches both # line 1 and line 2, line 2 is the longer domain, line 2 wins. Rewriting these in any order # changes nothing. # # A NEWLY ONBOARDED SERVICE NEEDS NO CHANGE HERE. Every catalog ingressRoute host is # `