Three connected changes, all triggered by the same outage. base_domain leaves mgaction.town. That zone has a wildcard A+AAAA pointing at neptun, and DNS wildcards match multi-label names, so jupiter.hosts.mgaction.town resolved publicly to NEPTUN and Caddy proxied to itself -- a silent loop rather than a lookup failure. Nesting the tailnet inside the LAN domain as orbit.sol keeps the theme and resolves unambiguously, since tailscale matches routes by longest suffix. override_local_dns = true with pihole as the only global nameserver, so roaming devices get ad blocking and .sol names off-LAN. With it false, globalResolvers land in the netmap's FallbackResolvers, which a phone with carrier DNS never consults. No public fallback is listed on purpose: tailscale treats the list as a set, so a second entry would let queries slip past the filter whenever mercury is slow. The cost is that mercury is now a single point of failure for tailnet DNS. neptun and mercury opt out individually. mercury would otherwise resolve through itself. neptun must not depend on a Pi behind a domestic line to renew the certificates for the control server every other node needs -- and it is circular besides, since tailscaled has to resolve vpn.mgaction.town to connect at all. Instead neptun runs a dnsmasq stub forwarding just orbit.sol to MagicDNS on 100.100.100.100, which tailscaled answers whenever it is running regardless of --accept-dns. That resolves jupiter live, so the hardcoded /etc/hosts pin is gone. Also sets dns.nameservers.split explicitly: nixpkgs renders its own dns.split option one level too high, but headscale reads dns.nameservers.split (hscontrol/types/config.go:722) and so does headplane, whose DNS page dies on the missing key with "Cannot convert undefined or null to object". The module's option is dead as written. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
6.1 KiB
Nix
124 lines
6.1 KiB
Nix
{ 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=/<host>.sol/<ip>` 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 <issuer>.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 ];
|
|
}
|