{ 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 "hosts.mgaction.town" matches the # "jupiter.hosts.mgaction.town" names used in 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); 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 = { base_domain = "hosts.mgaction.town"; nameservers.global = [ "1.1.1.1" "9.9.9.9" ]; # Leave each client's own resolvers alone; only route base_domain to # MagicDNS. Upstream defaults this to true, which replaces resolv.conf # with 100.100.100.100 on every node — that silently breaks the LAN's # `.sol` names (pihole on mercury serves those, and the global # nameservers above return NXDOMAIN for them) and takes ad blocking # with it. It also makes a node's entire DNS depend on tailscaled # being up, which is what forced --accept-dns=false onto neptun and # mercury individually. override_local_dns = false; }; # 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 ]; }