From 5507dfac0acad120e8b188c8e5a7ef79c5b2c681 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Sun, 19 Jul 2026 22:36:40 +0200 Subject: [PATCH] neptun: add Headscale + Headplane Headscale is the tailnet control server every host's services/tailscale.nix already points at (--login-server=https://vpn.mgaction.town). MagicDNS base_domain "hosts.mgaction.town" matches the "jupiter.hosts.mgaction.town" names already used in this repo's Caddy vhosts. Headplane is its web UI, running as headscale's own user (native process integration, no container). No OIDC wired up - log in with a headscale API key generated on the box. Both proxied through Caddy; headscale's vhost needs flush_interval -1 since its node-update endpoint is a long-poll. Co-Authored-By: Claude Sonnet 5 --- hosts/neptun/configuration.nix | 15 +++++++++++++++ hosts/neptun/secrets.nix | 3 +++ secrets/neptun.yaml | 5 +++-- services/headplane.nix | 20 ++++++++++++++++++++ services/headscale.nix | 28 ++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 services/headplane.nix create mode 100644 services/headscale.nix diff --git a/hosts/neptun/configuration.nix b/hosts/neptun/configuration.nix index c6db923..0514ec0 100644 --- a/hosts/neptun/configuration.nix +++ b/hosts/neptun/configuration.nix @@ -10,6 +10,8 @@ ../../services/caddy.nix ../../services/tailscale.nix ../../services/zitadel.nix + ../../services/headscale.nix + ../../services/headplane.nix ]; # ---- Boot (UEFI) ---- @@ -57,6 +59,19 @@ reverse_proxy http://localhost:8080 ''; + # ---- Headscale + Headplane (tailnet control server + its web UI) ---- + # Both run locally on neptun (see services/{headscale,headplane}.nix). + # headscale's node-update endpoint is a long-poll, hence `flush_interval + # -1` — without it Caddy buffers the response and clients see stale state. + services.caddy.virtualHosts."vpn.mgaction.town".extraConfig = '' + reverse_proxy http://localhost:8082 { + flush_interval -1 + } + ''; + services.caddy.virtualHosts."headplane.mgaction.town".extraConfig = '' + reverse_proxy http://localhost:3000 + ''; + # ---- Gitea SSH forward ---- # Caddy only proxies HTTP; git-over-ssh needs a raw TCP forward. Gitea's # own built-in SSH server runs on jupiter:2222 (see services/gitea.nix — diff --git a/hosts/neptun/secrets.nix b/hosts/neptun/secrets.nix index 89299ff..fbd1fff 100644 --- a/hosts/neptun/secrets.nix +++ b/hosts/neptun/secrets.nix @@ -36,4 +36,7 @@ Password: ${config.sops.placeholder.zitadel_admin_password} PasswordChangeRequired: false ''; + + # Headplane: cookie_secret_path takes a path natively (no store leak). + sops.secrets.headplane_cookie_secret = { }; } diff --git a/secrets/neptun.yaml b/secrets/neptun.yaml index 47f17cd..33378d0 100644 --- a/secrets/neptun.yaml +++ b/secrets/neptun.yaml @@ -2,6 +2,7 @@ tailscale_authkey: ENC[AES256_GCM,data:CBMidfdilBn1HP3TAu9TCOpuwPnzVQ/3OGCsf0vhF darman_password: ENC[AES256_GCM,data:otLevSn6bXBrjCu7Dv9PN7Umwrb7CD1PhC1i+J8rp2mURMYVuwF/ZnrlPC7/RDZgINIRBOXOkG9lZo3UjuRyqrtvfUzIm1jGL51l1zvZ/07oNW6OUp4IZEQpoOmz1GuXBlx+yKRQeRDHYA==,iv:pnwp3uAsBoiJdF+QV27h0jztgFJKrIqFlmMT7JkcHoA=,tag:Zb9ziCGsLQt+TcJRCv8tWA==,type:str] zitadel_master_key: ENC[AES256_GCM,data:LDZwbpwZdQ03O8I2tvIMYnK3MoQTgvOYwrBt2UuCHbk=,iv:sM66sj3JO+bb0Sk2D7M/Ehk7t7VZneuvTRP4IkT1se0=,tag:YKQ4shwZ4sIDWdNehTugew==,type:str] zitadel_admin_password: ENC[AES256_GCM,data:q+Od3Juzvl/dVPkm0w1C42pjK7kU8ur8V5fl2JAdQkA=,iv:n8NFzQczJVeqe1ybBldbkv4QGj0xyf9/P+uvsHs3NjI=,tag:rkXvmC8kZZOrWRQd+mIanA==,type:str] +headplane_cookie_secret: ENC[AES256_GCM,data:oXYRG4z16u6HS7zXoWrV2q/HL2o24n4UwVXnQvqBmbY=,iv:itAiy/w6ue4VzqO5xYnvSYN3uCLLmu52dvAAxZ2pCGc=,tag:MEFP171GpSQpAqUFsWY+VA==,type:str] sops: age: - enc: | @@ -22,7 +23,7 @@ sops: Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg== -----END AGE ENCRYPTED FILE----- recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp - lastmodified: "2026-07-19T20:28:23Z" - mac: ENC[AES256_GCM,data:3dRmmSJqaKi8w1mtmQIaL8AynSUCH4WRUpUax5g+3qKrhQeect7TdCFyTU991mfmUnxfQ8nI7nyAI2DdAb/TTHxAjDgXd0Qn31olGYZRQFRV25YOiXozvXoHVjroW86IQDgh6UBp5BkpU+HrfCfLs/opAx97jLu14M4sLLuu8R4=,iv:qmKCCX70IJ/JQvpjMW1eYM2cgjVx+1PS44ahhTXiOp0=,tag:hi7V9zwo4uZxAjEm3dUxBw==,type:str] + lastmodified: "2026-07-19T20:35:48Z" + mac: ENC[AES256_GCM,data:4cQsixlVeKbZmtnrBXuTb9H62QCyl1rn3hba7fh2EHO7jVJu6H5w93q0jZVkMSCz/iCqhQVjAg7pY1st/VpVKWjtiV2ys8N7Qpom4PzKBas+c8/5d1I84DYxR9yIPHYZPpEsFhhzjFh2V7g7gTR8yVHO7Wi2NG8R9Nhrhbwqcj8=,iv:Vr19xtQ/tQQoKVx7aXi/f9pl3mj5liwXCK/J/IFdx68=,tag:gCL1ydSdJsonAaaBXf+hCw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.1 diff --git a/services/headplane.nix b/services/headplane.nix new file mode 100644 index 0000000..b53f137 --- /dev/null +++ b/services/headplane.nix @@ -0,0 +1,20 @@ +{ config, ... }: + +# Headplane — web UI for headscale (services/headscale.nix; must be enabled +# first). Runs as headscale's own OS user via "native process integration", +# so it can restart headscale when settings change from the UI. +# +# No OIDC wired up — log in with a headscale API key instead +# (`headscale apikeys create` on the box, pasted into the Headplane login +# page). headscale.url/config_path/public_url all default correctly off of +# services.headscale's own options, so nothing to repeat here. +{ + services.headplane = { + enable = true; + settings.server = { + cookie_secret_path = config.sops.secrets.headplane_cookie_secret.path; + cookie_secure = true; # served over HTTPS via Caddy + base_url = "https://headplane.mgaction.town"; + }; + }; +} diff --git a/services/headscale.nix b/services/headscale.nix new file mode 100644 index 0000000..527d570 --- /dev/null +++ b/services/headscale.nix @@ -0,0 +1,28 @@ +{ ... }: + +# Headscale — self-hosted control server for the tailnet. Every host's +# services/tailscale.nix points --login-server at https://vpn.mgaction.town +# (this host). MagicDNS base_domain "hosts.mgaction.town" matches the +# "jupiter.hosts.mgaction.town" style names already used throughout this +# repo's Caddy vhosts (e.g. hosts/neptun/configuration.nix) — don't change +# one without the other. +# +# TLS terminates at Caddy (see the host's configuration.nix for the vhost, +# proxying with `flush_interval -1` since headscale's node-update endpoint +# is a long-poll and needs unbuffered responses); headscale itself only +# listens on localhost. +{ + services.headscale = { + enable = true; + port = 8082; # zitadel already sits on the usual 8080 on this host + + settings = { + server_url = "https://vpn.mgaction.town"; + + dns = { + base_domain = "hosts.mgaction.town"; + nameservers.global = [ "1.1.1.1" "9.9.9.9" ]; + }; + }; + }; +}