mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat(login): add optional TLS termination support (#11663)
# Which Problems Are Solved The login container can only serve HTTP. In deployments where a reverse proxy is not used, operators need the login container to terminate TLS itself. Closes #11473 # How the Problems Are Solved The container now supports optional HTTPS via three environment variables that match the pattern already used by the backend server: `ZITADEL_TLS_ENABLED`, `ZITADEL_TLS_CERTPATH`, and `ZITADEL_TLS_KEYPATH`. When `ZITADEL_TLS_ENABLED` is not set or set to `false`, behavior is identical to before. When set to `true`, the server reads the provided certificate and key files and serves HTTPS using the lower-level `getRequestHandlers` API from Next.js. The Docker healthcheck was updated to probe the correct scheme automatically based on the same environment variable. # Additional Changes Added a dockerized integration test that verifies both HTTP and HTTPS modes using testcontainers. # Additional Context The TLS path uses `getRequestHandlers` from `next/dist/server/lib/start-server` instead of `startServer` because the latter rejects self-signed certificates in production mode. This is the same internal API that `startServer` itself calls. --------- Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
parent
0261536243
commit
c8abf6de74
@@ -2,4 +2,5 @@
|
||||
!constants
|
||||
!src
|
||||
!locales
|
||||
!scripts/healthcheck.js
|
||||
!scripts/healthcheck.mjs
|
||||
!scripts/server.mjs
|
||||
@@ -13,9 +13,10 @@ ENV HOSTNAME="::" \
|
||||
NODE_ENV="production" \
|
||||
NODE_OPTIONS="--use-openssl-ca" \
|
||||
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt" \
|
||||
SSL_CERT_DIR="/etc/ssl/certs"
|
||||
SSL_CERT_DIR="/etc/ssl/certs" \
|
||||
ZITADEL_TLS_ENABLED="false"
|
||||
|
||||
# TODO: Check healthy, not ready
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/sh", "-c", "node /app/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"]
|
||||
CMD ["/usr/local/bin/node", "/app/healthcheck.mjs"]
|
||||
ENTRYPOINT ["/app/entrypoint.sh", "node", "apps/login/server.js" ]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
|
||||
import * as https from "node:https";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { generateCertificates } from "../ca/utils/tls.ts";
|
||||
|
||||
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUTPUT_DIR = path.join(TEST_DIR, "output");
|
||||
const CERTS_DIR = path.join(OUTPUT_DIR, "certs");
|
||||
const LOGIN_APP_DIR = path.join(TEST_DIR, "../..");
|
||||
|
||||
/**
|
||||
* Integration tests for TLS support in the login application.
|
||||
*
|
||||
* These tests verify that the login container can serve traffic over HTTPS
|
||||
* when configured with ZITADEL_TLS_ENABLED, ZITADEL_TLS_CERTPATH, and
|
||||
* ZITADEL_TLS_KEYPATH environment variables. This is essential for deployments
|
||||
* where the login application must terminate TLS itself rather than relying on
|
||||
* a reverse proxy.
|
||||
*
|
||||
* The test suite builds the login container image and runs it in two modes:
|
||||
* HTTP (default) and HTTPS (with TLS enabled). For HTTPS mode, a self-signed
|
||||
* certificate is generated and mounted into the container. The tests verify
|
||||
* that the application starts correctly and responds to health checks in both
|
||||
* modes.
|
||||
*/
|
||||
describe("Login Container TLS Support", () => {
|
||||
let loginImage: GenericContainer;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (fs.existsSync(OUTPUT_DIR)) {
|
||||
fs.rmSync(OUTPUT_DIR, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(CERTS_DIR, { recursive: true });
|
||||
|
||||
const certs = generateCertificates({
|
||||
serverCN: "localhost",
|
||||
dns: ["localhost"],
|
||||
ips: ["127.0.0.1"],
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(CERTS_DIR, "server.crt"), certs.server.cert);
|
||||
fs.writeFileSync(path.join(CERTS_DIR, "server.key"), certs.server.key);
|
||||
|
||||
loginImage = await GenericContainer.fromDockerfile(LOGIN_APP_DIR).build();
|
||||
});
|
||||
|
||||
describe("when TLS is not enabled", () => {
|
||||
let container: StartedTestContainer;
|
||||
let appUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = await loginImage
|
||||
.withExposedPorts(3000)
|
||||
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
|
||||
.start();
|
||||
|
||||
appUrl = `http://${container.getHost()}:${container.getMappedPort(3000)}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.stop();
|
||||
});
|
||||
|
||||
it("returns 200 from healthy endpoint", async () => {
|
||||
const response = await fetch(`${appUrl}/ui/v2/login/healthy`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when TLS is enabled", () => {
|
||||
let container: StartedTestContainer;
|
||||
let appUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = await loginImage
|
||||
.withExposedPorts(3000)
|
||||
.withEnvironment({
|
||||
ZITADEL_TLS_ENABLED: "true",
|
||||
ZITADEL_TLS_CERTPATH: "/certs/server.crt",
|
||||
ZITADEL_TLS_KEYPATH: "/certs/server.key",
|
||||
})
|
||||
.withCopyFilesToContainer([
|
||||
{
|
||||
source: path.join(CERTS_DIR, "server.crt"),
|
||||
target: "/certs/server.crt",
|
||||
},
|
||||
{
|
||||
source: path.join(CERTS_DIR, "server.key"),
|
||||
target: "/certs/server.key",
|
||||
},
|
||||
])
|
||||
.withWaitStrategy(Wait.forLogMessage(/Ready on https/))
|
||||
.start();
|
||||
|
||||
appUrl = `https://${container.getHost()}:${container.getMappedPort(3000)}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.stop();
|
||||
});
|
||||
|
||||
it("returns 200 from healthy endpoint over HTTPS", async () => {
|
||||
const response = await new Promise<https.IncomingMessage>((resolve, reject) => {
|
||||
https
|
||||
.get(`${appUrl}/ui/v2/login/healthy`, { rejectUnauthorized: false }, (res) => {
|
||||
res.resume();
|
||||
resolve(res);
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "NEXT_OUTPUT_MODE=standalone next build && cp -r public scripts/* .next/standalone/ && cp -r .next/static .next/standalone/apps/login/.next/ && rm .next/standalone/apps/login/.env",
|
||||
"build": "NEXT_OUTPUT_MODE=standalone next build && cp -r public scripts/* .next/standalone/ && mv .next/standalone/apps/login/server.js .next/standalone/apps/login/next-server.js && cp .next/standalone/server.mjs .next/standalone/apps/login/server.js && cp -r .next/static .next/standalone/apps/login/.next/ && rm .next/standalone/apps/login/.env",
|
||||
"build-vercel": "next build",
|
||||
"dev": "HOSTNAME=127.0.0.1 ./scripts/entrypoint.sh next dev",
|
||||
"prod": "cd ./.next/standalone && HOSTNAME=127.0.0.1 ./entrypoint.sh node apps/login/server.js",
|
||||
|
||||
@@ -131,6 +131,26 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"test-ssl": {
|
||||
"description": "Runs TLS integration tests using testcontainers",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"default",
|
||||
"!{projectRoot}/integration/**/*",
|
||||
"!{projectRoot}/acceptance/**/*",
|
||||
"!{projectRoot}/dockerized/**/output/**/*"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"commands": [
|
||||
"pnpm vitest --run --config vitest.config.dockerized.mts dockerized/ssl"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test-integration-run-login": {
|
||||
"description": "Runs the Login application under test. It has its own target, separate from test-integration, because it's a continuous task.",
|
||||
"dependsOn": [
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
const url = process.argv[2];
|
||||
|
||||
if (!url) {
|
||||
console.error("❌ No URL provided as command line argument.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) process.exit(1);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as https from "node:https";
|
||||
import * as http from "node:http";
|
||||
|
||||
const scheme = process.env.ZITADEL_TLS_ENABLED === "true" ? "https" : "http";
|
||||
const port = process.env.PORT || "3000";
|
||||
const url = new URL(process.argv[2] || `/ui/v2/login/healthy`, `${scheme}://localhost:${port}`);
|
||||
|
||||
const get = scheme === "https" ? https.get : http.get;
|
||||
|
||||
try {
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
// Safe: localhost-only probe, self-signed certs expected
|
||||
get(url, { rejectUnauthorized: false }, (res) => {
|
||||
res.resume();
|
||||
resolve(res);
|
||||
}).on("error", reject);
|
||||
});
|
||||
process.exit(res.statusCode >= 200 && res.statusCode < 400 ? 0 : 1);
|
||||
} catch (e) {
|
||||
console.error("Healthcheck failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readFileSync, accessSync, constants } from "node:fs";
|
||||
import { createServer } from "node:https";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
if (process.env.ZITADEL_TLS_ENABLED !== "true") {
|
||||
// The build script must rename the Next.js-generated server.js to next-server.js.
|
||||
// Validate that the file exists before importing to provide a clearer error if the build step fails.
|
||||
try {
|
||||
accessSync(new URL("./next-server.js", import.meta.url), constants.R_OK);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Unable to find or read "./next-server.js". Ensure the build script has renamed Next.js\'s server.js to next-server.js.',
|
||||
err,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await import("./next-server.js");
|
||||
} else {
|
||||
const certPath = process.env.ZITADEL_TLS_CERTPATH;
|
||||
const keyPath = process.env.ZITADEL_TLS_KEYPATH;
|
||||
|
||||
if (!certPath || !keyPath) {
|
||||
console.error(
|
||||
"ZITADEL_TLS_ENABLED is set to true but ZITADEL_TLS_CERTPATH or ZITADEL_TLS_KEYPATH are missing. Both must be set when TLS is enabled.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
accessSync(certPath, constants.R_OK);
|
||||
} catch (err) {
|
||||
console.error(`Certificate file is not readable: ${certPath}`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
accessSync(keyPath, constants.R_OK);
|
||||
} catch (err) {
|
||||
console.error(`Key file is not readable: ${keyPath}`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cert = readFileSync(certPath);
|
||||
const key = readFileSync(keyPath);
|
||||
|
||||
// Requires Node.js >= 20.11.0 (or >= 21.2.0) for import.meta.dirname support.
|
||||
const appDir = import.meta.dirname;
|
||||
|
||||
const requiredServerFilesPath = new URL(".next/required-server-files.json", import.meta.url);
|
||||
|
||||
let requiredServerFiles;
|
||||
try {
|
||||
accessSync(requiredServerFilesPath, constants.R_OK);
|
||||
const requiredServerFilesContent = readFileSync(requiredServerFilesPath, "utf8");
|
||||
requiredServerFiles = JSON.parse(requiredServerFilesContent);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to load .next/required-server-files.json. The Next.js build may be incomplete or the file may contain invalid JSON.",
|
||||
err,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(requiredServerFiles.config);
|
||||
|
||||
process.env.NODE_ENV = "production";
|
||||
process.chdir(appDir);
|
||||
|
||||
const currentPort = parseInt(process.env.PORT, 10) || 3000;
|
||||
const hostname = process.env.HOSTNAME || "0.0.0.0";
|
||||
|
||||
// KEEP_ALIVE_TIMEOUT is expected to be in milliseconds.
|
||||
// Reject values that are negative or unreasonably large to avoid resource issues.
|
||||
const MAX_KEEP_ALIVE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10);
|
||||
if (
|
||||
Number.isNaN(keepAliveTimeout) ||
|
||||
!Number.isFinite(keepAliveTimeout) ||
|
||||
keepAliveTimeout < 0 ||
|
||||
keepAliveTimeout > MAX_KEEP_ALIVE_TIMEOUT_MS
|
||||
) {
|
||||
keepAliveTimeout = undefined;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
require("next");
|
||||
// NOTE: This imports an internal Next.js API that is not part of the public, semver-stable surface.
|
||||
// - Verified with Next.js version 15.5.10 (see package.json).
|
||||
// - This may break on Next.js upgrades; if Next.js is updated, re-check that
|
||||
// `next/dist/server/lib/start-server` still exists and that `getRequestHandlers` behaves as expected.
|
||||
// - This is currently required to support HTTPS with self-signed certificates in production mode,
|
||||
// as there is no equivalent public API in Next.js at this time.
|
||||
const { getRequestHandlers } = require("next/dist/server/lib/start-server");
|
||||
|
||||
const httpsServer = createServer({ key, cert });
|
||||
|
||||
if (keepAliveTimeout !== undefined) {
|
||||
httpsServer.keepAliveTimeout = keepAliveTimeout;
|
||||
}
|
||||
|
||||
const { requestHandler, upgradeHandler } = await getRequestHandlers({
|
||||
dir: appDir,
|
||||
port: currentPort,
|
||||
isDev: false,
|
||||
server: httpsServer,
|
||||
hostname,
|
||||
keepAliveTimeout,
|
||||
});
|
||||
|
||||
httpsServer.on("request", async (req, res) => {
|
||||
try {
|
||||
await requestHandler(req, res);
|
||||
} catch (err) {
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
console.error(`Failed to handle request for ${req.url}`);
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
httpsServer.on("upgrade", async (req, socket, head) => {
|
||||
try {
|
||||
await upgradeHandler(req, socket, head);
|
||||
} catch (err) {
|
||||
socket.destroy();
|
||||
console.error(`Failed to handle upgrade for ${req.url}`);
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
httpsServer.on("error", (err) => {
|
||||
console.error("Failed to start HTTPS server");
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
let cleanupStarted = false;
|
||||
const cleanup = () => {
|
||||
if (cleanupStarted) return;
|
||||
cleanupStarted = true;
|
||||
httpsServer.close((err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!process.env.NEXT_MANUAL_SIG_HANDLE) {
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
}
|
||||
|
||||
httpsServer.listen(currentPort, hostname, () => {
|
||||
const readyUrl = new URL(`https://${hostname}:${currentPort}`);
|
||||
console.log(`> Ready on ${readyUrl}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
include: ["dockerized/**/*.test.ts"],
|
||||
// Increased timeouts are intentional: Docker image build and container startup
|
||||
// can be slow in CI and local environments, so we allow up to 3 minutes.
|
||||
testTimeout: 180000,
|
||||
hookTimeout: 180000,
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user