From 6da5b3a8ab063f5b81d1bba4d47bdc5735b3f84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Waldson=20Patr=C3=ADcio?= Date: Thu, 23 Jul 2026 15:42:04 -0300 Subject: [PATCH] [009] Add Headscale stack behind Caddy at remote.waldson.com.br Headscale (the self-hosted Tailscale control plane) runs as a new stack on proxy_net, following the house pattern of syncthing/forgejo/ntfy: own directory, own compose file, committed config, git-ignored bind-mounted state, CLI-only admin via docker exec. - headscale/config.yaml: server_url https://remote.waldson.com.br, MagicDNS with base domain ts.waldson.com.br, embedded DERP relay with the upstream Tailscale DERP map dropped so relayed traffic stays on the droplet, HA subnet-router route failover, SQLite state under /var/lib/headscale. The CLI unix socket is relocated into the data dir so the UID 1000 container can use it. - headscale/compose.yaml: image pinned to the v0.29 minor, runs as 1000:1000, publishes only STUN on 3478/udp; gRPC not exposed. - Caddyfile: remote.waldson.com.br reverse-proxies to headscale-headscale-1:8080. - README: Headscale stack section (deploy, upgrade, user/node CLI runbook), firewall table row for 3478/udp, DNS row for remote.waldson.com.br. --- Caddyfile | 4 ++ README.md | 125 +++++++++++++++++++++++++++++++++-- headscale/compose.yaml | 50 ++++++++++++++ headscale/config.yaml | 146 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 headscale/compose.yaml create mode 100644 headscale/config.yaml diff --git a/Caddyfile b/Caddyfile index 726bf9e..d3b3663 100644 --- a/Caddyfile +++ b/Caddyfile @@ -17,3 +17,7 @@ git.waldson.com.br { push.waldson.com.br { reverse_proxy ntfy-ntfy-1:8080 } + +remote.waldson.com.br { + reverse_proxy headscale-headscale-1:8080 +} diff --git a/README.md b/README.md index 7e61355..ad3fecb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ forgejo/ the Forgejo stack (own compose file); web UI behind Caddy, git-over-SSH published on 2222 ntfy/ the ntfy stack (own compose file); push server behind Caddy, nothing published (pure HTTP through the proxy) +headscale/ the Headscale stack (own compose file); control plane behind + Caddy, STUN for the embedded DERP relay published on 3478/udp ``` ## Prerequisites @@ -312,6 +314,113 @@ docker compose pull && docker compose up -d A major bump is a deliberate tag edit; read the ntfy release notes first. +## Headscale stack + +A self-hosted [Headscale](https://headscale.net) coordination server (the +open-source Tailscale control plane) for the owner's mesh VPN (Virtual Private +Network) at `remote.waldson.com.br`. It lives in `headscale/` and deploys to +`/home/waldson/central-proxy/headscale` on the droplet. Every device (mercurio, +hermes, quicksilver, the phone, the Home Assistant box) joins as a stock +Tailscale client pointed at this login URL, so remote access to the home LAN +never depends on Tailscale Inc.'s servers. + +The control plane and login endpoints ride Caddy's existing 80/443: the central +`Caddyfile` carries the `remote.waldson.com.br` block reverse-proxying to +`headscale-headscale-1:8080`, and Caddy transparently upgrades the long-lived +client control connections (the same handling ntfy relies on). The one thing +published on the host is **STUN on `3478/udp`** for the embedded DERP +(Designated Encrypted Relay for Packets) server; the relay itself rides Caddy's +443. The whole posture (server URL, MagicDNS, embedded DERP) is a committed +`headscale/config.yaml`, so it is reproduced on every recreate rather than +depending on runtime state. + +The embedded DERP relay replaces Tailscale Inc.'s DERP map entirely +(`derp.urls: []`), so even traffic that cannot go peer-to-peer is relayed +through this droplet. MagicDNS is enabled with base domain `ts.waldson.com.br` +(deliberately different from the login hostname, which Headscale requires, and +never published to public DNS), so devices reach each other by name +(`ssh mercurio`). The gRPC remote-CLI port is not exposed; administration is +CLI-only via `docker exec ... headscale`, like every other stack. + +### Where data lives + +The SQLite database (`db.sqlite`), the auto-generated Noise and DERP private +keys, and the CLI unix socket all live under the host bind mount `headscale/data` +(mounted at `/var/lib/headscale` in the container), so state - the node list and +keys - survives container recreation and is inspectable over plain SSH. The +directory is git-ignored via the repo's `data/` rule. The container runs as +`1000:1000`; if the droplet's deploy user has a different id, adjust `user:` in +`compose.yaml` so the state files stay owned by that user. Headscale's default +unix socket lives under root-only `/var/run`, so `config.yaml` relocates it into +this data dir to keep the non-root container's CLI working. + +### Deploy + +From `headscale/` on the droplet: + +```sh +docker compose up -d +``` + +The container comes up as `headscale-headscale-1` on `proxy_net`, which is the +name Caddy reverse-proxies to. Recreating it preserves the node list and keys: + +```sh +docker compose up -d --force-recreate +``` + +### First-run setup (CLI) + +There is no web sign-up. All devices register under a single Headscale user, +created once via the CLI after the container is first up: + +```sh +docker exec headscale-headscale-1 headscale users create waldson +docker exec headscale-headscale-1 headscale users list +``` + +### Enrolling a device + +Registration is manual one-time approval per device (no pre-shared keys). On the +client, point Tailscale at this login server: + +```sh +tailscale up --login-server https://remote.waldson.com.br +``` + +The client prints a registration URL ending in an auth id; approve the node +under the user on the droplet with that id: + +```sh +docker exec headscale-headscale-1 \ + headscale auth register --user waldson --auth-id +docker exec headscale-headscale-1 headscale nodes list +``` + +Subnet routes advertised by a node (e.g. the Home Assistant box and mercurio +advertising the home LAN) are approved per node: + +```sh +docker exec headscale-headscale-1 headscale nodes approve-routes --identifier --routes +``` + +### Upgrade + +The image is pinned to the current Headscale minor (`headscale/headscale:v0.29`). +Because Headscale is pre-1.0, the minor is the breaking-change axis; patch +updates within it are safe: + +```sh +docker compose pull && docker compose up -d +``` + +A minor bump (editing the tag to `v0.30`) can change the config schema and may +run database migrations: diff `config.yaml` against that release's +`config-example.yaml`, back up `headscale/data` first, and read the release +notes. Headscale also trails the newest Tailscale client capabilities, so review +client compatibility when bumping - if a client refuses to connect after an +upgrade, version skew is the first suspect. + ## Droplet groundwork Baseline host configuration for the droplet (`wloud`, 174.138.36.232, @@ -352,15 +461,19 @@ firewall blocks them. Ports in use: | 80, 443 | tcp | Caddy (all HTTP/HTTPS traffic) | | 2222 | tcp | Forgejo git-over-SSH | | 22000 | tcp+udp | Syncthing sync protocol | +| 3478 | udp | Headscale STUN (embedded DERP) | -ntfy adds no row here: it is reached only through Caddy on 80/443. If ufw -rules are ever tightened, keep the four rows above allowed. To -re-verify reachability from any machine outside the droplet: +ntfy adds no row here: it is reached only through Caddy on 80/443. Headscale is +also fronted by Caddy on 80/443, but its embedded DERP relay needs STUN on +3478/udp for NAT traversal, which is the one row it adds. If ufw rules are ever +tightened, keep the rows above allowed. To re-verify reachability from any +machine outside the droplet: ```sh nc -vz 174.138.36.232 2222 nc -vz 174.138.36.232 22000 nc -vzu 174.138.36.232 22000 +nc -vzu 174.138.36.232 3478 ``` "Connection refused" means the port is reachable but nothing listens yet; @@ -378,12 +491,14 @@ and point at the droplet: | git.waldson.com.br | A | 174.138.36.232 | Forgejo | | sync.waldson.com.br| A | 174.138.36.232 | Syncthing GUI | | push.waldson.com.br| A | 174.138.36.232 | ntfy push server | +| remote.waldson.com.br | A | 174.138.36.232 | Headscale control plane | personaledson.com.br and nazareonline.com.br also resolve here for the -existing stacks. Check resolution with: +existing stacks. `ts.waldson.com.br` (the MagicDNS base domain) deliberately has +**no** public record: it exists only inside the tailnet. Check resolution with: ```sh -dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br +dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br remote.waldson.com.br ``` ## Notes diff --git a/headscale/compose.yaml b/headscale/compose.yaml new file mode 100644 index 0000000..f4629f6 --- /dev/null +++ b/headscale/compose.yaml @@ -0,0 +1,50 @@ +name: headscale + +# Self-hosted Headscale coordination server (the open-source Tailscale control +# plane) for the owner's tailnet, behind the central Caddy proxy at +# remote.waldson.com.br. The HTTP API and the login/OIDC endpoints are reached +# only through Caddy on proxy_net; Caddy transparently upgrades the long-lived +# client control connections, the same as ntfy. The one thing published to the +# host is STUN on 3478/udp for the embedded DERP relay. Administration is +# CLI-only via `docker exec ... headscale` (see README.md for the deploy, +# upgrade, and user/node runbook). The whole posture lives in the committed +# config.yaml; only SQLite state and generated keys are mutable at runtime. + +services: + headscale: + # Pinned to the current minor series. Headscale is pre-1.0, so the minor is + # the breaking-change axis: `pull` brings 0.29.x patches while a minor bump + # (0.30) stays a deliberate tag edit. Headscale trails new Tailscale client + # capabilities, so review compatibility before bumping the tag - if a client + # refuses to connect after an upgrade, version skew is the first suspect. + image: headscale/headscale:v0.29 + # The image entrypoint is `headscale`; the server needs the `serve` + # subcommand (same shape as ntfy's `serve`). + command: serve + restart: unless-stopped + # Run as the droplet's deploy user so the SQLite db and generated private + # keys in the bind mount stay owned by 1000 and are manageable over plain + # SSH, matching the Syncthing and ntfy stacks. Headscale's default unix + # socket lives under root-only /var/run; config.yaml relocates it into the + # data dir so this non-root process can create it and the CLI still works. + user: "1000:1000" + volumes: + # Committed posture: server URL, MagicDNS, embedded DERP. Read-only. + - ./config.yaml:/etc/headscale/config.yaml:ro + # SQLite database, noise/DERP private keys, and the CLI socket live here on + # a host bind mount, so the instance survives recreation and is + # inspectable over SSH. Git-ignored via the repo's data/ rule. + - ./data:/var/lib/headscale + ports: + # STUN for the embedded DERP relay's NAT traversal. The relay itself and + # every control connection ride Caddy's existing 443; only this UDP port + # is new on the host firewall. The gRPC remote-CLI port is deliberately + # not published - admin is via docker exec over the unix socket. + - "3478:3478/udp" + mem_limit: 256m + networks: + - proxy_net + +networks: + proxy_net: + external: true diff --git a/headscale/config.yaml b/headscale/config.yaml new file mode 100644 index 0000000..57441d1 --- /dev/null +++ b/headscale/config.yaml @@ -0,0 +1,146 @@ +# Headscale coordination server for the owner's self-hosted tailnet, reached at +# https://remote.waldson.com.br through the central Caddy proxy. This file is +# the committed posture: MagicDNS, the embedded DERP relay, and the SQLite data +# layout are all reproduced on every recreate instead of depending on runtime +# state. Only the SQLite database and the auto-generated private keys (under +# /var/lib/headscale, the git-ignored bind mount) are mutable at runtime. +# +# Schema tracks the pinned image (headscale/headscale:v0.29). When bumping the +# minor tag, diff this against that release's config-example.yaml: Headscale's +# config schema changes between minors. + +# Public login URL every client connects to. Caddy terminates TLS for this +# hostname and reverse-proxies to listen_addr below. DERP requires https here. +server_url: https://remote.waldson.com.br + +# Bind on all interfaces inside the container so Caddy can reach it by name on +# proxy_net (headscale-headscale-1:8080). The port is never published to the +# host; only Caddy on 80/443 fronts it, the same shape as the other stacks. +listen_addr: 0.0.0.0:8080 + +# Metrics and gRPC stay bound to loopback inside the container and are never +# published. Administration is done with `docker exec ... headscale` over the +# unix socket below, so the gRPC remote-CLI surface is not exposed. +metrics_listen_addr: 127.0.0.1:9090 +grpc_listen_addr: 127.0.0.1:50443 +grpc_allow_insecure: false + +# Caddy is the only thing in front of Headscale; trust its forwarding headers so +# logs and rate limiting see the real client IP. Caddy reaches Headscale from +# its own container on proxy_net, so trust that private range. +trusted_proxies: + - 172.16.0.0/12 + +noise: + private_key_path: /var/lib/headscale/noise_private.key + +# Tailnet address pools. Left at the standard Tailscale CGNAT/ULA ranges. +prefixes: + v4: 100.64.0.0/10 + v6: fd7a:115c:a1e0::/48 + allocation: sequential + +# Embedded DERP relay: run our own relay and STUN so even traffic that cannot go +# direct is relayed through this droplet, never through Tailscale Inc. The +# upstream DERP map is dropped (urls: [] and no auto-update) so the tailnet uses +# only this region. +derp: + server: + enabled: true + region_id: 999 + region_code: "waldson" + region_name: "Waldson Embedded DERP" + verify_clients: true + # STUN for NAT traversal. Published on the host as 3478/udp (the one new + # firewall row this stack adds); mandatory when the embedded DERP is on. + stun_listen_addr: "0.0.0.0:3478" + private_key_path: /var/lib/headscale/derp_server_private.key + automatically_add_embedded_derp_region: true + # Droplet public IPv4 so clients can reach this DERP region directly for + # better connection stability. No IPv6 line: the droplet has no stable + # public IPv6, and advertising a bogus one would send clients to a dead + # address. + ipv4: 174.138.36.232 + # No external DERP maps: relayed traffic must stay on our own infrastructure. + urls: [] + paths: [] + auto_update_enabled: false + update_frequency: 3h + +disable_check_updates: false + +node: + expiry: 0 + ephemeral: + inactivity_timeout: 30m + # HA subnet-router health probing. Kept on so that when the Home Assistant + # box (primary) and mercurio (failover) both advertise 192.168.0.0/24, the + # primary role fails over to whichever is healthy. This is the route-failover + # support the tailnet topology depends on. + routes: + ha: + probe_interval: 10s + probe_timeout: 5s + +database: + type: sqlite + debug: false + gorm: + prepare_stmt: true + parameterized_queries: true + skip_err_record_not_found: true + slow_threshold: 1000 + sqlite: + path: /var/lib/headscale/db.sqlite + write_ahead_log: true + wal_autocheckpoint: 1000 + +# TLS is terminated by Caddy, so Headscale serves plain HTTP on listen_addr and +# manages no certificates of its own. +tls_letsencrypt_hostname: "" +tls_letsencrypt_challenge_type: HTTP-01 +tls_cert_path: "" +tls_key_path: "" + +log: + level: info + format: text + +policy: + # Allow-all within the tailnet: single-user fleet, the tailnet boundary is the + # security boundary. An empty path keeps the built-in allow-all default. + mode: file + path: "" + +# MagicDNS so devices reach each other by name (ssh mercurio). The base domain +# is deliberately different from server_url's host, which Headscale requires, +# and exists only inside the tailnet (no public records for it). +dns: + magic_dns: true + base_domain: ts.waldson.com.br + override_local_dns: true + nameservers: + global: + - 1.1.1.1 + - 1.0.0.1 + - 2606:4700:4700::1111 + - 2606:4700:4700::1001 + split: {} + search_domains: [] + extra_records: [] + +# CLI connects over this unix socket with no auth. Relocated from the default +# /var/run/headscale (root-owned, not writable by our non-root container) into +# the bind-mounted data dir, so the UID 1000 process can create it and +# `docker exec ... headscale` works. +unix_socket: /var/lib/headscale/headscale.sock +unix_socket_permission: "0770" + +logtail: + enabled: false + +taildrop: + enabled: true + +auto_update: + enabled: false