A few months ago I switched from using Pi-hole to AdGuardHome for serving adblocking DNS to not just the clients in my home, but also my mobile devices.

There were a few reasons for this, but chief among them was its proper support for DNS-over-TLS (DOT) and DNS-over-HTTPS (DOH).

I had previously been running unencrypted DNS via Tailscale, but because of the way I have things configured, this mean that every DNS request from an external device looked like it was coming from my subnet router.

DOT and DOH both support the concept of unique client IDs, which makes it easier to track down which device a particular request is coming from.

However, one of the few problems that I’ve encountered with AdGuard is that there’s no way to separate the admin interface from the port that DOH uses.

I brought up this conundrum in the Discord server for the since defunct Self-Hosted Podcast, and one of the users there (thanks Quietsy!) made an interesting suggestion that I must admit hadn’t crossed my mind.

Simply put a reverse proxy in front of the application and implement access controls based on path.

DOH runs over the /dns-query endpoint, while the admin interface is at the root /.

I logged into the VPS that’s running my public-facing AdGuard server and installed nginx.

sudo apt install nginx

Then I stopped AdGuard:

sudo systemctl stop AdGuardHome

I then edited AdGuardHome.yaml (The path to this file may vary based on how you installed AdGuard).

sudo vim AdGuardHome.yaml

I changed the https_port from its default 443 to 4433, and the http listening port from 80 to 8080.

Then start AdGuard back up with:

sudo systemctl start AdGuardHome

Then I created a site in Nginx at /etc/nginx/sites-enabled/adguardhome with the following settings:

proxy_cache_path /var/cache/adguardhome levels=1:2 keys_zone=adguard_cache:10m max_size=3g inactive=120m use_temp_path=off;

upstream adguardhome {
    server 127.0.0.1:4433;
    keepalive 64;
}

server {
    server_name adguard.domain.tld;
    listen 80;
    # listen [::]:80 default_server;

    return 301 https://$host$request_uri;
}

server {
    server_name adguard.domain.tld;
    listen 443 ssl http2;

    access_log /var/log/nginx/agh.access.log;
    error_log /var/log/nginx/agh.error.log warn;

    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;

    ssl_dhparam /etc/nginx/ssl/dhparam.pem;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;
    ssl_session_tickets off;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    ssl_certificate /etc/letsencrypt/live/adguard.domain.tld/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/adguard.domain.tld/privkey.pem;

    ssl_early_data on;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy no-referrer;
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header Permissions-Policy "interest-cohort=()";

    # Discourage Google bots from indexing this site
    add_header X-Robots-Tag "noindex";

    # Allow the dns-query endpoint
    location /dns-query {
    proxy_pass https://adguardhome/dns-query;

    proxy_set_header Host $host;
    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_http_version 1.1;
    proxy_set_header Connection "";

    # Timeouts
    proxy_connect_timeout 90;
    proxy_send_timeout 300;
    proxy_read_timeout 90s;
    }

    # For all other locations, return 403.
    location / {
            return 403;
    }
}

Enable and start the webserver:

sudo systemctl enable --now nginx

Now, lets use doggo to test our DOH configuration.

doggo fedoraproject.org @https://adguard.domain.tld/dns-query/client-id
NAME                    TYPE    CLASS   TTL     ADDRESS         NAMESERVER
fedoraproject.org.      A       IN      47s     8.43.85.67      https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     8.43.85.73      https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     152.2.23.104    https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     34.211.44.206   https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     140.211.169.196 https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     38.145.32.21    https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     152.2.23.103    https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     67.219.144.68   https://adguard.domain.tld/dns-query/client-id
fedoraproject.org.      A       IN      47s     38.145.32.20    https://adguard.domain.tld/dns-query/client-id

Perfect!

Now, lets make sure I can’t access the admin interface from the public facing nginx…

Getting a 403 error as expected for other paths Awesome.

I am also notably NOT exposing port 53. Only 853 and 443, both of which are using TCP.