Run a DNS-over-HTTPS (DoH) server & pihole's web admin on the same (default) SSL port while still identifying DoH clients

I wanted to run the pi-hole web admin over HTTPS on the default SSL port (443), as well as serve DNS-over-HTTPS (DoH) traffic on the same port (443) to streamline usage of both. Since DoH support isn't currently in pihole, this meant running some 2nd service next to the pihole web server with some reverse proxy sitting in front of both of those on port 443, routing traffic appropriately.

Complicating this was that I wanted to have EDNS Client Subnet information survive the proxying process so that the pihole could still classify DoH clients. This meant the reverse proxy and the DoH server needed to add and/or preserve that information so that it was present for pihole to consume when EDNS0_ECS=true in the configuration.

The solution I got working was an nginx reverse proxy in front of pihole's lighttpd & dnsdist.

1. Get a Valid SSL Cert

Get a valid SSL certificate for your pi-hole. HMN76V's original post (archive) linked to this tutorial (archive), which worked for me.

2. dnsdist

dnsdist will be the DNS-over-HTTPS endpoint that will terminate DoH queries, attach client_subnet info, and forward them to pihole. We'll use the trustForwardedForHeader option to have dnsdist take the IP from an X-Forwarded-For header as the IP to use in the EDNS Client Subnet.

The dnsdist in the pihole's package manager will work; install with apt-get install dnsdist

Edit or create /etc/systemd/system/dnsdist.service:

[Unit]
Description=dnsdist DoH Server
Documentation=man:dnsdist(1)
Documentation=https://dnsdist.org
Wants=network-online.target
After=network-online.target

[Service]
ExecStartPre=/usr/bin/dnsdist --check-config
# Note: when editing the ExecStart command, keep --supervised and --disable-syslog
ExecStart=/usr/bin/dnsdist --supervised --disable-syslog -C /etc/dnsdist/dnsdist.conf
User=_dnsdist
Group=ssl-servers
SyslogIdentifier=dnsdist
Type=notify
Restart=on-failure
RestartSec=2
TimeoutStopSec=5
StartLimitInterval=0

# Tuning
TasksMax=8192
LimitNOFILE=16384
# Note: increasing the amount of lockable memory is required to use eBPF support
# LimitMEMLOCK=infinity

# Sandboxing
# Note: adding CAP_SYS_ADMIN (or CAP_BPF for Linux >= 5.8) is required to use eBPF support,
# and CAP_NET_RAW to be able to set the source interface to contact a backend
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
LockPersonality=true
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
# Setting PrivateUsers=true prevents us from opening our sockets
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=full
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
SystemCallFilter=~ @clock @debug @module @mount @raw-io @reboot @swap @cpu-emulation @obsolete

[Install]
WantedBy=multi-user.target

Edit or create /etc/dnsdist/dnsdist.conf:

-- dnsdist configuration file, an example can be found in /usr/share/doc/dnsdist/examples/

-- listen for DNS-over-HTTPs queries on 3054, authd by our pihole cert
addDOHLocal(
		'192.168.1.254:3054',
		'/usr/local/ssl/crt/pi.hole.crt',
		'/usr/local/ssl/crt/pi.hole.key',
		'/dns-query',
		{
			trustForwardedForHeader=true
		})

-- prepare to forward DNS queries to pihole w/ client subnet info
pihole = newServer({
	address="192.168.1.254:53",
	name="pihole",
	useClientSubnet=true
})

pihole:setUp()

-- send full client IP as subnet
setECSSourcePrefixV4(32)

-- disable security status polling via DNS
setSecurityPollSuffix("")

Change

  1. 192.168.1.254 to your pihole's static IP
  2. /usr/local/ssl/crt/pi.hole.crt to your pihole's SSL certificate
  3. /usr/local/ssl/crt/pi.hole.key to your pihole's SSL key that signed the cert

NOTE: do not use 127.0.0.1 for the newServer; while this will work to resolve DNS queries, it will cause the pihole to respond with that localhost IP for pi.hole lookups and you won't be able to browse the web admin at pi.hole!

NOTE: pihole:setUp() disables dnsdist's health-check queries to pihole. In this setup there is no alternative to pihole, and it's not dnsdist's job to solve pihole being down - queries should always go to pihole and if pihole's down they just won't resolve!

3. nginx

nginx will be the SSL-terminating reverse-proxy in front of both the dnsdist DoH endpoint and the pihole web admin. We'll take the real IP of clients connecting to nginx and put them in the X-Forwarded-For HTTP header before sending the request along to dnsdist.

The nginx that is in pihole's package manager will work; apt-get install nginx.

Edit or create /etc/systemd/system/nginx.service:

[Unit]
Description=nginx
After=syslog.target network.target

[Service]
Type=forking
PIDFile=/run/nginx.pid
ExecStartPre=/usr/local/nginx/sbin/nginx -t -c /etc/nginx/conf.d/https-reverse-proxy.conf
ExecStart=/usr/local/nginx/sbin/nginx -c /etc/nginx/conf.d/https-reverse-proxy.conf
ExecReload=/usr/bin/kill -s HUP $MAINPID
ExecStop=/usr/bin/kill -s QUIT $MAINPID
# Hardening
InaccessiblePaths=/etc/shadow /etc/ssh
ProtectSystem=full # /usr, /boot/, /efi read-only!
ProtectKernelTunables=yes
ProtectControlGroups=yes
SystemCallFilter=~@clock @cpu-emulation @debug @keyring @module @mount @obsolete @raw-io
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictRealtime=yes

[Install]
WantedBy=multi-user.target

Change

  1. 192.168.1.254 to your pihole's static IP

Create /etc/nginx/conf.d/https-reverse-proxy.conf:

(this config sets X-Real-IP too, for good measure)

pid /run/nginx.pid;
error_log /var/log/nginx/error.log;

events {}

http {
		access_log /var/log/nginx/http-access.log;
		error_log /var/log/nginx/http-error.log;

		# dnsdist https
		upstream doh-ecs {
			zone doh-ecs 64k;
			server 127.0.0.1:3054;
		}

		# pihole lighttpd
		upstream pi.hole {
			zone pihole-web 64k;
			server 192.168.1.254:80;
		}

		# This virtual server accepts HTTP/2 over HTTPS
		server {
			listen *:443 ssl http2;

			server_name pi.hole pihole.local 192.168.1.254;
			ssl_certificate /usr/local/ssl/crt/pi.hole.crt;
			ssl_certificate_key /usr/local/ssl/crt/pi.hole.key;

			access_log /var/log/nginx/ssl-proxy-access.log;
			error_log /var/log/nginx/ssl-proxy-error.log;

			# send all traffic to pihole's web-ui by default
			location / {
				proxy_http_version 1.1;
				proxy_set_header Connection "";
				proxy_pass http://pi.hole;
				access_log /var/log/nginx/https-to-pihole.log;
			}

			# send /dns-queries to dnsdist to add ecs info
			location /dns-query {
				proxy_http_version 1.1;
				proxy_set_header Connection "";
				proxy_set_header X-Real-IP $remote_addr;
				proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
				proxy_set_header X-Forwarded-Proto $scheme;
				proxy_pass https://doh-ecs;
				access_log /var/log/nginx/dns-query.log;
			}
	}
}

Change:

  1. 192.168.1.254 to your pihole's static IP
  2. /usr/local/ssl/crt/pi.hole.crt to your pihole's SSL certificate
  3. /usr/local/ssl/crt/pi.hole.key to your pihole's SSL key that signed the cert
  4. the server.name line to list all the Subject Alternative Names (SAN) that you provided for your pihole in its certs. This must at the very least include
    • pi.hole
    • (the static IP of the pihole)

4. done!

Remember to systemctl enable nginx and systemctl enable dnsdist so they'll start when the pihole does.

With that, assuming a pihole IP of 192.168.1.254, you should find:

  1. 192.168.1.254 works as pihole DNS, per normal
  2. https://pi.hole redirects to https://pi.hole/admin & serves the web interface over HTTPS with your certificate
  3. The pihole web interface remains accessible over HTTP on port 80
    • Disabling this left as an exercise to the reader (for now?)
  4. https://192.168.1.254/dns-query works for DNS-over-HTTPS queries, with your certificate
  5. DoH queries are correctly mapped to individual clients in the pihole logs

Bonus

Go forth and use DoH!

Set Windows 11 to use DoH:

(this took me far too long to figure out; couldn't find any clear instructions anywhere, either)

Network & internet > Ethernet --> "DNS Server Assignment" section

windows-doh-config

Set brave browser to use DoH

Search "dns" in settings, then configure a custom DNS provider:

brave-config

Out of curiosity, why do you run a DoH server locally? I don’t see any real world benefit in doing this, besides the fact that it is a nice project to broaden one’s technical understanding of the matter.