Probing the live Debian VPS turned up three mismatches between what it serves and what this config declares: - git.mgaction.town had no vhost at all. Gitea's web UI and HTTPS clones are public today; only its SSH side (the :2222 socat forward) had been ported, so a deploy would have taken the web side offline. - Audiobookshelf is served as abs.mgaction.town, not the longer audiobookshelf.mgaction.town this config used. The mobile app is configured with the short name. - The apex returns 200 from Caddy. Left unserved deliberately, so it now gets Caddy's default 404; noted in a comment so it doesn't look like an oversight next time. Gitea's ROOT_URL was http:// while Caddy terminates TLS for that name. Gitea builds absolute URLs from it, so clone buttons, redirects and webhooks were handing out downgraded links. Also record that defaultGateway6 is confirmed rather than assumed -- `ip -6 route show default` on the VPS gives "default via fe80::1 dev eth0 metric 1024 onlink". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
6.4 KiB
Nix
136 lines
6.4 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" ];
|
|
|
|
# ---- Don't take MagicDNS from our own control server ----
|
|
# headscale pushes override_local_dns, so joining the tailnet would point
|
|
# neptun's resolv.conf at a MagicDNS served by the tailscaled neptun itself
|
|
# hosts. A tailscaled failure would then also kill DNS, and with it ACME
|
|
# renewal — expiring the certs for the very control server every other node
|
|
# needs to recover. Keep the public resolvers above and resolve the one
|
|
# tailnet name we proxy to from /etc/hosts instead.
|
|
services.tailscale.extraUpFlags = [ "--accept-dns=false" ];
|
|
|
|
# ⚠️ PLACEHOLDER IP — replace once jupiter first enrols, with its address
|
|
# from `headscale nodes list` on this box. Until then the audiobookshelf
|
|
# vhost and the gitea SSH forward below both fail to connect.
|
|
networking.hosts."100.64.0.2" = [ "jupiter.hosts.mgaction.town" ];
|
|
|
|
# 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. Both of these live on jupiter and are reached
|
|
# over the tailnet — see the /etc/hosts pin above.
|
|
#
|
|
# `abs`, not `audiobookshelf`: that's the name this has always been served
|
|
# under, and the mobile app is configured with it.
|
|
services.caddy.virtualHosts."abs.mgaction.town".extraConfig = ''
|
|
reverse_proxy http://jupiter.hosts.mgaction.town:8000
|
|
'';
|
|
# 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
|
|
'';
|
|
# The apex mgaction.town is deliberately not served — it returns Caddy's
|
|
# default 404.
|
|
|
|
# ---- 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
|
|
}
|
|
}
|
|
'';
|
|
|
|
# ---- 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.hosts.mgaction.town:2222";
|
|
Restart = "always";
|
|
};
|
|
};
|
|
networking.firewall.allowedTCPPorts = [ 2222 ];
|
|
|
|
system.stateVersion = "26.05"; # set at install time; do NOT bump on upgrades
|
|
}
|