From 09232f7d70ff598a5b543477f2b593a4b5c6f636 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Tue, 26 May 2026 01:50:51 +0000 Subject: [PATCH] Publish Headscale via Pangolin --- automation/bin/pangolin_upsert_headscale.py | 254 ++++++++++++++++++++ headscale/.env.example | 4 +- headscale/README.md | 44 +++- headscale/config/headplane/config.yaml | 2 +- headscale/config/headscale/config.yaml | 2 +- 5 files changed, 295 insertions(+), 11 deletions(-) create mode 100755 automation/bin/pangolin_upsert_headscale.py diff --git a/automation/bin/pangolin_upsert_headscale.py b/automation/bin/pangolin_upsert_headscale.py new file mode 100755 index 0000000..024a5b4 --- /dev/null +++ b/automation/bin/pangolin_upsert_headscale.py @@ -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()) diff --git a/headscale/.env.example b/headscale/.env.example index e258ad2..2fe1b1c 100644 --- a/headscale/.env.example +++ b/headscale/.env.example @@ -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. diff --git a/headscale/README.md b/headscale/README.md index 5ce6432..af7631b 100644 --- a/headscale/README.md +++ b/headscale/README.md @@ -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`: diff --git a/headscale/config/headplane/config.yaml b/headscale/config/headplane/config.yaml index 4e39482..7593a6a 100644 --- a/headscale/config/headplane/config.yaml +++ b/headscale/config/headplane/config.yaml @@ -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 diff --git a/headscale/config/headscale/config.yaml b/headscale/config/headscale/config.yaml index 3b22711..09e6c60 100644 --- a/headscale/config/headscale/config.yaml +++ b/headscale/config/headscale/config.yaml @@ -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