feat(dns): add Technitium+Dockhand stack templates and end-to-end pfSense migration runbooks

- add docker-compose template for Technitium DNS and Dockhand
- add required DNS/Dockhand env vars to .env.sample
- add single-flow migration runbook (Pi Zero -> Pi 4 with same IP
cutover)
- add forced DNS and DoT/DoH hardening quick-entry guides
- document wildcard DNS strategy and Technitium replacement mapping
This commit is contained in:
Brian Pooe 2026-03-01 12:01:35 +02:00
parent 84eaa82397
commit 6720cf8d62
10 changed files with 999 additions and 0 deletions

View file

@ -66,3 +66,29 @@ EMAIL_PORT=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
DEFAULT_FROM_EMAIL=
# DNS stack (Technitium + Dockhand)
# Bind IP for DNS service (use your DNS host IP, e.g. 192.168.60.5)
DNS_BIND_IP=192.168.60.5
TECHNITIUM_WEB_BIND_IP=0.0.0.0
TECHNITIUM_WEB_PORT=5380
TECHNITIUM_WEB_CONTAINER_PORT=5380
# Technitium
TECHNITIUM_DNS_DOMAIN=home.arpa
TECHNITIUM_ADMIN_PASSWORD=
TECHNITIUM_RECURSION_MODE=UseSpecifiedNetworkACL
TECHNITIUM_RECURSION_ACL=127.0.0.1/32,::1/128,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,fc00::/7
TECHNITIUM_BLOCKING_TYPE=AnyAddress
TECHNITIUM_BLOCKLIST_URLS=https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
TECHNITIUM_ALLOW_BLOCKING_REPORT=false
TECHNITIUM_PREFER_IPV6=false
# Dockhand
DOCKHAND_ENCRYPTION_KEY=
DOCKHAND_PORT=3000
DOCKHAND_BIND_IP=0.0.0.0
# Host path containing compose stacks that Dockhand should manage
DOCKHAND_STACKS_DIR=/opt/stacks
# Find with: stat -c '%g' /var/run/docker.sock
DOCKER_GID=999

View file

@ -229,3 +229,7 @@ gramps.home.brianpooe.com {
reverse_proxy 192.168.10.18:80
}
seafile.home.brianpooe.com {
import common
reverse_proxy 192.168.10.17:8005
}

View file

@ -0,0 +1,119 @@
# End-to-End Migration Runbook (Pi-hole on Pi Zero 2 W -> Technitium + Dockhand on Pi 4)
Use this as the single flow document. Each step links to the deeper guide where needed.
## 1) Confirm baseline and keep rollback assets
1. Keep your current backups safe:
- pfSense backup XML
- Pi-hole teleporter tarball
2. Keep the old Pi Zero 2 W untouched until final cutover is stable.
3. Review your analyzed current-state map:
- [Current architecture analysis](/Users/luda/Documents/synology-docker-services/dns/pfsense-pihole-technitium-analysis.md)
## 2) Prepare the new Pi 4 with a temporary DNS IP
1. Connect Pi 4 to the same Adblock VLAN (`192.168.60.0/24`).
2. In pfSense, create a temporary DHCP static mapping for Pi 4, for example `192.168.60.6`.
3. Confirm Pi 4 gets the temporary IP and has network reachability.
Why temporary IP first:
- You can build and test everything without disturbing production DNS (`192.168.60.5`).
## 3) Configure compose environment and render stack
1. Fill required values in `.env` (or environment file you use):
- `DNS_BIND_IP=192.168.60.6` (temporary during staging)
- `TECHNITIUM_ADMIN_PASSWORD=<strong-password>`
- `DOCKHAND_ENCRYPTION_KEY=<long-random-string>`
- `DOCKER_GID=<docker.sock group id>`
- `DOCKHAND_STACKS_DIR=<path you want Dockhand to manage>`
2. Render the compose from template:
```bash
./substitute_env.sh docker-compose-files/technitium-dockhand_template.yaml docker-compose-files/technitium-dockhand.yaml .env
```
3. Start the stack:
```bash
docker compose -f docker-compose-files/technitium-dockhand.yaml up -d
```
Reference:
- [Technitium + Dockhand deployment guide](/Users/luda/Documents/synology-docker-services/dns/technitium-dockhand-deployment.md)
- [Compose template](/Users/luda/Documents/synology-docker-services/docker-compose-files/technitium-dockhand_template.yaml)
## 4) Configure Technitium to mirror current behavior
1. Open Technitium UI on Pi 4 temporary IP:
- `http://192.168.60.6:5380`
2. Create primary zone `home.brianpooe.com`.
3. Add:
- `A` record: `caddy.home.brianpooe.com -> 192.168.10.5`
- wildcard `CNAME`: `*.home.brianpooe.com -> caddy.home.brianpooe.com`
4. Configure blocklist:
- `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts`
5. Add allowlist entry:
- `www.googleadservices.com`
Reference:
- [Technitium cutover checklist](/Users/luda/Documents/synology-docker-services/dns/technitium-cutover-checklist.md)
## 5) Pre-cutover validation on temporary IP
From clients (or directly from Pi 4), validate against `192.168.60.6`:
```bash
nslookup google.com 192.168.60.6
nslookup proxmox.home.brianpooe.com 192.168.60.6
nslookup switchlite8poe.home.brianpooe.com 192.168.60.6
```
If these fail, do not proceed to IP swap.
## 6) Apply/confirm pfSense DNS enforcement policy
If not already done, apply forced DNS interception for LAN + VLANs.
Reference (fast entry):
- [Forced DNS quick entry](/Users/luda/Documents/synology-docker-services/dns/pfsense-forced-dns-quick-entry.md)
Reference (full explanation):
- [Forced DNS all VLANs](/Users/luda/Documents/synology-docker-services/dns/pfsense-forced-dns-all-vlans.md)
Optional hardening for encrypted DNS bypass:
- [DoT/DoH blocking quick entry](/Users/luda/Documents/synology-docker-services/dns/pfsense-dot-doh-blocking-quick-entry.md)
## 7) Safe same-IP handover (make Pi 4 become `192.168.60.5`)
1. Schedule a short maintenance window.
2. Stop/disconnect old Pi Zero 2 W first (avoid duplicate IP and ARP conflict).
3. In pfSense DHCP static mappings (Adblock VLAN):
- Move `192.168.60.5` reservation from old Pi MAC to new Pi 4 MAC.
4. On Pi 4:
- Renew DHCP lease or reboot networking/container host.
5. Confirm Pi 4 now has `192.168.60.5`.
6. If needed, clear stale ARP entry for `192.168.60.5` on pfSense.
7. Update `.env`:
- set `DNS_BIND_IP=192.168.60.5`
8. Re-render and restart stack:
```bash
./substitute_env.sh docker-compose-files/technitium-dockhand_template.yaml docker-compose-files/technitium-dockhand.yaml .env
docker compose -f docker-compose-files/technitium-dockhand.yaml up -d
```
## 8) Post-cutover validation (production IP)
Run tests from each VLAN segment:
```bash
nslookup google.com 192.168.60.5
nslookup cloudflare.com 1.1.1.1
nslookup proxmox.home.brianpooe.com 192.168.60.5
nslookup switchlite8poe.home.brianpooe.com 192.168.60.5
```
Expected:
- DNS works from all VLANs.
- Hardcoded public DNS on port 53 still resolves (redirected to your local DNS).
- Local app hostnames resolve correctly.
- Queries appear in Technitium logs.
## 9) Rollback (if needed)
1. Stop Technitium stack on Pi 4.
2. In pfSense, move `192.168.60.5` DHCP reservation back to old Pi Zero 2 W MAC.
3. Start old Pi Zero 2 W.
4. Validate DNS from clients.
## 10) After stabilization
1. Update Caddy host references if you want `pihole.home...` renamed to `dns.home...`.
2. Keep old Pi offline but available for a few days.
3. Export Technitium backup after final state is verified.

