Add Headscale pilot stack for PD
This commit is contained in:
@@ -20,6 +20,7 @@ photos/ # immich server + machine learning
|
|||||||
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
|
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
|
||||||
dev/ # gitea, rackpeek
|
dev/ # gitea, rackpeek
|
||||||
ai/ # ollama, openwebui
|
ai/ # ollama, openwebui
|
||||||
|
headscale/ # pilot self-hosted tailnet control plane + Headplane UI on PD
|
||||||
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
|
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map
|
||||||
automation/ # planned: gotify, n8n
|
automation/ # planned: gotify, n8n
|
||||||
search/ # planned: qdrant
|
search/ # planned: qdrant
|
||||||
|
|||||||
13
headscale/.env.example
Normal file
13
headscale/.env.example
Normal 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
162
headscale/README.md
Normal 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.
|
||||||
26
headscale/config/headplane/config.yaml
Normal file
26
headscale/config/headplane/config.yaml
Normal 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
|
||||||
99
headscale/config/headscale/config.yaml
Normal file
99
headscale/config/headscale/config.yaml
Normal 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
|
||||||
51
headscale/config/headscale/policy.hujson
Normal file
51
headscale/config/headscale/policy.hujson
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
headscale/docker-compose.yaml
Normal file
55
headscale/docker-compose.yaml
Normal 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
|
||||||
26
headscale/examples/traefik-routes.yml
Normal file
26
headscale/examples/traefik-routes.yml
Normal 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"
|
||||||
Reference in New Issue
Block a user