Comments had drifted into multi-paragraph narrative (git commit lineage, debugging stories, restated code) in several hot spots (scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix). Trim every comment to its load-bearing "why" — gotchas, safety warnings, and non-obvious rationale survive verbatim in substance, just tightened to 1-2 sentences; historical narrative and anything already covered in CLAUDE.md is cut. No code/logic changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
190 lines
8.5 KiB
Nix
190 lines
8.5 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/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
|
|
}
|