From 7e064397ecdff4e0c341a8820d511118b6b2f647 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Tue, 26 May 2026 00:48:01 +0000 Subject: [PATCH] Add Headscale pilot stack for PD --- README.md | 1 + headscale/.env.example | 13 ++ headscale/README.md | 162 +++++++++++++++++++++++ headscale/config/headplane/config.yaml | 26 ++++ headscale/config/headscale/config.yaml | 99 ++++++++++++++ headscale/config/headscale/policy.hujson | 51 +++++++ headscale/docker-compose.yaml | 55 ++++++++ headscale/examples/traefik-routes.yml | 26 ++++ 8 files changed, 433 insertions(+) create mode 100644 headscale/.env.example create mode 100644 headscale/README.md create mode 100644 headscale/config/headplane/config.yaml create mode 100644 headscale/config/headscale/config.yaml create mode 100644 headscale/config/headscale/policy.hujson create mode 100644 headscale/docker-compose.yaml create mode 100644 headscale/examples/traefik-routes.yml diff --git a/README.md b/README.md index bf021c6..0c6913b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ photos/ # immich server + machine learning home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard dev/ # gitea, rackpeek ai/ # ollama, openwebui +headscale/ # pilot self-hosted tailnet control plane + Headplane UI on PD meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map automation/ # planned: gotify, n8n search/ # planned: qdrant diff --git a/headscale/.env.example b/headscale/.env.example new file mode 100644 index 0000000..e258ad2 --- /dev/null +++ b/headscale/.env.example @@ -0,0 +1,13 @@ +TZ=America/Chicago + +# Private DNS names for the pilot. Add matching records in Technitium before device enrollment. +HEADSCALE_SERVER_URL=http://headscale.home.paccoco.com:8084 +HEADPLANE_BASE_URL=http://headplane.home.paccoco.com:3005 + +# Must be replaced with an actual 32-character secret before deployment. +HEADPLANE_COOKIE_SECRET=CHANGE_ME_TO_EXACTLY_32_CHARS + +# Optional future OIDC wiring for Headscale users. Leave blank for the initial pilot. +HEADSCALE_OIDC_ISSUER= +HEADSCALE_OIDC_CLIENT_ID= +HEADSCALE_OIDC_CLIENT_SECRET= diff --git a/headscale/README.md b/headscale/README.md new file mode 100644 index 0000000..1c39579 --- /dev/null +++ b/headscale/README.md @@ -0,0 +1,162 @@ +# Headscale pilot on PD + +Self-hosted Headscale + Headplane pilot stack for replacing the Tailscale free-tier 3-user limit without doing a blind cutover. + +## Why this exists + +- remove the 3-human-user ceiling from the current Tailscale free-tier setup +- keep the control plane on PD, the long-term primary Docker host +- prove a small household-safe access model before expanding scope +- keep the repo copy as the source of truth for stack config and policy + +## Pilot shape + +- Host: **PlausibleDeniability** +- Live path: `/mnt/docker-ssd/docker/compose/headscale` +- Services: + - `headscale` + - `headplane` +- Data paths: + - `/mnt/docker-ssd/docker/appdata/headscale` + - `/mnt/docker-ssd/docker/appdata/headplane` +- No OIDC on day one +- No subnet-router or exit-node rollout on day one +- No public Pangolin exposure on day one + +## URLs for the pilot + +- Headscale control plane: `http://headscale.home.paccoco.com:8084` +- Headplane UI: `http://headplane.home.paccoco.com:3005/admin` + +These are intentionally LAN/private-DNS endpoints for the pilot. If you later want Traefik/Pangolin exposure, use `examples/traefik-routes.yml` as a starting point instead of changing the pilot shape first. + +## Initial users + +- `fizzlepoof` +- `manndra` + +Policy references them as: +- `fizzlepoof@` +- `manndra@` + +## Initial tags + +- `tag:infra` +- `tag:apps` +- `tag:admin` + +## Access model + +- `fizzlepoof@` = full pilot admin access to `tag:infra`, `tag:apps`, `tag:admin`, and his own devices +- `manndra@` = app access only via `tag:apps` plus her own devices +- no limited-user tier yet +- no routed-LAN access yet + +## Files + +- `docker-compose.yaml` +- `.env.example` +- `config/headscale/config.yaml` +- `config/headscale/policy.hujson` +- `config/headplane/config.yaml` +- `examples/traefik-routes.yml` + +## Pre-deploy edits + +Before first deploy, replace the placeholder values in: + +- `.env` copied from `.env.example` +- `config/headplane/config.yaml` + +Required changes: +- replace `CHANGE_ME_TO_EXACTLY_32_CHARS` with a real 32-character cookie secret +- confirm the hostnames/IP-backed DNS records exist in Technitium +- if you want different ports or hostnames, update both the config files and `.env` + +## Deploy on PD + +1. Sync this directory into the live compose tree: + - `/mnt/docker-ssd/docker/compose/headscale` +2. Copy `.env.example` to `.env` +3. Create persistent appdata paths: + - `/mnt/docker-ssd/docker/appdata/headscale` + - `/mnt/docker-ssd/docker/appdata/headscale/run` + - `/mnt/docker-ssd/docker/appdata/headplane` +4. Validate: + +```bash +cd /mnt/docker-ssd/docker/compose/headscale +docker compose --env-file .env config +``` + +5. Bring up Headscale first: + +```bash +docker compose --env-file .env up -d headscale +docker logs headscale --tail=100 +``` + +6. Create pilot users: + +```bash +docker exec -it headscale headscale users create fizzlepoof +docker exec -it headscale headscale users create manndra +docker exec -it headscale headscale users list +``` + +7. Generate a Headscale API key for Headplane login: + +```bash +docker exec -it headscale headscale apikeys create --expiration 90d +``` + +Save the returned key somewhere secure. Headplane uses it for the initial admin login. + +8. Enroll John's admin device. +9. Bring up Headplane: + +```bash +docker compose --env-file .env up -d headplane +docker logs headplane --tail=100 +``` + +10. Log in to Headplane with the API key from step 7. +11. Register one tagged service node. +12. Enroll one Manndra device. + +## Policy reload + +After editing `config/headscale/policy.hujson`: + +```bash +docker exec -it headscale kill -HUP 1 +``` + +Then inspect the container logs for policy parse results. + +## What to validate before expanding + +- Headscale stays healthy +- Headplane is usable enough to justify keeping it +- John can reach `tag:infra`, `tag:admin`, and `tag:apps` +- Manndra can reach `tag:apps` but not `tag:infra` or `tag:admin` +- one tagged service node registers cleanly and behaves as expected +- existing Tailscale remains intact as rollback during the pilot + +## Notes on Headplane integration + +This stack enables Headplane's Docker integration so it can identify the Headscale container via the label: + +- `me.tale.headplane.target=headscale` + +It also mounts the tracked Headscale config into Headplane so the UI can inspect and, if you later choose, manage more than just node registration. + +## Not done here on purpose + +- no OIDC wiring yet +- no DERP customization yet +- no public auth/proxy routing yet +- no explicit subnet-router policy yet +- no migration of the existing infrastructure stack's `tailscale` container yet + +Pilot first. Cutover later. diff --git a/headscale/config/headplane/config.yaml b/headscale/config/headplane/config.yaml new file mode 100644 index 0000000..54b8368 --- /dev/null +++ b/headscale/config/headplane/config.yaml @@ -0,0 +1,26 @@ +server: + host: "0.0.0.0" + port: 3000 + base_url: "http://headplane.home.paccoco.com:3005" + cookie_secret: "CHANGE_ME_TO_EXACTLY_32_CHARS" + cookie_secure: false + cookie_max_age: 86400 + data_path: "/var/lib/headplane" + +headscale: + url: "http://headscale:8080" + public_url: "http://headscale.home.paccoco.com:8084" + config_path: "/etc/headscale/config.yaml" + config_strict: true + +integration: + agent: + enabled: false + docker: + enabled: true + container_label: "me.tale.headplane.target=headscale" + socket: "unix:///var/run/docker.sock" + kubernetes: + enabled: false + proc: + enabled: false diff --git a/headscale/config/headscale/config.yaml b/headscale/config/headscale/config.yaml new file mode 100644 index 0000000..3b22711 --- /dev/null +++ b/headscale/config/headscale/config.yaml @@ -0,0 +1,99 @@ +server_url: http://headscale.home.paccoco.com:8084 +listen_addr: 0.0.0.0:8080 +metrics_listen_addr: 127.0.0.1:9090 +grpc_listen_addr: 0.0.0.0:50443 +grpc_allow_insecure: false +trusted_proxies: [] + +noise: + private_key_path: /var/lib/headscale/noise_private.key + +prefixes: + v4: 100.64.0.0/10 + v6: fd7a:115c:a1e0::/48 + allocation: sequential + +derp: + server: + enabled: false + region_id: 999 + region_code: "headscale" + region_name: "Headscale Embedded DERP" + verify_clients: true + stun_listen_addr: "0.0.0.0:3478" + private_key_path: /var/lib/headscale/derp_server_private.key + automatically_add_embedded_derp_region: true + ipv4: 198.51.100.1 + ipv6: 2001:db8::1 + urls: + - https://controlplane.tailscale.com/derpmap/default + paths: [] + auto_update_enabled: true + update_frequency: 3h + +disable_check_updates: false + +node: + expiry: 0 + ephemeral: + inactivity_timeout: 30m + 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 + +acme_url: https://acme-v02.api.letsencrypt.org/directory +acme_email: "" +tls_letsencrypt_hostname: "" +tls_letsencrypt_cache_dir: /var/lib/headscale/cache +tls_letsencrypt_challenge_type: HTTP-01 +tls_letsencrypt_listen: ":http" + +tls_cert_path: "" +tls_key_path: "" + +log: + level: info + format: text + +policy: + mode: file + path: /etc/headscale/policy.hujson + +dns: + magic_dns: true + base_domain: tail.home.paccoco.com + override_local_dns: true + nameservers: + global: + - 10.5.30.8 + - 10.5.30.9 + - 10.5.30.10 + split: {} + search_domains: [] + extra_records: [] + +unix_socket: /var/run/headscale/headscale.sock +unix_socket_permission: "0770" + +logtail: + enabled: false + +taildrop: + enabled: true + +auto_update: + enabled: false diff --git a/headscale/config/headscale/policy.hujson b/headscale/config/headscale/policy.hujson new file mode 100644 index 0000000..75c973a --- /dev/null +++ b/headscale/config/headscale/policy.hujson @@ -0,0 +1,51 @@ +{ + // Small deny-by-default-ish pilot policy for the household tailnet. + // Expand only after John + Manndra + one tagged service node validate cleanly. + + "groups": { + "group:admins": ["fizzlepoof@"], + "group:vip": ["manndra@"] + }, + + "tagOwners": { + "tag:infra": ["group:admins"], + "tag:apps": ["group:admins"], + "tag:admin": ["group:admins"] + }, + + "acls": [ + { + "action": "accept", + "src": ["group:admins"], + "dst": [ + "tag:infra:*", + "tag:apps:*", + "tag:admin:*", + "autogroup:self:*" + ] + }, + { + "action": "accept", + "src": ["group:vip"], + "dst": [ + "tag:apps:*", + "autogroup:self:*" + ] + } + ], + + "ssh": [ + { + "action": "accept", + "src": ["group:admins"], + "dst": ["tag:infra"], + "users": ["autogroup:nonroot"] + }, + { + "action": "accept", + "src": ["group:admins"], + "dst": ["tag:admin"], + "users": ["autogroup:nonroot"] + } + ] +} diff --git a/headscale/docker-compose.yaml b/headscale/docker-compose.yaml new file mode 100644 index 0000000..84b80d5 --- /dev/null +++ b/headscale/docker-compose.yaml @@ -0,0 +1,55 @@ +name: headscale + +networks: + headscale-net: + driver: bridge + pangolin: + external: true + +services: + headscale: + image: headscale/headscale:0.26.0 + container_name: headscale + restart: unless-stopped + command: serve + labels: + me.tale.headplane.target: headscale + networks: + - headscale-net + - pangolin + ports: + - "8084:8080" + volumes: + - /mnt/docker-ssd/docker/compose/headscale/config/headscale/config.yaml:/etc/headscale/config.yaml + - /mnt/docker-ssd/docker/compose/headscale/config/headscale/policy.hujson:/etc/headscale/policy.hujson + - /mnt/docker-ssd/docker/appdata/headscale:/var/lib/headscale + - /mnt/docker-ssd/docker/appdata/headscale/run:/var/run/headscale + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + headplane: + image: ghcr.io/tale/headplane:latest + container_name: headplane + restart: unless-stopped + depends_on: + - headscale + networks: + - headscale-net + - pangolin + ports: + - "3005:3000" + volumes: + - /mnt/docker-ssd/docker/compose/headscale/config/headplane/config.yaml:/etc/headplane/config.yaml + - /mnt/docker-ssd/docker/compose/headscale/config/headscale/config.yaml:/etc/headscale/config.yaml + - /mnt/docker-ssd/docker/appdata/headplane:/var/lib/headplane + - /var/run/docker.sock:/var/run/docker.sock:ro + healthcheck: + test: ["CMD", "/bin/hp_healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/headscale/examples/traefik-routes.yml b/headscale/examples/traefik-routes.yml new file mode 100644 index 0000000..8184654 --- /dev/null +++ b/headscale/examples/traefik-routes.yml @@ -0,0 +1,26 @@ +http: + routers: + headscale: + rule: "Host(`headscale.home.paccoco.com`)" + entryPoints: [web] + service: headscale + middlewares: + - security-headers + + headplane: + rule: "Host(`headplane.home.paccoco.com`)" + entryPoints: [web] + service: headplane + middlewares: + - security-headers + + services: + headscale: + loadBalancer: + servers: + - url: "http://headscale:8080" + + headplane: + loadBalancer: + servers: + - url: "http://headplane:3000"