neptun: serve Headplane at vpn.mgaction.town/admin, auth via Zitadel OIDC
Path-route Headplane under /admin on the same vhost as headscale instead of its own subdomain - Caddy handle blocks split on the prefix, headscale gets everything else. base_url drops to the site root since Headplane appends /admin (and the OIDC callback path) itself. Wire Zitadel as the OIDC provider. client_id/client_secret/the headscale API key can't be real until Zitadel and headscale are actually deployed and an application/key exist, so those are REPLACE_ME placeholders for now (documented in services/headplane.nix) - direct API-key login stays enabled as a fallback so this can't lock anyone out in the meantime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,16 +60,23 @@
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
# ---- Headscale + Headplane (tailnet control server + its web UI) ----
|
# ---- Headscale + Headplane (tailnet control server + its web UI) ----
|
||||||
# Both run locally on neptun (see services/{headscale,headplane}.nix).
|
# Both run locally on neptun (see services/{headscale,headplane}.nix), path
|
||||||
# headscale's node-update endpoint is a long-poll, hence `flush_interval
|
# -routed on the same vhost: Headplane owns /admin* (its whole app,
|
||||||
# -1` — without it Caddy buffers the response and clients see stale state.
|
# including static assets and the OIDC callback, lives under that prefix —
|
||||||
|
# `handle` keeps the prefix in the forwarded path, unlike `handle_path`,
|
||||||
|
# since Headplane needs to see it), everything else goes to headscale
|
||||||
|
# itself (tailnet client traffic). 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 = ''
|
services.caddy.virtualHosts."vpn.mgaction.town".extraConfig = ''
|
||||||
reverse_proxy http://localhost:8082 {
|
handle /admin* {
|
||||||
flush_interval -1
|
reverse_proxy http://localhost:3000
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy http://localhost:8082 {
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
'';
|
|
||||||
services.caddy.virtualHosts."headplane.mgaction.town".extraConfig = ''
|
|
||||||
reverse_proxy http://localhost:3000
|
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# ---- Gitea SSH forward ----
|
# ---- Gitea SSH forward ----
|
||||||
|
|||||||
@@ -38,5 +38,10 @@
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
# Headplane: cookie_secret_path takes a path natively (no store leak).
|
# Headplane: cookie_secret_path takes a path natively (no store leak).
|
||||||
|
# oidc.client_secret + the headscale API key are still REPLACE_ME
|
||||||
|
# placeholders (see services/headplane.nix) until Zitadel/headscale are
|
||||||
|
# actually deployed and those get created for real.
|
||||||
sops.secrets.headplane_cookie_secret = { };
|
sops.secrets.headplane_cookie_secret = { };
|
||||||
|
sops.secrets.headplane_oidc_client_secret = { };
|
||||||
|
sops.secrets.headplane_headscale_api_key = { };
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -3,6 +3,8 @@ darman_password: ENC[AES256_GCM,data:otLevSn6bXBrjCu7Dv9PN7Umwrb7CD1PhC1i+J8rp2m
|
|||||||
zitadel_master_key: ENC[AES256_GCM,data:LDZwbpwZdQ03O8I2tvIMYnK3MoQTgvOYwrBt2UuCHbk=,iv:sM66sj3JO+bb0Sk2D7M/Ehk7t7VZneuvTRP4IkT1se0=,tag:YKQ4shwZ4sIDWdNehTugew==,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]
|
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]
|
headplane_cookie_secret: ENC[AES256_GCM,data:oXYRG4z16u6HS7zXoWrV2q/HL2o24n4UwVXnQvqBmbY=,iv:itAiy/w6ue4VzqO5xYnvSYN3uCLLmu52dvAAxZ2pCGc=,tag:MEFP171GpSQpAqUFsWY+VA==,type:str]
|
||||||
|
headplane_oidc_client_secret: ENC[AES256_GCM,data:v86GLehXAI4Ol2ZxjtdtB1aDau0qsm68HBz3Iu4HjySPEi9jpg==,iv:EwtrNGKyVYcZg8AnD87EpHpmp0odeBXi1PM/9uUe+ko=,tag:VATaEMNxt5pnmgAHV13rLw==,type:str]
|
||||||
|
headplane_headscale_api_key: ENC[AES256_GCM,data:pb2ipeI4rkTXMQMG6hbkSU7OBLcpYJ2/a+9TgSA7PAfL2tTFoDWico8=,iv:j3FkiixUbT+6oLREKP21jQGFFUUU2KqX/ym2xQCeM0k=,tag:ZulMJDpaM1c81yT/LWNWgA==,type:str]
|
||||||
sops:
|
sops:
|
||||||
age:
|
age:
|
||||||
- enc: |
|
- enc: |
|
||||||
@@ -23,7 +25,7 @@ sops:
|
|||||||
Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg==
|
Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp
|
recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp
|
||||||
lastmodified: "2026-07-19T20:35:48Z"
|
lastmodified: "2026-07-19T20:41:24Z"
|
||||||
mac: ENC[AES256_GCM,data:4cQsixlVeKbZmtnrBXuTb9H62QCyl1rn3hba7fh2EHO7jVJu6H5w93q0jZVkMSCz/iCqhQVjAg7pY1st/VpVKWjtiV2ys8N7Qpom4PzKBas+c8/5d1I84DYxR9yIPHYZPpEsFhhzjFh2V7g7gTR8yVHO7Wi2NG8R9Nhrhbwqcj8=,iv:Vr19xtQ/tQQoKVx7aXi/f9pl3mj5liwXCK/J/IFdx68=,tag:gCL1ydSdJsonAaaBXf+hCw==,type:str]
|
mac: ENC[AES256_GCM,data:5LXTmULolfpLxdbnpCIiZ2ys7jqmug7UWr9f6rwrzfpxRlpyXcDhgh1zFqXfXlrvn6eVABNfcGJhIXz7+gZFhTuoEC5GEe60+Wx/mXn6LTJk8T25zk4KUnzTUPSLqz2S5BsGrBOkLGUnJOAyhmzMfToI1qOqzzsswfuuT7hTaeE=,iv:sqIZ4yhCsQ5LKSRrnPJUrG6qaSYW+EOFLjJvDdjStGw=,tag:kt0UzOXfoI+wQHGqLUYAhA==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
version: 3.13.1
|
version: 3.13.1
|
||||||
|
|||||||
+27
-5
@@ -4,17 +4,39 @@
|
|||||||
# first). Runs as headscale's own OS user via "native process integration",
|
# first). Runs as headscale's own OS user via "native process integration",
|
||||||
# so it can restart headscale when settings change from the UI.
|
# so it can restart headscale when settings change from the UI.
|
||||||
#
|
#
|
||||||
# No OIDC wired up — log in with a headscale API key instead
|
# Served at vpn.mgaction.town/admin (path-routed alongside headscale itself
|
||||||
# (`headscale apikeys create` on the box, pasted into the Headplane login
|
# by Caddy — see hosts/neptun/configuration.nix), not its own subdomain.
|
||||||
# page). headscale.url/config_path/public_url all default correctly off of
|
# base_url is the site root WITHOUT the /admin prefix — Headplane appends
|
||||||
# services.headscale's own options, so nothing to repeat here.
|
# that itself, including for the OIDC callback (.../admin/oidc/callback).
|
||||||
|
#
|
||||||
|
# Auth is Zitadel (services/zitadel.nix), via OIDC. client_id isn't secret
|
||||||
|
# (it's a public identifier) so it's a plain string here, but it — along
|
||||||
|
# with client_secret and the headscale API key — can't be known until
|
||||||
|
# Zitadel/headscale are actually deployed and running. Until then these are
|
||||||
|
# placeholders; direct headscale-API-key login (disable_api_key_login stays
|
||||||
|
# false) still works as a fallback so this doesn't lock anyone out. Once
|
||||||
|
# live:
|
||||||
|
# 1. In Zitadel: create a project + a Web application for Headplane, with
|
||||||
|
# redirect URI https://vpn.mgaction.town/admin/oidc/callback. Copy the
|
||||||
|
# generated client ID into oidc.client_id below.
|
||||||
|
# 2. `./scripts/edit_secrets secrets/neptun.yaml` and replace
|
||||||
|
# headplane_oidc_client_secret with the app's client secret.
|
||||||
|
# 3. `headscale apikeys create` on the box, and replace
|
||||||
|
# headplane_headscale_api_key the same way.
|
||||||
{
|
{
|
||||||
services.headplane = {
|
services.headplane = {
|
||||||
enable = true;
|
enable = true;
|
||||||
settings.server = {
|
settings.server = {
|
||||||
cookie_secret_path = config.sops.secrets.headplane_cookie_secret.path;
|
cookie_secret_path = config.sops.secrets.headplane_cookie_secret.path;
|
||||||
cookie_secure = true; # served over HTTPS via Caddy
|
cookie_secure = true; # served over HTTPS via Caddy
|
||||||
base_url = "https://headplane.mgaction.town";
|
base_url = "https://vpn.mgaction.town";
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.oidc = {
|
||||||
|
issuer = "https://auth.mgaction.town";
|
||||||
|
client_id = "REPLACE_ME_zitadel_client_id"; # not secret, but not known until the app exists in Zitadel
|
||||||
|
client_secret_path = config.sops.secrets.headplane_oidc_client_secret.path;
|
||||||
|
headscale_api_key_path = config.sops.secrets.headplane_headscale_api_key.path;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user