/** * DBackup Custom Server - HTTPS by default, HTTP optional. * * This file replaces the Next.js standalone server.js as the Docker entry point. * It creates an HTTPS server (with auto-generated self-signed cert if needed) * and delegates request handling to the Next.js application. * * Environment variables: * DISABLE_HTTPS - "true" to use plain HTTP (default: false - HTTPS) * PORT - Listen port (default: 3000) * HOSTNAME - Bind address (default: "0.0.0.0") * CERTS_DIR - Directory for tls.crt/tls.key (default: "/data/certs") * TRUSTED_PROXIES - The reverse proxies whose X-Forwarded-For counts, see server/client-address.ts */ import { createServer as createHttpsServer } from "node:https"; import { createServer as createHttpServer, type Server } from "node:http"; import { execSync } from "node:child_process"; import type { BlockList } from "node:net"; import path from "node:path"; import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import next from "next"; import { CLIENT_ADDRESS_HEADER, clientAddressOf, trustedProxiesFrom } from "./server/client-address"; // ── Configuration ────────────────────────────────────────────── (process.env as Record).NODE_ENV = "production"; process.chdir(__dirname); const port = parseInt(process.env.PORT || "3000", 10); const hostname = process.env.HOSTNAME || "0.0.0.0"; const disableHttps = process.env.DISABLE_HTTPS === "true"; const certsDir = process.env.CERTS_DIR || "/data/certs"; const certPath = path.join(certsDir, "tls.crt"); const keyPath = path.join(certsDir, "tls.key"); // Checked before anything starts, so an entry with a typo stops the server instead of trusting the wrong proxies. let trustedProxies: BlockList; try { trustedProxies = trustedProxiesFrom(process.env.TRUSTED_PROXIES); console.log(`[Server] X-Forwarded-For counts from: ${process.env.TRUSTED_PROXIES?.trim() || "private (the default)"}`); } catch (err: unknown) { console.error(`[Server] ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } // ── Next.js Setup ──────────────────────────────────────────── // Set standalone config env BEFORE importing next (matches generated server.js) const nextConfig = JSON.parse( fs.readFileSync(path.join(__dirname, ".next/required-server-files.json"), "utf8") ).config; process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig); const app = next({ dev: false, hostname, port, dir: __dirname, conf: nextConfig }); const handler = app.getRequestHandler(); // ── TLS Certificate Management ──────────────────────────────── function isSelfSigned(): boolean { try { const info = execSync(`openssl x509 -in "${certPath}" -noout -issuer -subject`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000, }); const issuer = info.match(/issuer\s*=\s*(.*)/i)?.[1]?.trim() || ""; const subject = info.match(/subject\s*=\s*(.*)/i)?.[1]?.trim() || ""; return issuer === subject; } catch { return false; } } function isExpired(): boolean { try { execSync(`openssl x509 -in "${certPath}" -noout -checkend 0`, { stdio: "pipe", timeout: 5000, }); return false; // checkend 0 exits 0 if NOT expired } catch { return true; // exits 1 if expired } } /** * Extracts additional SAN entries from BETTER_AUTH_URL. * Returns a comma-prefixed string like ",DNS:myhost.example.com" or ",IP:192.168.1.1", * or an empty string if no extra SAN is needed. */ function getExtraSansFromAuthUrl(): string { const authUrl = process.env.BETTER_AUTH_URL || ""; if (!authUrl) return ""; try { const hostname = new URL(authUrl).hostname; if (!hostname || hostname === "localhost" || hostname === "127.0.0.1") return ""; const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); return isIp ? `,IP:${hostname}` : `,DNS:${hostname}`; } catch { return ""; } } function generateSelfSignedCert(): void { const extraSans = getExtraSansFromAuthUrl(); execSync( `openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" ` + `-days 365 -nodes -subj "/CN=DBackup/O=DBackup Self-Signed" ` + `-addext "subjectAltName=DNS:localhost,IP:127.0.0.1${extraSans}"`, { stdio: "pipe", timeout: 30000 } ); execSync(`chmod 600 "${keyPath}"`, { stdio: "pipe" }); execSync(`chmod 644 "${certPath}"`, { stdio: "pipe" }); } /** * Checks whether the existing self-signed cert covers the hostname from BETTER_AUTH_URL. * Returns true if the cert needs to be regenerated. */ function selfSignedCertMissesHostname(): boolean { const authUrl = process.env.BETTER_AUTH_URL || ""; if (!authUrl) return false; let hostname: string; try { hostname = new URL(authUrl).hostname; } catch { return false; } if (!hostname || hostname === "localhost" || hostname === "127.0.0.1") return false; try { const sans = execSync( `openssl x509 -in "${certPath}" -noout -ext subjectAltName`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 } ); return !sans.includes(hostname); } catch { return false; } } function ensureCertificate(): boolean { if (!fs.existsSync(certsDir)) { fs.mkdirSync(certsDir, { recursive: true, mode: 0o700 }); } if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { // Check if existing cert is expired if (isExpired()) { if (isSelfSigned()) { console.log("[TLS] Self-signed certificate expired - regenerating..."); try { generateSelfSignedCert(); console.log("[TLS] Self-signed certificate renewed successfully"); return true; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[TLS] Failed to renew certificate:", message); return false; } } else { console.warn( "[TLS] WARNING: Custom certificate has expired! " + "Upload a new certificate via Settings - Certificate or replace files in the configured CERTS_DIR" ); } } else if (isSelfSigned() && selfSignedCertMissesHostname()) { console.log( "[TLS] Self-signed certificate does not cover the configured hostname - regenerating..." ); try { generateSelfSignedCert(); console.log("[TLS] Self-signed certificate regenerated with updated hostname"); return true; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[TLS] Failed to regenerate certificate:", message); return false; } } else { console.log("[TLS] Using existing certificate from configured CERTS_DIR"); } return true; } console.log("[TLS] No certificate found. Generating self-signed certificate..."); try { generateSelfSignedCert(); console.log("[TLS] Self-signed certificate generated successfully"); return true; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[TLS] Failed to generate certificate:", message); console.error( "[TLS] Falling back to HTTP. Install openssl or provide custom certificates." ); return false; } } // ── Server Start ────────────────────────────────────────────── app.prepare().then(() => { let server: Server; let protocol: string; const requestHandler = async (req: IncomingMessage, res: ServerResponse) => { try { // The app reads the address of the client from here only. A value the client sent is replaced. req.headers[CLIENT_ADDRESS_HEADER] = clientAddressOf( req.socket.remoteAddress, req.headers["x-forwarded-for"], req.headers["x-real-ip"], trustedProxies ); await handler(req, res); } catch (err) { console.error("[Server] Request error:", err); res.statusCode = 500; res.end("Internal Server Error"); } }; if (disableHttps) { protocol = "http"; server = createHttpServer(requestHandler); console.log("[Server] HTTPS disabled via DISABLE_HTTPS=true - using HTTP"); } else { const certReady = ensureCertificate(); if (certReady && fs.existsSync(certPath) && fs.existsSync(keyPath)) { protocol = "https"; const tlsOptions = { cert: fs.readFileSync(certPath), key: fs.readFileSync(keyPath), }; server = createHttpsServer(tlsOptions, requestHandler); console.log("[Server] HTTPS enabled with certificate from configured CERTS_DIR"); } else { protocol = "http"; server = createHttpServer(requestHandler); console.warn( "[Server] WARNING: Falling back to HTTP - certificate generation failed" ); } } server.listen(port, hostname, () => { console.log( `[Server] DBackup ready on ${protocol}://${hostname}:${port}` ); }); // Graceful shutdown const shutdown = (signal: string) => { console.log(`[Server] ${signal} received - shutting down...`); server.close(() => { console.log("[Server] Closed"); process.exit(0); }); setTimeout(() => process.exit(1), 10000); }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); });