From 2017b56985530dbf34de655ec32bf737bfcd63a5 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 4 Feb 2026 21:06:01 -0600 Subject: [PATCH] Add NPM + Pi-hole DNS sync automation and documentation ## Changes - Created comprehensive NPM + Pi-hole setup documentation - Added DNS sync script to automatically sync NPM proxy hosts to Pi-hole - Updated hosts.yml with npm-pihole host entry ## Features - Automatic local DNS resolution for homelab services - Fixes 403 errors with "Internal Only" access lists - Hourly cron sync keeps Pi-hole updated with NPM changes - Cloudflare real IP configuration for proper IP detection ## Files Added - server-configs/networking/nginx-proxy-manager-pihole.md - server-configs/networking/scripts/npm-pihole-sync.sh ## Files Modified - server-configs/hosts.yml (added npm-pihole host) Co-Authored-By: Claude Sonnet 4.5 --- server-configs/hosts.yml | 18 ++ .../networking/nginx-proxy-manager-pihole.md | 302 ++++++++++++++++++ .../networking/scripts/npm-pihole-sync.sh | 74 +++++ 3 files changed, 394 insertions(+) create mode 100644 server-configs/networking/nginx-proxy-manager-pihole.md create mode 100755 server-configs/networking/scripts/npm-pihole-sync.sh diff --git a/server-configs/hosts.yml b/server-configs/hosts.yml index f00894d..19a5a67 100644 --- a/server-configs/hosts.yml +++ b/server-configs/hosts.yml @@ -110,6 +110,7 @@ hosts: services: - n8n - omni-tools + - termix # Foundry VTT LXC (Proxmox) foundry-lxc: @@ -187,6 +188,23 @@ hosts: - major-domo-dev - strat-gameplay-webapp + # NPM + Pi-hole Host + npm-pihole: + type: docker + ssh_alias: npm-pihole + ip: 10.10.0.16 + user: cal + description: "Nginx Proxy Manager + Pi-hole DNS server" + config_paths: + scripts: /home/cal/scripts + services: + - nginx-proxy-manager + - pihole + documentation: server-configs/networking/nginx-proxy-manager-pihole.md + automation: + dns_sync: /home/cal/scripts/npm-pihole-sync.sh + cron: "0 * * * *" # Hourly + # Akamai Cloud Server akamai: type: docker diff --git a/server-configs/networking/nginx-proxy-manager-pihole.md b/server-configs/networking/nginx-proxy-manager-pihole.md new file mode 100644 index 0000000..cf0775e --- /dev/null +++ b/server-configs/networking/nginx-proxy-manager-pihole.md @@ -0,0 +1,302 @@ +# Nginx Proxy Manager + Pi-hole Setup + +**Host**: 10.10.0.16 +**Services**: Nginx Proxy Manager, Pi-hole DNS + +This host runs both NPM (reverse proxy) and Pi-hole (DNS server) as Docker containers. + +## Quick Info + +| Property | Value | +|----------|-------| +| **IP** | 10.10.0.16 | +| **User** | cal | +| **NPM Container** | nginx-proxy-manager_app_1 | +| **Pi-hole Container** | pihole | +| **Pi-hole Web** | http://10.10.0.16/admin | + +## Services + +### Nginx Proxy Manager +- **Container**: nginx-proxy-manager_app_1 +- **Web UI**: http://10.10.0.16:81 +- **Ports**: 80, 443, 81 +- **Database**: SQLite at `/data/database.sqlite` +- **Config**: `/data/nginx/` +- **Logs**: `/data/logs/` + +### Pi-hole +- **Container**: pihole +- **Web UI**: http://10.10.0.16/admin +- **Ports**: 53 (DNS), 80 (Web) +- **Config**: `/etc/pihole/` +- **Custom DNS**: `/etc/pihole/custom.list` + +## Architecture + +``` +┌─────────────┐ +│ Clients │ +│ (Homelab) │ +└──────┬──────┘ + │ DNS Query: termix.manticorum.com? + ▼ +┌─────────────────────┐ +│ Pi-hole (10.10.0.16)│ +│ Returns: 10.10.0.16 │ ← Local DNS override +└──────┬──────────────┘ + │ HTTPS Request + ▼ +┌──────────────────────────┐ +│ NPM (10.10.0.16) │ +│ - Checks access list │ ← Sees real client IP (10.0.0.206) +│ - Terminates SSL │ +│ - Proxies to backend │ +└──────┬───────────────────┘ + │ HTTP Proxy + ▼ +┌──────────────────────┐ +│ Backend Service │ +│ (e.g., Termix) │ +│ 10.10.0.210:8180 │ +└──────────────────────┘ +``` + +## DNS Sync Automation + +### Problem Solved +When accessing homelab services by domain name (e.g., `termix.manticorum.com`), DNS would resolve to Cloudflare IPs, causing traffic to hairpin through the internet. This broke IP-based access lists since NPM would see your public IP instead of your internal IP. + +### Solution +Automatically sync all NPM proxy hosts to Pi-hole's local DNS, pointing them to NPM's internal IP (10.10.0.16). + +### Sync Script + +**Location**: `/home/cal/scripts/npm-pihole-sync.sh` (on 10.10.0.16) +**Repository**: `server-configs/networking/scripts/npm-pihole-sync.sh` + +The script: +1. Queries NPM's SQLite database for all enabled proxy hosts +2. Extracts domain names +3. Creates Pi-hole custom DNS entries pointing to NPM (10.10.0.16) +4. Reloads Pi-hole DNS + +**Usage:** +```bash +# Dry run (preview changes) +ssh cal@10.10.0.16 /home/cal/scripts/npm-pihole-sync.sh --dry-run + +# Apply changes +ssh cal@10.10.0.16 /home/cal/scripts/npm-pihole-sync.sh +``` + +**Automation:** +- Runs hourly via cron: `0 * * * * /home/cal/scripts/npm-pihole-sync.sh >> /home/cal/logs/npm-pihole-sync.log 2>&1` +- Logs: `/home/cal/logs/npm-pihole-sync.log` + +**Deployment:** +```bash +# Copy script to server +scp server-configs/networking/scripts/npm-pihole-sync.sh cal@10.10.0.16:/home/cal/scripts/ + +# Make executable +ssh cal@10.10.0.16 "chmod +x /home/cal/scripts/npm-pihole-sync.sh" + +# Test +ssh cal@10.10.0.16 "/home/cal/scripts/npm-pihole-sync.sh --dry-run" + +# Apply +ssh cal@10.10.0.16 "/home/cal/scripts/npm-pihole-sync.sh" +``` + +## Cloudflare Real IP Configuration + +To support external access through Cloudflare while maintaining proper IP detection for access lists, NPM is configured to trust Cloudflare's IP ranges and read the real client IP from the `CF-Connecting-IP` header. + +**Configuration** (in `/etc/nginx/nginx.conf` inside NPM container): + +```nginx +# Trust internal networks +set_real_ip_from 10.0.0.0/8; +set_real_ip_from 172.16.0.0/12; +set_real_ip_from 192.168.0.0/16; + +# Trust Cloudflare IPs +set_real_ip_from 173.245.48.0/20; +set_real_ip_from 103.21.244.0/22; +set_real_ip_from 103.22.200.0/22; +set_real_ip_from 103.31.4.0/22; +set_real_ip_from 141.101.64.0/18; +set_real_ip_from 108.162.192.0/18; +set_real_ip_from 190.93.240.0/20; +set_real_ip_from 188.114.96.0/20; +set_real_ip_from 197.234.240.0/22; +set_real_ip_from 198.41.128.0/17; +set_real_ip_from 162.158.0.0/15; +set_real_ip_from 104.16.0.0/13; +set_real_ip_from 104.24.0.0/14; +set_real_ip_from 172.64.0.0/13; +set_real_ip_from 131.0.72.0/22; + +# Read real IP from Cloudflare header +real_ip_header CF-Connecting-IP; +real_ip_recursive on; +``` + +This ensures: +- **Local access**: Client IP seen correctly (e.g., 10.0.0.206) +- **External access via Cloudflare**: Real public IP seen (not Cloudflare's proxy IP) + +## Access Lists + +NPM supports IP-based access restrictions. Common patterns: + +### Internal Only +Allows access only from local networks: +``` +allow 10.0.0.0/24; +allow 10.10.0.0/24; +deny all; +``` + +Use this for: +- Internal services (Termix, n8n, OmniTools) +- Admin interfaces +- Development environments + +### Cloudflare Only +Trusts Cloudflare's authenticated access (Cloudflare Access/Zero Trust): +- No IP restrictions needed +- Cloudflare handles authentication +- Use for public services behind Cloudflare Access + +## Management + +### Access Containers +```bash +# SSH to host +ssh cal@10.10.0.16 + +# Access NPM container +docker exec -it nginx-proxy-manager_app_1 bash + +# Access Pi-hole container +docker exec -it pihole bash +``` + +### NPM Commands +```bash +# View proxy host configs +docker exec nginx-proxy-manager_app_1 ls /data/nginx/proxy_host/ + +# View specific proxy host +docker exec nginx-proxy-manager_app_1 cat /data/nginx/proxy_host/21.conf + +# Test nginx config +docker exec nginx-proxy-manager_app_1 nginx -t + +# Reload nginx +docker exec nginx-proxy-manager_app_1 nginx -s reload + +# View access logs +docker exec nginx-proxy-manager_app_1 tail -f /data/logs/proxy-host-21_access.log +``` + +### Pi-hole Commands +```bash +# View custom DNS +docker exec pihole cat /etc/pihole/custom.list + +# Restart DNS +docker exec pihole pihole restartdns reload + +# View logs +docker logs pihole -f + +# Pi-hole status +docker exec pihole pihole status +``` + +### Query NPM Database +```bash +# List all proxy hosts +docker exec nginx-proxy-manager_app_1 python3 -c " +import sqlite3 +conn = sqlite3.connect('/data/database.sqlite') +cursor = conn.cursor() +cursor.execute('SELECT id, domain_names, forward_host, forward_port FROM proxy_host') +for row in cursor.fetchall(): + print(row) +conn.close() +" +``` + +## Troubleshooting + +### 403 Forbidden Errors + +**Symptom**: Getting 403 errors when accessing services with "Internal Only" access list. + +**Causes**: +1. **Hairpinning through Cloudflare**: DNS resolving to Cloudflare IPs instead of local + - **Fix**: Ensure Pi-hole has local DNS override (run sync script) + - **Check**: `dig +short service.manticorum.com @10.10.0.16` should return `10.10.0.16` + +2. **Access list doesn't include your subnet**: Your IP not in allowed ranges + - **Fix**: Add your subnet to access list (e.g., `10.0.0.0/24`) + - **Check**: View access logs to see what IP NPM is seeing + +3. **Real IP not detected**: NPM seeing proxy/Cloudflare IP instead of client IP + - **Fix**: Ensure Cloudflare IPs are in `set_real_ip_from` and `real_ip_header CF-Connecting-IP` is set + - **Check**: Access logs should show your real IP, not Cloudflare's + +**Debug Steps**: +```bash +# Check what IP NPM sees +docker exec nginx-proxy-manager_app_1 tail -20 /data/logs/proxy-host-21_access.log + +# Check DNS resolution +dig +short termix.manticorum.com @10.10.0.16 + +# Check access list in proxy host config +docker exec nginx-proxy-manager_app_1 cat /data/nginx/proxy_host/21.conf | grep -A 10 "Access Rules" +``` + +### DNS Not Resolving Locally + +**Symptom**: Domains still resolve to Cloudflare IPs. + +**Causes**: +1. DNS sync not run or failed +2. Desktop not using Pi-hole for DNS +3. DNS cache on desktop + +**Fix**: +```bash +# Re-run sync +ssh cal@10.10.0.16 /home/cal/scripts/npm-pihole-sync.sh + +# Check Pi-hole has the record +ssh cal@10.10.0.16 "docker exec pihole cat /etc/pihole/custom.list | grep termix" + +# Verify DNS server on desktop +cat /etc/resolv.conf # Should show 10.10.0.16 + +# Flush DNS cache on desktop +sudo resolvectl flush-caches # SystemD +# or +sudo systemd-resolve --flush-caches +``` + +## Related Documentation + +- [Gitea Server](../gitea/README.md) - Uses NPM proxy +- [n8n Automation](../../productivity/n8n/CONTEXT.md) - Uses NPM proxy +- [Akamai NPM](../akamai/docker-compose/nginx-proxy-manager/) - Public-facing NPM instance + +## Deployment Date + +**Created**: 2026-02-04 +**By**: Claude Code +**NPM Version**: (check with `docker exec nginx-proxy-manager_app_1 cat /app/package.json | grep version`) +**Pi-hole Version**: (check with `docker exec pihole pihole -v`) diff --git a/server-configs/networking/scripts/npm-pihole-sync.sh b/server-configs/networking/scripts/npm-pihole-sync.sh new file mode 100755 index 0000000..6050832 --- /dev/null +++ b/server-configs/networking/scripts/npm-pihole-sync.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# NPM to Pi-hole DNS Sync +# Syncs Nginx Proxy Manager proxy hosts to Pi-hole local DNS +# All domains point to NPM's IP, not the forward destination + +set -e + +DRY_RUN=false +if [[ "$1" == "--dry-run" ]]; then + DRY_RUN=true +fi + +# NPM's IP address (where all domains should point) +NPM_IP="10.10.0.16" + +echo "NPM → Pi-hole DNS Sync" +echo "============================================================" + +# Query NPM database for all enabled proxy hosts +DOMAINS=$(docker exec nginx-proxy-manager_app_1 python3 -c ' +import sqlite3 +import json + +conn = sqlite3.connect("/data/database.sqlite") +cursor = conn.cursor() +cursor.execute("SELECT domain_names FROM proxy_host WHERE enabled = 1") + +domains = [] +for (domain_names,) in cursor.fetchall(): + for domain in json.loads(domain_names or "[]"): + domains.append(domain) + +for domain in sorted(domains): + print(domain) + +conn.close() +') + +# Count records +RECORD_COUNT=$(echo "$DOMAINS" | wc -l) +echo "Found $RECORD_COUNT enabled proxy hosts" +echo "" +echo "All domains will point to NPM at: $NPM_IP" +echo "" +echo "Domains to sync:" +echo "$DOMAINS" | awk -v ip="$NPM_IP" '{printf " %-15s %s\n", ip, $0}' + +if [ "$DRY_RUN" = true ]; then + echo "" + echo "[DRY RUN] Not applying changes" + exit 0 +fi + +# Build new custom.list +NEW_DNS="# Pi-hole Local DNS Records +# Auto-synced from Nginx Proxy Manager +# All domains point to NPM at $NPM_IP + +" + +while IFS= read -r domain; do + NEW_DNS+="$NPM_IP $domain"$'\n' +done <<< "$DOMAINS" + +# Write to Pi-hole +echo "$NEW_DNS" | docker exec -i pihole tee /etc/pihole/custom.list > /dev/null + +# Reload Pi-hole DNS +docker exec pihole pihole restartdns reload > /dev/null + +echo "" +echo "✓ Updated $RECORD_COUNT DNS records in Pi-hole" +echo "✓ All domains now point to NPM at $NPM_IP" +echo "✓ Reloaded Pi-hole DNS"