12 Troubleshooting - seadogger-tech/seadogger-homelab GitHub Wiki
- Date: 2025-08-15
- Service: Ansible Deployment
- Type Debug / Tech Debt
- Status: Complete
This document outlines a comprehensive debugging session that resolved a cascading series of failures in the Ansible deployment playbook. The root cause of the initial instability was traced to faulty hardware (a PoE network port), which was causing filesystem corruption on the anakin.seadogger-homelab node. Subsequent investigation revealed and corrected multiple latent bugs in the Ansible playbooks.
-
Symptom: The
anakin.seadogger-homelabnode was frequently going down, and its root filesystem was mounting as read-only (ro). The node also required an SD card to boot despite having its rootfs on an NVMe drive. -
Investigation:
- Confirmed the Pi's EEPROM bootloader was not configured to prioritize the NVMe drive.
- Identified that running the
wipe_k3s_cluster.ymlplaybook was making the node unbootable, suggesting a destructive operation was corrupting the disk.
-
Resolution:
- The primary root cause was discovered to be a faulty PoE port on the network switch, which was providing unstable power to the node. Moving the node to a different port resolved the instability.
- As a preventative measure, a dangerous
sgdisk --zap-allcommand, which was likely corrupting the disk's partition table, was commented out from thewipe_k3s_cluster.ymlplaybook.
-
Symptom: The
wipe_k3s_cluster.ymlplaybook failed on the[Flush all iptables rules]task with a "command not found" error on freshly imaged nodes. -
Resolution: An
ansible.builtin.apttask was added to the beginning of thewipe_k3s_cluster.ymlplaybook to ensure theiptablespackage is installed on all nodes before it is used.
-
Symptom: The
k3s_control_plane.ymlplaybook failed on tasks delegated tolocalhostbecause it was unnecessarily trying to usesudo. -
Resolution: The
become: falsedirective was added to all tasks delegated tolocalhostwithin thek3s_control_plane.ymlplaybook, preventing them from attempting privilege escalation on the Ansible controller machine.
-
Symptom: Even with
become: false, a delegated task failed while trying to create~/.kube/config, with the path incorrectly resolving to/root/.kube. -
Resolution: The variable
{{ ansible_env.HOME }}was replaced with{{ lookup('env', 'HOME') }}for alllocalhosttasks. This ensures the path correctly resolves to the home directory of the user running the playbook, not therootuser.
-
Symptom: The
rook_ceph_deploy_part2.ymlplaybook failed because it was trying to use an undefined variableenv_varsforkubectlcommands. -
Resolution: All instances of
environment: "{{ env_vars }}"were replaced with a correct environment block defining theKUBECONFIGvariable:environment: { KUBECONFIG: /etc/rancher/k3s/k3s.yaml }.
- Symptom: The user requested stricter control over which applications are deployed.
-
Resolution: The logic in
config.ymlfor allenable_*application flags was changed fromortoand. An application is now only deployed if the globalcold_start_stage_3_install_applicationsflag is true AND its specificmanual_install_*flag is also true.
- Date: 2025-08-11
- Service: Rook-Ceph NFS Ganesha Server
- Type Debug / Tech Debt
-
Ansible Playbook:
seadogger-homelab/ansible/tasks/rook_ceph_deploy.yml - Status: Deprecated
The primary issue was the inability to mount the Rook-Ceph NFS share on any client. The initial investigation pointed towards a networking problem, as the rook-nfs-loadbalancer service in the rook-ceph namespace was stuck in a <pending> state for its external IP address.
- Problem: The MetalLB deployment, managed by ArgoCD, was failing. Logs from the MetalLB controller indicated a configuration issue.
-
Analysis: We inspected the
metallbArgoCD application and its corresponding Helm chart configuration inseadogger-homelab/ansible/tasks/metallb_deploy.yml. -
Root Cause: A
helm.valueFilesoverride was incorrectly pointing to a non-existent values file, which was a remnant from a previous configuration. This caused the entire MetalLB installation to fail. -
Resolution: The faulty
helm.valueFilessection was removed frommetallb_deploy.yml. After redeploying the Ansible playbook, MetalLB started correctly, and therook-nfs-loadbalancerservice successfully acquired the IP address192.168.1.254.
-
Problem: Even with a valid IP, the NFS share was still not accessible. Further inspection of the
rook-nfs-loadbalancerservice revealed that while an IP was assigned, it might not have been correctly configured. -
Analysis: We reviewed the service definition within
seadogger-homelab/ansible/tasks/rook_ceph_deploy_part2.yml. -
Root Cause: The service was using the deprecated
metallb.universe.tf/loadBalancerIPsannotation to request a specific IP. The correct method for the installed version of MetalLB is to use thespec.loadBalancerIPfield directly in the service specification. -
Resolution: The YAML in
rook_ceph_deploy_part2.ymlwas updated to remove the annotation and add thespec.loadBalancerIP: 192.168.1.254field.
-
Problem: After fixing the service IP assignment, the NFS share could be successfully mounted from a Linux client (
yoda.local), but all mount attempts from a macOS client failed withrpc.gssapi.mechis.mech_gss_log_status: a gss_display_status() failed. -
Analysis: This error pointed towards a GSSAPI/Kerberos or authentication-level issue. However, given the server was configured for
AUTH_SYS, this was misleading. To get to the true root cause, we performed a packet capture on the client during a mount attempt usingtcpdump. -
Root Cause: Analysis of the
nfs_traffic.pcapfile in Wireshark definitively showed the server responding to the macOS client'sNFSv4.1CREATE_SESSIONrequest with anNFS4ERR_MINOR_VERS_MISMATCHerror. This indicates a fundamental protocol incompatibility between the macOS NFSv4.1 client and the Ganesha NFS server's v4.1 implementation as configured by Rook-Ceph. The Linux client, which likely defaulted to a compatible minor version (or NFSv3), succeeded. - Resolution: No immediate code fix is possible. This is a known incompatibility. The resolution is to document this limitation and use Linux clients or explore alternative file-sharing solutions for macOS if required.
The seadogger-homelab NFS service is correctly configured and fully functional. The inability for macOS clients to connect is not a bug in our configuration but a fundamental protocol version mismatch between the client and the server. The issue is now considered understood and documented as a known limitation.
- Date: 2025-08-10
- Service: Rook-Ceph NFS Ganesha Server
- Type Debug / Tech Debt
-
Ansible Playbook:
seadogger-homelab/ansible/tasks/rook_ceph_deploy.yml - Status: Deprecated
The Ansible playbook deployment consistently failed at the Verify Ganesha loaded export-100 from RADOS task. Inspection of the Kubernetes cluster revealed that the rook-ceph-nfs-nfs-ec-... pod was in a CrashLoopBackOff state.
-
Log Analysis:
- Used
kubectl logs -n rook-ceph rook-ceph-nfs-nfs-ec-... --previousto inspect the logs of the crashed container. - The logs revealed a
Permission deniederror originating from the NFS Ganesha process. This pointed towards an authentication issue with thecephxuser configured for the CephFS filesystem access.
- Used
-
Configuration Inspection:
- The Ganesha configuration is not stored in a standard ConfigMap but directly within Ceph's RADOS object store.
- The Ansible playbook creates a RADOS object named
export-100in the.nfspool within thenfs-ecnamespace. - The
FSAL(File System Abstraction Layer) block within this object contained thecephxcredentials.
-
Identifying the Root Cause:
- The
User_Idin theFSALblock was set toclient.nfs.nfs-ec. - Through research and analysis of Ganesha's behavior with Ceph, it was discovered that Ganesha automatically prepends the
client.prefix to theUser_Idwhen authenticating. - This resulted in an incorrect, double-prefixed user ID being sent to Ceph (
client.client.nfs.nfs-ec), causing the authentication to fail and the pod to crash.
- The
-
Corrected
User_Id:- The
fsal_user_idvariable in the Ansible playbook was changed to provide the user ID without theclient.prefix (i.e.,"nfs.nfs-ec"). - This allowed Ganesha to correctly form the
cephxuser ID (client.nfs.nfs-ec) and successfully authenticate with the Ceph cluster.
- The
-
Improved Verification Task:
- The verification task in the Ansible playbook was prone to shell-specific errors (
Illegal option -o pipefail). - The task was updated to explicitly use
/bin/bashas the executable, ensuring consistent behavior. - The
grepcommand's regex was also made more flexible to reliably detect the "export created" message in the Ganesha logs.
- The verification task in the Ansible playbook was prone to shell-specific errors (
-
Removed Redundant Task:
- The playbook contained a task to create the
.nfsmetadata pool for Ganesha. - It was determined that Rook creates this pool automatically when the
CephNFSCRD is created. - This redundant task was removed to simplify the playbook and prevent potential conflicts.
- The playbook contained a task to create the
After applying these fixes, the Ansible playbook seadogger-homelab/ansible/tasks/rook_ceph_deploy.yml executed successfully from start to finish. The rook-ceph-nfs pod now starts up and remains in a stable, Running state.
Although the deployment is now successful, a subsequent manual test to mount the NFS share from a client machine failed with a timeout. Initial investigation with kubectl get svc -n rook-ceph revealed that the rook-nfs-loadbalancer service was being assigned multiple external IP addresses by MetalLB, instead of the single static IP (192.168.1.253) defined in its configuration. The next phase of work will be to diagnose and resolve this MetalLB issue.
- Date: 2025-08-10
- Service: Rook-Ceph NFS Ganesha Server
- Type Debug / Tech Debt
-
Ansible Playbook:
seadogger-homelab/ansible/tasks/rook_ceph_deploy.yml - Status: Deprecated
We diagnosed a failing NFS deployment and, after a lengthy investigation, arrived at a definitive solution.
-
Initial State: The NFS server pods were in a
CrashLoopBackOffstate, and theLoadBalancerservice was missing. -
Pod Crash Analysis: Log analysis revealed the pods were crashing due to a Kerberos-related error (
gssd_refresh_krb5_machine_credential). This was caused by an incorrect security configuration. -
CRD Schema Investigation: Multiple attempts to fix the configuration via manual
kubectl applycommands failed due to a fundamental misunderstanding of theCephNFSCRD schema for Rook v1.14. We incorrectly assumed the NFS server was configured via aganeshaConfigblock or a separateCephNFSExportresource. -
Definitive Discovery: By consulting the official
values.yamlfor therook-ceph-clusterHelm chart (versionrelease-1.14), we discovered that the NFS server is not configured via the Helm chart at all. It must be deployed as a separate Kubernetes resource.
The definitive plan is to manage the CephNFS resource declaratively within the Ansible playbook, separate from the Helm release.
-
Clean
values.yaml: Remove the invalidcephNFSblock fromdeployments/rook-ceph/rook-ceph-cluster-values.yaml. -
Update Ansible Playbook: Add a new task to
ansible/tasks/rook_ceph_deploy.ymlthat uses theansible.builtin.k8smodule to create theCephNFSresource directly. This new resource will contain the correct security settings to prevent the pod crash. - Deploy and Verify: Run the updated Ansible playbook and verify that the NFS share is accessible.
- Date: 2026-03-07
- Service: ArgoCD
- Type: Authentication / GitOps
- Status: Known Issue - GitHub App migration needed
-
Update (2026-07-21):
portalandvelero-uiboth synced cleanly throughout a long session of frequent pushes (dozens of commits across both repos). Whether this means the PAT was rotated, GitHub App auth was already migrated, or the token simply hasn't hit its expiry yet is unconfirmed β don't treat this as resolved without checkingrepo-credentialsdirectly.
ArgoCD Applications fail to sync with error:
Failed to load target state: failed to generate manifest for source 1 of 1:
rpc error: code = Unknown desc = failed to list refs: authentication required:
Invalid username or token. Password authentication is not supported for Git operations.
Affected Applications:
-
portal(seadogger-homelab-pro repo) -
velero-ui(seadogger-homelab-pro repo)
ArgoCD uses GitHub Personal Access Token (PAT) stored in repo-credentials secret. PATs expire periodically (typically 90 days to 1 year), requiring manual rotation.
Deploy affected applications manually:
# Portal
cd /path/to/seadogger-homelab-pro/deployments/portal
kubectl apply -k .
# Velero UI
cd /path/to/seadogger-homelab-pro/deployments/velero-ui
kubectl apply -k .Migrate to GitHub App authentication (tokens auto-refresh, never expire):
-
Create GitHub App:
- Go to Settings β Developer settings β GitHub Apps β New GitHub App
- Set Homepage URL (any valid URL)
- Permissions: Repository β Contents (Read-only)
- Install app on
seadogger-homelab-prorepository
-
Get credentials:
- Note the App ID
- Generate and download private key
- Note the Installation ID
-
Update ArgoCD secret:
kubectl create secret generic repo-credentials \ --from-literal=type=git \ --from-literal=url=https://github.com/seadogger-tech/seadogger-homelab-pro \ --from-file=githubAppPrivateKey=/path/to/private-key.pem \ --from-literal=githubAppID=<APP_ID> \ --from-literal=githubAppInstallationID=<INSTALLATION_ID> \ --dry-run=client -o yaml | kubectl apply -f - -n argocd
-
Verify:
kubectl get application -n argocd portal -o jsonpath='{.status.sync.status}' kubectl get application -n argocd velero-ui -o jsonpath='{.status.sync.status}'
- ArgoCD GitHub App Documentation
- Issue #8 - Tracking issue for migration
- Date: 2026-07-18 through 2026-07-21
- Service: anakin node (PoE+ HAT), Rook-Ceph, cluster-wide CPU allocation
- Type: Hardware failure / Multi-day recovery
- Status: Resolved
-
Symptom:
anakin.localwentNotReady; kubelet stopped posting status. Not a DNS issue (the usual macOS mDNS cache problem, see below) β the node didn't respond to ping or SSH by IP either. - Root cause: the node's PoE+ HAT failed. Confirmed physically: the Ubiquiti switch showed PoE+ being drawn, but the Pi's own Ethernet jack showed zero link/activity lights β the fault was on the Pi side, not the network. Plugging in a USB-C power supply directly produced a burnt-electronics smell, confirming actual hardware damage (not just a loose connection).
-
Resolution: physical HAT replacement. Verified by temporarily
borrowing a known-good HAT from
obiwan(see below) before the replacement part arrived.
- With
anakin's OSD down,ceph -sreportedHEALTH_WARN, 24% degraded objects, 142 undersized PGs.ceph-fs-data-ecusesk=2,m=1erasure coding across exactly 3 hosts β with only 2 up, EC pools cannot reach full redundancy no matter how long you wait; this is expected, not a separate bug. - Marking the down OSD
out(ceph osd out osd.0) is safe and fully reversible β it just tells Ceph to stop counting on that device for placement. When the node returns,ceph osd in osd.0(or Rook's own reconciliation) rebalances data back onto it automatically; no data is lost by marking it out in the meantime. - Stuck
Terminatingpods on the dead node (ArgoCD, Jellyfin, Rook CSI controllers) neededkubectl delete pod --grace-period=0 --forceβ the API server can't get a graceful-termination confirmation from a kubelet that will never respond again.
- To verify the HAT theory before the replacement part arrived,
obiwan's HAT was temporarily moved toanakin. Every Ceph pool hasmin_size: 2(replicated pools included, not just thek=2,m=1EC pool) β so taking down a second node, even briefly, drops the cluster to 1 of 3 OSDs and makes all storage read/write unavailable, not just degraded. This was done deliberately, withobiwancordoned and its non-Ceph workloads evicted first, and the outage window was bounded (a few minutes) before the HAT went back. - Confirmed the theory:
anakincame up cleanly and pinged immediately once given a working HAT.
- After both nodes were back and OSDs re-joined, several pods
(
hermes-jason's dev pod,signal-cli) stayed stuck onFailedAttachVolume: volume attachment is being deletedorMulti-Attach error, long after the underlying node problems were fixed. -
Root cause: force-deleting pods on an unreachable node (step 1
above) doesn't cleanly release their CSI
VolumeAttachmentobjects β the attach/detach controller is left waiting for a detach confirmation from a kubelet that already restarted. These staleVolumeAttachments then blocked new attach attempts for the same PVC on any node. -
Resolution:
kubectl patch volumeattachment <name> -p '{"metadata":{"finalizers":null}}' --type=mergeclears the stuck object without waiting for the CSI driver's own confirmation. Safe for CephFS-backed volumes (RWX, no exclusive-lock risk); would warrant more caution on RBD (RWO, exclusive-lock) volumes, since it bypasses the detach protocol. - Also required a restart of the CephFS/RBD CSI ctrlplugin pods
(
kubectl delete pod ...) to clear an in-memory "operation already in progress" lock left over from the same force-deletes.
While investigating cluster-wide CPU pressure exposed by this outage
(see below), an unpinned helm upgrade against the Rook chart repo
picked up a breaking version and briefly took down the CSI
controller-plugin. See 13-ADR-Index ADR-010 for the full
incident and the chart-pinning fix.
- Losing a node exposed that all 4 nodes were running close to 90%+ CPU
requested (not actually used β real usage was 13-47%). Chasing
this down to yoda's mon disk-space warning (
MON_DISK_LOW) revealed the real, unrelated cause: yoda's mon store is only 113 MB, but its root disk (which yoda's mon shares with the k3s control plane and containerd β yoda holds no Ceph OSD) was at 77% (334 GB) because containerd's image cache had accumulated ~50-60 GB of stale, unreferencedhermes-agentimage layers (imagePullPolicy: Always+:latest+ frequent upstream rebuilds = old layers pile up, never garbage collected). -
Fix:
k3s crictl rmi --pruneon yoda freed ~188 GB (77% β 34% disk usage), which cleared theMON_DISK_LOWwarning immediately. -
Attempted but reverted: lowering kubelet's image-GC thresholds
(default 85%/80%) via
--kubelet-arg=image-gc-high-threshold-percentbriefly crash-looped yoda's kubelet β those flags were removed from kubelet in this Kubernetes version in favor of a structuredKubeletConfigurationYAML file. Reverted; the underlying disk-bloat problem is solved via the manual prune above, the proactive-GC-tuning idea is parked, not implemented. - Separately, actual CPU requests (not disk) were right-sized: Rook CSI plugin/provisioner containers (~1.1 cores freed, see ADR-010) and several idle-most-of-the-time app requests β jellyfin, hermes, signal-cli, bedrock-gateway (~840m freed) β were lowered to match observed usage. OSD/mon/mgr/rgw requests were deliberately left untouched; they exist specifically to guarantee CPU during the kind of recovery burst this incident produced.
- A node's kubelet going silent and a DNS resolution failure produce
similar-looking symptoms from
kubectlβ always independently verify withping/sshby IP before assuming it's the known macOS mDNS cache issue. - Check
min_sizeon every pool before taking down a second node for any reason, even briefly β it's easy to reason correctly about the EC pool's tolerance and forget that replicated pools have their own, possibly different, threshold. - Force-deleting pods on a dead node is necessary but not free β it can
leave stale
VolumeAttachmentobjects and CSI driver in-memory locks that block scheduling long after the node itself recovers. Check for these explicitly during recovery, don't assume "node is Ready again" means "fully recovered." -
kubectl top nodesrequests-vs-usage gaps are worth investigating even when nothing is on fire β this incident's disk-space root cause (containerd image bloat) had been silently accumulating for weeks and would have eventually caused its own outage independent of the hardware failure.
Symptom: After a house power failure, "every wired interface is
down." Nothing resolves β but the network is actually fine. Nodes ping,
the gateway responds, k3s is Ready on all four nodes. Nearly every pod
sits in ImagePullBackOff with:
dial tcp: lookup registry-1.docker.io: Try again
-
Pi-hole's only upstream is its own
cloudflaredsidecar (FTLCONF_dns_upstreams=127.0.0.1#5053, DoH). No fallback. - That sidecar is
crazymax/cloudflared:latestwithimagePullPolicy: Always.Alwaysforces kubelet to contact Docker Hub on every container start β even when a valid copy is cached locally. Reaching Docker Hub needs DNS. -
Every node resolves via
192.168.1.250β the Pi-hole LoadBalancer. Pi-hole runs on the cluster that depends on it.
So: DNS needs cloudflared β cloudflared needs a registry pull β the pull needs DNS. Nothing recovers on its own.
Compounding factor: Raspberry Pis have no battery-backed RTC. Three
of four nodes came back with clocks up to 2m39s off. That skewed the
Ceph mons (clock skew detected on mon.d, mon.e, 143 slow ops), which
made PVC mounts time out β so Pi-hole couldn't start even once its image
was available. NTP couldn't correct it because NTP also needs DNS.
# 1. Break the DNS loop β temporary public resolver on every node.
# NOTE: ansible_user is `pi`, not your laptop username.
for h in 192.168.1.95 192.168.1.96 192.168.1.97 192.168.1.98; do
ssh pi@$h 'sudo sed -i "1i nameserver 1.1.1.1" /etc/resolv.conf'
done
# 2. Resync clocks (fixes the Ceph mon skew / slow ops)
for h in 192.168.1.96 192.168.1.97 192.168.1.98; do
ssh pi@$h 'sudo systemctl restart systemd-timesyncd'
done
# verify: timedatectl | grep synchronized -> "yes" on all nodes
# 3. Let the images pull, then kick anything stuck in backoff
kubectl get pods -A --no-headers | awk '$4 ~ /ImagePull|ErrImage/ {print $1" "$2}' \
| while read ns p; do kubectl delete pod -n "$ns" "$p" --wait=false; done
# 4. Once Pi-hole is 2/2 and resolving, remove the temporary resolver
for h in 192.168.1.95 192.168.1.96 192.168.1.97 192.168.1.98; do
ssh pi@$h 'sudo sed -i "/^nameserver 1.1.1.1$/d" /etc/resolv.conf'
done-
/etc/resolv.confis managed by NetworkManager on these nodes (systemd-resolvedanddhcpcdare both inactive). A manual edit is not persistent β it is lost on reboot, so this workaround does not protect against the next power failure. See 19-Refactoring-Roadmap Priority 9 for the durable options. -
Don't restart CoreDNS to "fix" DNS during an outage. Doing so
during this incident rescheduled it onto a node with no cached CoreDNS
image, taking cluster DNS down completely. If you must, pin it first:
kubectl patch deploy coredns -n kube-system -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":"<node-with-image>"}}}}}' -
Check which nodes actually have an image cached before letting a
pod reschedule:
Note this list can be stale β it may report an image kubelet no longer has. Trust the
kubectl get nodes -o json | python3 -c " import json,sys for n in json.load(sys.stdin)['items']: names=[i for img in n['status'].get('images',[]) for i in img.get('names',[])] print(n['metadata']['name'], [i for i in names if 'cloudflared' in i] or 'NOT CACHED')"
Pullingevent over the node status. -
ArgoCD is also DNS-blocked during the outage (
failed to get command args to log: helm pull ... lookup ... server misbehaving), soselfHealwon't fight your emergency patches β but it will revert them the moment DNS recovers. Anything you want to keep must go in git.
The manual recovery above should no longer be needed. All of this is now either in git or in the UDM controller config:
| Fix | Where | Effect |
|---|---|---|
cloudflared pinned 2025.9.1 + IfNotPresent
|
deployments/pihole/pihole-values.yaml |
kubelet no longer needs a registry round-trip to start the DoH sidecar. Chart default was already IfNotPresent; the values file had overridden it to Always. |
Node DNS = Pi-hole primary + 1.1.1.1 fallback |
ansible/tasks/pihole_deploy.yml (NetworkManager profile) |
Nodes can resolve during a Pi-hole outage β images pull β cluster self-heals |
| CoreDNS forwards explicitly to Pi-hole | forward . 192.168.1.250 |
Pods cannot reach the node fallback, so filtering is never bypassed |
| CoreDNS image seeded on all 4 nodes | one-time pre-pull | A CoreDNS reschedule can no longer land on a node without the image |
Why the node fix lives in NetworkManager, not /etc/resolv.conf:
NM owns that file on these nodes (systemd-resolved and dhcpcd are
both inactive) and rewrites it on reboot. Editing resolv.conf directly
looks like it works and then silently vanishes β which is exactly how the
deadlock stays armed.
Why CoreDNS must be pinned explicitly: it previously used
forward . /etc/resolv.conf, inheriting whatever the node used. Once the
node gained a 1.1.1.1 fallback, CoreDNS would have inherited it too β
and its forward plugin defaults to policy random, which
load-balances rather than failing over. Roughly half of all pod DNS
would have gone to 1.1.1.1 unfiltered, permanently. Pinning is what
makes the node fallback safe to have.
Before this work, "everything uses Pi-hole" was a convention, not a
rule β DHCP handed out 192.168.1.250 on all three networks, but
nothing stopped a device from ignoring it.
Four DNS firewall rules existed and were enabled, and had matched zero packets since they were written:
num pkts bytes target
1 0 0 RETURN β allow Pi-hole DNS
4 0 0 DROP β "drop hardcoded DNS"
7 3626K 926M RETURN β default allow-everything
Three independent defects:
-
Port group on both Source and Destination. The rule required
source port 53 AND destination port 53. Real queries use an
ephemeral source port (
sport=33124 dport=53), so nothing ever matched. -
Wrong source address. The allow rules matched
src = 192.168.1.250, the MetalLB VIP. Kubernetes SNATs pod egress to the node IP, so the VIP never appears as a source. The VIP is ingress-only. -
Dead MAC filter. The allow rules were pinned to
dc:a6:32:f2:73:9dβ adc:a6:32(Pi 4-era) MAC with no active lease. The current nodes are all2c:cf:67(Pi 5). It was almost certainly the original Pi-hole host from before the Pi 5 rebuild.
group "K3s Nodes Pi-hole Egress" = 192.168.1.95, .96, .97, .98
20001 ACCEPT src=K3s Nodes dst=port 53 (recovery escape hatch)
20002 ACCEPT src=K3s Nodes dst=port 443 (Pi-hole's cloudflared DoH)
20003 DROP src=(any) dst=port 53 (hardcoded DNS)
20004 DELETED
The node group does double duty: it is both Pi-hole's real egress identity and the power-failure recovery exception. Because Pi-hole relocates between nodes, all four IPs must be listed β a rule pinned to one node IP works until the next reschedule.
β οΈ Rule 20004 was deleted, not fixed. It targetedDNSSEC_Port, which is port 443. Correcting it the way 20003 needed correcting βany β dst port 443β would have dropped all outbound HTTPS for the entire network. DNSSEC is port 53; port 443 for DNS is DoH, and DoH can only be blocked by destination address, never by port.
WAN_OUT rules structurally cannot see LANβrouter traffic, and blocking
it was impossible on two of three networks anyway β br2 (IoT) and br3
(gaming) use UBIOS_GUEST_LOCAL_USER, whose built-in allow sits above
any user rule:
1 RETURN tcp dpt:53
2 RETURN udp dpt:53 β built-in, cannot be overridden
7 DROP all
The real cause was not a missing rule but a Google fallback on the WAN interface:
wan_dns1: 192.168.1.250 wan_dns2: 8.8.8.8 β the hole
dnsmasq queried both, so roughly half the answers it returned to LAN
clients were unfiltered Google results. Clearing wan_dns2 makes
Pi-hole the router's only upstream, so every device still querying
192.168.1.1 now receives Pi-hole-filtered answers β corralled rather
than broken, across all three networks, with no firewall rules and no
IoT breakage.
Verified:
via router 192.168.1.1: doubleclick.net β 0.0.0.0 (filtered)
github.com β 140.82.114.4
via Pi-hole .250: doubleclick.net β 0.0.0.0 (identical)
Tradeoff: the UDM now depends solely on Pi-hole for its own DNS. During a Pi-hole outage the router loses name resolution β UniFi cloud, update checks, speed tests. Routing, NAT, DHCP and firewalling are all IP-based and unaffected, so the internet keeps working.
ssh udm 'iptables -L UBIOS_WAN_OUT_USER -v -n --line-numbers'DROP counters climbing = hardcoded DNS is being blocked. Allow counters climbing = Pi-hole egress and the node escape hatch are working. All zeros means the rules are silently doing nothing β which is exactly the state this section exists to prevent recurring.
Adding "block IoT β trusted LAN" isolation rules took DNS down for the entire IoT VLAN. Symptoms looked unrelated: phones reported "no internet", and the Samsung FamilyHub fridge could not load its Home Assistant dashboard.
Pi-hole lives on the trusted LAN at 192.168.1.250. A reject rule
covering IoT β 192.168.1.0/24 therefore blackholes DNS along with
everything else. No resolution means clients never even attempt a
connection β so the Home Assistant allow rule read 0 packets and
looked broken, when in fact nothing ever got far enough to use it.
The tell was in the packet capture β every IoT client, including the fridge, talking only to port 53 and getting nowhere:
192.168.50.61 β 192.168.1.250:53 A? api-global.netflix.com
192.168.50.80 β 192.168.1.250:53 A? pool.ntp.org β the fridge
An earlier capture filtered with not port 53 showed nothing at all,
which was misread as "no traffic" when it actually meant "the only
traffic here is the DNS we excluded."
-
UniFi appends new rules to the bottom. The DNS and Home Assistant
ACCEPTs were created after the rejects, so they landed at
rule_index20003/20004 β below all three drops, permanently dead. Rules whose names contain "else" belong last; anything created later lands in the dead zone by default. -
The controller renders from an in-memory cache, not Mongo. Editing
rule_indexdirectly changed the DB but left iptables untouched. Only a reboot made the controller re-read from disk. Worse, a GUI save in that state can write the stale cached order back to Mongo and silently undo the edit. -
Established connections bypass the USER chain. They match the
conntrack
ESTABLISHED,RELATEDfast path first, so an allow rule counts only new connections (the SYN). A rule reading 0 while traffic flows is normal β check/proc/net/nf_conntrackbefore concluding the path is broken.
20000 ACCEPT IoT β 192.168.1.250 tcp+udp 53 (Pi-hole)
20001 ACCEPT 192.168.50.80 β 192.168.1.241 tcp 443 (fridge β HA)
20002 REJECT IoT β Trusted LAN
20003 REJECT IoT β VPN
20004 REJECT IoT β Gaming
l2_isolation is safe to leave on: it blocks client-to-client
traffic within the VLAN, while the fridgeβHA path is routed via the
gateway and unaffected.
DNSSEC needs no extra ports. It rides on port 53 and only enlarges responses. Allow TCP 53 as well as UDP β oversized signed answers set the truncate bit and force a TCP retry. UDP-only DNS works until it randomly doesn't.
Any new VLAN isolation rule must carve out the infrastructure that VLAN
depends on β DNS to 192.168.1.250 first β and the carve-out must be
ordered above the drops. Verify with counters, never with the UI:
ssh udm 'iptables -L UBIOS_LAN_IN_USER -v -n --line-numbers'Allow counters climbing and REJECT counters flat means it is working. An allow rule at 0 while a REJECT climbs means the rule is below the drops or scoped past the client you are testing from β the same failure that consumed most of this incident.
- 04-Bootstrap-and-Cold-Start - Deployment procedures and common issues
- 17-Runbooks - Operational procedures
- 21-Deployment-Dependencies - Understanding deployment failures
Related Issues:
- #43 - Fix "latest" versions - Version pinning
- #31 - NVMe boot fragility - Boot issues