View file

@ -0,0 +1,110 @@
# pfSense DoT/DoH Blocking Quick Entry (Copy-Style)
Use this after forced DNS redirect is in place.
Goal:
- Block DNS-over-TLS (DoT) on port `853`.
- Block known DNS-over-HTTPS (DoH) endpoints on port `443`.
## 0) Create aliases
Path: `Firewall > Aliases`
Create network alias:
- Name: `DNS_CLIENT_NETS`
- Type: `Network(s)`
- Values:
- `LAN net`
- `Office net` (OPT2)
- `Family net` (OPT3)
- `IoT net` (OPT4)
- `Media net` (OPT5)
- `Guest net` (OPT6)
Create host alias:
- Name: `DOH_PROVIDERS`
- Type: `Host(s)`
- Values (FQDNs):
- `dns.google`
- `cloudflare-dns.com`
- `one.one.one.one`
- `security.cloudflare-dns.com`
- `family.cloudflare-dns.com`
- `dns.quad9.net`
- `dns.nextdns.io`
- `doh.opendns.com`
- `doh.cleanbrowsing.org`
- `dns.adguard-dns.com`
- `unfiltered.adguard-dns.com`
- `family.adguard-dns.com`
- `dns.controld.com`
- `doh.mullvad.net`
Notes:
- This list is intentionally practical, not exhaustive.
- You can add/remove providers later without editing firewall rules.
## 1) Floating rule: block DoT (853)
Path: `Firewall > Rules > Floating` then `Add`
Set fields:
- Action: `Block`
- Quick: `checked`
- Interface: `LAN, OPT2, OPT3, OPT4, OPT5, OPT6`
- Direction: `out`
- Address Family: `IPv4+IPv6`
- Protocol: `TCP/UDP`
- Source: `Single host or alias` = `DNS_CLIENT_NETS`
- Source Port: `any`
- Destination: `any`
- Destination Port Range: `853 (DoT)`
- Log: `checked` (recommended for tuning)
- Description: `Block DoT 853 from client VLANs`
Save, but do not apply yet.
## 2) Floating rule: block known DoH (443)
Path: `Firewall > Rules > Floating` then `Add`
Set fields:
- Action: `Block`
- Quick: `checked`
- Interface: `LAN, OPT2, OPT3, OPT4, OPT5, OPT6`
- Direction: `out`
- Address Family: `IPv4+IPv6`
- Protocol: `TCP`
- Source: `Single host or alias` = `DNS_CLIENT_NETS`
- Source Port: `any`
- Destination: `Single host or alias` = `DOH_PROVIDERS`
- Destination Port Range: `HTTPS (443)`
- Log: `checked` (recommended for tuning)
- Description: `Block known DoH 443 from client VLANs`
## 3) Rule order (critical)
In `Firewall > Rules > Floating`:
- Keep both block rules above broader floating pass rules.
- Keep `Quick` enabled so they match immediately.
## 4) Apply and verify
Apply:
- `Save` + `Apply Changes`
Client-side checks (from LAN and one VLAN):
```bash
# DoT test (should fail)
openssl s_client -connect 1.1.1.1:853 -brief
# DoH test (should fail or reset)
curl -I https://dns.google/dns-query
# Normal DNS still works because of forced redirect
nslookup google.com 8.8.8.8
```
Expected:
- DoT/DoH tests fail.
- `nslookup ... 8.8.8.8` still resolves via your local DNS redirect path.
## 5) Important limitations
- DoH blocking by known provider list is not complete; new/custom DoH endpoints can still bypass.
- For strict control, use egress allow-listing and/or proxy-based controls.
- Encrypted ClientHello (ECH) trends reduce hostname-based control over time.

