aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--CLAUDE.md33
-rw-r--r--README.md36
-rwxr-xr-xdeploy.sh19
-rw-r--r--modules/adguard.nix27
-rw-r--r--modules/common.nix13
6 files changed, 81 insertions, 50 deletions
diff --git a/.gitignore b/.gitignore
index 5445a93..d09a4c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-*.pub \ No newline at end of file
+*.pub
+.env \ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index e18f301..6817d40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,20 +18,14 @@ Check the flake evaluates without deploying:
nix flake check
```
-Deploy a change (from an x86_64-linux machine):
+Deploy a change:
```bash
-nixos-rebuild switch --flake .#eurovm \
- --target-host admin@<server-ipv4> \
- --elevate=sudo
-```
-
-Deploy from a non-x86_64-linux machine (e.g. Apple Silicon) — the server builds its own closure:
-```bash
-nixos-rebuild switch --flake .#eurovm \
- --target-host admin@<server-ipv4> \
- --build-host admin@<server-ipv4> \
- --elevate=sudo
+./deploy.sh
```
+`deploy.sh` reads `SERVER_IP` from a git-ignored `.env` file (copy `.env.example` to start) —
+the server's Tailscale IP — and runs `nixos-rebuild switch --flake .#eurovm` against it with
+both `--target-host` and `--build-host` set to that address, so the server always builds its
+own closure regardless of the local machine's architecture.
First-time provisioning of a fresh VM (partitions the disk via disko, installs NixOS):
```bash
@@ -71,8 +65,9 @@ service module:
**Service modules (`modules/`)** are one file per concern, each self-contained and imported by
`hosts/eurovm/default.nix`:
-- `common.nix` — SSH hardening (key-only, no root login), firewall (22/80/443/53 only), timezone,
- nix settings (flakes, auto-gc, auto-optimise-store), base packages, disabled autoUpgrade.
+- `common.nix` — SSH hardening (key-only, no root login), firewall (22/80/443/853/8443 only —
+ plain DNS on 53 is deliberately closed, see `adguard.nix`), timezone, nix settings (flakes,
+ auto-gc, auto-optimise-store), base packages, disabled autoUpgrade.
- `sops.nix` — sops-nix wiring. The host's own age key is derived at boot from
`/etc/ssh/ssh_host_ed25519_key`, so no separate host key material needs to be provisioned.
Declares which secrets exist, their owning user/group, and file mode.
@@ -88,9 +83,13 @@ service module:
8080, 8086) are bound to `127.0.0.1` and never exposed directly — Caddy is the public HTTP(S)
entry point for everything except AdGuard's encrypted-DNS ports, and handles Let's Encrypt
automatically for its own certs.
-- `adguard.nix` — AdGuard Home; plain DNS on `0.0.0.0:53`, DNS-over-TLS on `0.0.0.0:853`,
- DNS-over-HTTPS on `0.0.0.0:8443` (path `/dns-query`), web UI on `0.0.0.0:3000` (port 3000 is
- never opened in the firewall, so it's still effectively internal-only, reached via Caddy).
+- `adguard.nix` — AdGuard Home; plain DNS (`dns.port`) is set to `0` and deliberately not opened
+ in the firewall — CERT-Bund/BSI flagged this host as an open DNS resolver abusable for UDP
+ reflection/amplification DDoS (spoofed source IP, no handshake needed), so plain DNS is
+ disabled entirely and DNS-over-TLS (`0.0.0.0:853`) / DNS-over-HTTPS (`0.0.0.0:8443`, path
+ `/dns-query`) — both TLS-handshake-based and therefore not spoofable the same way — are the
+ only supported client paths. Web UI on `0.0.0.0:3000` (port 3000 is never opened in the
+ firewall, so it's still effectively internal-only, reached via Caddy).
`http.address` binds `0.0.0.0` rather than `127.0.0.1` specifically because AdGuard's
HTTPS/DoH listener reuses that same bind host internally (confirmed from AdGuard's source) —
narrowing it back to loopback would silently make DoH unreachable. TLS for DoH/DoT uses the
diff --git a/README.md b/README.md
index 824776a..6cae516 100644
--- a/README.md
+++ b/README.md
@@ -12,8 +12,9 @@ A flake-based, modular NixOS configuration for a personal server running in Euro
| https://git.karanj.com | cgit (git repository browser) |
All services are reverse-proxied by **Caddy** with automatic TLS via Let's Encrypt.
-AdGuard also listens directly on **port 53 (UDP + TCP)** for plain DNS, **853/tcp** for
-DNS-over-TLS, and **8443/tcp** for DNS-over-HTTPS - see [AdGuard Home](#adguard-home) below.
+AdGuard also listens directly on **853/tcp** for DNS-over-TLS and **8443/tcp** for
+DNS-over-HTTPS - see [AdGuard Home](#adguard-home) below. Plain DNS (port 53) is disabled;
+see the note there for why.
---
@@ -205,16 +206,13 @@ Re-encrypt secrets with the new recipient:
sops updatekeys secrets/secrets.yaml
```
-Commit and push `.sops.yaml` and the re-encrypted `secrets/secrets.yaml`, then deploy:
+Commit and push `.sops.yaml` and the re-encrypted `secrets/secrets.yaml`, then deploy. Copy
+`.env.example` to `.env`, set `SERVER_IP` to the server's Tailscale IP (the server must be
+joined to your tailnet first), then:
```bash
-nixos-rebuild switch --flake .#eurovm \
- --target-host admin@<server-ipv4> \
- --build-host admin@<server-ipv4> \
- --elevate=sudo
+./deploy.sh
```
-If your local machine is also x86_64-linux you can omit `--build-host`.
-
### Step 7 - First login to each service
| Service | URL | Action |
@@ -224,7 +222,8 @@ If your local machine is also x86_64-linux you can omit `--build-host`.
| Actual Budget | https://budget.karanj.com | Set a server password in the browser on first visit - no pre-configuration needed |
| cgit | https://git.karanj.com | No login needed - public read-only |
-To use the server as your device's DNS resolver, point its DNS settings to `<server-ipv4>`.
+To use the server as your device's DNS resolver, configure DNS-over-TLS or DNS-over-HTTPS as
+described under [AdGuard Home](#adguard-home) below - plain DNS (port 53) is disabled.
### Step 8 - (Optional) enable automatic OS updates
@@ -238,13 +237,12 @@ If you push this repo to GitHub/Forgejo/cgit, update the `autoUpgrade.flake` lin
### Deploy updates
```bash
-nixos-rebuild switch --flake .#eurovm \
- --target-host admin@<server-ipv4> \
- --build-host admin@<server-ipv4> \
- --elevate=sudo
+./deploy.sh
```
-If your local machine is also x86_64-linux you can omit `--build-host`.
+Reads `SERVER_IP` from `.env` (copy `.env.example` if you haven't already) and runs
+`nixos-rebuild switch` against it over Tailscale, with `--build-host` so the server always
+builds its own closure regardless of your local machine's architecture.
### Edit secrets
```bash
@@ -280,7 +278,9 @@ To reset the password:
# Then: systemctl restart adguardhome
```
-**Encrypted DNS client setup.** Point client devices at:
+**Encrypted DNS client setup.** Plain DNS (port 53) is disabled - CERT-Bund/BSI flagged it as
+an abusable open resolver (spoofable UDP reflection/amplification DDoS vector). Point client
+devices at encrypted DNS instead:
- **DNS-over-TLS:** `tls://dns.karanj.com:853`
- **DNS-over-HTTPS:** `https://dns.karanj.com:8443/dns-query`
@@ -289,6 +289,9 @@ automatically via `security.acme` - see `modules/acme.nix`), independently of Ca
certificate for the same domain. Nothing to configure manually; the cert renews on its own
and restarts `adguardhome` automatically when it does.
+On iOS, an app like **DNS Override** can install the DoT/DoH settings as a signed
+configuration profile (Settings -> General -> VPN & Device Management) without needing an MDM.
+
### Miniflux
To reset the password from the server:
@@ -325,7 +328,6 @@ Caddy logs: `journalctl -u caddy -f`
| 22 | TCP | SSH (admin + git push) |
| 80 | TCP | HTTP (Caddy redirects to HTTPS; also serves the ACME HTTP-01 webroot) |
| 443 | TCP | HTTPS (all web services, via Caddy) |
-| 53 | TCP + UDP | Plain DNS (AdGuard Home) |
| 853 | TCP | DNS-over-TLS (AdGuard Home) |
| 8443 | TCP | DNS-over-HTTPS (AdGuard Home) |
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..c5ae6b6
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")"
+
+if [ ! -f .env ]; then
+ echo "Missing .env - copy .env.example to .env and set SERVER_IP (the server's Tailscale IP)." >&2
+ exit 1
+fi
+
+set -a
+source .env
+set +a
+
+: "${SERVER_IP:?SERVER_IP must be set in .env}"
+
+nixos-rebuild switch --flake .#eurovm \
+ --target-host "admin@${SERVER_IP}" \
+ --build-host "admin@${SERVER_IP}" \
+ --elevate=sudo
diff --git a/modules/adguard.nix b/modules/adguard.nix
index e66d072..35cafc3 100644
--- a/modules/adguard.nix
+++ b/modules/adguard.nix
@@ -2,12 +2,17 @@
# AdGuard Home - DNS resolver + optional network-wide ad blocker.
#
-# Plain DNS listens on port 53 (udp/tcp) directly on the public IP.
-# DNS-over-TLS (853/tcp) and DNS-over-HTTPS (8443/tcp, path /dns-query) are
-# also served directly by AdGuard itself, terminating TLS with their own
-# independent Let's Encrypt cert (modules/acme.nix) - NOT via Caddy, since
-# Caddy already owns port 443 for its own reverse-proxied vhosts and
-# AdGuard's HTTPS/DoH listener needs its own port.
+# Plain DNS (port 53, udp/tcp) is disabled (dns.port = 0 below): CERT-Bund/BSI
+# flagged this host as an open DNS resolver, abusable for UDP reflection/
+# amplification DDoS since a spoofed source IP gets an amplified response with
+# no handshake required. DNS-over-TLS (853/tcp) and DNS-over-HTTPS (8443/tcp,
+# path /dns-query) are unaffected by this - both require a real TLS handshake
+# with the real client IP, so they aren't spoofable the same way - and remain
+# the only supported way to use this resolver. They're served directly by
+# AdGuard itself, terminating TLS with their own independent Let's Encrypt
+# cert (modules/acme.nix) - NOT via Caddy, since Caddy already owns port 443
+# for its own reverse-proxied vhosts and AdGuard's HTTPS/DoH listener needs
+# its own port.
#
# AdGuard's HTTPS/DoH listener binds to the same host as the plain `http`
# listener below (confirmed in AdGuardHome's source: both come from
@@ -45,11 +50,11 @@
dns = {
bind_hosts = [ "0.0.0.0" ];
- port = 53;
- # Clients configure this server's public IPv4 address (not
- # dns.karanj.com - that hostname only resolves to the HTTPS web UI
- # via Caddy) as their plain DNS resolver, e.g. <server-ipv4>:53.
- # For encrypted DNS, see the client setup notes in README.md.
+ # port = 0 disables the plain UDP/TCP:53 listener entirely; it does
+ # not affect the DoT/DoH listeners below, which are configured
+ # separately under `tls` and stay on. Clients use DoT/DoH exclusively
+ # - see the client setup notes in README.md.
+ port = 0;
# Upstream DNS resolvers (privacy-respecting)
upstream_dns = [
"https://dns.quad9.net/dns-query"
diff --git a/modules/common.nix b/modules/common.nix
index e27d276..7f46af8 100644
--- a/modules/common.nix
+++ b/modules/common.nix
@@ -15,14 +15,19 @@
};
};
- # Firewall: SSH, HTTP/HTTPS (Caddy), plain DNS, and AdGuard's own
- # DNS-over-TLS (853) + DNS-over-HTTPS (8443) listeners.
+ # Firewall: SSH, HTTP/HTTPS (Caddy), and AdGuard's own DNS-over-TLS (853) +
+ # DNS-over-HTTPS (8443) listeners.
+ # Plain DNS (port 53) is deliberately NOT opened: CERT-Bund/BSI flagged this
+ # host as an open DNS resolver abusable for UDP reflection/amplification
+ # DDoS (spoofed source IP, no handshake). AdGuard's plain listener is
+ # disabled entirely in modules/adguard.nix (dns.port = 0) - all clients use
+ # DoT/DoH instead, which require a real TLS handshake with the real client
+ # IP and so aren't spoofable the same way.
# Port 3000 (AdGuard's plain-HTTP web UI) is deliberately NOT opened here -
# see modules/adguard.nix for why it still binds 0.0.0.0 anyway.
networking.firewall = {
enable = true;
- allowedTCPPorts = [ 22 80 443 53 853 8443 ];
- allowedUDPPorts = [ 53 ];
+ allowedTCPPorts = [ 22 80 443 853 8443 ];
};
# Nix settings: flakes, auto-gc, auto-optimise