- Fix YAML indentation in SOPS template - Add myModules.podman.enable dependency - Remove unused tmpfiles rule (using named volume) - Remove redundant firewall config (nginx module handles 443) - Fix lib.types.listOf parentheses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
307 lines
10 KiB
Markdown
307 lines
10 KiB
Markdown
# 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
|
|
|
|
```nix
|
|
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 │ │
|
|
│ │ │ └─────────────┘ │ │
|
|
│ │ │ │ │
|
|
│ │ │ ┌─────────────┐ │ │
|
|
│ │ │ │ data/ │ ← persistent vol │ │
|
|
│ │ │ │ (stats,logs)│ │ │
|
|
│ │ │ └─────────────┘ │ │
|
|
│ │ └─────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌─────────────┐ │
|
|
│ │ Firewall │ │
|
|
│ │ │ │
|
|
│ │ Block 53/udp│ ← External port 53 blocked │
|
|
│ │ Block 53/tcp│ │
|
|
│ └─────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Request Flow
|
|
|
|
1. External client sends DoH request to `https://dns.ashisgreat.xyz/dns-query/CLIENTID`
|
|
- Example: `https://dns.ashisgreat.xyz/dns-query/xK9mP2vL7nQ4`
|
|
2. Nginx handles TLS termination and proxies to AdGuard container on `127.0.0.1:3000`
|
|
3. AdGuard validates the ClientID in the request path
|
|
4. Unknown ID → Connection dropped
|
|
5. Valid ID → Query resolved via Mullvad DoH upstream
|
|
|
|
## ClientID Configuration
|
|
|
|
### SOPS Secrets
|
|
|
|
ClientIDs are stored encrypted in `secrets/secrets.yaml`:
|
|
|
|
```yaml
|
|
adguard_client_phone: "xK9mP2vL7nQ4"
|
|
adguard_client_laptop: "jH3fR8wT5yB1"
|
|
```
|
|
|
|
### Module Configuration
|
|
|
|
In `configuration.nix`:
|
|
|
|
```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 (auto-created by module or manual)
|
|
sops.secrets.adguard_client_phone = { };
|
|
sops.secrets.adguard_client_laptop = { };
|
|
```
|
|
|
|
## Implementation Details
|
|
|
|
### Container Definition
|
|
|
|
```nix
|
|
virtualisation.oci-containers.containers."adguard" = {
|
|
image = "docker.io/adguard/adguardhome:latest";
|
|
ports = [ "127.0.0.1:${toString cfg.port}:3000/tcp" ];
|
|
extraOptions = [
|
|
"--cap-drop=ALL"
|
|
"--read-only"
|
|
"--tmpfs=/tmp"
|
|
];
|
|
volumes = [
|
|
"${config.sops.templates."adguardhome.yaml".path}:/opt/adguardhome/conf/AdGuardHome.yaml:ro"
|
|
"adguard-data:/opt/adguardhome/work"
|
|
];
|
|
};
|
|
```
|
|
|
|
**Notes:**
|
|
- Container runs with minimal capabilities (`--cap-drop=ALL`)
|
|
- Config file is read-only (managed by Nix/SOPS)
|
|
- `adguard-data` volume persists stats and query logs
|
|
|
|
### Data Directory
|
|
|
|
```nix
|
|
systemd.tmpfiles.rules = [
|
|
"d /var/lib/adguard 0755 root root -"
|
|
];
|
|
```
|
|
|
|
### SOPS Template for AdGuardHome.yaml
|
|
|
|
```nix
|
|
sops.templates."adguardhome.yaml" = {
|
|
content = ''
|
|
http:
|
|
address: 0.0.0.0:3000
|
|
|
|
dns:
|
|
upstream_dns:
|
|
- ${cfg.upstreamDoh}
|
|
bootstrap_dns:
|
|
${lib.concatStringsSep "\n " (map (d: "- ${d}") cfg.bootstrapDns)}
|
|
querylog_enabled: true
|
|
querylog_file_enabled: true
|
|
statistics_enabled: true
|
|
|
|
clients:
|
|
persistent:
|
|
${lib.concatStringsSep "\n " (
|
|
map (client: ''
|
|
- name: ${client.name}
|
|
ids:
|
|
- ${config.sops.placeholder.${client.idSecret}}
|
|
'') cfg.clients
|
|
)}
|
|
|
|
filtering:
|
|
protection_enabled: true
|
|
filtering_enabled: true
|
|
|
|
safebrowsing:
|
|
enabled: false
|
|
|
|
parental:
|
|
enabled: false
|
|
|
|
safesearch:
|
|
enabled: false
|
|
|
|
log:
|
|
file: ""
|
|
max_backups: 0
|
|
max_size: 100
|
|
compress: false
|
|
local_time: false
|
|
verbose: false
|
|
'';
|
|
};
|
|
```
|
|
|
|
### SOPS Secret Declarations (Auto-generated)
|
|
|
|
The module automatically declares SOPS secrets for each client:
|
|
|
|
```nix
|
|
sops.secrets = lib.mkMerge (
|
|
map (client: {
|
|
${client.idSecret} = { };
|
|
}) cfg.clients
|
|
);
|
|
```
|
|
|
|
## Nginx Configuration
|
|
|
|
The module configures nginx directly (not via the nginx module) because DoH requires special handling:
|
|
|
|
```nix
|
|
services.nginx.virtualHosts."${cfg.domain}" = {
|
|
enableACME = true;
|
|
forceSSL = true;
|
|
|
|
# Regex location to match /dns-query and /dns-query/{clientId}
|
|
locations."~ ^/dns-query" = {
|
|
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
|
extraConfig = ''
|
|
proxy_http_version 1.1;
|
|
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;
|
|
|
|
# DoH uses POST with application/dns-message
|
|
proxy_pass_request_body on;
|
|
proxy_set_header Content-Type "application/dns-message";
|
|
|
|
# Buffer settings for DNS queries
|
|
proxy_buffers 8 16k;
|
|
proxy_buffer_size 32k;
|
|
'';
|
|
};
|
|
|
|
# Block all other paths including admin UI
|
|
locations."/" = {
|
|
return = "404";
|
|
};
|
|
};
|
|
|
|
# Ensure nginx user can access ACME certs
|
|
users.users.nginx.extraGroups = [ "acme" ];
|
|
|
|
# Open HTTPS port
|
|
networking.firewall.allowedTCPPorts = [ 443 ];
|
|
```
|
|
|
|
**Security Notes:**
|
|
- ClientIDs appear in URL paths and may be logged by nginx
|
|
- Consider filtering nginx logs to redact ClientIDs if needed
|
|
- Admin UI is completely blocked (returns 404)
|
|
- No rate limiting configured (can be added if abuse occurs)
|
|
|
|
## 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 runtime
|
|
- `sops-nix` - secrets management
|
|
- `services.nginx` - reverse proxy (shared with other modules)
|