The set_env/env_set helper escaped its value with
`esc=${val//\/\\}; esc=${esc//|/\|}; esc=${esc//&/\&}` and then
interpolated it into `sed -i -e "s|^KEY=.*|KEY=${esc}|"`. The escaping does
not do what it looks like. Tested on bash 5.2:
set_env K 'a&b' -> K=aK=seedb (sed expanded & to the whole match)
set_env K 'a|b' -> sed: unknown option to `s' (rc!=0, aborts under set -e)
So any value containing & is silently corrupted and any value containing the
s||| delimiter kills the run. That is reachable: headscale writes
OIDC_CLIENT_SECRET through this, pocket-id writes REDIRECT_URL, copyparty
writes DATA_DIR. A generated secret or a URL query string hits both cases.
The copies in headscale and pocket-id were additionally mangled when they
were introduced -- `${val//\/\}` (pattern `\/`, a literal SLASH) and a raw
newline inside `printf '%s=%s\n'`. The mangled form is a no-op rather than a
corrupter, so the practical failure mode was the same as the original.
Replace all of them with an awk rewrite that passes the key and value through
the ENVIRONMENT, so the value is never parsed as part of a script and needs no
escaping at all. ENVIRON and index() are POSIX, so busybox awk handles them.
Output goes to a temp file and is copied back with `cat >`, which preserves the
original mode and owner -- a .env holding secrets stays 0600. If awk fails,
set -e aborts before .env is touched, which `sed -i` could not promise.
Verified against plain, a&b, a|b, a\b, p@ss&w|rd\x, R&D, a URL with a query
string, s/foo/bar/, a trailing space and the empty string; plus the
append-when-key-absent path, the file-does-not-exist path, non-target lines
left intact, no line-count drift, and mode preservation.
copyparty/update.sh and the rebuilt copyparty payload are included because
update.sh is embedded; regenerated with build.sh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
headscale
Headscale — a self-hosted Tailscale control server — behind Caddy, with OIDC login delegated to pocket-id.
Prerequisite
Register an OIDC client in pocket-id's admin UI with redirect URI:
https://${HEADSCALE_DOMAIN}/oidc/callback
Then paste its OIDC_CLIENT_ID / OIDC_CLIENT_SECRET below.
Required .env values
| Variable | Notes |
|---|---|
HEADSCALE_DOMAIN |
Public hostname (e.g. hs.example.com). Clients connect here over HTTPS. |
ACME_EMAIL |
Let's Encrypt registration email. |
TAILNET_DOMAIN |
MagicDNS suffix (e.g. tail.example.com). Must differ from HEADSCALE_DOMAIN. |
POCKETID_DOMAIN |
OIDC issuer hostname (your pocket-id). |
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET |
From the pocket-id client above. |
The deploy substitutes these into config.yaml from the embedded template. See
.env.example.
Deploy
./automations.sh # Deploy on this host → deploy: headscale
Or build + run the self-contained artifact:
./build.sh
scp deploy.sh root@host:
ssh root@host 'bash deploy.sh'
# non-interactive: pass HEADSCALE_DOMAIN, ACME_EMAIL, TAILNET_DOMAIN,
# POCKETID_DOMAIN, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, SKIP_PROMPTS=1
Unattended provisioning: cloud-init.yml.
DNS for HEADSCALE_DOMAIN must resolve to the host and 80/443 be reachable
before deploy.
Headplane (web UI)
Headplane ships in the stack, served by
Caddy at https://HEADSCALE_DOMAIN/admin. It runs API-only — it talks
to the headscale API with a key and does not get the Docker socket, so it
can't control the host. deploy.sh mints the headscale API key automatically
on first deploy (stored in .env) and generates headplane.yaml.
Login: to use OIDC via pocket-id, register a second OIDC client in pocket-id with redirect URI:
https://HEADSCALE_DOMAIN/admin/oidc/callback
and pass HEADPLANE_OIDC_CLIENT_ID / HEADPLANE_OIDC_CLIENT_SECRET (prompted
by the launcher, or set in .env). Leave them blank to use headplane's
API-key login instead. Pin the image with HEADPLANE_TAG (defaults to
latest).
Gate /admin to a superuser group: restrict it at pocket-id — edit the
headplane OIDC client and set its Allowed User Groups to your superuser
group. Only members of that group can complete the OIDC login, so only they can
reach /admin. (Enforced at the IdP, so it covers the UI regardless of
headplane's own checks.)
headplane integration is version-sensitive; if the UI doesn't come up, check
docker compose logs headplaneand verifyheadplane.yaml/ the mintedHEADPLANE_HS_API_KEYin.env.
ACL policy
A starter ACL ships in policy.hujson (installed to
$STACK_DIR/policy.hujson on first deploy; your edits survive re-deploys).
headscale uses Tailscale's legacy acls format, not the newer grants
syntax from the Tailscale admin console — the bundled policy is that default
translated over (per-user self-access, a tag:shared everyone can reach, and
SSH to your own devices). Some pieces (autogroup:self, the ssh block) are
newer/experimental in headscale; a permissive fallback is included in the file.
$EDITOR /srv/headscale/policy.hujson
headscale policy check # validate (host CLI wrapper)
cd /srv/headscale && docker compose restart headscale # apply