Design for private DoH server with ClientID-based access control. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.4 KiB
7.4 KiB
AdGuard Home Module Design
Date: 2026-03-18 Status: Approved
Overview
Add a NixOS module for AdGuard Home that provides private DNS-over-HTTPS (DoH) with ClientID-based access control. Only requests with recognized ClientIDs are processed; unknown requests are dropped.
Requirements
- AdGuard Home running in Podman container
- DoH via nginx at
dns.ashisgreat.xyz - ClientID-based authentication (acts as password for DNS)
- ClientIDs stored as SOPS secrets
- Upstream DNS: Mullvad DoH (
https://dns.mullvad.net/dns-query) - Port 53 blocked externally (already handled by existing firewall)
- No admin UI exposed externally
Module Options
myModules.adguard = {
enable = lib.mkEnableOption "AdGuard Home DNS server";
domain = lib.mkOption {
type = lib.types.str;
example = "dns.example.com";
description = "Public domain name for DoH endpoint";
};
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Internal port for AdGuard HTTP/DoH listener";
};
upstreamDoh = lib.mkOption {
type = lib.types.str;
default = "https://dns.mullvad.net/dns-query";
description = "Upstream DoH server URL";
};
bootstrapDns = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "194.242.2.2" "2a07:e340::2" ];
description = "Bootstrap DNS servers for resolving DoH upstream";
};
clients = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Friendly name for the client device";
};
idSecret = lib.mkOption {
type = lib.types.str;
description = "SOPS secret name containing the ClientID";
};
};
});
default = [ ];
description = "List of clients with their ClientID secrets";
};
};
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ VPS Host │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────────┐ │
│ │ nginx │ │ adguard container │ │
│ │ (host) │ │ ┌─────────────────────────────┐ │ │
│ │ │ │ │ AdGuard Home │ │ │
│ │ dns.ashis │─────▶│ │ │ │ │
│ │ .great.xyz │:3000 │ │ - DoH server on :3000 │ │ │
│ │ │ │ │ - Validates ClientIDs │ │ │
│ │ /dns-query/ │ │ │ - Proxies to Mullvad DoH │ │ │
│ │ {clientId} │ │ └─────────────────────────────┘ │ │
│ └─────────────┘ │ ▲ │ │
│ │ │ │ read-only │ │
│ │ │ ┌──────┴──────┐ │ │
│ │ │ │AdGuardHome. │ ← generated by │ │
│ │ │ │ yaml │ Nix from SOPS │ │
│ │ │ └─────────────┘ │ │
│ │ └─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Firewall │ │
│ │ │ │
│ │ Block 53/udp│ ← External port 53 blocked │
│ │ Block 53/tcp│ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Request Flow
- External client sends DoH request to
https://dns.ashisgreat.xyz/dns-query/{clientId} - Nginx handles TLS termination and proxies to AdGuard container on
127.0.0.1:3000 - AdGuard validates the ClientID in the request path
- Unknown ID → Connection dropped
- Valid ID → Query resolved via Mullvad DoH upstream
ClientID Configuration
SOPS Secrets
ClientIDs are stored encrypted in secrets/secrets.yaml:
adguard_client_phone: "xK9mP2vL7nQ4"
adguard_client_laptop: "jH3fR8wT5yB1"
Module Configuration
In configuration.nix:
myModules.adguard = {
enable = true;
domain = "dns.ashisgreat.xyz";
clients = [
{ name = "phone"; idSecret = "adguard_client_phone"; }
{ name = "laptop"; idSecret = "adguard_client_laptop"; }
];
};
# SOPS secret declarations
sops.secrets.adguard_client_phone = { };
sops.secrets.adguard_client_laptop = { };
Generated AdGuardHome.yaml
The module generates the config via SOPS template:
dns:
upstream_dns:
- https://dns.mullvad.net/dns-query
bootstrap_dns:
- 194.242.2.2
- 2a07:e340::2
http:
address: 0.0.0.0:3000
clients:
persistent:
- name: phone
ids:
- xK9mP2vL7nQ4
- name: laptop
ids:
- jH3fR8wT5yB1
Nginx Configuration
The module configures nginx directly (not via the nginx module):
services.nginx.virtualHosts."dns.ashisgreat.xyz" = {
enableACME = true;
forceSSL = true;
locations."/dns-query" = {
proxyPass = "http://127.0.0.1:3000";
extraConfig = ''
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_method POST;
proxy_pass_request_body on;
proxy_buffers 8 16k;
proxy_buffer_size 32k;
'';
};
locations."/" = {
return = "404";
};
};
Notes:
- Admin UI not exposed externally
- All config changes via git +
nixos-rebuild switch - ACME certificate auto-provisioned
Firewall
No changes needed. Port 53 is already blocked by the existing firewall configuration since it's not in allowedTCPPorts. Only ports 22 (SSH), 80, and 443 (via nginx) are open.
Files to Create/Modify
| File | Action |
|---|---|
modules/adguard.nix |
Create - new module |
modules/default.nix |
Modify - add import |
configuration.nix |
Modify - enable module with clients |
secrets/secrets.yaml |
Modify - add ClientID secrets |
Dependencies
myModules.podman- container runtimesops-nix- secrets managementservices.nginx- reverse proxy (shared with other modules)