318 lines
10 KiB
Markdown
318 lines
10 KiB
Markdown
# 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
|
|
- Headscale can be published later as a dedicated public control-plane hostname without exposing Headplane the same way
|
|
|
|
## URLs for the pilot
|
|
|
|
- Headscale control plane for clients: `https://headscale.paccoco.com`
|
|
- Headplane admin UI (restricted/LAN-only): `http://headplane.home.paccoco.com:3005/admin`
|
|
|
|
This is the recommended **Option C** shape:
|
|
- publish **Headscale** on a stable public HTTPS hostname so phones and laptops can enroll/use it off-LAN
|
|
- keep **Headplane** off the public internet by default; treat it as an admin surface reached on LAN/private DNS (or a separately restricted admin path later)
|
|
|
|
The internal Traefik example in `examples/traefik-routes.yml` is still useful for private-DNS/LAN convenience, but it is not the public control-plane path for off-LAN device enrollment.
|
|
|
|
### Current live pilot note
|
|
|
|
Before the public hostname is wired, the live PD pilot uses direct-IP URLs:
|
|
- Headscale: `http://10.5.30.6:8084`
|
|
- Headplane: `http://10.5.30.6:3005/admin`
|
|
|
|
Once `https://headscale.paccoco.com` is created and verified through Pangolin, off-LAN clients should use that public Headscale URL. Headplane can stay on the restricted LAN/private-DNS path above.
|
|
|
|
## 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 restricted Headplane LAN/private-DNS record exists if you want the nicer admin hostname
|
|
- for off-LAN clients, create/verify the public Pangolin hostname for Headscale
|
|
- if you want different ports or hostnames, update both the config files and `.env`
|
|
|
|
Important live-ops note:
|
|
- the repo-tracked `config/headplane/config.yaml` intentionally keeps a placeholder `cookie_secret`
|
|
- before restarting the live Headplane container, render or restore the real 32-character secret from the live secret-bearing `.env`
|
|
- blindly syncing the tracked placeholder over the live config will make Headplane fail startup with `server.cookie_secret must be exactly length 32`
|
|
|
|
## 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.
|
|
|
|
## Public Headscale via Pangolin
|
|
|
|
For the off-LAN/mobile path, publish only Headscale itself.
|
|
|
|
Desired public hostname:
|
|
- `https://headscale.paccoco.com`
|
|
|
|
Desired Pangolin target shape:
|
|
- resource name: `headscale`
|
|
- site: `Plausible Deniability` (siteId `4`)
|
|
- target: `headscale:8080`
|
|
- healthcheck path: `/health`
|
|
- Pangolin auth/SSO: **disabled** for this route
|
|
|
|
Why:
|
|
- Headscale clients need a plain reachable control-plane endpoint; adding browser SSO in front of it is the wrong shape
|
|
- Headplane is an admin UI and should stay restricted/admin-only instead of being published like a normal household app
|
|
|
|
Use `automation/bin/pangolin_upsert_headscale.py` to create or reconcile the public Pangolin resource.
|
|
|
|
## 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.
|
|
|
|
## Owner-facing Headplane pilot checklist
|
|
|
|
This section is for John as the stack owner using the Web UI. Doris should handle the CLI and backend admin work; this checklist is about what John should look for in Headplane and when to escalate.
|
|
|
|
### Where to go
|
|
|
|
- Open: `http://headplane.home.paccoco.com:3005/admin`
|
|
- Log in with the temporary Headscale API key Doris generated for the pilot
|
|
|
|
### What you should expect to see first
|
|
|
|
After initial pilot bring-up, Headplane should show:
|
|
- two users:
|
|
- `fizzlepoof`
|
|
- `manndra`
|
|
- John's enrolled device(s)
|
|
- one tagged pilot service node
|
|
- a generally healthy/online control plane with no obvious UI errors
|
|
|
|
If any of those are missing, that is a Doris problem to fix, not a John problem to debug.
|
|
|
|
### UI-first smoke test
|
|
|
|
#### 1. Confirm the users exist
|
|
Look for:
|
|
- `fizzlepoof`
|
|
- `manndra`
|
|
|
|
Good:
|
|
- both users appear once
|
|
|
|
Bad / message Doris if:
|
|
- a user is missing
|
|
- a duplicate or unexpected user exists
|
|
- user ownership looks wrong
|
|
|
|
#### 2. Confirm John's device is present
|
|
Look for at least one clearly identifiable John-owned device.
|
|
|
|
Good:
|
|
- device shows as online
|
|
- device is owned by `fizzlepoof`
|
|
- the name is recognizable enough to tell what it is
|
|
|
|
Bad / message Doris if:
|
|
- device is offline when it should be online
|
|
- device is attached to the wrong user
|
|
- device appears more than once unexpectedly
|
|
|
|
#### 3. Confirm Manndra's device is present
|
|
Good:
|
|
- device is owned by `manndra`
|
|
- it appears separately from John's devices
|
|
|
|
Bad / message Doris if:
|
|
- it shows under the wrong user
|
|
- it never appears after enrollment
|
|
- it appears to have broad access it should not have
|
|
|
|
#### 4. Confirm the tagged node looks like a service node
|
|
Look for one node tagged as a pilot service node, typically with `tag:apps` or `tag:infra`.
|
|
|
|
Good:
|
|
- the node is visibly tagged
|
|
- it is not owned like a normal human personal device
|
|
- it is online when the underlying service is online
|
|
|
|
Bad / message Doris if:
|
|
- the node is untagged
|
|
- it appears owned like a personal device when it should be infra
|
|
- it has the wrong tag
|
|
|
|
#### 5. Confirm the UI is usable enough to keep
|
|
Good:
|
|
- pages load consistently
|
|
- node/user details are understandable
|
|
- no obvious blank/error screens
|
|
- refreshing does not randomly lose state
|
|
|
|
Bad / message Doris if:
|
|
- login repeatedly fails with a known-good API key
|
|
- pages partially load or spin forever
|
|
- the UI looks disconnected from reality
|
|
- the UI feels too broken to trust for visibility
|
|
|
|
### Expected access behavior
|
|
|
|
John does not need to test raw ACL syntax. The practical expectations are:
|
|
|
|
- `fizzlepoof` should have broad pilot visibility/access
|
|
- `manndra` should be limited to app-level access
|
|
- Manndra should **not** have broad admin/infra access by default
|
|
|
|
If Manndra can reach something that feels like core infra/admin, treat that as a problem and tell Doris.
|
|
|
|
### What counts as pilot success from John's side
|
|
|
|
The pilot is good enough to continue if:
|
|
- Headplane reliably loads
|
|
- the users/nodes make sense at a glance
|
|
- John's devices are visible
|
|
- Manndra's device is visible and separated correctly
|
|
- the tagged service node is visible and clearly infra-like
|
|
- nothing suggests accidental overexposure
|
|
|
|
### When John should call Doris instead of poking at it
|
|
|
|
Call Doris if:
|
|
- login stops working
|
|
- users disappear or duplicate
|
|
- devices are attached to the wrong owner
|
|
- a tagged node loses its tag or looks wrong
|
|
- Manndra appears to have too much access
|
|
- the UI starts showing stale, contradictory, or obviously broken state
|
|
|
|
The point of the Web UI is visibility and confidence, not pushing John into VPN-control-plane babysitting.
|
|
|
|
## 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.
|