{ config, ... }: # Headscale — self-hosted control server for the tailnet. Every host's # services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town # (this host). MagicDNS base_domain "orbit.sol" matches the # "jupiter.orbit.sol" names used in this repo's Caddy vhosts # (hosts/neptun/configuration.nix) — changing base_domain means changing # those too, and re-pointing neptun's dnsmasq stub at the new suffix. # # TLS terminates at Caddy (see the host's configuration.nix); headscale # itself only listens on localhost. { 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 # pointing at neptun, and DNS wildcards match multi-label names — so # with base_domain = hosts.mgaction.town, `jupiter.hosts.mgaction.town` # resolved publicly to NEPTUN and Caddy proxied to itself: a silent # loop rather than a lookup failure. # # `.sol` is the LAN domain pihole serves, so this nests the tailnet # inside it: planets sit on the LAN as jupiter.sol, and reach each # other in orbit as jupiter.orbit.sol. Resolution is unambiguous # because tailscale matches routes by LONGEST suffix, so orbit.sol # goes to MagicDNS even when everything else funnels to pihole. # # Never give a LAN host the name `orbit`: pihole's # `address=/.sol/` lines match a name AND everything under # it, so an `orbit` host would swallow this entire zone. base_domain = "orbit.sol"; # pihole on mercury, over the tailnet — so every roaming device gets # ad blocking and .sol names wherever it is, not just on the LAN. # Deliberately NO public fallback: tailscale treats the list as a set, # so adding 9.9.9.9 here would let queries slip past the filter # whenever mercury is briefly slow. Strict blocking, at the cost of # mercury being a single point of failure for tailnet DNS. # # ⚠️ A hardcoded tailnet address, so it changes if mercury re-enrols # — check `headscale nodes list` if DNS dies tailnet-wide. nameservers.global = [ "100.64.0.7" ]; # Must be set, and must be HERE rather than via the module's # `dns.split` option. nixpkgs renders that option one level too high # (a sibling of `nameservers:`), but headscale reads # dns.nameservers.split (hscontrol/types/config.go:722) and so does # headplane. So the module's option is dead, and the missing key makes # headplane's DNS page die with # TypeError: Cannot convert undefined or null to object # from Object.keys(config.dns.nameservers.split). nameservers.split = { }; # Point every node's resolver at MagicDNS, which forwards on to the # global nameserver above. That is the only way to get pihole onto a # roaming device: with this false, globalResolvers land in the # netmap's FallbackResolvers (hscontrol/types/config.go:826-830) and a # phone with carrier DNS never consults them. # # The cost is that every node's DNS now depends on mercury and on the # home connection, so mercury going down costs name resolution # everywhere, not just `.sol`. neptun and mercury opt out of this # individually with --accept-dns=false — see their configuration.nix. override_local_dns = true; }; # Authentik as the login provider, so `tailscale up --login-server ...` # sends you to a browser instead of needing a pre-auth key. This is a # SEPARATE Authentik application from headplane's — its own provider, # slug `headscale`, redirect https://vpn.mgaction.town/oidc/callback # (headscale's own callback; headplane's is under /admin). # # ⚠️ headscale performs OIDC discovery at STARTUP and a failure is # FATAL ("creating OIDC provider from issuer config: 404 Not Found") — # it will not boot, taking the whole tailnet's control plane with it. # Never point `issuer` at an application that doesn't exist yet; verify # with: # curl -s .well-known/openid-configuration # # Headless hosts still enrol with pre-auth keys. Note also that users # created here are distinct from `headscale users create` ones: matching # is by the OIDC `sub` claim against the user's providerId, and 0.28 # dropped map_legacy_users, so CLI-made users never gain one. 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. # # With the default (urls = [controlplane.tailscale.com/derpmap/default], # auto_update_enabled = true) headscale fetches that map at startup and # treats failure as FATAL — so a DNS blip or a Tailscale outage stops the # control server from booting at all. A self-hosted control plane that # can't start without Tailscale's infrastructure rather misses the point. # # The relay itself rides Caddy on :443 (hence the 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 ]; }