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