{ config, ... }: # Headscale — self-hosted control server for the tailnet; every host's # services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town. # TLS terminates at Caddy; headscale itself only listens on localhost. Changing # base_domain below also means updating this repo's Caddy vhosts and neptun's # dnsmasq stub, which assume "orbit.sol". { services.headscale = { enable = true; port = 8082; # off the default 8080 to stay clear of other web apps settings = { server_url = "https://vpn.mgaction.town"; dns = { # Deliberately outside mgaction.town: that zone has a wildcard A+AAAA at # neptun, so a name under it would resolve publicly to neptun and Caddy # would proxy to itself. Nested under `.sol` (pihole's LAN domain) so # jupiter.sol (LAN) and jupiter.orbit.sol (tailnet) resolve unambiguously # — tailscale matches by longest suffix. Never name a LAN host `orbit`: # pihole's `address=/.sol/` would swallow this whole zone. base_domain = "orbit.sol"; # pihole on mercury, over the tailnet, so roaming devices get ad blocking # and .sol names everywhere. Deliberately no public fallback — tailscale # treats this as a set, so adding one would let queries slip past the # filter whenever mercury is briefly slow, at the cost of mercury being a # single point of failure for tailnet DNS. # ⚠️ Hardcoded tailnet address — check `headscale nodes list` if it # changes (mercury re-enrolled) and DNS dies tailnet-wide. nameservers.global = [ "100.64.0.7" ]; # Must be set here, not via the module's `dns.split` option — nixpkgs # renders that one level too high, but headscale (and headplane) read # dns.nameservers.split; the missing key crashes headplane's DNS page. nameservers.split = { }; # Routes every node's resolver through MagicDNS to the global nameserver # above — the only way pihole reaches a roaming device (otherwise it # lands in netmap's FallbackResolvers and carrier DNS never consults it). # Cost: all DNS now depends on mercury and the home connection; neptun # and mercury opt out individually with --accept-dns=false. override_local_dns = true; }; # Authentik as the login provider (own application, slug `headscale`, # separate from headplane's) so `tailscale up --login-server ...` opens a # browser instead of needing a pre-auth key; headless hosts still use those. # ⚠️ headscale does OIDC discovery at startup and a failure is fatal — it # won't boot, taking the whole control plane with it. Never point `issuer` # at an application that doesn't exist yet; verify with # curl -s .well-known/openid-configuration # Users created here are matched by OIDC `sub`, so `headscale users # create`-made users never link to one (0.28 dropped map_legacy_users). oidc = { issuer = "https://auth.mgaction.town/application/o/headscale/"; client_id = "14vhRYaLiONHmI2YFIxbQEveJDLu5cCvzSkTb9oq"; client_secret_path = config.sops.secrets.headscale_oidc_client_secret.path; }; # Run our own DERP relay instead of pulling Tailscale's map: the default # fetches that map at startup and treats a failure as fatal, so a DNS blip # or Tailscale outage would stop this control server from booting at all. # The relay rides Caddy on :443 (hence flush_interval -1 on that vhost); # only STUN needs its own UDP port. derp = { urls = [ ]; auto_update_enabled = false; server = { enabled = true; region_id = 999; # 900-999 is the custom range region_code = "neptun"; region_name = "neptun"; stun_listen_addr = "0.0.0.0:3478"; automatically_add_embedded_derp_region = true; }; }; }; }; # STUN for the embedded DERP server above. Also needs a matching inbound-UDP # rule in netcup's edge firewall — it is stateless and defaults to denying # inbound UDP outright, which silently kills every DNS/NTP reply too. networking.firewall.allowedUDPPorts = [ 3478 ]; }