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" ]; + }; + }; + }; +}