{ 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/monitoring/node-exporter.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" ]; # netcup's router still sends periodic RAs on this segment despite fully static # addressing, spamming "ndisc_router_discovery failed to add default route" on the # console. Stop processing RAs on eth0 entirely instead of living with the noise. boot.kernel.sysctl."net.ipv6.conf.eth0.accept_ra" = 0; # ---- Local split-DNS stub ---- # neptun must NOT take the tailnet's DNS: headscale points every node at pihole on # mercury, and a public reverse proxy depending on a Pi on a domestic line for name # resolution (and thus for its own ACME renewals) would be fragile and circular. # It opts out (--accept-dns=false) and runs its own split DNS instead: dnsmasq # forwards the tailnet suffix to MagicDNS (100.100.100.100, still answered by # tailscaled) and everything else to the public resolvers above — jupiter's address # is resolved live, never pinned. 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 ''; # ---- Immich ---- services.caddy.virtualHosts."immich.mgaction.town".extraConfig = '' reverse_proxy http://jupiter.orbit.sol:2283 ''; # ---- Obsidian LiveSync (CouchDB on jupiter) ---- # Published publicly (mobile apps refuse cleartext HTTP; *.jupiter.sol has no public # cert), kept safe by the plugin's end-to-end encryption (jupiter stores only # ciphertext) plus this allowlist — CouchDB otherwise exposes Fauxton, /_all_dbs and # /_node/_local/_config, the last of which can rewrite the server's config with admin # creds. Use the tailnet directly for those: `curl http://jupiter.orbit.sol:5984/_utils/`. # # The regex keys off CouchDB's own naming rule (system paths start with `_`, user # databases can't) rather than listing vaults, plus `_session` for cookie auth — so a # mistyped-but-legal name reaches CouchDB (real 404) while an illegal one gets # caddy's 404 with no CORS, which Obsidian shows as a silent connection failure. # Never point two vaults at the same database (LiveSync merges them, not reversibly). # # `flush_interval -1` is required, not tuning — replication rides a continuous # _changes feed that caddy would otherwise buffer, stalling sync. services.caddy.virtualHosts."notes.mgaction.town".extraConfig = '' @livesync path_regexp ^/(_session|[a-z][a-z0-9_$()+-]*)?(/.*)?$ handle @livesync { reverse_proxy http://jupiter.orbit.sol:5984 { flush_interval -1 } } handle { respond 404 } ''; # ---- Hermes dashboard ---- # Authentik-gated (hosts/mars/hermes-agent.nix has the OIDC config and the # "create the Authentik app" instructions — moved here from jupiter). services.caddy.virtualHosts."hermes.mgaction.town".extraConfig = '' reverse_proxy http://mars.orbit.sol:9119 ''; # ---- 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 }