Merge branch 009-headscale-stack-behind-caddy
This commit is contained in:
commit
a114606a23
4 changed files with 320 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
125
README.md
125
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 <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,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
50
headscale/compose.yaml
Normal file
50
headscale/compose.yaml
Normal file
|
|
@ -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
|
||||
146
headscale/config.yaml
Normal file
146
headscale/config.yaml
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue