nixos-vps/docs/superpowers/specs/2026-03-18-adguard-home-design.md
ashisgreat22 70016fe9c4 docs: fix spec review issues
- 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>
2026-03-18 19:01:39 +01:00

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)