diff --git a/README.md b/README.md index 5eeb546..f33b769 100644 --- a/README.md +++ b/README.md @@ -226,11 +226,24 @@ another way in. - mercury's own `resolv.conf` is deliberately public resolvers, not its own pihole (`resolveLocalQueries = false`, see `CLAUDE.md`) — so `.sol` names do not resolve *on mercury itself*. That is expected, not a fault. -- LAN `.sol` names resolve on tailnet members only because headscale sets - `override_local_dns = false`. With upstream's default of `true`, every node's - `resolv.conf` is replaced with MagicDNS and `.sol` returns NXDOMAIN - everywhere — along with losing ad blocking, since queries stop reaching - pihole at all. +- **mercury is load-bearing for the whole tailnet's DNS.** headscale sets + `override_local_dns = true` with pihole as the only global nameserver, so + every node — including a phone on mobile data — resolves through it and gets + ad blocking and `.sol` names anywhere. The flip side is that mercury (or the + home connection) going down costs name resolution on every device, not just + `.sol`. Recovery on a stranded device is turning Tailscale off. + There is deliberately no public fallback in `nameservers.global`: tailscale + treats that list as a set, so a second entry would let queries slip past the + filter whenever mercury is slow. + neptun and mercury opt out with `--accept-dns=false` — mercury because it + would otherwise resolve through itself, neptun because a public reverse + proxy must not depend on a Pi at home to renew its certificates. +- Tailnet names are `*.orbit.sol`, LAN names are `*.sol`. Both work everywhere + on the tailnet because tailscale matches DNS routes by **longest suffix**, so + `orbit.sol` reaches MagicDNS even though everything else goes to pihole. + Never name a LAN host `orbit`: pihole's `address=/.sol/` lines match + a name *and everything beneath it*, which would swallow the entire tailnet + zone. ## Adding a service diff --git a/common.nix b/common.nix index 2fe0c7e..a71ce5d 100644 --- a/common.nix +++ b/common.nix @@ -47,7 +47,7 @@ options = "--delete-older-than 30d"; }; - environment.systemPackages = with pkgs; [ vim git htop tmux curl ]; + environment.systemPackages = with pkgs; [ vim git htop tmux curl wget ]; # ---- Locale / firewall base ---- time.timeZone = "Europe/Berlin"; diff --git a/hosts/mercury/configuration.nix b/hosts/mercury/configuration.nix index 2125355..0ec3955 100644 --- a/hosts/mercury/configuration.nix +++ b/hosts/mercury/configuration.nix @@ -31,6 +31,12 @@ networking.defaultGateway = { address = "10.0.0.1"; interface = "eth0"; }; networking.nameservers = [ "1.1.1.1" "9.9.9.9" ]; + # Never take the tailnet's DNS on THIS host: headscale points every node at + # pihole, which runs here — mercury would be resolving through itself. Keep + # the public resolvers above for the Pi's own lookups, exactly as the + # unbound resolveLocalQueries note in CLAUDE.md requires. + services.tailscale.extraUpFlags = [ "--accept-dns=false" ]; + # ---- pihole web admin password (from sops) ---- # The pihole container reads FTLCONF_* env vars. Render an env file from the # sops secret and feed it to the container — password stays out of repo/store. diff --git a/hosts/neptun/configuration.nix b/hosts/neptun/configuration.nix index 66afd62..ca630a3 100644 --- a/hosts/neptun/configuration.nix +++ b/hosts/neptun/configuration.nix @@ -43,6 +43,29 @@ networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; }; networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ]; + # ---- Local split-DNS stub ---- + # neptun must NOT take the tailnet's DNS: headscale points every node at + # pihole on mercury, and making a public reverse proxy's name resolution + # depend on a Pi behind a domestic line would take ACME renewals — and so + # the certs for the control server every node needs — down with it. It is + # also circular, since tailscaled has to resolve vpn.mgaction.town to + # connect in the first place. + # + # So neptun opts out with --accept-dns=false and does its own split DNS. + # tailscaled still answers MagicDNS on 100.100.100.100 whenever it is + # running (--accept-dns only governs whether it rewrites resolv.conf), so + # dnsmasq forwards just the tailnet suffix there and everything else to the + # public resolvers above. jupiter's address is therefore resolved live and + # never pinned — nothing to update when the tailnet is rebuilt. + # + # resolveLocalQueries (default) points resolv.conf at 127.0.0.1 and feeds + # networking.nameservers to dnsmasq as upstreams via resolvconf. + services.tailscale.extraUpFlags = [ "--accept-dns=false" ]; + services.dnsmasq = { + enable = true; + settings.server = [ "/orbit.sol/100.100.100.100" ]; + }; + # firewall (enable + 22), caddy (80/443), tailscale (trust tailscale0 + join # headscale) come from ../../common.nix and ../../services/{caddy,tailscale}.nix. @@ -63,24 +86,24 @@ # ---- Audiobookshelf ---- services.caddy.virtualHosts."abs.mgaction.town".extraConfig = '' - reverse_proxy http://jupiter.hosts.mgaction.town:8000 + reverse_proxy http://jupiter.orbit.sol:8000 ''; # ---- Seerr ---- services.caddy.virtualHosts."seerr.mgaction.town".extraConfig = '' - reverse_proxy http://jupiter.hosts.mgaction.town:5055 + reverse_proxy http://jupiter.orbit.sol:5055 ''; # ---- Jellyfin ---- services.caddy.virtualHosts."jellyfin.mgaction.town".extraConfig = '' - reverse_proxy http://jupiter.hosts.mgaction.town:8096 + reverse_proxy http://jupiter.orbit.sol:8096 ''; # ---- Gitea WebUI ---- # Gitea's web UI and HTTPS clones (services/dev/gitea.nix, HTTP_PORT 3000). # Its SSH side is the separate :2222 forward further down. services.caddy.virtualHosts."git.mgaction.town".extraConfig = '' - reverse_proxy http://jupiter.hosts.mgaction.town:3000 + reverse_proxy http://jupiter.orbit.sol:3000 ''; # ---- Gitea SSH forward ---- @@ -95,7 +118,7 @@ wantedBy = [ "multi-user.target" ]; serviceConfig = { DynamicUser = true; - ExecStart = "${pkgs.socat}/bin/socat TCP-LISTEN:2222,fork,reuseaddr TCP:jupiter.hosts.mgaction.town:2222"; + ExecStart = "${pkgs.socat}/bin/socat TCP-LISTEN:2222,fork,reuseaddr TCP:jupiter.orbit.sol:2222"; Restart = "always"; }; }; diff --git a/services/vpn/headscale.nix b/services/vpn/headscale.nix index 9671f22..fd7559a 100644 --- a/services/vpn/headscale.nix +++ b/services/vpn/headscale.nix @@ -2,9 +2,10 @@ # 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. +# (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. @@ -17,18 +18,54 @@ server_url = "https://vpn.mgaction.town"; dns = { - base_domain = "hosts.mgaction.town"; - nameservers.global = [ "1.1.1.1" "9.9.9.9" ]; + # 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" ]; - # 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; + # 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 ...`