feat(k3s-edge): rewrite edge Caddy as dumb TLS forwarder + legacy 301s

Edge Caddy now does exactly three things:
1. Terminate TLS for researchstack.info + *.researchstack.info via
   Porkbun DNS-01 (wildcard cert covers all subdomains in one renewal)
2. 301-redirect legacy subdomains to canonical path equivalents:
     status.*  → /server/status/
     dash.*, home.*  → /
     media.*  → /apps/jellyfin/
     books.*  → /apps/books/
     music.*  → /apps/music/
     vault.*  → /server/vault/
     pulse.*  → /api/registry/
     apps.*  → /apps/
     *.* (wildcard fallback)  → /
3. Forward all other traffic to the internal router (host Caddy :80 on
   k3s-server over Tailscale) with X-Forwarded-* headers preserved.
   auth.* and mail/webmail.* are forwarded unchanged (stable subdomains).

No path routing logic on the edge. Traefik Ingress (k3s-server) owns
all path decisions. This commit has no effect until nixos-rebuild switch
is run on microvm-racknerd (deploy after k3s-server is verified).

Generated with Devin (https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Brandon Schneider 2026-05-26 21:04:17 -05:00
parent cfa43cf07f
commit 6ca8fd439b
3 changed files with 236 additions and 98 deletions

View file

@ -8,10 +8,68 @@ no external dependencies embedded in the flake.
> A node goes online → it joins → it goes offline → the cluster adjusts. > A node goes online → it joins → it goes offline → the cluster adjusts.
The flake spans the full topology spectrum: ## Architecture
- **Server-class x86** (core, judge, mirror, foxtop) — full k8s workloads
- **Thin client / Pi** (edge) — lightweight k3s agent, pulse heartbeat only ```
- **microvm-nerdrack** — zero work, just pulse. Tainted `pulse-only:NoSchedule`. ┌─────────────────────────────────────────────────────────────────────┐
│ Internet │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Public Edge (microvm-racknerd) │ │
│ │ Caddy: TLS termination only (Porkbun DNS-01) │ │
│ │ Ports 80/443 → forwards ALL traffic over Tailscale │ │
│ └───────────────────────────┬─────────────────────────────────┘ │
│ │ Tailscale mesh │
│ ┌───────────────────────────▼─────────────────────────────────┐ │
│ │ Traefik Ingress (nixos-laptop / k3s-server :80) │ │
│ │ Path routing + forward_auth middleware (Authentik) │ │
│ │ Defined in manifests/ingress/ (Ingress + Middleware CRDs) │ │
│ └───────────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼─────────────────────────────────┐ │
│ │ k3s Service Layer (ClusterIP) │ │
│ │ Hermes, Authentik, Uptime Kuma, Homarr, control-plane APIs │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Worker Pool (qfox-1, steamdeck, 361395-1, ...) │ │
│ │ GPU compute, storage, downloaders, codecs │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## URL Contract (canonical paths on `https://researchstack.info`)
| Path | Service | Type |
|------|---------|------|
| `/` | Homer directory | Dashboard |
| `/gettingstarted` | Static page | Info |
| `/apps/chat/*` | Hermes (chat/orchestrator) | App |
| `/apps/jellyfin/*` | Jellyfin | App |
| `/apps/books/*` | Audiobookshelf | App |
| `/apps/music/*` | Navidrome | App |
| `/apps/budget/*` | Actual Budget | App |
| `/server/status/*` | Uptime Kuma | Ops UI |
| `/server/dash/*` | Homarr | Ops UI |
| `/server/vault/*` | Vaultwarden | Ops UI |
| `/api/cred/*` | Credential Server | Control-plane |
| `/api/registry/*` | Registry API (join/heartbeat) | Control-plane |
| `/api/jobs/*` | Job Router | Control-plane |
| `/api/blobs/*` | Blob Plane | Control-plane |
**Stable subdomains** (not path-routed):
- `auth.researchstack.info` → Authentik (OIDC issuer — must not change)
- `mail.researchstack.info` / `webmail.researchstack.info` → Mail services
**Legacy subdomain redirects** (301 → canonical path):
- `status.*``/server/status/`
- `dash.*` / `home.*``/`
- `media.*``/apps/jellyfin/`
- `books.*``/apps/books/`
- `music.*``/apps/music/`
- `vault.*``/server/vault/`
- `apps.*``/apps/`
- `pulse.*``/api/registry/`
## File Layout ## File Layout
@ -19,7 +77,8 @@ The flake spans the full topology spectrum:
4-Infrastructure/k3s-flake/ 4-Infrastructure/k3s-flake/
├── flake.nix — 6 topology configurations ├── flake.nix — 6 topology configurations
├── k3s-configuration.nix — base module (Tailscale, SSH, Nix, firewall, sops) ├── k3s-configuration.nix — base module (Tailscale, SSH, Nix, firewall, sops)
├── k3s-server.nix — control plane + Caddy/Porkbun + deploy oneshot ├── k3s-server.nix — control plane + Traefik Ingress + deploy oneshot
├── k3s-edge.nix — public TLS edge (Caddy) + mail services
├── .sops.yaml — age key rules ├── .sops.yaml — age key rules
├── secrets/ — encrypted at rest, decrypted at activation ├── secrets/ — encrypted at rest, decrypted at activation
│ ├── k3s-token.age — K3S_TOKEN=<value> │ ├── k3s-token.age — K3S_TOKEN=<value>
@ -32,13 +91,21 @@ The flake spans the full topology spectrum:
│ ├── edge.nix — label: role=edge, taint: pulse-only:NoSchedule │ ├── edge.nix — label: role=edge, taint: pulse-only:NoSchedule
│ └── foxtop.nix — label: role=foxtop │ └── foxtop.nix — label: role=foxtop
├── manifests/ — Kubernetes resources, auto-deployed by systemd ├── manifests/ — Kubernetes resources, auto-deployed by systemd
│ ├── kustomization.yaml │ ├── kustomization.yaml — master resource list
│ ├── namespace.yaml — namespace: services │ ├── namespace.yaml — namespace: services
│ ├── authentik/ — HelmChart CRD (official chart + in-cluster PG/Redis) │ ├── ingress/ — Traefik Ingress + Middleware CRDs (path routing)
│ ├── uptime-kuma/ — Deployment + NodePort 30801 + PVC │ ├── authentik/ — HelmChart CRD (OIDC @ auth.researchstack.info)
│ ├── heimdall/ — Deployment + NodePort 30802 + PVC │ ├── hermes/ — Deployment + ClusterIP (placeholder → /apps/chat/)
│ ├── homer/ — Deployment + NodePort 30803 + ConfigMap │ ├── credential-server/ — Deployment + ClusterIP (/api/cred/*)
│ └── pulse-receiver/ — Deployment + NodePort 30804 (inline Python receiver) │ ├── control-plane/ — Registry, Jobs, Blobs APIs (/api/*)
│ ├── uptime-kuma/ — Deployment + Service (/server/status/)
│ ├── homer/ — Deployment + Service (/)
│ ├── homarr/ — Deployment + Service (/server/dash/)
│ ├── actual-budget/ — Deployment + Service (/apps/budget/)
│ ├── vaultwarden/ — Deployment + Service (/server/vault/)
│ ├── media/ — Jellyfin, Navidrome, Audiobookshelf, *arr stack
│ ├── heimdall/ — [LEGACY] being replaced by path routing
│ └── pulse-receiver/ — [LEGACY] being replaced by /api/registry/
└── scripts/ └── scripts/
└── deploy-services.sh — idempotent kubectl apply, called by systemd oneshot └── deploy-services.sh — idempotent kubectl apply, called by systemd oneshot
``` ```
@ -49,35 +116,51 @@ The flake spans the full topology spectrum:
| Role | k3s Label | Taints | Workload | | Role | k3s Label | Taints | Workload |
|---------|-------------------------------------------------|---------------------------|----------| |---------|-------------------------------------------------|---------------------------|----------|
| server | — (control plane) | — | Caddy ingress + deploy-manifests | | server | — (control plane) | — | Internal Caddy router + deploy-manifests |
| core | `topology.researchstack.io/role=core` | — | General compute (PG, Redis, etc.) | | core | `topology.researchstack.io/role=core` | — | General compute (PG, Redis, Authentik) |
| judge | `topology.researchstack.io/role=judge` | — | Validation / audit | | judge | `topology.researchstack.io/role=judge` | — | Validation / audit |
| mirror | `topology.researchstack.io/role=mirror` | — | Storage / replication | | mirror | `topology.researchstack.io/role=mirror` | — | Storage / replication |
| edge | `topology.researchstack.io/role=edge` | `pulse-only:NoSchedule` | Pulse heartbeat only | | edge | `topology.researchstack.io/role=edge` | `pulse-only:NoSchedule` | Public TLS edge + mail |
| foxtop | `topology.researchstack.io/role=foxtop` | — | Top-level orchestrator | | foxtop | `topology.researchstack.io/role=foxtop` | — | Primary compute / orchestrator |
### Service Placement ### Service Placement
| Service | NodePort | Prefers Role | Stateful? | | Service | Port | Path | Prefers Role | Stateful? |
|--------------------|----------|--------------|-----------| |--------------------|-------|-------------------|--------------|-----------|
| Authentik | 30800 | core, server | Yes (PG + Redis PVCs) | | Authentik | 80 | auth.* subdomain | core | Yes (PG + Redis PVCs) |
| Uptime Kuma | 30801 | any | PVC (1Gi) — node-bound | | Hermes | 80 | /apps/chat/ | any | No (placeholder) |
| Heimdall | 30802 | any | PVC (1Gi) — node-bound | | Uptime Kuma | 3001 | /server/status/ | any | PVC (1Gi) |
| Homer | 30803 | any | No (ConfigMap) | | Homer | 8080 | / | any | No (ConfigMap) |
| Pulse Receiver | 30804 | any | No (stateless) | | Homarr | 7575 | /server/dash/ | any | PVC |
| Actual Budget | 5006 | /apps/budget/ | any | PVC |
| Vaultwarden | 80 | /server/vault/ | any | PVC |
| Credential Server | 8444 | /api/cred/ | any | Secret vol |
| Registry API | 8080 | /api/registry/ | any | No (stub) |
| Jobs API | 8080 | /api/jobs/ | any | No (stub) |
| Blobs API | 8080 | /api/blobs/ | any | No (stub) |
### Domain Mapping (Caddy) All services use ClusterIP and are routed via Traefik Ingress. Legacy
NodePort assignments are preserved for backward compat but are not the
primary routing path.
All services are served under `*.YOUR_DOMAIN` (e.g. `researchstack.info`): ### Routing Model (Caddy edge + Traefik Ingress)
- `auth.YOUR_DOMAIN` → NodePort 30800 → Authentik **Public edge** (`k3s-edge.nix` / microvm-racknerd):
- `status.YOUR_DOMAIN` → NodePort 30801 → Uptime Kuma - Caddy terminates TLS via Porkbun DNS-01 wildcard
- `apps.YOUR_DOMAIN` → NodePort 30802 → Heimdall - Forwards all traffic to Traefik (`100.102.173.61:80`) over Tailscale
- `home.YOUR_DOMAIN` → NodePort 30803 → Homer - Handles legacy subdomain 301 redirects at the edge
- `pulse.YOUR_DOMAIN` → NodePort 30804 → Pulse Receiver - `auth.*` and `mail.*`/`webmail.*` forwarded with Host header preserved
- `YOUR_DOMAIN` → static response "k3s unified topology — Research Stack"
TLS via Porkbun DNS challenge (caddy-dns/porkbun plugin). **Traefik Ingress** (k3s built-in, nixos-laptop):
- Listens on `:80` (node port, HTTP — TLS handled by edge)
- Path-based routing defined in `manifests/ingress/ingress.yaml`
- Traefik Middleware CRDs for:
- `authentik-forward-auth` — SSO gate for `/apps/*`, `/server/*`, `/`
- `strip-*` — prefix stripping per route
- `/api/*` routes skip `forward_auth` (token-authenticated)
- `auth.researchstack.info` matched by separate Ingress (no middleware)
TLS via Porkbun DNS challenge (caddy-dns/porkbun plugin) at the edge.
## Node Lifecycle ## Node Lifecycle

View file

@ -2,6 +2,8 @@
let let
mailDomain = domain; mailDomain = domain;
# Internal router target (k3s-server / nixos-laptop over Tailscale)
internalRouter = "100.102.173.61:80";
sendAlert = pkgs.writeShellScriptBin "send-alert" '' sendAlert = pkgs.writeShellScriptBin "send-alert" ''
set -euo pipefail set -euo pipefail
subject="''${1:-Alert}" subject="''${1:-Alert}"
@ -35,7 +37,7 @@ in
networking.hostName = lib.mkDefault hostName; networking.hostName = lib.mkDefault hostName;
networking.domain = lib.mkDefault mailDomain; networking.domain = lib.mkDefault mailDomain;
# ── Postfix ─────────────────────────────────────────────────────────────── # ── Postfix (mail stays on edge — non-HTTP protocol) ─────────────────────
services.postfix = { services.postfix = {
enable = true; enable = true;
hostname = "mail.${mailDomain}"; hostname = "mail.${mailDomain}";
@ -62,7 +64,6 @@ in
ssl_min_protocol = TLSv1.2 ssl_min_protocol = TLSv1.2
mail_location = maildir:/var/mail/%n mail_location = maildir:/var/mail/%n
auth_mechanisms = plain login auth_mechanisms = plain login
ssl = yes
passdb { passdb {
driver = passwd-file driver = passwd-file
args = /etc/dovecot/users args = /etc/dovecot/users
@ -74,7 +75,6 @@ in
''; '';
}; };
# Create mailbox user and directory
users.users.postmaster = { users.users.postmaster = {
isSystemUser = true; isSystemUser = true;
uid = 5000; uid = 5000;
@ -87,37 +87,7 @@ in
members = [ "postmaster" ]; members = [ "postmaster" ];
}; };
# ── ProtonMail Bridge ───────────────────────────────────────────────────── # ── msmtp alert relay ────────────────────────────────────────────────────
systemd.services.protonmail-bridge = lib.mkIf (pkgs ? protonmail-bridge) {
description = "ProtonMail Bridge SMTP relay";
after = [ "network.target" ];
wants = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "protonmail-bridge";
StateDirectory = "protonmail/bridge";
ExecStart = "${pkgs.writeShellScript "bridge-run" ''
${pkgs.socat}/bin/socat TCP-LISTEN:1025,fork TCP:127.0.0.1:1025 &
${pkgs.socat}/bin/socat TCP-LISTEN:1143,fork TCP:127.0.0.1:1143 &
rm -f /tmp/bridge-faketty
mkfifo /tmp/bridge-faketty
cat /tmp/bridge-faketty | ${pkgs.protonmail-bridge}/bin/protonmail-bridge --cli
''}";
Restart = "on-failure";
RestartSec = 10;
};
};
users.users.protonmail-bridge = lib.mkIf (pkgs ? protonmail-bridge) {
isSystemUser = true;
group = "protonmail-bridge";
home = "/var/lib/protonmail";
createHome = true;
};
users.groups.protonmail-bridge = lib.mkIf (pkgs ? protonmail-bridge) {};
# ── ProtonMail alert relay ────────────────────────────────────────────────
environment.etc."msmtprc".text = '' environment.etc."msmtprc".text = ''
defaults defaults
auth login auth login
@ -149,45 +119,129 @@ in
msmtp msmtp
sendAlert sendAlert
socat socat
] ++ lib.optional (pkgs ? protonmail-bridge) pkgs.protonmail-bridge; ];
# ── rs-surface ──────────────────────────────────────────────────────────── # ── Caddy — Public TLS edge ──────────────────────────────────────────────
systemd.services.rs-surface = { # This is a "dumb TLS edge": terminates TLS for researchstack.info +
description = "Research Stack Surface Daemon"; # *.researchstack.info via Porkbun DNS-01 challenge, then forwards ALL
after = [ "network.target" "tailscaled.service" ]; # HTTP traffic to the internal Caddy router over Tailscale.
wants = [ "network.target" "tailscaled.service" ]; #
wantedBy = [ "multi-user.target" ]; # Exceptions:
environment = { # - auth.researchstack.info → forwarded with Host preserved (OIDC issuer)
RS_CREDENTIAL_CONFIG = "/etc/rs-surface/credentials.json"; # - mail/webmail subdomains → forwarded to internal router
RS_SURFACE_PORT = "8444"; #
RS_SURFACE_HOST = "0.0.0.0"; # No path routing logic lives here. Traefik (k3s built-in) handles all
RUST_LOG = "info"; # path-based routing inside the cluster via Ingress resources.
}; systemd.services.caddy.serviceConfig.EnvironmentFile = [ "/etc/caddy/porkbun.env" ];
serviceConfig = {
Type = "simple"; sops.secrets.porkbun-env = {
WorkingDirectory = "/opt/rs-surface"; sopsFile = ./secrets/porkbun-env.age;
ExecStart = "${pkgs.bash}/bin/bash /opt/rs-surface/run.sh"; format = "yaml";
Restart = "always"; path = "/etc/caddy/porkbun.env";
RestartSec = 5;
};
}; };
# ── Caddy ─────────────────────────────────────────────────────────────────
services.caddy = { services.caddy = {
enable = true; enable = true;
package = pkgs.caddy;
extraConfig = '' extraConfig = ''
mail.${domain} { reverse_proxy 127.0.0.1:30808 } (porkbun_tls) {
webmail.${domain} { reverse_proxy 127.0.0.1:30808 } tls {
cred.${domain} { reverse_proxy 127.0.0.1:8444 } dns porkbun {
api_key {$PORKBUN_API_KEY}
api_secret_key {$PORKBUN_SECRET_KEY}
}
}
}
# Primary domain — forward everything to Traefik on k3s-server
researchstack.info {
import porkbun_tls
reverse_proxy ${internalRouter} {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
# Auth subdomain — stable OIDC issuer (preserved, not redirected)
auth.${domain} {
import porkbun_tls
reverse_proxy ${internalRouter} {
header_up Host auth.${domain}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host auth.${domain}
}
}
# Mail subdomains — forward to Traefik (mail Ingress pending)
mail.${domain}, webmail.${domain} {
import porkbun_tls
reverse_proxy ${internalRouter} {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
}
# Legacy subdomain catch-all — 301 to canonical paths
# These exist for bookmark/client compat during migration.
status.${domain} {
import porkbun_tls
redir https://${domain}/server/status/{uri} 301
}
dash.${domain}, home.${domain} {
import porkbun_tls
redir https://${domain}/ 301
}
media.${domain} {
import porkbun_tls
redir https://${domain}/apps/jellyfin/{uri} 301
}
books.${domain} {
import porkbun_tls
redir https://${domain}/apps/books/{uri} 301
}
music.${domain} {
import porkbun_tls
redir https://${domain}/apps/music/{uri} 301
}
vault.${domain} {
import porkbun_tls
redir https://${domain}/server/vault/{uri} 301
}
pulse.${domain} {
import porkbun_tls
redir https://${domain}/api/registry/{uri} 301
}
apps.${domain} {
import porkbun_tls
redir https://${domain}/apps/{uri} 301
}
# Wildcard fallback — anything else gets a redirect to root
*.${domain} {
import porkbun_tls
redir https://${domain}{uri} 301
}
''; '';
}; };
# ── Firewall ────────────────────────────────────────────────────────────── # ── Firewall ──────────────────────────────────────────────────────────────
networking.firewall.allowedTCPPorts = [ networking.firewall.allowedTCPPorts = [
25 143 465 587 993 25 143 465 587 993 # Mail protocols (Postfix + Dovecot)
1025 1143
8444
30808
]; ];
} }

View file

@ -79,12 +79,11 @@
default: true default: true
exposedPort: 30080 exposedPort: 30080
protocol: TCP protocol: TCP
# websecure NodePort not exposed — TLS terminates at the edge Caddy,
# never reaches Traefik. Disabling reduces surface area.
websecure: websecure:
port: 8443
nodePort: 30443
expose: expose:
default: false default: false
protocol: TCP
service: service:
type: NodePort type: NodePort
ingressRoute: ingressRoute:
@ -101,11 +100,13 @@
package = pkgs.caddy; package = pkgs.caddy;
extraConfig = '' extraConfig = ''
:80 { :80 {
reverse_proxy 127.0.0.1:30080 { reverse_proxy 100.102.173.61:30080 {
header_up Host {host} header_up Host {host}
header_up X-Real-IP {remote} header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote} header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto https # Preserve the proto set by the edge Caddy (https) rather than
# overwriting it. The edge is authoritative; we just pass it through.
header_up X-Forwarded-Proto {http.request.header.X-Forwarded-Proto}
header_up X-Forwarded-Host {host} header_up X-Forwarded-Host {host}
} }
} }