From 80ea8184769dc434302ad91148cffcb7c8f4dabb Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 16 Feb 2026 22:30:57 +0000 Subject: [PATCH] [ci skip] Add pfsense-dnsmasq-interface-binding skill, update ndots skill to v1.1.0 --- .../SKILL.md | 53 +++++- .../SKILL.md | 169 ++++++++++++++++++ 2 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/pfsense-dnsmasq-interface-binding/SKILL.md diff --git a/.claude/skills/k8s-ndots-search-domain-nxdomain-flood/SKILL.md b/.claude/skills/k8s-ndots-search-domain-nxdomain-flood/SKILL.md index 0ceb2664..5712ea78 100644 --- a/.claude/skills/k8s-ndots-search-domain-nxdomain-flood/SKILL.md +++ b/.claude/skills/k8s-ndots-search-domain-nxdomain-flood/SKILL.md @@ -9,8 +9,8 @@ description: | domain, (4) DNS cache hit ratio is unexpectedly low despite stable workloads. Applies to any Kubernetes cluster using CoreDNS with a custom DNS search domain. author: Claude Code -version: 1.0.0 -date: 2026-02-15 +version: 1.1.0 +date: 2026-02-17 --- # Kubernetes ndots:5 Search Domain NxDomain Flood @@ -52,9 +52,39 @@ Check DNS query logs for the pattern: kubectl exec -n technitium PODNAME -- grep "cluster.local.yourdomain" /etc/dns/logs/*.log ``` -### Step 2: Add CoreDNS template block -Add a server block to the CoreDNS Corefile that returns NXDOMAIN immediately for -`cluster.local.yourdomain.lan` without forwarding to the external DNS: +### Step 2: Add generic CoreDNS template regex (RECOMMENDED) + +Instead of creating specific catch-all blocks for each junk suffix pattern, add a single +`template` directive with a regex inside the `yourdomain.lan` server block. This catches +ALL multi-label junk queries (e.g., `*.cluster.local.yourdomain.lan`, +`*.yourdomain.lan.yourdomain.lan`, `www.cloudflare.com.yourdomain.lan`) in one rule: + +``` +yourdomain.lan:53 { + errors + template ANY ANY yourdomain.lan { + match ".*\..*\.yourdomain\.lan\.$" + rcode NXDOMAIN + fallthrough + } + forward . + cache { + success 10000 300 6 + denial 10000 300 60 + } +} +``` + +**How it works**: The regex `.*\..*\.yourdomain\.lan\.$` matches any query with 2+ labels +before `.yourdomain.lan` — meaning only single-label queries like `idrac.yourdomain.lan` +fall through to the real DNS server. All junk multi-label queries get instant NXDOMAIN. + +**Important**: The `fallthrough` directive is required so that legitimate single-label +queries (which don't match the regex) continue to the `forward` plugin. + +#### Alternative: Specific catch-all blocks (DEPRECATED) + +The older approach used separate server blocks per junk suffix pattern: ``` cluster.local.yourdomain.lan:53 { @@ -68,7 +98,8 @@ cluster.local.yourdomain.lan:53 { } ``` -This block must appear **before** the general `yourdomain.lan` block in the Corefile. +This requires adding a new block for each pattern and doesn't catch arbitrary junk queries +like `www.cloudflare.com.yourdomain.lan`. The generic regex approach above is preferred. ### Step 3: Apply the CoreDNS ConfigMap ```bash @@ -103,14 +134,18 @@ This prevents the cache hit rate from resetting to zero after every restart. ## Notes - The same `ndots:5` issue also causes `*.yourdomain.lan.yourdomain.lan` (double suffix) - and `*.yourdomain.me.yourdomain.lan` patterns, but at lower volume + and `*.yourdomain.me.yourdomain.lan` patterns — the generic regex catches all of these - The top DNS client IPs will be the **node IPs** (not pod IPs) because CoreDNS forwards via NodePort, and the source becomes the node's IP - `ndots:5` is the Kubernetes default and shouldn't be changed cluster-wide as it breaks short-name service resolution - Individual pods can set `dnsConfig.options: [{name: ndots, value: "2"}]` to reduce search domain lookups, but this is a per-pod opt-in +- Prometheus scrape targets using `.yourdomain.lan` hostnames should add a trailing dot + (e.g., `idrac.yourdomain.lan.:161`) to bypass ndots expansion entirely +- ExternalName services don't need trailing dots — the generic template regex handles them ## See also -- `crowdsec-agent-registration-failure` - another common K8s DNS-adjacent issue -- `loki-helm-deployment-pitfalls` - Loki deployment patterns +- `pfsense-dnsmasq-interface-binding` — Related: preserve client IPs for DNS port forwarding +- `crowdsec-agent-registration-failure` — another common K8s DNS-adjacent issue +- `loki-helm-deployment-pitfalls` — Loki deployment patterns diff --git a/.claude/skills/pfsense-dnsmasq-interface-binding/SKILL.md b/.claude/skills/pfsense-dnsmasq-interface-binding/SKILL.md new file mode 100644 index 00000000..eb1f9056 --- /dev/null +++ b/.claude/skills/pfsense-dnsmasq-interface-binding/SKILL.md @@ -0,0 +1,169 @@ +--- +name: pfsense-dnsmasq-interface-binding +description: | + Restrict pfSense dnsmasq (DNS Forwarder) to specific interfaces to free port 53 on + other interfaces for port forwarding. Use when: (1) pfSense blocks port 53 NAT port + forward because dnsmasq is listening on *:53, (2) need to forward DNS from WAN to an + internal DNS server while preserving client source IPs, (3) dnsmasq shows *:53 in + sockstat despite --listen-address flags, (4) pfSense loses DNS resolution after + restricting dnsmasq interfaces, (5) NAT rdr rules for port 53 silently fail to + generate in /tmp/rules.debug. +author: Claude Code +version: 1.0.0 +date: 2026-02-17 +--- + +# pfSense dnsmasq Interface Binding for DNS Port Forwarding + +## Problem +pfSense's dnsmasq (DNS Forwarder) binds to `*:53` by default. This prevents creating +NAT port forward rules for port 53 — pfSense silently skips generating the pf `rdr` +directive. You need to restrict dnsmasq to specific interfaces to free port 53 on other +interfaces (e.g., WAN) for forwarding to an internal DNS server. + +## Context / Trigger Conditions +- Attempting to create a NAT port forward for port 53 on the WAN interface +- Port forward rule saves to config.xml but `pfctl -sn` shows no corresponding `rdr` rule +- `sockstat -4 | grep ":53"` shows `dnsmasq` on `*:53` +- Goal: Forward DNS queries from one network to an internal DNS server (e.g., Technitium) + while preserving client source IPs (no masquerading) + +## Solution + +### Step 1: Bind dnsmasq to specific interfaces + +Set the interface field in pfSense's dnsmasq config: + +```php +ssh admin@10.0.20.1 'php -r '"'"' +require_once("config.inc"); +require_once("service-utils.inc"); +global $config; +$config = parse_config(true); +$config["dnsmasq"]["interface"] = "lan,opt1"; // Only LAN and OPT1, NOT wan +write_config("Bind dnsmasq to LAN and OPT1 only"); +'"'"'' +``` + +This adds `--listen-address=` flags to dnsmasq but does NOT change socket binding. + +### Step 2: Add bind-dynamic (CRITICAL) + +Without `bind-dynamic`, dnsmasq still binds the socket to `*:53` even with +`--listen-address` flags. The `--listen-address` only controls which queries get +responses, not the actual socket binding. + +```php +ssh admin@10.0.20.1 'php -r '"'"' +require_once("config.inc"); +require_once("service-utils.inc"); +global $config; +$config = parse_config(true); +$existing = base64_decode($config["dnsmasq"]["custom_options"]); +if (strpos($existing, "bind-dynamic") === false) { + $existing = "bind-dynamic\n" . $existing; + $config["dnsmasq"]["custom_options"] = base64_encode($existing); + write_config("Add bind-dynamic to restrict dnsmasq socket binding"); +} +'"'"'' +``` + +### Step 3: Add localhost listen address (CRITICAL) + +pfSense's own `resolv.conf` points to `127.0.0.1`. Without this, pfSense itself +loses DNS resolution after the interface restriction. + +```php +# Add to custom_options (base64-encoded in config): +listen-address=127.0.0.1 +``` + +### Step 4: Restart dnsmasq + +```php +services_dnsmasq_configure(); +``` + +### Step 5: Verify binding + +```bash +sockstat -4 | grep ":53 " +# Should show specific IPs, not *:53: +# 127.0.0.1:53 +# 10.0.10.1:53 (lan) +# 10.0.20.1:53 (opt1) +# NOT 192.168.1.2:53 (wan) +``` + +### Step 6: Add the port forward rule + +**Critical format note**: The `source` field must use `array("any" => "")`, NOT +`array("network" => "192.168.1.0/24")`. The CIDR source format silently fails to +generate the pf `rdr` directive. + +```php +ssh admin@10.0.20.1 'php -r '"'"' +require_once("config.inc"); +require_once("filter.inc"); +require_once("shaper.inc"); +global $config; +$config = parse_config(true); + +$rule = array( + "source" => array("any" => ""), // MUST be "any", not CIDR + "destination" => array( + "network" => "wanip", + "port" => "53" + ), + "ipprotocol" => "inet", + "protocol" => "udp", + "target" => "10.0.20.204", // Internal DNS server + "local-port" => "53", + "interface" => "wan", + "associated-rule-id" => "pass", + "descr" => "DNS to internal DNS (preserve client IP)", + "created" => array("time" => (string)time(), "username" => "admin"), + "updated" => array("time" => (string)time(), "username" => "admin") +); +array_unshift($config["nat"]["rule"], $rule); +write_config("Add DNS port forward"); +filter_configure(); +'"'"'' +``` + +### Step 7: Verify the redirect rule + +```bash +pfctl -sn | grep "domain\|:53" +# Should show: rdr pass on vtnet0 inet proto udp from any to 192.168.1.2 port = domain -> 10.0.20.204 +``` + +## Verification + +1. pfSense own DNS: `nslookup google.com 127.0.0.1` (from pfSense shell) +2. Internal DNS: `nslookup google.com 10.0.20.1` (from LAN/OPT1 clients) +3. Port forward: `dig @192.168.1.2 example.com` (from WAN-side client) +4. Client IP: Check DNS server logs — should show real client IP, not pfSense IP + +## Pitfalls + +| Pitfall | Symptom | Fix | +|---------|---------|-----| +| Missing `bind-dynamic` | sockstat shows `*:53`, port forward still blocked | Add `bind-dynamic` to custom_options | +| Missing `listen-address=127.0.0.1` | pfSense loses all DNS resolution | Add to custom_options | +| Source `"network" => "CIDR"` in NAT rule | Rule saves to config but no `rdr` in `pfctl -sn` | Use `"any" => ""` instead | +| Using local `$config` variable | Config not persisted after PHP exit | Always use `global $config` | +| Not calling `filter_configure()` | Rule in config.xml but not in pf | Call after `write_config()` | +| Custom options not base64 | dnsmasq fails to start | pfSense stores custom_options as base64 | + +## Notes +- `bind-dynamic` is preferred over `bind-interfaces` because it handles interfaces that + come up after dnsmasq starts (e.g., VPN tunnels) +- The pf `rdr` rule is a redirect, not masquerade — source IP is preserved +- dnsmasq custom_options in pfSense config.xml are base64-encoded +- Check `/tmp/rules.debug` for the generated pf ruleset (before loading into pf) +- Use `pfctl -sn` to see rules actually loaded in the running firewall + +## See also +- `pfsense` — General pfSense management skill +- `k8s-ndots-search-domain-nxdomain-flood` — Related DNS optimization