[ci skip] Add pfsense-dnsmasq-interface-binding skill, update ndots skill to v1.1.0
This commit is contained in:
parent
530986e3c6
commit
80ea818476
2 changed files with 213 additions and 9 deletions
|
|
@ -9,8 +9,8 @@ description: |
|
||||||
domain, (4) DNS cache hit ratio is unexpectedly low despite stable workloads.
|
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.
|
Applies to any Kubernetes cluster using CoreDNS with a custom DNS search domain.
|
||||||
author: Claude Code
|
author: Claude Code
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
date: 2026-02-15
|
date: 2026-02-17
|
||||||
---
|
---
|
||||||
|
|
||||||
# Kubernetes ndots:5 Search Domain NxDomain Flood
|
# 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
|
kubectl exec -n technitium PODNAME -- grep "cluster.local.yourdomain" /etc/dns/logs/*.log
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Add CoreDNS template block
|
### Step 2: Add generic CoreDNS template regex (RECOMMENDED)
|
||||||
Add a server block to the CoreDNS Corefile that returns NXDOMAIN immediately for
|
|
||||||
`cluster.local.yourdomain.lan` without forwarding to the external DNS:
|
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 . <your-dns-server-ip>
|
||||||
|
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 {
|
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
|
### Step 3: Apply the CoreDNS ConfigMap
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -103,14 +134,18 @@ This prevents the cache hit rate from resetting to zero after every restart.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- The same `ndots:5` issue also causes `*.yourdomain.lan.yourdomain.lan` (double suffix)
|
- 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
|
- 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
|
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
|
- `ndots:5` is the Kubernetes default and shouldn't be changed cluster-wide as it breaks
|
||||||
short-name service resolution
|
short-name service resolution
|
||||||
- Individual pods can set `dnsConfig.options: [{name: ndots, value: "2"}]` to reduce
|
- Individual pods can set `dnsConfig.options: [{name: ndots, value: "2"}]` to reduce
|
||||||
search domain lookups, but this is a per-pod opt-in
|
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
|
## See also
|
||||||
- `crowdsec-agent-registration-failure` - another common K8s DNS-adjacent issue
|
- `pfsense-dnsmasq-interface-binding` — Related: preserve client IPs for DNS port forwarding
|
||||||
- `loki-helm-deployment-pitfalls` - Loki deployment patterns
|
- `crowdsec-agent-registration-failure` — another common K8s DNS-adjacent issue
|
||||||
|
- `loki-helm-deployment-pitfalls` — Loki deployment patterns
|
||||||
|
|
|
||||||
169
.claude/skills/pfsense-dnsmasq-interface-binding/SKILL.md
Normal file
169
.claude/skills/pfsense-dnsmasq-interface-binding/SKILL.md
Normal file
|
|
@ -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=<IP>` 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue