Publish Headscale via Pangolin
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled

This commit is contained in:
Fizzlepoof
2026-05-26 01:50:51 +00:00
parent 9826548fd7
commit 09232f7d70
5 changed files with 295 additions and 11 deletions

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Create or update the Pangolin resource/target for Headscale.
Expected env vars:
- PANGOLIN_API_URL e.g. https://api.paccoco.com/v1
- PANGOLIN_API_KEY bearer token for Pangolin integration API
- PANGOLIN_ORG_ID e.g. paccoco
Optional env defaults:
- PANGOLIN_SITE_ID default 4 (Plausible Deniability)
- PANGOLIN_DOMAIN_ID default domain1
Default desired resource:
- host/domain: headscale.paccoco.com
- site: Plausible Deniability (siteId 4)
- target: headscale:8080
- Pangolin auth: disabled (sso=false)
- healthcheck: GET /health
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from urllib import error, parse, request
DEFAULT_API_URL = "https://api.paccoco.com/v1"
DEFAULT_ORG_ID = "paccoco"
DEFAULT_DOMAIN_ID = "domain1"
DEFAULT_SITE_ID = 4
DEFAULT_NAME = "headscale"
DEFAULT_SUBDOMAIN = "headscale"
DEFAULT_TARGET_IP = "headscale"
DEFAULT_TARGET_PORT = 8080
DEFAULT_HC_PATH = "/health"
USER_AGENT = "doris-pangolin-headscale/1.0"
def env(name: str, default: str | None = None) -> str | None:
value = os.environ.get(name)
if value is None or value == "":
return default
return value
def api_request(base_url: str, api_key: str, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]:
url = base_url.rstrip("/") + path
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = request.Request(url, data=data, headers=headers, method=method.upper())
try:
with request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8", "replace")
return resp.status, json.loads(raw) if raw else None
except error.HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
payload = json.loads(raw)
except Exception:
payload = raw
return exc.code, payload
def fail(msg: str, payload: Any = None) -> int:
print(msg, file=sys.stderr)
if payload is not None:
if isinstance(payload, (dict, list)):
print(json.dumps(payload, indent=2, sort_keys=True), file=sys.stderr)
else:
print(payload, file=sys.stderr)
return 1
def find_resource(resources: list[dict[str, Any]], *, name: str, full_domain: str) -> dict[str, Any] | None:
for resource in resources:
if resource.get("fullDomain") == full_domain:
return resource
for resource in resources:
if resource.get("name") == name:
return resource
return None
def main() -> int:
parser = argparse.ArgumentParser(description="Upsert Pangolin Headscale resource")
parser.add_argument("--api-url", default=env("PANGOLIN_API_URL", DEFAULT_API_URL))
parser.add_argument("--api-key", default=env("PANGOLIN_API_KEY"))
parser.add_argument("--org-id", default=env("PANGOLIN_ORG_ID", DEFAULT_ORG_ID))
site_id_default = env("PANGOLIN_SITE_ID", str(DEFAULT_SITE_ID)) or str(DEFAULT_SITE_ID)
parser.add_argument("--site-id", type=int, default=int(site_id_default))
parser.add_argument("--domain-id", default=env("PANGOLIN_DOMAIN_ID", DEFAULT_DOMAIN_ID))
parser.add_argument("--name", default=DEFAULT_NAME)
parser.add_argument("--subdomain", default=DEFAULT_SUBDOMAIN)
parser.add_argument("--target-ip", default=DEFAULT_TARGET_IP)
parser.add_argument("--target-port", type=int, default=DEFAULT_TARGET_PORT)
parser.add_argument("--hc-path", default=DEFAULT_HC_PATH)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if not args.api_key:
return fail("Missing PANGOLIN_API_KEY / --api-key")
full_domain = f"{args.subdomain}.paccoco.com"
status, resources_resp = api_request(
args.api_url,
args.api_key,
"GET",
f"/org/{parse.quote(args.org_id)}/resources?pageSize=200",
)
if status != 200:
return fail("Failed to list Pangolin resources", resources_resp)
resources = resources_resp.get("data", {}).get("resources", [])
resource = find_resource(resources, name=args.name, full_domain=full_domain)
create_body = {
"name": args.name,
"subdomain": args.subdomain,
"http": True,
"protocol": "tcp",
"domainId": args.domain_id,
}
if resource is None:
if args.dry_run:
print(json.dumps({"action": "create_resource", "body": create_body}, indent=2))
resource_id = None
else:
status, create_resp = api_request(
args.api_url,
args.api_key,
"PUT",
f"/org/{parse.quote(args.org_id)}/resource",
create_body,
)
if status not in (200, 201):
return fail("Failed to create Pangolin resource", create_resp)
resource_id = create_resp.get("data", {}).get("resourceId") or create_resp.get("data", {}).get("resource", {}).get("resourceId")
if not resource_id:
return fail("Create resource succeeded but resourceId was missing", create_resp)
status, resource_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back created resource", resource_resp)
resource = resource_resp.get("data", {}).get("resource") or resource_resp.get("data") or {}
else:
resource_id = resource["resourceId"]
resource_update = {
"name": args.name,
"niceId": resource.get("niceId") if resource else None,
"subdomain": args.subdomain,
"ssl": True,
"sso": False,
"blockAccess": False,
"emailWhitelistEnabled": False,
"applyRules": False,
"domainId": args.domain_id,
"enabled": True,
"stickySession": False,
"tlsServerName": None,
"setHostHeader": None,
"skipToIdpId": None,
"headers": None,
"maintenanceModeEnabled": False,
"maintenanceModeType": "forced",
"maintenanceTitle": None,
"maintenanceMessage": None,
"maintenanceEstimatedTime": None,
"postAuthPath": None,
}
target_body = {
"siteId": args.site_id,
"method": "http",
"ip": args.target_ip,
"port": args.target_port,
"enabled": True,
"hcEnabled": True,
"hcPath": args.hc_path,
"hcScheme": "http",
"hcMode": "http",
"hcHostname": args.target_ip,
"hcPort": args.target_port,
"hcInterval": 30,
"hcUnhealthyInterval": 30,
"hcTimeout": 10,
"hcHeaders": None,
"hcFollowRedirects": True,
"hcMethod": "GET",
"hcStatus": 200,
"hcTlsServerName": None,
"hcHealthyThreshold": 1,
"hcUnhealthyThreshold": 3,
"path": None,
"pathMatchType": None,
"rewritePath": None,
"rewritePathType": None,
}
existing_targets = resource.get("targets", []) if resource else []
matching_target = None
for target in existing_targets:
if target.get("siteId") == args.site_id:
matching_target = target
break
if args.dry_run:
print(json.dumps({
"resource_id": resource_id,
"full_domain": full_domain,
"resource_update": resource_update,
"target_action": "update" if matching_target else "create",
"target_id": matching_target.get("targetId") if matching_target else None,
"target_body": target_body,
}, indent=2))
return 0
if resource_id is not None:
status, update_resp = api_request(args.api_url, args.api_key, "POST", f"/resource/{resource_id}", resource_update)
if status not in (200, 201):
return fail("Failed to update Pangolin resource", update_resp)
if matching_target:
target_id = matching_target["targetId"]
status, target_resp = api_request(args.api_url, args.api_key, "POST", f"/target/{target_id}", target_body)
if status not in (200, 201):
return fail("Failed to update Pangolin target", target_resp)
else:
status, target_resp = api_request(args.api_url, args.api_key, "PUT", f"/resource/{resource_id}/target", target_body)
if status not in (200, 201):
return fail("Failed to create Pangolin target", target_resp)
status, final_resp = api_request(args.api_url, args.api_key, "GET", f"/resource/{resource_id}")
if status != 200:
return fail("Failed to read back final Pangolin resource", final_resp)
payload = final_resp.get("data", {}).get("resource") or final_resp.get("data") or final_resp
print(json.dumps(payload, indent=2))
return 0
return fail("Unexpected state: resource_id is missing")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,7 +1,7 @@
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
# Public control-plane hostname for off-LAN clients.
HEADSCALE_SERVER_URL=https://headscale.paccoco.com
HEADPLANE_BASE_URL=http://headplane.home.paccoco.com:3005
# Must be replaced with an actual 32-character secret before deployment.

View File

@@ -21,22 +21,26 @@ Self-hosted Headscale + Headplane pilot stack for replacing the Tailscale free-t
- `/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
- 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: `http://headscale.home.paccoco.com:8084`
- Headplane UI: `http://headplane.home.paccoco.com:3005/admin`
- Headscale control plane for clients: `https://headscale.paccoco.com`
- Headplane admin UI (restricted/LAN-only): `http://headplane.home.paccoco.com:3005/admin`
These are the intended 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.
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
The live PD pilot is currently using direct-IP URLs instead:
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`
Reason: the `home.paccoco.com` records for these pilot names have not yet been added in Technitium, and the current Technitium admin flow is 2FA-gated. Once those DNS records exist, flip the live stack back to the hostname-based URLs above.
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
@@ -78,9 +82,15 @@ Before first deploy, replace the placeholder values in:
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
- 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:
@@ -132,6 +142,26 @@ docker logs headplane --tail=100
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`:

View File

@@ -9,7 +9,7 @@ server:
headscale:
url: "http://headscale:8080"
public_url: "http://headscale.home.paccoco.com:8084"
public_url: "https://headscale.paccoco.com"
config_path: "/etc/headscale/config.yaml"
config_strict: true

View File

@@ -1,4 +1,4 @@
server_url: http://headscale.home.paccoco.com:8084
server_url: https://headscale.paccoco.com
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 0.0.0.0:50443