Add Headscale pilot stack for PD

This commit is contained in:
Fizzlepoof
2026-05-26 00:48:01 +00:00
parent 68bab888db
commit 7e064397ec
8 changed files with 433 additions and 0 deletions

13
headscale/.env.example Normal file
View File

@@ -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=

162
headscale/README.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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"]
}
]
}

View File

@@ -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

View File

@@ -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"