commit e031064dcfd7b4560726a712eb47ae84d9df7686 Author: Lumen Date: Mon Jun 22 11:33:43 2026 +0200 Initial scaffold: push-to-deploy + auth-proxy + public-switch template diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e498e3c --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Copy to .env for local dev. In production these come from Gitea Actions secrets +# (Settings → Actions → Secrets), NOT from a committed file. +# +# Generate strong values: +# for s in AUTH_SECRET JWT_SECRET DB_PASSWORD; do echo "$s=$(openssl rand -base64 32)"; done + +# NextAuth v5 (Auth.js) session secret. Rotating invalidates all sessions. +AUTH_SECRET=changeme-base64-32 + +# Backend HMAC signing key (base64; JwtService base64-decodes it). +# Rotating invalidates all previously issued access/refresh tokens. +JWT_SECRET=changeme-base64-32 + +# Postgres role password for the live DB role. +# NOTE: only applies on FIRST volume init; the deploy reconciles existing +# volumes via ALTER USER (see .gitea/workflows/deploy.yml). +DB_PASSWORD=changeme-base64-24 + +# ── Local-only frontend origin (override in compose for public phase) ── +# For LOCAL phase point these at the LAN host: +# NEXTAUTH_URL=http://192.168.188.119:__FRONTEND_PORT__ +# AUTH_URL=http://192.168.188.119:__FRONTEND_PORT__ +# For PUBLIC phase the TrueNAS override sets them to https://__SUBDOMAIN__ +NEXTAUTH_URL=http://localhost:3000 +AUTH_URL=http://localhost:3000 +AUTH_TRUST_HOST=true +BACKEND_URL=http://backend:8080 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..ce4db5a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,137 @@ +name: Deploy to TrueNAS + +# ───────────────────────────────────────────────────────────────────────────── +# HOMELAB APP TEMPLATE — push-to-deploy workflow. +# Proven on InspectFlow + CannaManage. See homelab-release-runbook.md. +# +# Before first push, replace these placeholders everywhere in the repo: +# __PROJECT__ compose project name + container prefix (e.g. "myapp") +# __FRONTEND_PORT__ LAN host port the frontend publishes (e.g. 3001) — must be +# unique across all stacks on TrueNAS (see runbook §2 registry) +# __BACKEND_PORT__ LAN host port for backend debug (e.g. 8082) — unique too, +# or remove the backend ports block entirely if not needed +# +# Auto-deploys on push to main via the INSTANCE-LEVEL self-hosted Gitea Actions +# runner on TrueNAS (no per-repo runner registration needed). The runner mounts +# the host Docker socket, so `docker compose` acts on the TrueNAS daemon and +# (re)builds + restarts the live __PROJECT__ stack from the exact pushed commit. +# +# db is internal-only (no host publish) — reachable as db:5432 on the compose net. +# ───────────────────────────────────────────────────────────────────────────── + +on: + push: + branches: [main] + +# Avoid overlapping deploys if pushes land in quick succession. +concurrency: + group: truenas-deploy-__PROJECT__ + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + env: + COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p __PROJECT__ + # Production secrets — set in Gitea repo Settings → Actions → Secrets. + # AUTH_SECRET : NextAuth v5 session secret (rotating invalidates sessions) + # JWT_SECRET : base64 backend HMAC key (rotating invalidates all tokens) + # DB_PASSWORD : Postgres role password (must match the live DB role) + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + steps: + - name: Check out pushed commit + uses: actions/checkout@v4 + + - name: Show toolchain + run: | + set -euo pipefail + docker version --format 'docker {{.Server.Version}}' + docker compose version + + # NOTE: Backend tests and frontend lint are a LOCAL-ONLY gate. The + # self-hosted act runner uses Docker-in-Docker which doesn't support volume + # mounts for nested containers. Run them before pushing. + + - name: Build images + run: | + set -euo pipefail + $COMPOSE build + + - name: Ensure DB up & reconcile role password + run: | + set -euo pipefail + # Start just the db first (idempotent — reuses the running container + # and the persistent __PROJECT___pgdata volume). + $COMPOSE up -d db + echo "Waiting for db to accept connections ..." + for i in $(seq 1 20); do + if docker exec __PROJECT__-db pg_isready -U __PROJECT__ -q; then break; fi + echo " attempt $i/20 — waiting 3s"; sleep 3 + done + # POSTGRES_PASSWORD only applies on FIRST volume init, so an existing + # volume still holds the old role password. Force the live role to match + # the rotated ${DB_PASSWORD} so the backend can authenticate. Local + # socket connections inside the container use trust auth (no password). + # Skipped when the secret is unset to avoid blanking the dev password. + if [ -n "${DB_PASSWORD:-}" ]; then + docker exec __PROJECT__-db psql -U __PROJECT__ -d __PROJECT__ \ + -c "ALTER USER __PROJECT__ WITH PASSWORD '${DB_PASSWORD}';" + echo "✅ DB role password reconciled" + else + echo "⚠️ DB_PASSWORD secret not set — leaving role password unchanged" + fi + + - name: Roll out stack + run: | + set -euo pipefail + $COMPOSE up -d --remove-orphans + + - name: Wait for backend health + run: | + set -euo pipefail + echo "Waiting for backend health on :__BACKEND_PORT__ ..." + for i in $(seq 1 20); do + if wget -q -O /dev/null http://192.168.188.119:__BACKEND_PORT__/actuator/health; then + echo "✅ Backend healthy after ${i} attempt(s)" + exit 0 + fi + echo " attempt $i/20 — waiting 6s" + sleep 6 + done + echo "❌ Backend did not become healthy — recent logs:" + $COMPOSE logs --tail=40 backend + exit 1 + + - name: Verify frontend + run: | + set -euo pipefail + # Probe the frontend on its own loopback INSIDE the container via the + # bundled node runtime. Network-namespace-independent (no reliance on the + # host port being wired during a mid-recreate window, which caused a + # transient false-failure previously) and needs no wget/curl in the image. + # Any HTTP status < 500 counts as "up" — root returns 307 -> /login when + # unauthenticated, which is healthy. + echo "Waiting for frontend on container loopback :3000 ..." + for i in $(seq 1 20); do + if docker exec __PROJECT__-frontend node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"; then + echo "✅ Frontend responding after ${i} attempt(s)" + exit 0 + fi + echo " attempt $i/20 — waiting 5s" + sleep 5 + done + echo "❌ Frontend did not respond — recent logs:" + $COMPOSE logs --tail=40 frontend + exit 1 + + - name: Prune dangling images + run: docker image prune -f || true + + - name: Deployment summary + run: | + echo "=== __PROJECT__ deployed to TrueNAS ===" + echo "Commit: ${GITHUB_SHA}" + echo "Backend: http://192.168.188.119:__BACKEND_PORT__" + echo "Frontend: http://192.168.188.119:__FRONTEND_PORT__" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec4e190 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Secrets — never commit. Use Gitea Actions secrets in production. +.env +.env.local +.env.*.local + +# Node / Next +node_modules/ +.next/ +out/ +*.log + +# Java / Maven +target/ +*.class + +# OS / editor +.DS_Store +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..27bd4c3 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# homelab-app-template + +Scaffold for a new homelab alpha app that deploys to TrueNAS via Gitea Actions +and can be publicly hosted over HTTPS with one script. Encodes the proven +InspectFlow / CannaManage pattern so Work Lumen can onboard a project in minutes, +not days. + +**Read first:** [`homelab-release-runbook.md`](../plans/homelab-release-runbook.md:1) +— the authoritative procedure and the **port/subdomain registry** (allocate your +ports there before touching configs). + +--- + +## What's in the box + +``` +.gitea/workflows/deploy.yml push-to-main → build + run on TrueNAS +docker-compose.yml base: db + backend + frontend +docker-compose.truenas.yml homelab override: ports, secrets, db internal-only +frontend/src/app/api/backend/[...path]/route.ts ⭐ the auth-token proxy fix +.env.example secret names + how to generate them +``` + +You bring the actual app code (Spring backend in `backend/`, Next.js frontend in +`frontend/`). The template wires the deploy + auth + hosting plumbing only. + +--- + +## Onboard a new project (local phase) + +1. **Allocate** a frp remotePort + frontend/backend host ports + subdomain in the + registry (runbook §2). Next free frp port lives there. + +2. **Substitute placeholders.** From the new repo root: + ```bash + PROJECT=myapp + FRONTEND_PORT=3001 # unique LAN host port + BACKEND_PORT=8082 # unique LAN host port (or remove backend ports block) + SUBDOMAIN=myapp.plate-software.de + + grep -rlZ '__PROJECT__\|__FRONTEND_PORT__\|__BACKEND_PORT__\|__SUBDOMAIN__' . \ + | xargs -0 sed -i \ + -e "s/__PROJECT__/$PROJECT/g" \ + -e "s/__FRONTEND_PORT__/$FRONTEND_PORT/g" \ + -e "s/__BACKEND_PORT__/$BACKEND_PORT/g" \ + -e "s/__SUBDOMAIN__/$SUBDOMAIN/g" + ``` + +3. **Set Gitea Actions secrets** (repo → Settings → Actions → Secrets): + `AUTH_SECRET`, `JWT_SECRET`, `DB_PASSWORD`. Generate: + ```bash + for s in AUTH_SECRET JWT_SECRET DB_PASSWORD; do echo "$s=$(openssl rand -base64 32)"; done + ``` + +4. **Push to `main`.** The instance-level act_runner on TrueNAS auto-deploys. + App is live at `http://192.168.188.119:$FRONTEND_PORT`. Done — you can stay + here for the whole early-alpha period. + +## Go public (additive switch, when ready) + +```bash +# DNS A-record $SUBDOMAIN → 82.165.206.45 must exist first. +./scripts/homelab-publish.sh $PROJECT $FRONTEND_PORT $FRP_REMOTE_PORT $SUBDOMAIN +``` +(`homelab-publish.sh` lives in the pi_mcps repo `scripts/`.) Verify per runbook §5. + +--- + +## Why this template exists + +- **The auth fix.** `frontend/.../api/backend/[...path]/route.ts` injects the + Bearer token server-side via `auth()`. A static Next rewrite cannot do this and + silently breaks auth — the ~9-day CannaManage blocker. Never delete this file. +- **db is internal-only** (`ports: !override []`) — no LAN Postgres exposure. +- **Password rotation reconcile** in deploy.yml handles the persistent-volume + `POSTGRES_PASSWORD` gotcha. +- **Frontend verify** uses a container-loopback node probe (no transient + false-failures from host-port timing). + +See the runbook for the full topology, auth gotchas (NextAuth v5 `auth()` vs +`getToken()`), and the Let's Encrypt CA pin. diff --git a/docker-compose.truenas.yml b/docker-compose.truenas.yml new file mode 100644 index 0000000..6461697 --- /dev/null +++ b/docker-compose.truenas.yml @@ -0,0 +1,63 @@ +# TrueNAS homelab override — applied on top of docker-compose.yml for the +# homelab deployment on TrueNAS.local. Proven on InspectFlow + CannaManage. +# +# Replace placeholders before first push: +# __PROJECT__ container prefix / compose project name +# __FRONTEND_PORT__ unique LAN host port for the frontend (registry §2) +# __BACKEND_PORT__ unique LAN host port for backend debug (or remove block) +# __SUBDOMAIN__ public hostname (only matters once you go public) +# +# Topology (public phase — additive, see runbook §4): +# browser ──HTTPS──> IONOS Apache (82.165.206.45, TLS via acme.sh/LE) +# ──ProxyPass──> VPS frps (85.214.154.199:) +# ──frp tunnel──> TrueNAS frpc ──> frontend:__FRONTEND_PORT__ (this stack) +# frontend proxies /api/backend/* to backend:8080 via the server-side Route +# Handler (src/app/api/backend/[...path]/route.ts), so only the frontend port +# needs to be tunnelled — no separate API exposure. +# +# Usage (run by the Gitea act_runner on push to main): +# docker compose -f docker-compose.yml -f docker-compose.truenas.yml \ +# -p __PROJECT__ up -d --build --remove-orphans +services: + db: + # Internal-only: drop any host :5432 publish inherited from docker-compose.yml. + # Postgres must NOT be exposed to the LAN. The backend reaches it over the + # compose network (db:5432) and the deploy's ALTER USER reconcile uses + # `docker exec`, so no published host port is needed. (!override [] replaces + # the inherited ports list — compose otherwise concatenates lists.) + ports: !override [] + # POSTGRES_PASSWORD only takes effect on FIRST volume init; an existing + # volume keeps its current role password (the deploy reconciles it via + # ALTER USER). This value seeds a fresh volume with the prod password. + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD:-__PROJECT___dev} + + backend: + # Remap host port to a unique value (8080 is taken by other stacks on TrueNAS). + # !override replaces the inherited ports list. Internal container port stays + # 8080 so frontend's BACKEND_URL=http://backend:8080 is unaffected. + # Remove this whole ports block if you don't need LAN debug access. + ports: !override + - "__BACKEND_PORT__:8080" + environment: + # Real production password (must match the live DB role; see ALTER USER). + SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-__PROJECT___dev} + # Rotated production JWT signing key (base64 — JwtService base64-decodes it). + # Rotating this invalidates all previously issued access/refresh tokens. + __PROJECT___SECURITY_JWT_SECRET: ${JWT_SECRET} + + frontend: + ports: !override + - "__FRONTEND_PORT__:3000" + environment: + # Public origin so NextAuth callbacks/cookies resolve to the HTTPS host. + # For LOCAL-ONLY phase you can set these to http://192.168.188.119:__FRONTEND_PORT__ + NEXTAUTH_URL: https://__SUBDOMAIN__ + AUTH_URL: https://__SUBDOMAIN__ + # NextAuth v5 (Auth.js) reads AUTH_SECRET. Rotating it invalidates sessions. + AUTH_SECRET: ${AUTH_SECRET} + # Trust the X-Forwarded-* headers from the Apache/frp chain (TLS terminates + # upstream; plain HTTP is proxied into the container). + AUTH_TRUST_HOST: "true" + # Server-side proxy target for /api/backend/* (internal compose DNS). + BACKEND_URL: http://backend:8080 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b644aca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +# Base compose — defines the three services. The TrueNAS override +# (docker-compose.truenas.yml) layers homelab port/secret specifics on top. +# +# Replace __PROJECT__ before first push (container names + db credentials). +# This base is intentionally LAN/dev-shaped; the override hardens it for TrueNAS. +services: + db: + image: postgres:16-alpine + container_name: __PROJECT__-db + environment: + POSTGRES_USER: __PROJECT__ + POSTGRES_DB: __PROJECT__ + POSTGRES_PASSWORD: __PROJECT___dev + volumes: + - pgdata:/var/lib/postgresql/data + # In dev you may publish 5432; the TrueNAS override drops it (internal-only). + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U __PROJECT__"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: __PROJECT__-backend + depends_on: + db: + condition: service_healthy + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/__PROJECT__ + SPRING_DATASOURCE_USERNAME: __PROJECT__ + SPRING_DATASOURCE_PASSWORD: __PROJECT___dev + ports: + - "8080:8080" + + frontend: + build: ./frontend + container_name: __PROJECT__-frontend + depends_on: + - backend + environment: + BACKEND_URL: http://backend:8080 + ports: + - "3000:3000" + +volumes: + pgdata: + name: __PROJECT___pgdata diff --git a/frontend/src/app/api/backend/[...path]/route.ts b/frontend/src/app/api/backend/[...path]/route.ts new file mode 100644 index 0000000..b3a97d4 --- /dev/null +++ b/frontend/src/app/api/backend/[...path]/route.ts @@ -0,0 +1,121 @@ +/** + * Server-side API proxy for the backend. ⭐ THE SYSTEMIC AUTH FIX ⭐ + * + * Do NOT replace this with a static `rewrites()` proxy in next.config.mjs. A + * static rewrite forwards requests as-is and CANNOT inject an Authorization + * header — that was the root cause of the "no token reaches the backend" bug + * (every browser fetch hit the backend unauthenticated → 401/500). This cost + * ~9 days on CannaManage; the template ships the fix so it never recurs. + * + * This Route Handler runs on the server, reads the NextAuth session via + * `auth()` (so the JWT never leaves the server), and forwards the request to + * `${BACKEND_URL}/api/v1/` with `Authorization: Bearer `. + * + * Method- and content-agnostic: query string preserved; raw request body + * streamed unparsed (JSON + multipart uploads + binary all work); upstream + * response body streamed back verbatim (byte-exact downloads). + * + * Adjust the upstream path prefix (`/api/v1/`) if your backend differs. + */ +import { NextResponse } from "next/server" + +import type { NextRequest } from "next/server" + +import { auth } from "@/lib/auth" + +// Always run dynamically — this proxy depends on per-request auth + body. +export const dynamic = "force-dynamic" + +const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080" + +// Hop-by-hop and host-specific headers that must not be forwarded upstream. +const STRIPPED_REQUEST_HEADERS = new Set([ + "host", + "connection", + "content-length", + "transfer-encoding", + "accept-encoding", +]) + +// Headers that must not be copied from the upstream response back to the client. +const STRIPPED_RESPONSE_HEADERS = new Set([ + "connection", + "transfer-encoding", + "content-encoding", + "content-length", +]) + +async function proxy(req: NextRequest, path: string[]): Promise { + const session = await auth() + const accessToken = session?.accessToken + + // Build the upstream URL: /api/backend/ → BACKEND_URL/api/v1/ + const search = req.nextUrl.search // includes leading "?" or "" + const upstreamUrl = `${BACKEND_URL}/api/v1/${path.join("/")}${search}` + + // Clone the incoming headers, stripping hop-by-hop/host ones, then inject auth. + const headers = new Headers() + req.headers.forEach((value, key) => { + if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) { + headers.set(key, value) + } + }) + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`) + } + + const method = req.method.toUpperCase() + const hasBody = method !== "GET" && method !== "HEAD" + + try { + const upstream = await fetch(upstreamUrl, { + method, + headers, + // Stream the raw body through unparsed (works for JSON + multipart + binary). + body: hasBody ? req.body : undefined, + // Required by undici/Node when sending a streaming request body. + ...(hasBody ? { duplex: "half" } : {}), + redirect: "manual", + cache: "no-store", + } as RequestInit) + + // Copy upstream response headers, dropping ones that break a re-emitted body. + const responseHeaders = new Headers() + upstream.headers.forEach((value, key) => { + if (!STRIPPED_RESPONSE_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value) + } + }) + + // Stream the body straight back — byte-exact for downloads. + return new NextResponse(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }) + } catch { + return NextResponse.json( + { code: "BACKEND_UNREACHABLE", message: "Unable to reach the API." }, + { status: 502 } + ) + } +} + +// Next.js 15: the second arg's `params` is a Promise. +type Ctx = { params: Promise<{ path: string[] }> } + +export async function GET(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function POST(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function PUT(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function PATCH(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +} +export async function DELETE(req: NextRequest, ctx: Ctx) { + return proxy(req, (await ctx.params).path) +}