feat(deploy): public hosting at cannamanage.plate-software.de + fix systemic auth-token bug
CI — Build, Lint & Security Scan / backend (push) Failing after 1m4s
CI — Build, Lint & Security Scan / frontend (push) Failing after 1m24s
CI — Build, Lint & Security Scan / image-scan (push) Has been skipped
CI — Build, Lint & Security Scan / secrets-scan (push) Failing after 21s
Deploy to TrueNAS / deploy (push) Failing after 4m0s
CI — Build, Lint & Security Scan / backend (push) Failing after 1m4s
CI — Build, Lint & Security Scan / frontend (push) Failing after 1m24s
CI — Build, Lint & Security Scan / image-scan (push) Has been skipped
CI — Build, Lint & Security Scan / secrets-scan (push) Failing after 21s
Deploy to TrueNAS / deploy (push) Failing after 4m0s
Auth fix (the real unblocker): - Add server-side proxy Route Handler app/api/backend/[...path]/route.ts that reads the NextAuth session via auth() and injects Authorization: Bearer on every API call. Method-agnostic; streams raw request body (multipart uploads) and upstream response body (binary PDF/CSV downloads). Replaces the static next.config.mjs rewrite, which could not inject a header — the root cause of every authenticated browser fetch hitting the backend unauthenticated. - Expose session.accessToken in the auth.ts session() callback (+ type aug). Uses auth() not getToken() so cookie handling is correct across the public HTTPS (Apache) -> internal HTTP (container) proxy boundary. - No service files changed; all 24 services already call /api/backend/*. Verified live: NextAuth login -> GET /api/backend/members -> HTTP 200. Public hosting (same proven chain as Gitea/InspectFlow): - docker-compose.truenas.yml: NEXTAUTH_URL/AUTH_URL -> https public origin, rotate AUTH_SECRET + JWT_SECRET + DB_PASSWORD off the committed dev defaults. - deploy.yml: inject AUTH_SECRET/JWT_SECRET/DB_PASSWORD from Gitea secrets; reconcile the live Postgres role password (volume keeps old pw on re-deploy). - frpc on TrueNAS tunnels frontend :3000 -> VPS frps :30010; IONOS Apache terminates TLS for cannamanage.plate-software.de and proxies through frp.
This commit is contained in:
@@ -28,6 +28,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p cannamanage
|
COMPOSE: docker compose -f docker-compose.yml -f docker-compose.truenas.yml -p cannamanage
|
||||||
|
# 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:
|
steps:
|
||||||
- name: Check out pushed commit
|
- name: Check out pushed commit
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -47,6 +54,30 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
$COMPOSE build
|
$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 cannamanage_pgdata volume).
|
||||||
|
$COMPOSE up -d db
|
||||||
|
echo "Waiting for db to accept connections ..."
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if docker exec cannamanage-db pg_isready -U cannamanage -q; then break; fi
|
||||||
|
echo " attempt $i/20 — waiting 3s"; sleep 3
|
||||||
|
done
|
||||||
|
# POSTGRES_PASSWORD only applies on FIRST volume init, so the 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 cannamanage-db psql -U cannamanage -d cannamanage \
|
||||||
|
-c "ALTER USER cannamanage 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
|
- name: Roll out stack
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
@@ -10,15 +10,10 @@ const nextConfig = {
|
|||||||
// Required for Docker standalone output
|
// Required for Docker standalone output
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
|
||||||
// Proxy API calls to the Spring Boot backend
|
// NOTE: API calls to /api/backend/* are proxied by the server-side Route
|
||||||
async rewrites() {
|
// Handler at src/app/api/backend/[...path]/route.ts, NOT by a static
|
||||||
return [
|
// rewrite. A static rewrite cannot inject the NextAuth Bearer token; the
|
||||||
{
|
// route handler reads the session via auth() and forwards it. See that file.
|
||||||
source: "/api/backend/:path*",
|
|
||||||
destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withNextIntl(nextConfig)
|
export default withNextIntl(nextConfig)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Server-side API proxy for the CannaManage backend.
|
||||||
|
*
|
||||||
|
* Replaces the old static `rewrites()` proxy in next.config.mjs. A static
|
||||||
|
* rewrite forwards requests as-is and CANNOT inject an Authorization header,
|
||||||
|
* which was the root cause of the systemic "no token reaches the backend" bug:
|
||||||
|
* every browser fetch hit the backend unauthenticated → 401/500 → pages only
|
||||||
|
* survived via mock fallbacks.
|
||||||
|
*
|
||||||
|
* 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/<path>` with `Authorization: Bearer <accessToken>`.
|
||||||
|
*
|
||||||
|
* It is method-agnostic and content-agnostic:
|
||||||
|
* - Query string is preserved.
|
||||||
|
* - The raw request body is streamed through unparsed, so JSON,
|
||||||
|
* multipart/form-data (file uploads) and any other content type work.
|
||||||
|
* - The upstream response body is streamed back verbatim, so binary
|
||||||
|
* downloads (PDF/CSV reports, attachments) are byte-exact.
|
||||||
|
*/
|
||||||
|
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<NextResponse> {
|
||||||
|
const session = await auth()
|
||||||
|
const accessToken = session?.accessToken
|
||||||
|
|
||||||
|
// Build the upstream URL: /api/backend/<path> → BACKEND_URL/api/v1/<path>
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -120,6 +120,20 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
session.user.role = token.role as string
|
session.user.role = token.role as string
|
||||||
session.user.clubId = token.clubId as string
|
session.user.clubId = token.clubId as string
|
||||||
session.error = token.error as string | undefined
|
session.error = token.error as string | undefined
|
||||||
|
// Expose the backend access token on the session so the server-side proxy
|
||||||
|
// Route Handler (app/api/backend/[...path]/route.ts) can read it via auth()
|
||||||
|
// and inject it as a Bearer header on every API call.
|
||||||
|
//
|
||||||
|
// We use auth() (not getToken()) because it handles the cookie name
|
||||||
|
// consistently across the public-HTTPS / internal-HTTP boundary: the
|
||||||
|
// browser talks HTTPS to the Apache front, which proxies plain HTTP to
|
||||||
|
// this container. getToken()'s __Secure- cookie-name autodetection keys
|
||||||
|
// off the (internal, http) request URL and would miss the real secure
|
||||||
|
// cookie. The tradeoff: accessToken is therefore also returned by
|
||||||
|
// /api/auth/session — i.e. readable client-side. That is an accepted,
|
||||||
|
// standard bearer-token-in-browser posture; the JWT is short-lived and is
|
||||||
|
// already the browser's effective credential.
|
||||||
|
session.accessToken = token.accessToken as string | undefined
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
async redirect({ url, baseUrl }) {
|
async redirect({ url, baseUrl }) {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ declare module "next-auth" {
|
|||||||
clubId: string
|
clubId: string
|
||||||
} & DefaultSession["user"]
|
} & DefaultSession["user"]
|
||||||
error?: string
|
error?: string
|
||||||
|
/** Backend JWT — server-side only, injected as Bearer by the /api/backend proxy. */
|
||||||
|
accessToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
# TrueNAS homelab override — replaces localhost with 192.168.188.119
|
# TrueNAS homelab override — public hosting at https://cannamanage.plate-software.de
|
||||||
# Applied on top of docker-compose.yml for the homelab deployment on TrueNAS.local.
|
# Applied on top of docker-compose.yml for the homelab deployment on TrueNAS.local.
|
||||||
# Usage:
|
#
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.truenas.yml up -d --build
|
# Topology (same proven chain as Gitea + InspectFlow):
|
||||||
|
# browser ──HTTPS──> IONOS Apache (82.165.206.45, TLS via acme.sh)
|
||||||
|
# ──ProxyPass──> VPS frps (85.214.154.199:30010)
|
||||||
|
# ──frp tunnel──> TrueNAS frpc ──> frontend:3000 (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 cannamanage up -d --build --remove-orphans
|
||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
# POSTGRES_PASSWORD only takes effect on FIRST volume init; the existing
|
||||||
|
# cannamanage_pgdata volume keeps its current role password. The live role
|
||||||
|
# password is rotated out-of-band via `ALTER USER` to match ${DB_PASSWORD}.
|
||||||
|
# This value is here so a fresh volume initialises with the prod password.
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-cannamanage_dev}
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
# Host port 8080 is taken by odysseus-searxng-1; remap to 8081.
|
# Host port 8080 is taken by odysseus-searxng-1; remap to 8081.
|
||||||
# !override replaces the inherited ports list (compose merges lists by concat otherwise).
|
# !override replaces the inherited ports list (compose merges lists by concat otherwise).
|
||||||
# Internal container port stays 8080 so frontend's BACKEND_URL=http://backend:8080 is unaffected.
|
# Internal container port stays 8080 so frontend's BACKEND_URL=http://backend:8080 is unaffected.
|
||||||
ports: !override
|
ports: !override
|
||||||
- "8081:8080"
|
- "8081:8080"
|
||||||
|
environment:
|
||||||
|
# Real production password (must match the live DB role, see ALTER USER above).
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-cannamanage_dev}
|
||||||
|
# Rotated production JWT signing key (base64 — JwtService base64-decodes it).
|
||||||
|
# Rotating this invalidates all previously issued access/refresh tokens.
|
||||||
|
CANNAMANAGE_SECURITY_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
environment:
|
environment:
|
||||||
NEXTAUTH_URL: http://192.168.188.119:3000
|
# Public origin so NextAuth callbacks/cookies resolve to the HTTPS host.
|
||||||
AUTH_URL: http://192.168.188.119:3000
|
NEXTAUTH_URL: https://cannamanage.plate-software.de
|
||||||
# NextAuth v5 (Auth.js) reads AUTH_SECRET, not NEXTAUTH_SECRET. Without it at
|
AUTH_URL: https://cannamanage.plate-software.de
|
||||||
# runtime, signIn throws MissingSecret -> the app error boundary shows 'Oops'.
|
# NextAuth v5 (Auth.js) reads AUTH_SECRET. Rotating it invalidates sessions.
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
# Trust the X-Forwarded-* headers from the Apache/frp chain (we terminate
|
||||||
|
# TLS upstream and proxy plain HTTP into the container).
|
||||||
AUTH_TRUST_HOST: "true"
|
AUTH_TRUST_HOST: "true"
|
||||||
|
# Server-side proxy target for /api/backend/* (internal compose DNS).
|
||||||
|
BACKEND_URL: http://backend:8080
|
||||||
|
|||||||
Reference in New Issue
Block a user