Files
homelab/hosts/neptun/configuration.nix
T
darmanandClaude Opus 4.8 0995a5fe2f headscale: move the tailnet to orbit.sol, route all DNS through pihole
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>
2026-07-20 23:01:47 +02:00

155 lines
6.9 KiB
Nix

{ config, pkgs, lib, ... }:
# netcup VPS (UEFI, /dev/vda). Public reverse proxy + tailnet node.
{
imports = [
./hardware-configuration.nix
./disk-config.nix # disko: vda partitions + filesystems
./secrets.nix # sops-nix: tailscale authkey
../../common.nix # shared base: user / ssh / nix / firewall
../../services/network/caddy.nix
../../services/vpn/tailscale.nix
../../services/identity/authentik.nix
../../services/vpn/headscale.nix
../../services/vpn/headplane.nix
];
# ---- Boot (UEFI) ----
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
# Root is on the virtio disk — pin these so stage-1 mounts it regardless of
# what nixos-generate-config detects in the installer.
boot.initrd.availableKernelModules = [ "virtio_pci" "virtio_blk" "virtio_scsi" ];
networking.hostName = "neptun";
# 8 GB and no swap device (netcup gives one disk, disko takes all of it for
# root). authentik's server + worker + postgres are the memory-hungry part;
# zram is enough headroom at this size and costs no disk.
zramSwap.enable = true;
# ---- Static networking (netcup) ----
# No LAN fallback: get this right or the box is unreachable (use netcup's
# VNC console / rescue system to fix). Values captured from the running VPS.
networking.useDHCP = false;
networking.usePredictableInterfaceNames = false; # keep the NIC named eth0
networking.interfaces.eth0 = {
ipv4.addresses = [ { address = "159.195.64.117"; prefixLength = 22; } ];
ipv6.addresses = [ { address = "2a0a:4cc0:c2:19e1:44b4:8dff:fe4d:c7d7"; prefixLength = 64; } ];
};
networking.defaultGateway = { address = "159.195.64.1"; interface = "eth0"; };
# Confirmed against `ip -6 route show default` on the VPS:
# default via fe80::1 dev eth0 metric 1024 onlink
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.
# ---- ACME account email ----
# services.caddy.email would render the address into the world-readable
# store, so pass it via EnvironmentFile and reference it with the Caddyfile
# {$VAR} parse-time placeholder (see hosts/neptun/secrets.nix). globalConfig
# is types.lines, so this appends to the module's own global block.
services.caddy.environmentFile = config.sops.templates."caddy.env".path;
services.caddy.globalConfig = "email {$ACME_EMAIL}";
# ---- Public reverse proxy vhosts ----
# Caddy gets automatic public HTTPS (Let's Encrypt) for real domains; the
# *.mgaction.town wildcard already points every name here (A + AAAA), and
# the module opens 80/443. All of these live on jupiter and are reached over
# the tailnet by their MagicDNS name, which resolves because headscale no
# longer overrides local DNS (see services/vpn/headscale.nix).
# ---- Audiobookshelf ----
services.caddy.virtualHosts."abs.mgaction.town".extraConfig = ''
reverse_proxy http://jupiter.orbit.sol:8000
'';
# ---- Seerr ----
services.caddy.virtualHosts."seerr.mgaction.town".extraConfig = ''
reverse_proxy http://jupiter.orbit.sol:5055
'';
# ---- Jellyfin ----
services.caddy.virtualHosts."jellyfin.mgaction.town".extraConfig = ''
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.orbit.sol:3000
'';
# ---- Gitea SSH forward ----
# Caddy only proxies HTTP; forward :2222 over the tailnet to gitea's own
# SSH server on jupiter (services/dev/gitea.nix), so
# `ssh://git@git.mgaction.town:2222/...` works. Also needs a matching
# inbound-2222 rule in netcup's edge firewall panel (not managed by Nix).
systemd.services.gitea-ssh-forward = {
description = "Forward :2222 to jupiter's gitea SSH server over tailscale";
after = [ "network-online.target" "tailscaled.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
DynamicUser = true;
ExecStart = "${pkgs.socat}/bin/socat TCP-LISTEN:2222,fork,reuseaddr TCP:jupiter.orbit.sol:2222";
Restart = "always";
};
};
networking.firewall.allowedTCPPorts = [ 2222 ];
# ---- Authentik (identity/OIDC provider) ----
# Runs locally on neptun (see services/identity/authentik.nix); Caddy just
# terminates TLS and proxies to its loopback HTTP listener. Authentik serves
# its UI and its OIDC endpoints from one port — no second frontend upstream,
# and no h2c (it's plain HTTP/1.1, unlike Zitadel's gRPC).
services.caddy.virtualHosts."auth.mgaction.town".extraConfig = ''
reverse_proxy http://127.0.0.1:9000
'';
# ---- Headscale + Headplane (tailnet control server + its web UI) ----
# Path-routed on one vhost: Headplane owns /admin* (uses `handle`, not
# `handle_path`, since it needs the prefix kept in the forwarded path for
# its own assets + OIDC callback); everything else goes to headscale.
# `flush_interval -1`: headscale's node-update endpoint is a long-poll and
# Caddy would otherwise buffer it, showing clients stale state.
services.caddy.virtualHosts."vpn.mgaction.town".extraConfig = ''
handle /admin* {
reverse_proxy http://localhost:3000
}
handle {
reverse_proxy http://localhost:8082 {
flush_interval -1
}
}
'';
system.stateVersion = "26.05"; # set at install time; do NOT bump on upgrades
}