View file

@ -0,0 +1,115 @@
# pfSense Rule Set: Force DNS on LAN + All VLANs
This runbook enforces DNS redirection to your local resolver (`192.168.60.5`) across:
- LAN (`192.168.1.0/24`)
- Office / OPT2 (`192.168.10.0/24`)
- Family / OPT3 (`192.168.20.0/24`)
- IoT / OPT4 (`192.168.30.0/24`)
- Media / OPT5 (`192.168.40.0/24`)
- Guest / OPT6 (`192.168.50.0/24`)
## Why this is needed
Right now, redirect NAT is only on LAN. VLAN clients can still bypass your DNS filter by hardcoding public DNS on port 53.
## Prerequisites
- DNS server IP remains `192.168.60.5` (Pi-hole now, Technitium later).
- Existing inter-VLAN firewall policy still allows DNS to `192.168.60.5`.
## Step 1: Create pfSense aliases
Path: `Firewall > Aliases`
Create host alias:
- Name: `LOCAL_DNS`
- Type: `Host(s)`
- Value: `192.168.60.5`
Optional (cleaner descriptions):
- Name: `DNS_CLIENT_INTERFACES`
- Type: `Network(s)`
- Values: `LAN net`, `Office net`, `Family net`, `IoT net`, `Media net`, `Guest net`
## Step 2: Create NAT Port Forward rules (one per interface)
Path: `Firewall > NAT > Port Forward`
Create 6 rules, one each on: `LAN`, `OPT2`, `OPT3`, `OPT4`, `OPT5`, `OPT6`.
Use these exact fields for each rule:
- Interface: `<that interface>`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `<that interface> net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked` (this means destination is NOT `192.168.60.5`)
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (<interface>)`
- Filter rule association: `Add associated filter rule`
Important:
- Keep these 6 NAT rules near the top of the Port Forward list.
- Do not apply this rule on `OPT7` (Adblock VLAN hosting the DNS server), to avoid self-redirection edge cases.
## Step 3: Clean up conflicting/obsolete firewall rules
Path: `Firewall > Rules`
For LAN:
- Disable or remove `Block all rogue DNS requests` (your current rule on LAN).
Reason:
- With NAT redirection + associated pass rules, that block rule is redundant.
- If moved above broad allows without careful matching, it can block legitimate redirected DNS.
For OPT3/OPT4/OPT5/OPT6:
- Keep `Pi-hole` pass rules (or associated pass from NAT) above RFC1918 block rules.
## Step 4: Apply changes
- Save and `Apply Changes` in NAT and Rules tabs.
## Step 5: Verify enforcement
From one client in each segment (LAN + OPT2/3/4/5/6), run:
```bash
nslookup google.com 8.8.8.8
nslookup cloudflare.com 1.1.1.1
```
Expected result:
- Queries still succeed.
- In your DNS server logs (Pi-hole/Technitium), source appears as the client, proving interception is active.
Also verify internal name resolution:
```bash
nslookup proxmox.home.brianpooe.com
nslookup switchlite8poe.home.brianpooe.com
```
## Optional hardening (recommended)
Port 53 forcing does not stop encrypted DNS bypass:
- DoT uses TCP `853`
- DoH uses HTTPS `443`
Minimum hardening:
- Add block rules for outbound TCP/UDP `853` on client VLANs.
Advanced hardening:
- Maintain alias lists of known DoH endpoints and block those on `443`.
- Or enforce egress via proxy/policy controls if you need strict DNS governance.
## Rule logic diagram
```mermaid
flowchart LR
A["Client on LAN/OPT2-OPT6"] --> B["Tries DNS to 8.8.8.8:53"]
B --> C["pfSense NAT Port Forward\n(destination != 192.168.60.5)"]
C --> D["Redirect to 192.168.60.5:53"]
D --> E["Pi-hole or Technitium handles query"]
```
## Fast entry version
If you want one block per interface for direct UI entry, use:
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-forced-dns-quick-entry.md`
Related hardening quick-entry:
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-dot-doh-blocking-quick-entry.md`

View file

@ -0,0 +1,141 @@
# pfSense Forced DNS Quick Entry (Copy-Style)
Use this when entering rules manually in pfSense with minimal interpretation.
## 0) Create alias first
Path: `Firewall > Aliases`
Create:
- Name: `LOCAL_DNS`
- Type: `Host(s)`
- Value: `192.168.60.5`
## 1) NAT Port Forward rules (enter these 6)
Path: `Firewall > NAT > Port Forward`
For each block below, click `Add` and use exactly these fields.
### Rule A: LAN
- Interface: `LAN`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `LAN net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked`
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (LAN)`
- Filter rule association: `Add associated filter rule`
### Rule B: Office (OPT2)
- Interface: `OPT2`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `OPT2 net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked`
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (OPT2)`
- Filter rule association: `Add associated filter rule`
### Rule C: Family (OPT3)
- Interface: `OPT3`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `OPT3 net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked`
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (OPT3)`
- Filter rule association: `Add associated filter rule`
### Rule D: IoT (OPT4)
- Interface: `OPT4`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `OPT4 net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked`
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (OPT4)`
- Filter rule association: `Add associated filter rule`
### Rule E: Media (OPT5)
- Interface: `OPT5`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `OPT5 net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked`
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (OPT5)`
- Filter rule association: `Add associated filter rule`
### Rule F: Guest (OPT6)
- Interface: `OPT6`
- Address Family: `IPv4`
- Protocol: `TCP/UDP`
- Source: `OPT6 net`
- Source Port: `any`
- Destination: `Single host or alias` = `LOCAL_DNS`
- Invert Match (destination): `checked`
- Destination Port Range: `DNS (53)`
- Redirect target IP: `LOCAL_DNS`
- Redirect target port: `DNS (53)`
- Description: `Force DNS to LOCAL_DNS (OPT6)`
- Filter rule association: `Add associated filter rule`
Do not add this redirect on `OPT7` (DNS server VLAN).
## 2) Rule order checks (critical)
### NAT order
In `Firewall > NAT > Port Forward`:
- Keep all `Force DNS to LOCAL_DNS (...)` rules above other generic redirects.
### Firewall order
In `Firewall > Rules` on each interface:
- Keep associated DNS pass rule above any RFC1918 block rule.
- On LAN, disable/remove your old `Block all rogue DNS requests` rule.
## 3) Apply and test
Apply:
- `Save` + `Apply Changes` in NAT and Rules.
Test from one client per segment:
```bash
nslookup google.com 8.8.8.8
nslookup cloudflare.com 1.1.1.1
nslookup proxmox.home.brianpooe.com
nslookup switchlite8poe.home.brianpooe.com
```
Expected:
- Queries succeed.
- Requests appear in your DNS server logs (Pi-hole now / Technitium later).
## 4) Optional bypass hardening
Port `53` interception does not stop encrypted DNS:
- DoT: TCP `853`
- DoH: HTTPS `443`
Minimum extra control:
- Add block rules for outbound TCP/UDP `853` on LAN/OPT2/OPT3/OPT4/OPT5/OPT6.
Related hardening quick-entry:
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-dot-doh-blocking-quick-entry.md`

View file

@ -0,0 +1,183 @@
# pfSense + Pi-hole DNS Architecture Analysis and Technitium Mapping
## Scope and source files
- pfSense backup: `/Users/luda/Downloads/config-pfSense.home.arpa-20260301104624.xml`
- Pi-hole teleporter: `/Users/luda/Downloads/pi-hole-raspberrypihole-teleporter_2026-03-01_08-38-24.tar.gz`
## Executive summary
Your current design is solid and intentional:
- pfSense DHCP points clients to Pi-hole (`192.168.60.5`) on all major VLANs.
- Pi-hole serves local split-DNS for reverse-proxied apps via `caddy.home.brianpooe.com`.
- Pi-hole forwards upstream DNS to pfSense (`192.168.1.1`), then pfSense uses its configured upstream resolvers.
- Cross-VLAN isolation is enforced, with explicit exceptions for DNS to Pi-hole and selective access to Caddy.
Technitium can replace Pi-hole cleanly. The biggest migration requirement is preserving the same service IP (`192.168.60.5`) or updating every dependent DHCP/firewall/NAT reference.
## Current network and DNS components
- VLAN interfaces on pfSense:
- LAN: `192.168.1.0/24`
- Office (OPT2): `192.168.10.0/24`
- Family (OPT3): `192.168.20.0/24`
- IoT (OPT4): `192.168.30.0/24`
- Media (OPT5): `192.168.40.0/24`
- Guest (OPT6): `192.168.50.0/24`
- Adblock (OPT7): `192.168.60.0/24`
- Pi-hole static DHCP mapping on Adblock VLAN:
- `raspberrypihole` -> `192.168.60.5`
- Caddy static DHCP mapping on Office VLAN:
- `raspberrypiproxy` -> `192.168.10.5`
## How pfSense and Pi-hole are connected
### 1. DHCP dependency (clients -> Pi-hole)
pfSense DHCP advertises `192.168.60.5` as DNS server for:
- LAN
- Office
- Family
- IoT
- Media
- Guest
This is the primary coupling between pfSense and Pi-hole.
### 2. DNS forwarding dependency (Pi-hole -> pfSense)
From Pi-hole backup:
- `PIHOLE_DNS_1=192.168.1.1`
- `PIHOLE_DNS_2=192.168.1.1`
- `server=192.168.1.1`
So every query path is:
1. Client -> Pi-hole (`192.168.60.5`)
2. Pi-hole -> pfSense (`192.168.1.1`)
3. pfSense DNS path -> upstream resolvers (for example `1.1.1.1` / `8.8.8.8` as configured in this backup)
Note:
- You confirmed your active setup is not using Unbound as part of the current design.
### 3. NAT DNS interception on pfSense
There is one DNS redirect NAT rule:
- Interface: `LAN` only
- Match: any source, destination `NOT 192.168.60.5`, port `53` TCP/UDP
- Redirect target: `192.168.60.5:53`
Important implication: forced redirect is currently configured only for LAN traffic, not all VLANs.
### 4. Firewall policy that enables segmented DNS
For Family/IoT/Media/Guest VLANs, you have explicit pass rules allowing DNS to `192.168.60.5:53` plus RFC1918 block rules. This is why segmented VLANs can still use centralized DNS.
### 4a. Per-segment behavior (what is effectively enforced)
| Segment | DHCP DNS handed out | Explicit allow to Pi-hole `192.168.60.5:53` | RFC1918 block rule | Caddy cross-VLAN allow (`192.168.10.5`) | DNS force-redirect NAT |
|---|---|---|---|---|---|
| LAN | Yes | Associated NAT pass rule exists | Yes (`Block all rogue DNS requests`) | N/A (LAN allow-any) | Yes (LAN only) |
| Office (OPT2) | Yes | No dedicated rule (allow-any policy) | No | Implicit via allow-any | No |
| Family (OPT3) | Yes | Yes | Yes | Yes | No |
| IoT (OPT4) | Yes | Yes | Yes | No | No |
| Media (OPT5) | Yes | Yes | Yes | Yes | No |
| Guest (OPT6) | Yes | Yes | Yes | No | No |
Interpretation:
- DNS centralization is primarily driven by DHCP, not global NAT interception.
- Isolation model is strong on OPT3/4/5/6 due to RFC1918 blocking plus explicit exceptions.
- Office VLAN is the least restricted of the VLANs shown.
### 5. Local service naming through Pi-hole
Pi-hole local DNS is doing two jobs:
- `custom.list` has:
- `caddy.home.brianpooe.com -> 192.168.10.5`
- `05-pihole-custom-cname.conf` has 26 CNAME entries, e.g.:
- `proxmox.home.brianpooe.com -> caddy.home.brianpooe.com`
- `radarr.home.brianpooe.com -> caddy.home.brianpooe.com`
- `paperless-ngx.home.brianpooe.com -> caddy.home.brianpooe.com`
### 6. Ad-blocking state
From Pi-hole teleporter:
- Adlists: 1 (StevenBlack hosts)
- Exact allowlist: 1 (`www.googleadservices.com`)
- Exact/regex deny lists: none
- Client groups: default group only (no per-client policy split)
## Diagram: current flow
```mermaid
flowchart LR
A["Clients on VLANs\n192.168.1/10/20/30/40/50"] --> B["pfSense DHCP\nDNS server handed out: 192.168.60.5"]
A --> C["Pi-hole\n192.168.60.5 (Adblock VLAN)"]
C --> D["pfSense DNS service\n192.168.1.1"]
D --> E["Upstream resolvers\n(e.g. 1.1.1.1 / 8.8.8.8)"]
A --> F["DNS to external resolvers:53"]
F -. "LAN only NAT redirect" .-> C
C --> G["A record\ncaddy.home.brianpooe.com -> 192.168.10.5"]
C --> H["26 CNAMEs\n*.home.brianpooe.com -> caddy.home.brianpooe.com"]
A --> I["Caddy reverse proxy\n192.168.10.5"]
```
## Where Gemini was correct vs needs correction
### Correct
- Technitium can replace Pi-hole and keep most pfSense-side design.
- Your CNAME-based app routing can be moved cleanly to Technitium zones.
- VLAN segmentation + explicit DNS access pattern is present.
- Pi-hole upstream currently points at pfSense (`192.168.1.1`).
### Needs correction
- "Forced DNS intercept" is not global right now:
- NAT redirect rule is only on `LAN` interface.
- "Block rogue DNS" on LAN exists, but appears below broad LAN allow rules:
- With default allow-first ordering, this specific block may not be doing what its label suggests.
- VLANs are not force-redirecting DNS to Pi-hole by NAT:
- They rely on DHCP DNS assignment and explicit allow to Pi-hole.
- Direct DNS to public resolvers on port 53 may still be possible from some VLANs unless separately blocked/redirected.
## Technitium mapping (feature-by-feature)
| Current function | Current implementation | Technitium equivalent | Migration action |
|---|---|---|---|
| Central DNS IP | Pi-hole at `192.168.60.5` | Technitium at `192.168.60.5` | Keep same IP to avoid touching multiple pfSense dependencies |
| DHCP DNS handout | pfSense DHCP -> `192.168.60.5` on VLANs | Same | No change needed if IP unchanged |
| Cross-VLAN DNS permit | Firewall pass rules to `192.168.60.5:53` | Same | No change needed |
| Forced DNS on LAN | NAT redirect to `192.168.60.5:53` | Same | No change needed |
| Local app DNS | `custom.list` + CNAME file | Authoritative zone in Technitium | Create `home.brianpooe.com` zone, add A for `caddy`, add CNAME records |
| Ad block list | StevenBlack in Pi-hole | Block List in Technitium | Add same list URL |
| Allowlist | `www.googleadservices.com` | Allow Zone / Whitelist | Add same domain |
| Upstream resolution | Pi-hole -> pfSense (`192.168.1.1`) -> upstream resolvers | Technitium recursive resolver or forwarders | Choose direct recursion or keep pfSense as upstream intentionally |
## Diagram: proposed with Technitium
```mermaid
flowchart LR
A["Clients on VLANs"] --> B["pfSense DHCP\nDNS server: 192.168.60.5"]
A --> C["Technitium DNS\n192.168.60.5"]
C --> D["Technitium recursion\n(root/TLD authoritative path)"]
D --> E["Internet DNS hierarchy"]
C --> F["Zone: home.brianpooe.com"]
F --> G["A: caddy -> 192.168.10.5"]
F --> H["CNAME: app hosts -> caddy.home.brianpooe.com"]
A --> I["Caddy reverse proxy\n192.168.10.5"]
```
## Practical migration checklist
1. Deploy Technitium on Raspberry Pi with IP `192.168.60.5` (or cutover with brief IP swap).
2. Recreate `home.brianpooe.com` zone:
- Add `A` record `caddy.home.brianpooe.com -> 192.168.10.5`
- Add all 26 CNAME records from current Pi-hole file.
3. Import StevenBlack block list and add allow entry for `www.googleadservices.com`.
4. Validate DNS from each VLAN (`dig/nslookup`) for:
- public domain resolution
- blocked ad domain
- `proxmox.home.brianpooe.com` and other internal names
5. Cut traffic to Technitium (same IP preferred).
6. Optional hardening after cutover:
- add DNS redirect/block on VLANs beyond LAN if you want true forced DNS everywhere
- review LAN DNS block rule order
- decide whether to allow/deny direct DNS-over-TLS and DNS-over-HTTPS egress
## Key risks to keep in mind
- If Technitium is not on `192.168.60.5`, you must update DHCP options, firewall rules, and NAT target references.
- DNS interception in your current config is partial (LAN-focused), not universal across all VLANs.
- IPv6 DNS policy is separate; current forced DNS logic is IPv4 (`inet`) only.
## Companion runbook
For step-by-step cutover execution and wildcard-vs-explicit record strategy, see:
- `/Users/luda/Documents/synology-docker-services/dns/technitium-cutover-checklist.md`

View file

@ -0,0 +1,156 @@
# Technitium Cutover Checklist (From Current pfSense + Pi-hole)
Start-here master flow:
- `/Users/luda/Documents/synology-docker-services/dns/end-to-end-migration-runbook.md`
## Short answer: do you need 26 CNAME records?
No, not if all your app hostnames should resolve to Caddy.
You can replace most manual CNAMEs with:
- `A` record: `caddy.home.brianpooe.com -> 192.168.10.5`
- wildcard `CNAME`: `*.home.brianpooe.com -> caddy.home.brianpooe.com`
This removes the need to add a new DNS record every time you add a new proxied service.
## Wildcard caveats (important)
- Wildcard does not cover the zone apex itself (`home.brianpooe.com`).
- Explicit records still override wildcard (this is good; you can keep special cases).
- A typo hostname will still resolve to Caddy; Caddy may return 404/cert mismatch until that host is configured there.
- Keep your reverse-proxy security policy in Caddy (host matching/auth), because DNS wildcard is broad by design.
## What your current data says
From Pi-hole backup:
- `caddy.home.brianpooe.com -> 192.168.10.5`
- 26 CNAMEs currently pointing to `caddy.home.brianpooe.com`
From your Caddyfile host blocks:
- 28 hostnames are defined in file.
- You marked these as no longer in use (excluded from active migration scope):
- `jellyseerr.home.brianpooe.com`
- `ollama.home.brianpooe.com`
- `paperless-ai.home.brianpooe.com`
- Active hostnames in scope: 25.
- 1 active hostname exists in Caddy but is missing from Pi-hole CNAME list:
- `switchlite8poe.home.brianpooe.com`
- 2 hostnames are in Pi-hole CNAME list but not currently in Caddy:
- `seafile.home.brianpooe.com`
- `tplink8pe.home.brianpooe.com`
A wildcard record closes this drift gap permanently.
## Cutover plan
### Docker stack files in this repo
- Compose template: `/Users/luda/Documents/synology-docker-services/docker-compose-files/technitium-dockhand_template.yaml`
- Deployment guide: `/Users/luda/Documents/synology-docker-services/dns/technitium-dockhand-deployment.md`
### 1. Keep network references stable
- Keep Technitium service IP as `192.168.60.5`.
- This avoids changing pfSense DHCP DNS settings, firewall allow rules, and LAN DNS redirect NAT target.
### 2. Deploy Technitium on Raspberry Pi
- Run Technitium container on the Adblock VLAN network reachable as `192.168.60.5`.
- Ensure TCP/UDP `53` reaches this container from all intended VLANs.
### 3. Create primary zone
- Zone name: `home.brianpooe.com`
- Zone type: Primary
### 4. Add DNS records (choose one model)
#### Model A: wildcard-first (recommended)
Create only these core records first:
- `A` `caddy.home.brianpooe.com` -> `192.168.10.5`
- `CNAME` `*.home.brianpooe.com` -> `caddy.home.brianpooe.com`
Optional explicit records for readability (not required if wildcard exists):
- `CNAME` `pihole.home.brianpooe.com` -> `caddy.home.brianpooe.com`
- `CNAME` `proxmox.home.brianpooe.com` -> `caddy.home.brianpooe.com`
- etc.
#### Model B: explicit-only (current behavior equivalent)
If you prefer explicit control, create:
- `A` `caddy.home.brianpooe.com` -> `192.168.10.5`
- CNAME records for each service:
- `pihole.home.brianpooe.com`
- `proxmox.home.brianpooe.com`
- `nas.home.brianpooe.com`
- `bazarr.home.brianpooe.com`
- `emby.home.brianpooe.com`
- `flaresolverr.home.brianpooe.com`
- `gluetun.home.brianpooe.com`
- `prowlarr.home.brianpooe.com`
- `qbittorrent.home.brianpooe.com`
- `radarr.home.brianpooe.com`
- `sabnzbd.home.brianpooe.com`
- `sonarr.home.brianpooe.com`
- `tplink8pe.home.brianpooe.com`
- `tplink16de.home.brianpooe.com`
- `pbs.home.brianpooe.com`
- `paperless-ngx.home.brianpooe.com`
- `it-tools.home.brianpooe.com`
- `bento-pdf.home.brianpooe.com`
- `beszel.home.brianpooe.com`
- `immich.home.brianpooe.com`
- `speedtest.home.brianpooe.com`
- `seafile.home.brianpooe.com`
- `gramps.home.brianpooe.com`
- `ytd.home.brianpooe.com`
- `dav.home.brianpooe.com`
- `seerr.home.brianpooe.com`
If using explicit-only, also add active Caddy hosts that currently have no Pi-hole entry:
- `switchlite8poe.home.brianpooe.com`
### 5. Recreate filtering behavior
- Add blocklist URL:
- `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts`
- Add allowlist domain:
- `www.googleadservices.com`
### 6. Choose upstream strategy in Technitium
Pick one:
- Least-change: forward to pfSense (`192.168.1.1`) first.
- Cleaner path: enable direct recursive resolution in Technitium.
### 7. Validate before full cutover
From a client in each VLAN, test:
- `nslookup proxmox.home.brianpooe.com 192.168.60.5`
- `nslookup switchlite8poe.home.brianpooe.com 192.168.60.5`
- `nslookup google.com 192.168.60.5`
- `nslookup www.googleadservices.com 192.168.60.5` (should be allowed)
Also test one blocked ad/tracker domain from query logs.
### 8. Cutover
- Stop Pi-hole service on `192.168.60.5`.
- Start Technitium on `192.168.60.5`.
- Confirm DNS responses and Caddy app access from each VLAN.
### 9. Rollback plan
If anything fails:
- Stop Technitium.
- Start Pi-hole back on `192.168.60.5`.
- Existing pfSense integration should recover immediately since IP dependencies are unchanged.
## Decision recommendation for your setup
Use Model A (wildcard-first).
Reason:
- Your Caddy host inventory changes over time and is already ahead of Pi-hole CNAME entries.
- Wildcard removes manual DNS drift while preserving the same user-facing URLs.
## DNS + proxy interaction (with wildcard)
```mermaid
flowchart LR
A["Client queries any host\n*.home.brianpooe.com"] --> B["Technitium wildcard CNAME"]
B --> C["caddy.home.brianpooe.com"]
C --> D["A record 192.168.10.5"]
D --> E["Caddy routes by Host header\nto matching app"]
```
## Related pfSense enforcement runbook
For exact NAT/firewall settings to force DNS across LAN + VLANs, see:
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-forced-dns-all-vlans.md`
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-forced-dns-quick-entry.md`
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-dot-doh-blocking-quick-entry.md`

