Compare commits

..

No commits in common. "a114606a238854cc388f78eb97eba8546b1bd3b6" and "04df6b9c094187938c3a57ebffc4387836bed005" have entirely different histories.

4 changed files with 5 additions and 320 deletions

View file

@ -17,7 +17,3 @@ git.waldson.com.br {
push.waldson.com.br {
reverse_proxy ntfy-ntfy-1:8080
}
remote.waldson.com.br {
reverse_proxy headscale-headscale-1:8080
}

125
README.md
View file

@ -24,8 +24,6 @@ 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
@ -314,113 +312,6 @@ 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 <auth-id-from-client-url>
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 <id> --routes <cidr>
```
### 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,
@ -461,19 +352,15 @@ 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. 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:
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:
```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;
@ -491,14 +378,12 @@ 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. `ts.waldson.com.br` (the MagicDNS base domain) deliberately has
**no** public record: it exists only inside the tailnet. Check resolution with:
existing stacks. Check resolution with:
```sh
dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br remote.waldson.com.br
dig +short waldson.com.br www.waldson.com.br git.waldson.com.br sync.waldson.com.br
```
## Notes

View file

@ -1,50 +0,0 @@
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

View file

@ -1,146 +0,0 @@
# 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