diff --git a/4-Infrastructure/k3s-flake/k3s-server.nix b/4-Infrastructure/k3s-flake/k3s-server.nix index a6b3b711..e64e31b0 100644 --- a/4-Infrastructure/k3s-flake/k3s-server.nix +++ b/4-Infrastructure/k3s-flake/k3s-server.nix @@ -2,18 +2,41 @@ { ########################################################################## - # EDIT HERE — set your domain - # The domain below is used for TLS certificates via Porkbun DNS challenge. - # Set PORKBUN_API_KEY and PORKBUN_SECRET_KEY in /etc/caddy/porkbun.env - # (sops-injected via porkbun-env.age). + # k3s-server.nix — Control plane + Traefik Ingress + # + # This node runs k3s in server mode. Traefik (k3s built-in) handles all + # path-based routing inside the cluster via Ingress resources defined in + # manifests/ingress/. + # + # Port layout: + # :80 — host Caddy pass-through (owns the port, Traefik cannot) + # :30080 — Traefik web NodePort (ServiceLB, bound by k3s) + # + # Traffic flow (internal leg): + # Edge Caddy (TLS, Porkbun, subdomain redirects) → Tailscale + # → host Caddy :80 → Traefik :30080 → k3s services (via Ingress) + # + # All TLS, Porkbun DNS-01, subdomain 301s, and wildcard catch-alls are + # handled exclusively by the edge Caddy in k3s-edge.nix. Nothing here + # does TLS or subdomain routing — this is a plain HTTP bridge. + # + # Traefik NodePort is configured via HelmChartConfig so it does not race + # with the host Caddy for port 80. + # + # URL contract (defined in manifests/ingress/ingress.yaml): + # / → Homer directory + # /apps/chat/* → Hermes (chat/orchestrator) + # /apps/budget/* → Actual Budget + # /server/status/* → Uptime Kuma + # /server/dash/* → Homarr + # /server/vault/* → Vaultwarden + # /api/cred/* → Credential Server + # /api/registry/* → Registry API (worker join/heartbeat) + # /api/jobs/* → Job Router + # /api/blobs/* → Blob Plane + # auth.researchstack.info → Authentik (stable OIDC issuer, via rs-auth Ingress) ########################################################################## - sops.secrets.porkbun-env = { - sopsFile = ./secrets/porkbun-env.age; - format = "yaml"; - path = "/etc/caddy/porkbun.env"; - }; - sops.secrets.authentik-secrets = { sopsFile = ./secrets/authentik-secrets.age; format = "yaml"; @@ -24,7 +47,6 @@ role = "server"; clusterInit = true; extraFlags = [ - "--disable=traefik" "--tls-san=100.102.173.61" "--tls-san=researchstack.info" "--tls-san=nixos-laptop" @@ -34,69 +56,71 @@ ]; }; - systemd.services.caddy.serviceConfig.EnvironmentFile = [ "/etc/caddy/porkbun.env" ]; + # ── Traefik entrypoint config (HelmChartConfig CRD) ──────────────────── + # k3s ships Traefik as a HelmChart. We override via HelmChartConfig to + # bind the web entrypoint to NodePort 30080 instead of the default :80, + # so the host Caddy can own :80 without a port conflict. + # + # k3s reads files placed under /var/lib/rancher/k3s/server/manifests/ and + # reconciles them against the live cluster on startup. + environment.etc."rancher/k3s/server/manifests/traefik-config.yaml".text = '' + apiVersion: helm.cattle.io/v1 + kind: HelmChartConfig + metadata: + name: traefik + namespace: kube-system + spec: + valuesContent: |- + ports: + web: + port: 8000 + nodePort: 30080 + expose: + default: true + exposedPort: 30080 + protocol: TCP + websecure: + port: 8443 + nodePort: 30443 + expose: + default: false + protocol: TCP + service: + type: NodePort + ingressRoute: + dashboard: + enabled: false + ''; + # ── Host Caddy — plain HTTP pass-through (:80 → Traefik :30080) ──────── + # Owns port 80 on the Tailscale interface. All routing logic lives in + # Traefik Ingress resources (manifests/ingress/). This Caddy instance is + # intentionally minimal — no TLS, no subdomain logic, no Porkbun. services.caddy = { enable = true; package = pkgs.caddy; - globalConfig = '' - auto_https off - ''; extraConfig = '' - http://auth.${domain} { - reverse_proxy 100.85.244.73:30080 - } - - http://status.${domain} { - reverse_proxy 127.0.0.1:30801 - } - - http://apps.${domain} { - reverse_proxy 127.0.0.1:30802 - } - - http://home.${domain} { - reverse_proxy 127.0.0.1:30803 - } - - http://pulse.${domain} { - reverse_proxy 127.0.0.1:30804 - } - - http://dash.${domain} { - reverse_proxy 127.0.0.1:30805 - } - - http://media.${domain} { - reverse_proxy 127.0.0.1:30810 - } - - http://vault.${domain} { - reverse_proxy 10.43.130.188:80 - } - - http://books.${domain} { - reverse_proxy 127.0.0.1:30807 - } - - http://music.${domain} { - reverse_proxy 127.0.0.1:30809 - } - - http://mail.${domain} { - reverse_proxy 127.0.0.1:30808 - } - - http://webmail.${domain} { - reverse_proxy 127.0.0.1:30808 - } - - http://${domain} { - respond "k3s unified topology — Research Stack" + :80 { + reverse_proxy 127.0.0.1:30080 { + header_up Host {host} + header_up X-Real-IP {remote} + header_up X-Forwarded-For {remote} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} + } } ''; }; + # Caddy starts after k3s so Traefik NodePort is ready before we proxy to it. + systemd.services.caddy = { + after = [ "k3s.service" "network-online.target" ]; + wants = [ "k3s.service" "network-online.target" ]; + }; + + networking.firewall.allowedTCPPorts = [ 80 ]; + + # ── Service deployment ────────────────────────────────────────────────── systemd.services.deploy-k3s-services = { description = "Deploy k3s topology services"; after = [ "k3s.service" "tailscaled.service" "network-online.target" ]; diff --git a/4-Infrastructure/k3s-flake/manifests/authentik/helm-chart.yaml b/4-Infrastructure/k3s-flake/manifests/authentik/helm-chart.yaml index 22359ee8..69c6092f 100644 --- a/4-Infrastructure/k3s-flake/manifests/authentik/helm-chart.yaml +++ b/4-Infrastructure/k3s-flake/manifests/authentik/helm-chart.yaml @@ -20,6 +20,7 @@ spec: existingSecret: authentik-secrets secretKeys: adminPasswordKey: postgresql-password + userPasswordKey: postgresql-password primary: persistence: size: 8Gi @@ -64,8 +65,9 @@ spec: limits: memory: 512Mi service: - type: NodePort - nodePort: 30080 + # ClusterIP — Traefik reaches Authentik via cluster DNS through the + # rs-auth Ingress (manifests/ingress/ingress.yaml). No NodePort needed. + type: ClusterIP worker: nodeSelector: kubernetes.io/hostname: steamdeck @@ -76,5 +78,4 @@ spec: limits: memory: 512Mi service: - type: NodePort - nodePort: 30080 + type: ClusterIP diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/Dockerfile.stub b/4-Infrastructure/k3s-flake/manifests/control-plane/Dockerfile.stub new file mode 100644 index 00000000..5114abde --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/Dockerfile.stub @@ -0,0 +1,5 @@ +FROM python:3.12-slim +RUN pip install --no-cache-dir fastapi uvicorn +WORKDIR /app +COPY app.py . +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/app.py b/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/app.py new file mode 100644 index 00000000..e5f396c6 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/app.py @@ -0,0 +1,35 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["fastapi", "uvicorn"] +# /// +"""Blobs API – binary object storage service.""" + +from fastapi import FastAPI + +app = FastAPI(title="Blobs API") + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "blobs"} + + +@app.put("/api/blobs/{key}") +def put_blob(key: str): + return {"key": key, "stored": True} + + +@app.get("/api/blobs/{key}") +def get_blob(key: str): + return {"key": key, "exists": False} + + +@app.get("/api/blobs/") +def list_blobs(): + return {"blobs": []} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/deployment.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/deployment.yaml new file mode 100644 index 00000000..851672d6 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: blobs-api + namespace: services + labels: + app: blobs-api +spec: + replicas: 1 + selector: + matchLabels: + app: blobs-api + template: + metadata: + labels: + app: blobs-api + spec: + containers: + - name: blobs-api + image: registry.researchstack.info/control-plane/blobs-api:latest + ports: + - containerPort: 8080 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/service.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/service.yaml new file mode 100644 index 00000000..a2f1e353 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/blobs-api/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: blobs-api + namespace: services + labels: + app: blobs-api +spec: + type: ClusterIP + selector: + app: blobs-api + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/app.py b/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/app.py new file mode 100644 index 00000000..3215af85 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/app.py @@ -0,0 +1,35 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["fastapi", "uvicorn"] +# /// +"""Jobs API – job submission and status tracking service.""" + +from fastapi import FastAPI + +app = FastAPI(title="Jobs API") + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "jobs"} + + +@app.post("/api/jobs/submit") +def submit_job(): + return {"job_id": "placeholder", "status": "queued"} + + +@app.get("/api/jobs/{job_id}") +def get_job(job_id: str): + return {"job_id": job_id, "status": "pending"} + + +@app.get("/api/jobs/") +def list_jobs(): + return {"jobs": []} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/deployment.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/deployment.yaml new file mode 100644 index 00000000..9e0e5f23 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jobs-api + namespace: services + labels: + app: jobs-api +spec: + replicas: 1 + selector: + matchLabels: + app: jobs-api + template: + metadata: + labels: + app: jobs-api + spec: + containers: + - name: jobs-api + image: registry.researchstack.info/control-plane/jobs-api:latest + ports: + - containerPort: 8080 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/service.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/service.yaml new file mode 100644 index 00000000..df4f80a1 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/jobs-api/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: jobs-api + namespace: services + labels: + app: jobs-api +spec: + type: ClusterIP + selector: + app: jobs-api + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/kustomization.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/kustomization.yaml new file mode 100644 index 00000000..399489da --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - registry-api/deployment.yaml + - registry-api/service.yaml + - jobs-api/deployment.yaml + - jobs-api/service.yaml + - blobs-api/deployment.yaml + - blobs-api/service.yaml diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/app.py b/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/app.py new file mode 100644 index 00000000..790fdbd8 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/app.py @@ -0,0 +1,35 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["fastapi", "uvicorn"] +# /// +"""Registry API – worker node registration and heartbeat service.""" + +from fastapi import FastAPI + +app = FastAPI(title="Registry API") + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "registry"} + + +@app.post("/api/registry/join") +def join(): + return {"accepted": True, "node_id": "placeholder"} + + +@app.post("/api/registry/heartbeat") +def heartbeat(): + return {"ack": True} + + +@app.get("/api/registry/nodes") +def list_nodes(): + return {"nodes": []} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/deployment.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/deployment.yaml new file mode 100644 index 00000000..05b34f6a --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/deployment.yaml @@ -0,0 +1,28 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry-api + namespace: services + labels: + app: registry-api +spec: + replicas: 1 + selector: + matchLabels: + app: registry-api + template: + metadata: + labels: + app: registry-api + spec: + containers: + - name: registry-api + image: registry.researchstack.info/control-plane/registry-api:latest + ports: + - containerPort: 8080 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" diff --git a/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/service.yaml b/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/service.yaml new file mode 100644 index 00000000..eac7ee67 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/control-plane/registry-api/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: registry-api + namespace: services + labels: + app: registry-api +spec: + type: ClusterIP + selector: + app: registry-api + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP diff --git a/4-Infrastructure/k3s-flake/manifests/credential-server/deployment.yaml b/4-Infrastructure/k3s-flake/manifests/credential-server/deployment.yaml new file mode 100644 index 00000000..15bd1a88 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/credential-server/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: credential-server + namespace: services + labels: + app: credential-server +spec: + replicas: 1 + selector: + matchLabels: + app: credential-server + template: + metadata: + labels: + app: credential-server + spec: + containers: + - name: credential-server + image: registry.researchstack.info/infra/credential-server:latest + ports: + - containerPort: 8444 + name: http + env: + - name: RS_CREDENTIAL_CONFIG + value: /etc/credential-server/credentials.json + - name: RS_SURFACE_PORT + value: "8444" + - name: RS_SURFACE_HOST + value: "0.0.0.0" + - name: RUST_LOG + value: "info" + volumeMounts: + - name: config + mountPath: /etc/credential-server + readOnly: true + resources: + requests: + memory: 64Mi + cpu: 50m + limits: + memory: 128Mi + volumes: + - name: config + secret: + secretName: credential-server-config + optional: true diff --git a/4-Infrastructure/k3s-flake/manifests/credential-server/kustomization.yaml b/4-Infrastructure/k3s-flake/manifests/credential-server/kustomization.yaml new file mode 100644 index 00000000..a33121c3 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/credential-server/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml diff --git a/4-Infrastructure/k3s-flake/manifests/credential-server/service.yaml b/4-Infrastructure/k3s-flake/manifests/credential-server/service.yaml new file mode 100644 index 00000000..653784bc --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/credential-server/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: credential-server + namespace: services +spec: + type: ClusterIP + selector: + app: credential-server + ports: + - port: 8444 + targetPort: 8444 + name: http diff --git a/4-Infrastructure/k3s-flake/manifests/hermes/configmap.yaml b/4-Infrastructure/k3s-flake/manifests/hermes/configmap.yaml new file mode 100644 index 00000000..8ea50c04 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/hermes/configmap.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: hermes-placeholder + namespace: services +data: + index.html: | + + + + Hermes — Research Stack + + + + +
+

Hermes

+

Chat / orchestrator — coming soon.

+

This placeholder will be replaced with the Hermes container.

+
+ + diff --git a/4-Infrastructure/k3s-flake/manifests/hermes/deployment.yaml b/4-Infrastructure/k3s-flake/manifests/hermes/deployment.yaml new file mode 100644 index 00000000..cde7c612 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/hermes/deployment.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hermes + namespace: services + labels: + app: hermes +spec: + replicas: 1 + selector: + matchLabels: + app: hermes + template: + metadata: + labels: + app: hermes + spec: + containers: + - name: hermes + image: nginx:alpine + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: placeholder-page + mountPath: /usr/share/nginx/html + resources: + requests: + memory: 32Mi + cpu: 10m + limits: + memory: 64Mi + volumes: + - name: placeholder-page + configMap: + name: hermes-placeholder diff --git a/4-Infrastructure/k3s-flake/manifests/hermes/kustomization.yaml b/4-Infrastructure/k3s-flake/manifests/hermes/kustomization.yaml new file mode 100644 index 00000000..8d19601e --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/hermes/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - configmap.yaml diff --git a/4-Infrastructure/k3s-flake/manifests/hermes/service.yaml b/4-Infrastructure/k3s-flake/manifests/hermes/service.yaml new file mode 100644 index 00000000..1f801618 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/hermes/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: hermes + namespace: services +spec: + type: ClusterIP + selector: + app: hermes + ports: + - port: 80 + targetPort: 80 + name: http diff --git a/4-Infrastructure/k3s-flake/manifests/homer/configmap.yaml b/4-Infrastructure/k3s-flake/manifests/homer/configmap.yaml index 7d2a6b58..bd594817 100644 --- a/4-Infrastructure/k3s-flake/manifests/homer/configmap.yaml +++ b/4-Infrastructure/k3s-flake/manifests/homer/configmap.yaml @@ -6,37 +6,63 @@ metadata: data: config.yml: | title: "Research Stack" - subtitle: "Unified Topology" + subtitle: "researchstack.info" header: true footer: false columns: "3" services: - - name: "Identity" + - name: "Applications" + icon: "fas fa-rocket" + items: + - name: "Hermes" + subtitle: "Chat / orchestrator" + url: "https://researchstack.info/apps/chat/" + target: "_blank" + - name: "Jellyfin" + subtitle: "Media streaming" + url: "https://researchstack.info/apps/jellyfin/" + target: "_blank" + - name: "Navidrome" + subtitle: "Music" + url: "https://researchstack.info/apps/music/" + target: "_blank" + - name: "Audiobookshelf" + subtitle: "Books & podcasts" + url: "https://researchstack.info/apps/books/" + target: "_blank" + - name: "Actual Budget" + subtitle: "Personal finance" + url: "https://researchstack.info/apps/budget/" + target: "_blank" + + - name: "Server" + icon: "fas fa-server" + items: + - name: "Uptime Kuma" + subtitle: "Service status" + url: "https://researchstack.info/server/status/" + target: "_blank" + - name: "Homarr" + subtitle: "Dashboard" + url: "https://researchstack.info/server/dash/" + target: "_blank" + - name: "Vaultwarden" + subtitle: "Password vault" + url: "https://researchstack.info/server/vault/" + target: "_blank" + + - name: "Identity & APIs" icon: "fas fa-id-card" items: - name: "Authentik" - logo: "https://auth.YOUR_DOMAIN/static/dist/assets/icons/authentik.svg" subtitle: "Single sign-on" - url: "https://auth.YOUR_DOMAIN" + url: "https://auth.researchstack.info" target: "_blank" - - - name: "Monitoring" - icon: "fas fa-heartbeat" - items: - - name: "Uptime Kuma" - logo: "https://status.YOUR_DOMAIN/favicon.ico" - subtitle: "Service status & uptime" - url: "https://status.YOUR_DOMAIN" + - name: "Registry API" + subtitle: "Node join & heartbeat" + url: "https://researchstack.info/api/registry/" target: "_blank" - - name: "Pulse Receiver" - subtitle: "Edge node heartbeats" - url: "https://pulse.YOUR_DOMAIN" - target: "_blank" - - - name: "Applications" - icon: "fas fa-th" - items: - - name: "Heimdall" - subtitle: "Application dashboard" - url: "https://apps.YOUR_DOMAIN" + - name: "Job Router" + subtitle: "Compute dispatch" + url: "https://researchstack.info/api/jobs/" target: "_blank" diff --git a/4-Infrastructure/k3s-flake/manifests/ingress/ingress.yaml b/4-Infrastructure/k3s-flake/manifests/ingress/ingress.yaml new file mode 100644 index 00000000..2d17c4a1 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/ingress/ingress.yaml @@ -0,0 +1,288 @@ +# Traefik Ingress — canonical path-based routing for researchstack.info +# +# This is the single source of truth for URL → service mapping inside k3s. +# The public edge Caddy terminates TLS and forwards to Traefik's entrypoint. +# Traefik handles path routing, prefix stripping, and forward-auth. +# +# Middleware annotation format: -@kubernetescrd +# +# Split into per-route Ingresses so each gets its own middleware chain: +# - /apps/*, /server/* → forward_auth (Authentik) + strip-prefix +# - /api/* → strip-prefix only (token-auth, no SSO) +# - / → forward_auth (Authentik), no strip +# - auth.* → no middleware (Authentik itself) + +# ═══════════════════════════════════════════════════════════════════════════ +# /apps/chat/* → Hermes (SSO-gated) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-apps-chat + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-authentik-forward-auth@kubernetescrd,services-strip-apps-chat@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /apps/chat + pathType: Prefix + backend: + service: + name: hermes + port: + number: 80 + +# ═══════════════════════════════════════════════════════════════════════════ +# /apps/budget/* → Actual Budget (SSO-gated) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-apps-budget + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-authentik-forward-auth@kubernetescrd,services-strip-apps-budget@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /apps/budget + pathType: Prefix + backend: + service: + name: actual-budget + port: + number: 5006 + +# ═══════════════════════════════════════════════════════════════════════════ +# /server/status/* → Uptime Kuma (SSO-gated) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-server-status + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-authentik-forward-auth@kubernetescrd,services-strip-server-status@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /server/status + pathType: Prefix + backend: + service: + name: uptime-kuma + port: + number: 3001 + +# ═══════════════════════════════════════════════════════════════════════════ +# /server/dash/* → Homarr (SSO-gated) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-server-dash + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-authentik-forward-auth@kubernetescrd,services-strip-server-dash@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /server/dash + pathType: Prefix + backend: + service: + name: homarr + port: + number: 7575 + +# ═══════════════════════════════════════════════════════════════════════════ +# /server/vault/* → Vaultwarden (SSO-gated) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-server-vault + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-authentik-forward-auth@kubernetescrd,services-strip-server-vault@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /server/vault + pathType: Prefix + backend: + service: + name: vaultwarden + port: + number: 80 + +# ═══════════════════════════════════════════════════════════════════════════ +# /api/cred/* → Credential Server (token-auth, no SSO) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-api-cred + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-strip-api-cred@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /api/cred + pathType: Prefix + backend: + service: + name: credential-server + port: + number: 8444 + +# ═══════════════════════════════════════════════════════════════════════════ +# /api/registry/* → Registry API (token-auth, no SSO) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-api-registry + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-strip-api-registry@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /api/registry + pathType: Prefix + backend: + service: + name: registry-api + port: + number: 8080 + +# ═══════════════════════════════════════════════════════════════════════════ +# /api/jobs/* → Job Router (token-auth, no SSO) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-api-jobs + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-strip-api-jobs@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /api/jobs + pathType: Prefix + backend: + service: + name: jobs-api + port: + number: 8080 + +# ═══════════════════════════════════════════════════════════════════════════ +# /api/blobs/* → Blob Plane (token-auth, no SSO) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-api-blobs + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-strip-api-blobs@kubernetescrd +spec: + rules: + - host: researchstack.info + http: + paths: + - path: /api/blobs + pathType: Prefix + backend: + service: + name: blobs-api + port: + number: 8080 + +# ═══════════════════════════════════════════════════════════════════════════ +# / → Homer landing page (SSO-gated, no strip) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-landing + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web + traefik.ingress.kubernetes.io/router.middlewares: services-authentik-forward-auth@kubernetescrd + traefik.ingress.kubernetes.io/router.priority: "1" +spec: + rules: + - host: researchstack.info + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: homer + port: + number: 8080 + +# ═══════════════════════════════════════════════════════════════════════════ +# auth.researchstack.info → Authentik (stable OIDC issuer, no middleware) +# ═══════════════════════════════════════════════════════════════════════════ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: rs-auth + namespace: services + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + rules: + - host: auth.researchstack.info + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: authentik + port: + number: 80 diff --git a/4-Infrastructure/k3s-flake/manifests/ingress/kustomization.yaml b/4-Infrastructure/k3s-flake/manifests/ingress/kustomization.yaml new file mode 100644 index 00000000..edb3c696 --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/ingress/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - middleware.yaml + - ingress.yaml diff --git a/4-Infrastructure/k3s-flake/manifests/ingress/middleware.yaml b/4-Infrastructure/k3s-flake/manifests/ingress/middleware.yaml new file mode 100644 index 00000000..c6c53f5d --- /dev/null +++ b/4-Infrastructure/k3s-flake/manifests/ingress/middleware.yaml @@ -0,0 +1,120 @@ +# Traefik middlewares for the Research Stack Ingress +# +# forward-auth: Authentik SSO gate (applied to /apps/*, /server/*) +# strip-apps-chat: strip /apps/chat prefix before forwarding to Hermes +# strip-apps-budget: strip /apps/budget prefix +# strip-server-status: strip /server/status prefix +# strip-server-dash: strip /server/dash prefix +# strip-server-vault: strip /server/vault prefix +# strip-api-cred: strip /api/cred prefix +# strip-api-registry: strip /api/registry prefix +# strip-api-jobs: strip /api/jobs prefix +# strip-api-blobs: strip /api/blobs prefix +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: authentik-forward-auth + namespace: services +spec: + forwardAuth: + address: http://authentik.services.svc.cluster.local/outpost.goauthentik.io/auth/caddy + authResponseHeaders: + - X-Authentik-Username + - X-Authentik-Email + - X-Authentik-Name + - X-Authentik-Uid + - X-Authentik-Jwt + - X-Authentik-Meta-Jwt + - X-Authentik-Meta-App + - X-Authentik-Meta-Version +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-apps-chat + namespace: services +spec: + stripPrefix: + prefixes: + - /apps/chat +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-apps-budget + namespace: services +spec: + stripPrefix: + prefixes: + - /apps/budget +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-server-status + namespace: services +spec: + stripPrefix: + prefixes: + - /server/status +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-server-dash + namespace: services +spec: + stripPrefix: + prefixes: + - /server/dash +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-server-vault + namespace: services +spec: + stripPrefix: + prefixes: + - /server/vault +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-api-cred + namespace: services +spec: + stripPrefix: + prefixes: + - /api/cred +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-api-registry + namespace: services +spec: + stripPrefix: + prefixes: + - /api/registry +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-api-jobs + namespace: services +spec: + stripPrefix: + prefixes: + - /api/jobs +--- +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: strip-api-blobs + namespace: services +spec: + stripPrefix: + prefixes: + - /api/blobs diff --git a/4-Infrastructure/k3s-flake/manifests/kustomization.yaml b/4-Infrastructure/k3s-flake/manifests/kustomization.yaml index 49bf7914..d2e20710 100644 --- a/4-Infrastructure/k3s-flake/manifests/kustomization.yaml +++ b/4-Infrastructure/k3s-flake/manifests/kustomization.yaml @@ -3,11 +3,24 @@ kind: Kustomization resources: - namespace.yaml + # Monitoring & dashboards - uptime-kuma - - heimdall - homer - - pulse-receiver - homarr - - authentik + # Apps + - hermes - actual-budget + - vaultwarden + # Identity + - authentik + # Control-plane APIs + - credential-server + - control-plane + # Ingress (Traefik path routing + middlewares) + - ingress + # Media (separate namespace) + - media + # Legacy (to be removed after migration) + - heimdall + - pulse-receiver diff --git a/4-Infrastructure/k3s-flake/scripts/deploy-services.sh b/4-Infrastructure/k3s-flake/scripts/deploy-services.sh index bd39b27b..49bc9043 100755 --- a/4-Infrastructure/k3s-flake/scripts/deploy-services.sh +++ b/4-Infrastructure/k3s-flake/scripts/deploy-services.sh @@ -9,9 +9,26 @@ set -euo pipefail # # Flow: # 1. Wait for k3s cluster to be healthy -# 2. Create the K8s Secret for authentik from the sops-decrypted file -# 3. Apply all manifests via kubectl -# 4. k3s built-in Helm controller picks up the HelmChart CRD for authentik +# 2. Ensure namespaces exist +# 3. Create the K8s Secret for authentik from the sops-decrypted file +# 4. Apply authentik HelmChart CRD +# 5. Apply all service manifests (kustomize where available) +# +# URL contract (canonical paths served by internal Caddy router): +# / → Homer +# /apps/chat/* → Hermes +# /apps/jellyfin/* → Jellyfin +# /apps/books/* → Audiobookshelf +# /apps/music/* → Navidrome +# /apps/budget/* → Actual Budget +# /server/status/* → Uptime Kuma +# /server/dash/* → Homarr +# /server/vault/* → Vaultwarden +# /api/cred/* → Credential Server +# /api/registry/* → Registry API +# /api/jobs/* → Job Router +# /api/blobs/* → Blob Plane +# auth.researchstack.info → Authentik (stable OIDC issuer) ########################################################################### MANIFESTS_DIR="/etc/nixos/k3s-flake/manifests" @@ -22,16 +39,14 @@ until kubectl cluster-info --request-timeout=5s >/dev/null 2>&1; do done echo "[deploy] cluster is healthy" -echo "[deploy] ensuring CoreDNS has one schedulable replica per main workload node..." +echo "[deploy] ensuring CoreDNS has schedulable replicas..." kubectl -n kube-system scale deployment/coredns --replicas=3 -echo "[deploy] ensuring services namespace exists..." +echo "[deploy] ensuring namespaces exist..." kubectl get namespace services >/dev/null 2>&1 || kubectl create namespace services +kubectl get namespace media >/dev/null 2>&1 || kubectl create namespace media -# Authentik secrets from sops-decrypted file. -# The file format is an env file: -# secret-key= -# postgresql-password= +# ── Authentik secrets from sops ────────────────────────────────────────── if [ -n "${AUTHENTIK_SECRETS:-}" ] && [ -f "$AUTHENTIK_SECRETS" ]; then echo "[deploy] creating authentik-secrets from sops file..." kubectl delete secret --ignore-not-found -n services authentik-secrets @@ -54,13 +69,13 @@ if [ -n "${AUTHENTIK_SECRETS:-}" ] && [ -f "$AUTHENTIK_SECRETS" ]; then fi fi +# ── Apply manifests ────────────────────────────────────────────────────── echo "[deploy] applying authentik HelmChart..." kubectl apply -f "$MANIFESTS_DIR/authentik/helm-chart.yaml" echo "[deploy] applying all service manifests..." find "$MANIFESTS_DIR" -maxdepth 1 -type d | sort | while read -r dir; do name=$(basename "$dir") - # Skip root dir and authentik (managed via HelmChart) [ "$dir" = "$MANIFESTS_DIR" ] && continue [ "$name" = "authentik" ] && continue if [ -f "$dir/kustomization.yaml" ] || [ -f "$dir/kustomization.yml" ]; then @@ -72,4 +87,4 @@ find "$MANIFESTS_DIR" -maxdepth 1 -type d | sort | while read -r dir; do fi done -echo "[deploy] done" +echo "[deploy] done — all services applied"