View file

@ -0,0 +1,61 @@
# Technitium + Dockhand Docker Deployment Guide
This guide deploys Technitium DNS and Dockhand using:
- `/Users/luda/Documents/synology-docker-services/docker-compose-files/technitium-dockhand_template.yaml`
## 1) Prepare env values
Update your `.env` with these required values:
- `DNS_BIND_IP=192.168.60.5`
- `TECHNITIUM_ADMIN_PASSWORD=<strong-password>`
- `DOCKHAND_ENCRYPTION_KEY=<long-random-string>`
- `DOCKHAND_STACKS_DIR=<host path containing compose files>`
- `DOCKER_GID=<docker.sock group id>`
Tip:
- `DOCKER_GID` command: `stat -c '%g' /var/run/docker.sock`
## 2) Render compose from template
Run:
```bash
./substitute_env.sh docker-compose-files/technitium-dockhand_template.yaml docker-compose-files/technitium-dockhand.yaml .env
```
## 3) Start stack
Run:
```bash
docker compose -f docker-compose-files/technitium-dockhand.yaml up -d
```
## 4) First-time Technitium setup (required)
Open:
- `http://<DNS_BIND_IP>:5380`
Then configure:
1. Create primary zone `home.brianpooe.com`.
2. Add `A` record: `caddy.home.brianpooe.com -> 192.168.10.5`.
3. Add wildcard `CNAME`: `*.home.brianpooe.com -> caddy.home.brianpooe.com`.
4. Add allowlist equivalent for `www.googleadservices.com`.
Reference details:
- `/Users/luda/Documents/synology-docker-services/dns/technitium-cutover-checklist.md`
## 5) pfSense integration
Keep DNS server IP as `192.168.60.5` to avoid changing existing DHCP and firewall dependencies.
For forced DNS across all VLANs:
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-forced-dns-quick-entry.md`
For DoT/DoH hardening:
- `/Users/luda/Documents/synology-docker-services/dns/pfsense-dot-doh-blocking-quick-entry.md`
## 6) Validation
Run from clients on each VLAN:
```bash
nslookup google.com 192.168.60.5
nslookup proxmox.home.brianpooe.com 192.168.60.5
nslookup switchlite8poe.home.brianpooe.com 192.168.60.5
```
## 7) Common gotchas
- If Technitium fails to bind port 53, check for other DNS services on the host (for example `systemd-resolved`, `dnsmasq`, old Pi-hole container).
- If Dockhand cannot manage containers, verify `DOCKER_GID` and docker socket mount.

View file

@ -0,0 +1,84 @@
version: '3.8'
# Technitium DNS + Dockhand stack
# - Technitium replaces Pi-hole + Unbound behavior (blocking + recursion)
# - Dockhand manages container stacks from a web UI
services:
technitium-dns:
image: technitium/dns-server:latest
container_name: technitium-dns
restart: unless-stopped
labels:
- "com.synology.stack=network"
- "com.synology.service=technitium-dns"
environment:
TZ: "{{TZ}}"
# Domain shown in Technitium UI defaults
DNS_SERVER_DOMAIN: "{{TECHNITIUM_DNS_DOMAIN:-home.arpa}}"
# Required: set a strong admin password in .env
DNS_SERVER_ADMIN_PASSWORD: "{{TECHNITIUM_ADMIN_PASSWORD}}"
# Recursive resolver mode (Technitium does root recursion)
DNS_SERVER_RECURSION: "{{TECHNITIUM_RECURSION_MODE:-UseSpecifiedNetworkACL}}"
DNS_SERVER_RECURSION_NETWORK_ACL: "{{TECHNITIUM_RECURSION_ACL:-127.0.0.1/32,::1/128,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,fc00::/7}}"
# Pi-hole-like ad blocking behavior
DNS_SERVER_BLOCKING_TYPE: "{{TECHNITIUM_BLOCKING_TYPE:-AnyAddress}}"
DNS_SERVER_BLOCK_LIST_URLS: "{{TECHNITIUM_BLOCKLIST_URLS:-https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts}}"
DNS_SERVER_ALLOW_TXT_BLOCKING_REPORT: "{{TECHNITIUM_ALLOW_BLOCKING_REPORT:-false}}"
# Operational defaults
DNS_SERVER_PREFER_IPV6: "{{TECHNITIUM_PREFER_IPV6:-false}}"
DNS_SERVER_WEB_SERVICE_HTTP_PORT: "{{TECHNITIUM_WEB_CONTAINER_PORT:-5380}}"
volumes:
- /etc/localtime:/etc/localtime:ro
- {{DOCKERCONFDIR}}/technitium:/etc/dns
ports:
# Core DNS service
- "{{DNS_BIND_IP:-0.0.0.0}}:53:53/udp"
- "{{DNS_BIND_IP:-0.0.0.0}}:53:53/tcp"
# Technitium web admin
- "{{TECHNITIUM_WEB_BIND_IP:-0.0.0.0}}:{{TECHNITIUM_WEB_PORT:-5380}}:{{TECHNITIUM_WEB_CONTAINER_PORT:-5380}}/tcp"
# Optional encrypted DNS listeners (enable only if you intend to use them)
# - "{{DNS_BIND_IP:-0.0.0.0}}:853:853/tcp" # DoT
# - "{{DNS_BIND_IP:-0.0.0.0}}:443:443/tcp" # DoH
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-file: "{{DOCKERLOGGING_MAXFILE}}"
max-size: "{{DOCKERLOGGING_MAXSIZE}}"
dockhand:
image: ghcr.io/dockhand-oss/dockhand:latest
container_name: dockhand
restart: unless-stopped
labels:
- "com.synology.stack=management"
- "com.synology.service=dockhand"
environment:
TZ: "{{TZ}}"
# Required: set a strong random value in .env
ENCRYPTION_KEY: "{{DOCKHAND_ENCRYPTION_KEY}}"
PORT: "3000"
DATA_DIR: "/opt/dockhand"
PUID: "{{PUID}}"
PGID: "{{PGID}}"
group_add:
- "{{DOCKER_GID:-999}}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- {{DOCKERCONFDIR}}/dockhand:/opt/dockhand
- {{DOCKHAND_STACKS_DIR:-/opt/stacks}}:/opt/stacks
ports:
- "{{DOCKHAND_BIND_IP:-0.0.0.0}}:{{DOCKHAND_PORT:-3000}}:3000/tcp"
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-file: "{{DOCKERLOGGING_MAXFILE}}"
max-size: "{{DOCKERLOGGING_MAXSIZE}}"