mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move acme certificate automation into a plugin (C7)
This commit is contained in:
1 parent
4dbce08379
commit
8c1e02af89
123 files changed
+4808
-1912
No files matched your search
@@ -41,7 +41,8 @@ import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||
import { DatabaseMigration } from "../utils/database-migration.js";
|
||||
import { UserDataExport } from "../utils/user-data-export.js";
|
||||
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
|
||||
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDismissedAlertRepository,
|
||||
@@ -302,6 +303,28 @@ app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /.well-known/acme-challenge/{token}:
|
||||
* get:
|
||||
* summary: Answer an ACME http-01 challenge
|
||||
* description: Public. Serves the key authorization a plugin published through ctx.system.publishHttpChallenge while it proves control of the domain.
|
||||
* tags:
|
||||
* - General
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: token
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The key authorization, as text/plain.
|
||||
* 404:
|
||||
* description: No challenge is published for this token.
|
||||
*/
|
||||
app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /version:
|
||||
@@ -2089,7 +2112,7 @@ export const serverReady = new Promise<void>((resolve) => {
|
||||
});
|
||||
});
|
||||
|
||||
const sslConfig = AutoSSLSetup.getSSLConfig();
|
||||
const sslConfig = getTlsConfig();
|
||||
if (sslConfig.enabled) {
|
||||
databaseLogger.info(`SSL is enabled`, {
|
||||
operation: "ssl_info",
|
||||
@@ -2098,59 +2121,39 @@ if (sslConfig.enabled) {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
sslConfig.enabled &&
|
||||
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
|
||||
) {
|
||||
try {
|
||||
const httpsServer = https.createServer(
|
||||
{
|
||||
cert: fs.readFileSync(sslConfig.certPath),
|
||||
key: fs.readFileSync(sslConfig.keyPath),
|
||||
},
|
||||
app,
|
||||
);
|
||||
// Built through the TLS service so a new certificate can be swapped in, or
|
||||
// HTTPS started, without a restart.
|
||||
void configureDirectHttps((options) => {
|
||||
const port = getTlsConfig().port;
|
||||
const httpsServer = https.createServer(options, app);
|
||||
|
||||
attachPluginWebSockets(httpsServer);
|
||||
attachPluginWebSockets(httpsServer);
|
||||
|
||||
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
`SSL port ${sslConfig.port} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "https_server_port_conflict",
|
||||
port: sslConfig.port,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
databaseLogger.error("HTTPS server error", err, {
|
||||
operation: "https_server_error",
|
||||
});
|
||||
});
|
||||
|
||||
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
|
||||
databaseLogger.success(
|
||||
`Backend is now also listening for HTTPS directly`,
|
||||
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
`SSL port ${port} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "https_server_started",
|
||||
port: sslConfig.port,
|
||||
operation: "https_server_port_conflict",
|
||||
port,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
databaseLogger.error("HTTPS server error", err, {
|
||||
operation: "https_server_error",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to start HTTPS server with configured SSL certificate",
|
||||
error,
|
||||
{
|
||||
operation: "https_server_start_failed",
|
||||
cert_path: sslConfig.certPath,
|
||||
key_path: sslConfig.keyPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
httpsServer.listen(port, "127.0.0.1", () => {
|
||||
databaseLogger.success(`Backend is now also listening for HTTPS directly`, {
|
||||
operation: "https_server_started",
|
||||
port,
|
||||
});
|
||||
});
|
||||
return httpsServer;
|
||||
});
|
||||
|
||||
/**
|
||||
* Status check columns from an export row. Exports from before 2.9.0 only
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { execFileSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const SSL_DIR = path.join(DATA_DIR, "ssl");
|
||||
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
|
||||
const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
|
||||
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
|
||||
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
|
||||
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
|
||||
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
|
||||
DATA_DIR,
|
||||
"ssl",
|
||||
"cloudflare.ini",
|
||||
);
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
email: string;
|
||||
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
|
||||
cloudflareToken: string;
|
||||
lastIssuedAt: string | null;
|
||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||
certExpiresAt: string | null;
|
||||
};
|
||||
|
||||
async function getAdminActor(
|
||||
userId: string | undefined,
|
||||
): Promise<UserRecord | null> {
|
||||
if (!userId) return null;
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user?.isAdmin ? user : null;
|
||||
}
|
||||
|
||||
function getCertInfo(): {
|
||||
status: "none" | "valid" | "expiring" | "expired";
|
||||
expiresAt: string | null;
|
||||
} {
|
||||
const certFile = path.join(SSL_DIR, "termix.crt");
|
||||
try {
|
||||
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const endDateRaw = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", certFile, "-noout", "-enddate"],
|
||||
{ stdio: "pipe" },
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.replace("notAfter=", "");
|
||||
const expiresAt = new Date(endDateRaw).toISOString();
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", certFile, "-checkend", "0", "-noout"],
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return { status: "expired", expiresAt };
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
return { status: "valid", expiresAt };
|
||||
} catch {
|
||||
return { status: "expiring", expiresAt };
|
||||
}
|
||||
} catch {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function getAcmeSettings(): Promise<AcmeSettings> {
|
||||
const { status, expiresAt } = getCertInfo();
|
||||
const value =
|
||||
await createCurrentSettingsRepository().get("acme_ssl_settings");
|
||||
const stored = value ? JSON.parse(value) : {};
|
||||
|
||||
return {
|
||||
enabled: stored.enabled ?? false,
|
||||
domain: stored.domain ?? "",
|
||||
email: stored.email ?? "",
|
||||
challengeType: stored.challengeType ?? "http-webroot",
|
||||
cloudflareToken: stored.cloudflareToken
|
||||
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
|
||||
: "",
|
||||
lastIssuedAt: stored.lastIssuedAt ?? null,
|
||||
certStatus: status,
|
||||
certExpiresAt: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAcmeSSLRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/acme-ssl-settings:
|
||||
* get:
|
||||
* summary: Get ACME SSL settings
|
||||
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ACME SSL settings and certificate status.
|
||||
* 500:
|
||||
* description: Failed to get ACME SSL settings.
|
||||
*/
|
||||
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
res.json(await getAcmeSettings());
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get ACME SSL settings", err);
|
||||
res.status(500).json({ error: "Failed to get ACME SSL settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/acme-ssl-settings:
|
||||
* patch:
|
||||
* summary: Update ACME SSL settings (admin only)
|
||||
* description: Saves ACME/Let's Encrypt configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* domain:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* challengeType:
|
||||
* type: string
|
||||
* enum: [http-webroot, dns-cloudflare, manual]
|
||||
* cloudflareToken:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ACME SSL settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update ACME SSL settings.
|
||||
*/
|
||||
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
|
||||
const { enabled, domain, email, challengeType, cloudflareToken } =
|
||||
req.body;
|
||||
|
||||
const updated = {
|
||||
...current,
|
||||
...(typeof enabled === "boolean" && { enabled }),
|
||||
...(typeof domain === "string" && { domain }),
|
||||
...(typeof email === "string" && { email }),
|
||||
...(typeof challengeType === "string" && { challengeType }),
|
||||
...(typeof cloudflareToken === "string" &&
|
||||
cloudflareToken &&
|
||||
!cloudflareToken.includes("*") && { cloudflareToken }),
|
||||
};
|
||||
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_acme_ssl_settings",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({
|
||||
enabled,
|
||||
domain,
|
||||
email,
|
||||
challengeType,
|
||||
hasCloudflareToken: !!updated.cloudflareToken,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(await getAcmeSettings());
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update ACME SSL settings", err);
|
||||
res.status(500).json({ error: "Failed to update ACME SSL settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/acme-ssl-request:
|
||||
* post:
|
||||
* summary: Request or renew Let's Encrypt certificate (admin only)
|
||||
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate issued or renewed successfully.
|
||||
* 400:
|
||||
* description: Invalid configuration.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate issuance failed.
|
||||
*/
|
||||
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const settingsValue =
|
||||
await createCurrentSettingsRepository().get("acme_ssl_settings");
|
||||
|
||||
if (!settingsValue) {
|
||||
return res.status(400).json({ error: "ACME settings not configured" });
|
||||
}
|
||||
|
||||
const settings = JSON.parse(settingsValue);
|
||||
const { domain, email, challengeType, cloudflareToken } = settings;
|
||||
|
||||
if (!domain || !email) {
|
||||
return res.status(400).json({ error: "Domain and email are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync("certbot", ["--version"], { stdio: "pipe" });
|
||||
} catch {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "certbot is not available in this environment" });
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
await fs.mkdir(ACME_WEBROOT, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
|
||||
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
|
||||
|
||||
const certbotDirArgs = [
|
||||
"--config-dir",
|
||||
CERTBOT_CONFIG_DIR,
|
||||
"--work-dir",
|
||||
CERTBOT_WORK_DIR,
|
||||
"--logs-dir",
|
||||
CERTBOT_LOGS_DIR,
|
||||
];
|
||||
|
||||
let certbotArgs: string[];
|
||||
|
||||
if (challengeType === "dns-cloudflare") {
|
||||
if (!cloudflareToken) {
|
||||
return res.status(400).json({
|
||||
error: "Cloudflare API token is required for DNS challenge",
|
||||
});
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(
|
||||
CLOUDFLARE_CREDENTIALS_FILE,
|
||||
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
certbotArgs = [
|
||||
"certonly",
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"--dns-cloudflare",
|
||||
"--dns-cloudflare-credentials",
|
||||
CLOUDFLARE_CREDENTIALS_FILE,
|
||||
"--dns-cloudflare-propagation-seconds",
|
||||
"30",
|
||||
"-d",
|
||||
domain,
|
||||
"--email",
|
||||
email,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
...certbotDirArgs,
|
||||
];
|
||||
} else {
|
||||
certbotArgs = [
|
||||
"certonly",
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"--webroot",
|
||||
"-w",
|
||||
ACME_WEBROOT,
|
||||
"-d",
|
||||
domain,
|
||||
"--email",
|
||||
email,
|
||||
"--cert-name",
|
||||
"termix",
|
||||
...certbotDirArgs,
|
||||
];
|
||||
}
|
||||
|
||||
authLogger.info("Requesting Let's Encrypt certificate", {
|
||||
domain,
|
||||
challengeType,
|
||||
operation: "acme_cert_request",
|
||||
});
|
||||
|
||||
execFileSync("certbot", certbotArgs, {
|
||||
stdio: "pipe",
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
|
||||
const fullchainSrc = path.join(liveDir, "fullchain.pem");
|
||||
const privkeySrc = path.join(liveDir, "privkey.pem");
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
|
||||
await fs.copyFile(fullchainSrc, certDest);
|
||||
await fs.copyFile(privkeySrc, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
|
||||
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
|
||||
await createCurrentSettingsRepository().set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Let's Encrypt certificate issued and installed", {
|
||||
domain,
|
||||
operation: "acme_cert_installed",
|
||||
});
|
||||
|
||||
const reload = reloadNginxWithSSL();
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "acme_ssl_request",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ domain, challengeType, success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getAcmeSettings()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
authLogger.error("ACME certificate request failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "acme_ssl_request",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res.status(500).json({ error: `Certificate request failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/manual-ssl-upload:
|
||||
* post:
|
||||
* summary: Upload a manual/custom SSL certificate and key (admin only)
|
||||
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* certificate:
|
||||
* type: string
|
||||
* privateKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate uploaded and installed successfully.
|
||||
* 400:
|
||||
* description: Invalid or missing certificate/key.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate installation failed.
|
||||
*/
|
||||
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
try {
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { certificate, privateKey } = req.body;
|
||||
|
||||
if (
|
||||
typeof certificate !== "string" ||
|
||||
typeof privateKey !== "string" ||
|
||||
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||
!privateKey.includes("PRIVATE KEY")
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "A valid PEM certificate and private key are required",
|
||||
});
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
|
||||
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
|
||||
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
|
||||
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
|
||||
|
||||
try {
|
||||
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"The provided certificate or private key is not valid PEM data",
|
||||
});
|
||||
}
|
||||
|
||||
const certPubkey = execFileSync(
|
||||
"openssl",
|
||||
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
const keyPubkey = execFileSync(
|
||||
"openssl",
|
||||
["pkey", "-in", tmpKeyFile, "-pubout"],
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
|
||||
if (!certPubkey.equals(keyPubkey)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The certificate and private key do not match" });
|
||||
}
|
||||
|
||||
const certDest = path.join(SSL_DIR, "termix.crt");
|
||||
const keyDest = path.join(SSL_DIR, "termix.key");
|
||||
await fs.rename(tmpCertFile, certDest);
|
||||
await fs.rename(tmpKeyFile, keyDest);
|
||||
await fs.chmod(keyDest, 0o600);
|
||||
await fs.chmod(certDest, 0o644);
|
||||
} finally {
|
||||
await fs.rm(tmpCertFile, { force: true });
|
||||
await fs.rm(tmpKeyFile, { force: true });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const existing = await settingsRepository.get("acme_ssl_settings");
|
||||
const current = existing ? JSON.parse(existing) : {};
|
||||
const updated = {
|
||||
...current,
|
||||
challengeType: "manual",
|
||||
lastIssuedAt: new Date().toISOString(),
|
||||
};
|
||||
await settingsRepository.set(
|
||||
"acme_ssl_settings",
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
|
||||
authLogger.info("Manual SSL certificate installed", {
|
||||
operation: "manual_ssl_installed",
|
||||
});
|
||||
|
||||
const reload = reloadNginxWithSSL();
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ success: true }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getAcmeSettings()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
authLogger.error("Manual SSL certificate upload failed", err);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor?.username ?? userId,
|
||||
action: "manual_ssl_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ error: message }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
});
|
||||
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Certificate installation failed: ${message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
import { TlsValidationError } from "../../tls/certificate.js";
|
||||
import {
|
||||
getTlsStatus,
|
||||
reloadTls,
|
||||
writeTlsCertificate,
|
||||
} from "../../tls/tls-service.js";
|
||||
|
||||
async function getAdminActor(
|
||||
userId: string | undefined,
|
||||
): Promise<UserRecord | null> {
|
||||
if (!userId) return null;
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user?.isAdmin ? user : null;
|
||||
}
|
||||
|
||||
export function registerTlsRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tls-certificate:
|
||||
* get:
|
||||
* summary: Get the served TLS certificate (admin only)
|
||||
* description: Returns whether HTTPS is on, the certificate core serves, and the plugin that renews it, if any.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate status.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to read the certificate.
|
||||
*/
|
||||
router.get("/tls-certificate", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const actor = await getAdminActor((req as AuthenticatedRequest).userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
res.json(await getTlsStatus());
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to read TLS certificate status", err);
|
||||
res.status(500).json({ error: "Failed to read TLS certificate status" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/tls-certificate:
|
||||
* post:
|
||||
* summary: Upload a TLS certificate and key (admin only)
|
||||
* description: Validates a PEM certificate chain and private key, installs them as the served certificate and reloads HTTPS without a restart.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* certificate:
|
||||
* type: string
|
||||
* privateKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Certificate installed. Carries the new status and a reload message.
|
||||
* 400:
|
||||
* description: Missing, unreadable, expired or mismatched certificate and key.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Certificate installation failed.
|
||||
*/
|
||||
router.post("/tls-certificate", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
const audit = (success: boolean, details: object, errorMessage?: string) =>
|
||||
logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "tls_certificate_upload",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify(details),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
const { certificate, privateKey } = req.body ?? {};
|
||||
const info = await writeTlsCertificate(certificate, privateKey);
|
||||
const reload = await reloadTls();
|
||||
await audit(true, { subject: info.subject, notAfter: info.notAfter });
|
||||
res.json({
|
||||
success: true,
|
||||
reloadMessage: reload.message,
|
||||
...(await getTlsStatus()),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err);
|
||||
await audit(false, {}, message);
|
||||
if (err instanceof TlsValidationError) {
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
authLogger.error("TLS certificate upload failed", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Certificate installation failed: ${message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
|
||||
import { registerTlsRoutes } from "./tls-routes.js";
|
||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
@@ -1648,7 +1648,7 @@ registerUserOidcAccountRoutes(router, {
|
||||
});
|
||||
|
||||
registerUserSettingsRoutes(router, authenticateJWT);
|
||||
registerAcmeSSLRoutes(router, authenticateJWT);
|
||||
registerTlsRoutes(router, authenticateJWT);
|
||||
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
registerBrandingRoutes(router, requireAdmin);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ctx.system: the server certificate. Core serves it; a plugin under
|
||||
* system:tls may replace it, reload it, answer ACME http-01 challenges and
|
||||
* say that it renews it. Every call is audited.
|
||||
*/
|
||||
|
||||
import type { PluginSystem } from "@termix/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
details: string,
|
||||
outcome: { success: boolean; errorMessage?: string },
|
||||
) => Promise<void>;
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
bag: DisposableBag;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
const CAPABILITY = "system:tls";
|
||||
|
||||
async function audited<T>(
|
||||
deps: Deps,
|
||||
action: string,
|
||||
details: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
await assertCapability(
|
||||
deps.manifest.id,
|
||||
CAPABILITY,
|
||||
deps.manifest.capabilities,
|
||||
);
|
||||
const result = await fn();
|
||||
await deps.audit(action, details, { success: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
await deps.audit(action, details, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts an undo in the plugin's bag and hands back one that also takes it out
|
||||
* again, so it runs once whether the plugin or deactivate gets there first.
|
||||
*/
|
||||
function tracked(deps: Deps, undo: () => void, label: string): () => void {
|
||||
let done = false;
|
||||
const run = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
undo();
|
||||
};
|
||||
const untrack = deps.bag.add(run, label);
|
||||
return () => {
|
||||
untrack();
|
||||
run();
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginSystem(deps: Deps): PluginSystem {
|
||||
return {
|
||||
tlsStatus: () =>
|
||||
audited(deps, "tls_status", "read certificate status", async () => {
|
||||
const { getTlsStatus } = await import("../tls/tls-service.js");
|
||||
return getTlsStatus();
|
||||
}),
|
||||
|
||||
writeTlsCertificate: (certificatePem, privateKeyPem) =>
|
||||
audited(
|
||||
deps,
|
||||
"tls_write",
|
||||
"replaced the server certificate",
|
||||
async () => {
|
||||
const { writeTlsCertificate } = await import("../tls/tls-service.js");
|
||||
return writeTlsCertificate(certificatePem, privateKeyPem);
|
||||
},
|
||||
),
|
||||
|
||||
reloadTls: () =>
|
||||
audited(
|
||||
deps,
|
||||
"tls_reload",
|
||||
"reloaded the server certificate",
|
||||
async () => {
|
||||
const { reloadTls } = await import("../tls/tls-service.js");
|
||||
return reloadTls();
|
||||
},
|
||||
),
|
||||
|
||||
publishHttpChallenge: (token, content) =>
|
||||
audited(
|
||||
deps,
|
||||
"tls_challenge",
|
||||
"published an ACME challenge",
|
||||
async () => {
|
||||
const { publishChallenge } =
|
||||
await import("../tls/acme-challenges.js");
|
||||
return tracked(
|
||||
deps,
|
||||
publishChallenge(token, content),
|
||||
"acme challenge",
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
registerTlsRenewer: () =>
|
||||
audited(
|
||||
deps,
|
||||
"tls_renewer",
|
||||
"renews the server certificate",
|
||||
async () => {
|
||||
const { registerTlsRenewer } = await import("../tls/tls-service.js");
|
||||
const undo = registerTlsRenewer({
|
||||
pluginId: deps.manifest.id,
|
||||
pluginName: deps.manifest.name,
|
||||
});
|
||||
return tracked(deps, undo, "tls renewer");
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginSchedule } from "./schedule.js";
|
||||
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginProcess } from "./ctx-process.js";
|
||||
import { createPluginSystem } from "./ctx-system.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -828,6 +829,7 @@ export function createPluginContext(
|
||||
audit: auditCall,
|
||||
}),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
|
||||
@@ -3,7 +3,7 @@ import dotenv from "dotenv";
|
||||
import { promises as fs, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
|
||||
import { AutoSSLSetup } from "./tls/self-signed.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
|
||||
@@ -381,6 +381,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./utils/crypto-migration/step-ca-settings-migration.js");
|
||||
await runStepCaSettingsMigration();
|
||||
|
||||
const { runAcmeSslSettingsMigration } =
|
||||
await import("./utils/crypto-migration/acme-ssl-settings-migration.js");
|
||||
await runAcmeSslSettingsMigration();
|
||||
|
||||
const { runVaultSettingsMigration } =
|
||||
await import("./utils/crypto-migration/vault-settings-migration.js");
|
||||
await runVaultSettingsMigration();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
admins: new Set<string>(["admin-1"]),
|
||||
writes: [] as Array<[unknown, unknown]>,
|
||||
reloads: 0,
|
||||
audits: [] as Array<{ action: string; success: boolean }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: { action: string; success: boolean }) => {
|
||||
state.audits.push({ action: entry.action, success: entry.success });
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({
|
||||
id,
|
||||
username: id,
|
||||
isAdmin: state.admins.has(id),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../tls/tls-service.js", async () => {
|
||||
const { TlsValidationError } = await import("../../../tls/certificate.js");
|
||||
return {
|
||||
getTlsStatus: async () => ({
|
||||
enabled: true,
|
||||
certificate: null,
|
||||
renewal: null,
|
||||
}),
|
||||
writeTlsCertificate: async (cert: unknown, key: unknown) => {
|
||||
if (key === "mismatch") {
|
||||
throw new TlsValidationError(
|
||||
"The certificate and private key do not match",
|
||||
);
|
||||
}
|
||||
state.writes.push([cert, key]);
|
||||
return { subject: "CN=x", notAfter: "2030-01-01T00:00:00.000Z" };
|
||||
},
|
||||
reloadTls: async () => {
|
||||
state.reloads++;
|
||||
return { applied: true, message: "reloaded" };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { registerTlsRoutes } =
|
||||
await import("../../../database/routes/tls-routes.js");
|
||||
|
||||
const authenticate: RequestHandler = (req, _res, next) => {
|
||||
(req as unknown as { userId?: string }).userId =
|
||||
req.header("x-user") ?? undefined;
|
||||
next();
|
||||
};
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = express.Router();
|
||||
registerTlsRoutes(router, authenticate);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/users", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
state.writes = [];
|
||||
state.reloads = 0;
|
||||
state.audits = [];
|
||||
});
|
||||
|
||||
const upload = (user: string, body: unknown) =>
|
||||
fetch(`${base}/users/tls-certificate`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-user": user },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
describe("TLS certificate routes", () => {
|
||||
it("returns the status to an admin only", async () => {
|
||||
const ok = await fetch(`${base}/users/tls-certificate`, {
|
||||
headers: { "x-user": "admin-1" },
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(await ok.json()).toMatchObject({ enabled: true, renewal: null });
|
||||
|
||||
const denied = await fetch(`${base}/users/tls-certificate`, {
|
||||
headers: { "x-user": "user-1" },
|
||||
});
|
||||
expect(denied.status).toBe(403);
|
||||
});
|
||||
|
||||
it("installs an uploaded pair and reloads", async () => {
|
||||
const response = await upload("admin-1", {
|
||||
certificate: "CERT",
|
||||
privateKey: "KEY",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).reloadMessage).toBe("reloaded");
|
||||
expect(state.writes).toEqual([["CERT", "KEY"]]);
|
||||
expect(state.reloads).toBe(1);
|
||||
expect(state.audits).toEqual([
|
||||
{ action: "tls_certificate_upload", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers 400 for a mismatched pair without reloading", async () => {
|
||||
const response = await upload("admin-1", {
|
||||
certificate: "CERT",
|
||||
privateKey: "mismatch",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).error).toMatch(/do not match/);
|
||||
expect(state.reloads).toBe(0);
|
||||
expect(state.audits).toEqual([
|
||||
{ action: "tls_certificate_upload", success: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a non-admin upload", async () => {
|
||||
const response = await upload("user-1", {
|
||||
certificate: "CERT",
|
||||
privateKey: "KEY",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(state.writes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
granted: new Set<string>(),
|
||||
writes: [] as Array<[string, string]>,
|
||||
reloads: 0,
|
||||
renewers: [] as Array<{ pluginId: string; pluginName: string }>,
|
||||
challenges: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../tls/tls-service.js", () => ({
|
||||
getTlsStatus: async () => ({
|
||||
enabled: true,
|
||||
certificate: null,
|
||||
renewal: h.renewers[0] ?? null,
|
||||
}),
|
||||
writeTlsCertificate: async (cert: string, key: string) => {
|
||||
if (key === "wrong")
|
||||
throw new Error("The certificate and private key do not match");
|
||||
h.writes.push([cert, key]);
|
||||
return { subject: "CN=x" };
|
||||
},
|
||||
reloadTls: async () => {
|
||||
h.reloads++;
|
||||
return { applied: true, message: "ok" };
|
||||
},
|
||||
registerTlsRenewer: (renewer: { pluginId: string; pluginName: string }) => {
|
||||
h.renewers.push(renewer);
|
||||
return () => {
|
||||
h.renewers = h.renewers.filter((entry) => entry !== renewer);
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../tls/acme-challenges.js", () => ({
|
||||
publishChallenge: (token: string, content: string) => {
|
||||
h.challenges.set(token, content);
|
||||
return () => h.challenges.delete(token);
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginSystem } = await import("../../plugins/ctx-system.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
|
||||
let audits: Array<{ action: string; success: boolean }>;
|
||||
|
||||
function create(capabilities = ["system:tls"]) {
|
||||
const bag = new DisposableBag("acme-ssl");
|
||||
const system = createPluginSystem({
|
||||
manifest: {
|
||||
id: "acme-ssl",
|
||||
name: "ACME Certificates",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never,
|
||||
bag,
|
||||
audit: async (action, _details, outcome) => {
|
||||
audits.push({ action, success: outcome.success });
|
||||
},
|
||||
});
|
||||
return { system, bag };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.granted = new Set(["system:tls"]);
|
||||
h.writes = [];
|
||||
h.reloads = 0;
|
||||
h.renewers = [];
|
||||
h.challenges = new Map();
|
||||
audits = [];
|
||||
});
|
||||
|
||||
describe("ctx.system", () => {
|
||||
it("writes, reloads and audits every call", async () => {
|
||||
const { system } = create();
|
||||
await system.writeTlsCertificate("CERT", "KEY");
|
||||
await system.reloadTls();
|
||||
expect(h.writes).toEqual([["CERT", "KEY"]]);
|
||||
expect(h.reloads).toBe(1);
|
||||
expect(audits).toEqual([
|
||||
{ action: "tls_write", success: true },
|
||||
{ action: "tls_reload", success: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes a rejected pair back to the plugin and audits the failure", async () => {
|
||||
const { system } = create();
|
||||
await expect(system.writeTlsCertificate("CERT", "wrong")).rejects.toThrow(
|
||||
/do not match/,
|
||||
);
|
||||
expect(audits).toEqual([{ action: "tls_write", success: false }]);
|
||||
});
|
||||
|
||||
it("refuses without system:tls, declared or granted", async () => {
|
||||
await expect(create([]).system.reloadTls()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
h.granted.clear();
|
||||
await expect(create().system.tlsStatus()).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.reloads).toBe(0);
|
||||
expect(audits.every((entry) => !entry.success)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the renewer and challenges when the plugin is disabled", async () => {
|
||||
const { system, bag } = create();
|
||||
await system.registerTlsRenewer();
|
||||
await system.publishHttpChallenge("tok", "tok.key");
|
||||
expect((await system.tlsStatus()).renewal?.pluginId).toBe("acme-ssl");
|
||||
expect(h.challenges.get("tok")).toBe("tok.key");
|
||||
|
||||
await bag.disposeAll();
|
||||
expect(h.renewers).toEqual([]);
|
||||
expect(h.challenges.size).toBe(0);
|
||||
});
|
||||
|
||||
it("lets the plugin withdraw a challenge itself, once", async () => {
|
||||
const { system, bag } = create();
|
||||
const remove = await system.publishHttpChallenge("tok", "tok.key");
|
||||
remove();
|
||||
remove();
|
||||
expect(h.challenges.size).toBe(0);
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
TlsValidationError,
|
||||
readCertificateInfo,
|
||||
validateCertificatePair,
|
||||
} from "../../tls/certificate.js";
|
||||
import { caIssuedPair, selfSignedPair } from "./test-certs.js";
|
||||
|
||||
describe("validateCertificatePair", () => {
|
||||
it("accepts a matching pair and describes the leaf", async () => {
|
||||
const pair = await caIssuedPair("termix.example.com");
|
||||
const info = validateCertificatePair(pair.certificate, pair.privateKey);
|
||||
expect(info.names).toEqual(["termix.example.com"]);
|
||||
expect(info.selfSigned).toBe(false);
|
||||
expect(info.issuer).toContain("Test CA");
|
||||
expect(new Date(info.notAfter).getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("rejects a key that belongs to another certificate", async () => {
|
||||
const one = await caIssuedPair();
|
||||
const other = await caIssuedPair();
|
||||
expect(() =>
|
||||
validateCertificatePair(one.certificate, other.privateKey),
|
||||
).toThrow(/do not match/);
|
||||
});
|
||||
|
||||
it("rejects garbage and an expired certificate", async () => {
|
||||
const pair = await caIssuedPair();
|
||||
expect(() => validateCertificatePair("nope", pair.privateKey)).toThrow(
|
||||
TlsValidationError,
|
||||
);
|
||||
expect(() =>
|
||||
validateCertificatePair(pair.certificate, "not a key"),
|
||||
).toThrow(/private key/);
|
||||
|
||||
const expired = await caIssuedPair("old.example", { daysLeft: -1 });
|
||||
expect(() =>
|
||||
validateCertificatePair(expired.certificate, expired.privateKey),
|
||||
).toThrow(/expired/);
|
||||
});
|
||||
|
||||
it("reads the leaf of a full chain", async () => {
|
||||
const leaf = await caIssuedPair("chain.example");
|
||||
const ca = await selfSignedPair("ca.example");
|
||||
const info = validateCertificatePair(
|
||||
leaf.certificate + ca.certificate,
|
||||
leaf.privateKey,
|
||||
);
|
||||
expect(info.names).toEqual(["chain.example"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCertificateInfo", () => {
|
||||
it("marks core's own certificate as self-signed", async () => {
|
||||
const pair = await selfSignedPair("localhost");
|
||||
expect(readCertificateInfo(pair.certificate)?.selfSigned).toBe(true);
|
||||
expect(readCertificateInfo("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/** Throwaway certificates for the TLS tests, made in-process. */
|
||||
|
||||
import "reflect-metadata";
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import * as x509 from "@peculiar/x509";
|
||||
|
||||
x509.cryptoProvider.set(webcrypto as unknown as Crypto);
|
||||
|
||||
const ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
|
||||
const DAY = 86_400_000;
|
||||
|
||||
export interface TestPair {
|
||||
certificate: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
async function keys(): Promise<webcrypto.CryptoKeyPair> {
|
||||
return (await webcrypto.subtle.generateKey(ALG, true, [
|
||||
"sign",
|
||||
"verify",
|
||||
])) as webcrypto.CryptoKeyPair;
|
||||
}
|
||||
|
||||
async function keyPem(key: webcrypto.CryptoKey): Promise<string> {
|
||||
const der = Buffer.from(await webcrypto.subtle.exportKey("pkcs8", key));
|
||||
const body = der
|
||||
.toString("base64")
|
||||
.match(/.{1,64}/g)!
|
||||
.join("\n");
|
||||
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`;
|
||||
}
|
||||
|
||||
function sans(names: string[]) {
|
||||
return new x509.SubjectAlternativeNameExtension(
|
||||
names.map((value) => ({ type: "dns" as const, value })),
|
||||
);
|
||||
}
|
||||
|
||||
export async function selfSignedPair(
|
||||
name = "localhost",
|
||||
options: { daysLeft?: number } = {},
|
||||
): Promise<TestPair> {
|
||||
const pair = await keys();
|
||||
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: "01",
|
||||
name: `CN=${name}`,
|
||||
notBefore: new Date(Date.now() - DAY),
|
||||
notAfter: new Date(Date.now() + (options.daysLeft ?? 365) * DAY),
|
||||
keys: pair,
|
||||
signingAlgorithm: ALG,
|
||||
extensions: [sans([name])],
|
||||
});
|
||||
return {
|
||||
certificate: cert.toString("pem"),
|
||||
privateKey: await keyPem(pair.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
/** A leaf signed by a throwaway CA, like one from Let's Encrypt. */
|
||||
export async function caIssuedPair(
|
||||
name = "termix.example.com",
|
||||
options: { daysLeft?: number } = {},
|
||||
): Promise<TestPair> {
|
||||
const caKeys = await keys();
|
||||
const leafKeys = await keys();
|
||||
const cert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: "02",
|
||||
subject: `CN=${name}`,
|
||||
issuer: "CN=Test CA",
|
||||
notBefore: new Date(Date.now() - 2 * DAY),
|
||||
notAfter: new Date(Date.now() + (options.daysLeft ?? 90) * DAY),
|
||||
signingKey: caKeys.privateKey,
|
||||
publicKey: leafKeys.publicKey,
|
||||
signingAlgorithm: ALG,
|
||||
extensions: [sans([name])],
|
||||
});
|
||||
return {
|
||||
certificate: cert.toString("pem"),
|
||||
privateKey: await keyPem(leafKeys.privateKey),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Core keeps serving HTTPS on its own: a certificate on disk is served with
|
||||
* no plugin renewing it, a validated replacement is swapped in without a
|
||||
* restart, and nginx is reloaded instead when it terminates TLS.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import https from "node:https";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { caIssuedPair, selfSignedPair } from "./test-certs.js";
|
||||
|
||||
const nginx = vi.hoisted(() => ({
|
||||
reload: vi.fn(() => ({ applied: true, message: "nginx reloaded" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { systemLogger: logger, authLogger: logger };
|
||||
});
|
||||
|
||||
vi.mock("../../tls/nginx-reload.js", () => ({
|
||||
reloadNginxWithSSL: nginx.reload,
|
||||
persistSSLEnv: vi.fn(),
|
||||
}));
|
||||
|
||||
const service = await import("../../tls/tls-service.js");
|
||||
const challenges = await import("../../tls/acme-challenges.js");
|
||||
const { X509Certificate } = await import("node:crypto");
|
||||
|
||||
let dir: string;
|
||||
let servers: https.Server[] = [];
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
async function install(pair: { certificate: string; privateKey: string }) {
|
||||
await fs.mkdir(path.join(dir, "ssl"), { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "ssl", "termix.crt"), pair.certificate);
|
||||
await fs.writeFile(path.join(dir, "ssl", "termix.key"), pair.privateKey);
|
||||
}
|
||||
|
||||
function fingerprint(pem: string): string {
|
||||
return new X509Certificate(pem).fingerprint256;
|
||||
}
|
||||
|
||||
async function startServer(): Promise<number> {
|
||||
let resolvePort: (port: number) => void = () => {};
|
||||
const port = new Promise<number>((resolve) => (resolvePort = resolve));
|
||||
await service.configureDirectHttps((options) => {
|
||||
const server = https.createServer(options, (_req, res) => res.end("ok"));
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1", () =>
|
||||
resolvePort((server.address() as AddressInfo).port),
|
||||
);
|
||||
return server;
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
function servedFingerprint(port: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tls.connect(
|
||||
{ host: "127.0.0.1", port, rejectUnauthorized: false },
|
||||
() => {
|
||||
const cert = socket.getPeerCertificate();
|
||||
socket.end();
|
||||
resolve(cert.fingerprint256);
|
||||
},
|
||||
);
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), "tls-service-"));
|
||||
process.env.DATA_DIR = dir;
|
||||
process.env.ENABLE_SSL = "true";
|
||||
delete process.env.SSL_CERT_PATH;
|
||||
delete process.env.SSL_KEY_PATH;
|
||||
delete process.env.TERMIX_SSL_TERMINATED_BY_NGINX;
|
||||
service.resetTlsServiceForTests();
|
||||
challenges.clearChallengesForTests();
|
||||
nginx.reload.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.map(
|
||||
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
),
|
||||
);
|
||||
servers = [];
|
||||
process.env = { ...savedEnv };
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("serving with no renewer", () => {
|
||||
it("serves the certificate on disk when no plugin renews it", async () => {
|
||||
const pair = await caIssuedPair("termix.example.com");
|
||||
await install(pair);
|
||||
|
||||
const port = await startServer();
|
||||
expect(await servedFingerprint(port)).toBe(fingerprint(pair.certificate));
|
||||
|
||||
const status = await service.getTlsStatus();
|
||||
expect(status.renewal).toBeNull();
|
||||
expect(status.certificate?.names).toEqual(["termix.example.com"]);
|
||||
expect(status.certificate?.selfSigned).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the plugin that renews it until it goes away", async () => {
|
||||
const undo = service.registerTlsRenewer({
|
||||
pluginId: "acme-ssl",
|
||||
pluginName: "ACME Certificates",
|
||||
});
|
||||
expect((await service.getTlsStatus()).renewal?.pluginId).toBe("acme-ssl");
|
||||
undo();
|
||||
undo();
|
||||
expect((await service.getTlsStatus()).renewal).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeTlsCertificate and reloadTls", () => {
|
||||
it("swaps a new certificate in without a restart", async () => {
|
||||
await install(await selfSignedPair("localhost"));
|
||||
const port = await startServer();
|
||||
|
||||
const next = await caIssuedPair("termix.example.com");
|
||||
await service.writeTlsCertificate(next.certificate, next.privateKey);
|
||||
const result = await service.reloadTls();
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(await servedFingerprint(port)).toBe(fingerprint(next.certificate));
|
||||
expect(nginx.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a mismatched key and leaves the files alone", async () => {
|
||||
const current = await caIssuedPair("keep.example");
|
||||
await install(current);
|
||||
const one = await caIssuedPair("new.example");
|
||||
const other = await caIssuedPair("new.example");
|
||||
|
||||
await expect(
|
||||
service.writeTlsCertificate(one.certificate, other.privateKey),
|
||||
).rejects.toThrow(/do not match/);
|
||||
expect(await fs.readFile(path.join(dir, "ssl", "termix.crt"), "utf8")).toBe(
|
||||
current.certificate,
|
||||
);
|
||||
});
|
||||
|
||||
it("reloads nginx when nginx terminates TLS", async () => {
|
||||
process.env.TERMIX_SSL_TERMINATED_BY_NGINX = "true";
|
||||
const pair = await caIssuedPair();
|
||||
await service.writeTlsCertificate(pair.certificate, pair.privateKey);
|
||||
const result = await service.reloadTls();
|
||||
expect(nginx.reload).toHaveBeenCalledOnce();
|
||||
expect(result.message).toBe("nginx reloaded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACME http-01 challenges", () => {
|
||||
it("answers a published token until it is withdrawn", () => {
|
||||
const undo = challenges.publishChallenge("abc_DEF-1", "abc_DEF-1.thumb");
|
||||
expect(challenges.lookupChallenge("abc_DEF-1")).toBe("abc_DEF-1.thumb");
|
||||
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
body: "",
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
type() {
|
||||
return this;
|
||||
},
|
||||
send(body: string) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
challenges.acmeChallengeHandler(
|
||||
{ params: { token: "abc_DEF-1" } } as never,
|
||||
res as never,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("abc_DEF-1.thumb");
|
||||
|
||||
undo();
|
||||
expect(challenges.lookupChallenge("abc_DEF-1")).toBeNull();
|
||||
expect(() => challenges.publishChallenge("../etc", "x")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
|
||||
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
|
||||
* and leaves the old row, and a second run changes nothing.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
legacy: {} as Record<string, string>,
|
||||
pluginRows: [] as Array<{
|
||||
key: string;
|
||||
value: string | null;
|
||||
encrypted: boolean;
|
||||
}>,
|
||||
pluginInstalled: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.legacy[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.legacy[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
) => state.pluginRows.find((row) => row.key === key) ?? null,
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
_scope: string,
|
||||
_scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
encrypted = false,
|
||||
) => {
|
||||
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
|
||||
state.pluginRows.push({ key, value, encrypted });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-secret-crypto.js", () => ({
|
||||
encryptSystemSecret: async (value: string) => `enc:${value}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
|
||||
|
||||
const value = (key: string) => {
|
||||
const row = state.pluginRows.find((candidate) => candidate.key === key);
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.legacy = {
|
||||
acme_ssl_settings: JSON.stringify({
|
||||
enabled: true,
|
||||
domain: "termix.example.com",
|
||||
email: "admin@example.com",
|
||||
challengeType: "dns-cloudflare",
|
||||
cloudflareToken: "cf-token",
|
||||
lastIssuedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
});
|
||||
|
||||
describe("runAcmeSslSettingsMigration", () => {
|
||||
it("moves every field and encrypts the token", async () => {
|
||||
const result = await runAcmeSslSettingsMigration();
|
||||
expect(result.moved.sort()).toEqual(
|
||||
[
|
||||
"autoRenew",
|
||||
"challengeType",
|
||||
"cloudflareToken",
|
||||
"domain",
|
||||
"email",
|
||||
].sort(),
|
||||
);
|
||||
expect(value("domain")).toBe("termix.example.com");
|
||||
expect(value("email")).toBe("admin@example.com");
|
||||
expect(value("challengeType")).toBe("dns-cloudflare");
|
||||
expect(value("autoRenew")).toBe(true);
|
||||
expect(value("cloudflareToken")).toBe("enc:cf-token");
|
||||
expect(
|
||||
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
|
||||
).toBe(true);
|
||||
|
||||
const left = JSON.parse(state.legacy.acme_ssl_settings);
|
||||
expect(left.cloudflareToken).toBeUndefined();
|
||||
expect(left.domain).toBe("termix.example.com");
|
||||
});
|
||||
|
||||
it("maps the webroot challenge and turns renewal off for manual", async () => {
|
||||
state.legacy.acme_ssl_settings = JSON.stringify({
|
||||
enabled: true,
|
||||
domain: "a.example",
|
||||
email: "a@example.com",
|
||||
challengeType: "http-webroot",
|
||||
});
|
||||
await runAcmeSslSettingsMigration();
|
||||
expect(value("challengeType")).toBe("http-01");
|
||||
expect(value("autoRenew")).toBe(true);
|
||||
|
||||
state.pluginRows = [];
|
||||
state.legacy.acme_ssl_settings = JSON.stringify({
|
||||
enabled: true,
|
||||
challengeType: "manual",
|
||||
});
|
||||
await runAcmeSslSettingsMigration();
|
||||
expect(value("autoRenew")).toBe(false);
|
||||
expect(value("challengeType")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("changes nothing on a second run or over values already set", async () => {
|
||||
await runAcmeSslSettingsMigration();
|
||||
const snapshot = JSON.stringify(state.pluginRows);
|
||||
const second = await runAcmeSslSettingsMigration();
|
||||
expect(second.moved).toEqual([]);
|
||||
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it("does nothing before the plugin row exists or without a legacy row", async () => {
|
||||
state.pluginInstalled = false;
|
||||
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
|
||||
state.pluginInstalled = true;
|
||||
state.legacy = {};
|
||||
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* ACME http-01 answers. The CA asks for /.well-known/acme-challenge/<token>
|
||||
* on port 80 of the domain, which no plugin route can own, so core serves
|
||||
* whatever a plugin published through ctx.system.publishHttpChallenge.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
const MAX_AGE_MS = 60 * 60 * 1000;
|
||||
const TOKEN = /^[A-Za-z0-9_-]{1,256}$/;
|
||||
|
||||
const challenges = new Map<string, { content: string; expiresAt: number }>();
|
||||
|
||||
/** Returns the undo. A challenge also expires on its own after an hour. */
|
||||
export function publishChallenge(token: string, content: string): () => void {
|
||||
if (!TOKEN.test(token)) {
|
||||
throw new Error("ACME challenge tokens are base64url");
|
||||
}
|
||||
if (typeof content !== "string" || content.length === 0) {
|
||||
throw new Error("ACME challenge content must be a non-empty string");
|
||||
}
|
||||
const entry = { content, expiresAt: Date.now() + MAX_AGE_MS };
|
||||
challenges.set(token, entry);
|
||||
return () => {
|
||||
if (challenges.get(token) === entry) challenges.delete(token);
|
||||
};
|
||||
}
|
||||
|
||||
export function lookupChallenge(token: string): string | null {
|
||||
const entry = challenges.get(token);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
challenges.delete(token);
|
||||
return null;
|
||||
}
|
||||
return entry.content;
|
||||
}
|
||||
|
||||
export function clearChallengesForTests(): void {
|
||||
challenges.clear();
|
||||
}
|
||||
|
||||
/** GET /.well-known/acme-challenge/:token, public by design. */
|
||||
export function acmeChallengeHandler(req: Request, res: Response): void {
|
||||
const content = lookupChallenge(String(req.params.token ?? ""));
|
||||
if (content === null) {
|
||||
res.status(404).type("text/plain").send("Not found");
|
||||
return;
|
||||
}
|
||||
res.status(200).type("text/plain").send(content);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Reading and checking PEM certificates with node's own X509 parser, so none
|
||||
* of this needs the openssl binary.
|
||||
*/
|
||||
|
||||
import { X509Certificate, createPrivateKey } from "crypto";
|
||||
|
||||
export interface TlsCertificateInfo {
|
||||
subject: string;
|
||||
issuer: string;
|
||||
names: string[];
|
||||
notBefore: string;
|
||||
notAfter: string;
|
||||
selfSigned: boolean;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export class TlsValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
const PEM_CERT =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
|
||||
|
||||
/** The leaf is the first certificate in a full chain. */
|
||||
export function splitPemChain(pem: string): string[] {
|
||||
return pem.match(PEM_CERT) ?? [];
|
||||
}
|
||||
|
||||
function parseNames(subjectAltName: string | undefined): string[] {
|
||||
if (!subjectAltName) return [];
|
||||
return subjectAltName
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.map((entry) => {
|
||||
const match = /^(DNS|IP Address):(.+)$/.exec(entry);
|
||||
return match ? match[2].trim() : null;
|
||||
})
|
||||
.filter((name): name is string => !!name);
|
||||
}
|
||||
|
||||
function isSelfSigned(cert: X509Certificate): boolean {
|
||||
if (cert.subject !== cert.issuer) return false;
|
||||
try {
|
||||
return cert.checkIssued(cert) && cert.verify(cert.publicKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeCertificate(cert: X509Certificate): TlsCertificateInfo {
|
||||
return {
|
||||
subject: cert.subject.replace(/\n/g, ", "),
|
||||
issuer: cert.issuer.replace(/\n/g, ", "),
|
||||
names: parseNames(cert.subjectAltName),
|
||||
notBefore: new Date(cert.validFrom).toISOString(),
|
||||
notAfter: new Date(cert.validTo).toISOString(),
|
||||
selfSigned: isSelfSigned(cert),
|
||||
fingerprint: cert.fingerprint256,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses the leaf of a PEM chain. Null when there is nothing readable. */
|
||||
export function readCertificateInfo(pem: string): TlsCertificateInfo | null {
|
||||
const [leaf] = splitPemChain(pem);
|
||||
if (!leaf) return null;
|
||||
try {
|
||||
return describeCertificate(new X509Certificate(leaf));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws TlsValidationError unless the chain parses, the leaf is still valid
|
||||
* and the key belongs to it. Returns the leaf's details.
|
||||
*/
|
||||
export function validateCertificatePair(
|
||||
certificatePem: string,
|
||||
privateKeyPem: string,
|
||||
now: Date = new Date(),
|
||||
): TlsCertificateInfo {
|
||||
if (typeof certificatePem !== "string" || typeof privateKeyPem !== "string") {
|
||||
throw new TlsValidationError(
|
||||
"A PEM certificate and private key are required",
|
||||
);
|
||||
}
|
||||
|
||||
const chain = splitPemChain(certificatePem);
|
||||
if (chain.length === 0) {
|
||||
throw new TlsValidationError("The certificate is not PEM data");
|
||||
}
|
||||
|
||||
let leaf: X509Certificate;
|
||||
try {
|
||||
leaf = new X509Certificate(chain[0]);
|
||||
for (const extra of chain.slice(1)) new X509Certificate(extra);
|
||||
} catch {
|
||||
throw new TlsValidationError("The certificate could not be parsed");
|
||||
}
|
||||
|
||||
let key: ReturnType<typeof createPrivateKey>;
|
||||
try {
|
||||
key = createPrivateKey(privateKeyPem);
|
||||
} catch {
|
||||
throw new TlsValidationError("The private key could not be parsed");
|
||||
}
|
||||
|
||||
if (!leaf.checkPrivateKey(key)) {
|
||||
throw new TlsValidationError(
|
||||
"The certificate and private key do not match",
|
||||
);
|
||||
}
|
||||
|
||||
if (new Date(leaf.validTo).getTime() <= now.getTime()) {
|
||||
throw new TlsValidationError("The certificate has expired");
|
||||
}
|
||||
|
||||
return describeCertificate(leaf);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { authLogger } from "./logger.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
|
||||
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
|
||||
const NGINX_CONF = "/tmp/nginx/nginx.conf";
|
||||
@@ -11,7 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
const SSL_DIR = path.join(DATA_DIR, "ssl");
|
||||
const ENV_FILE = path.join(DATA_DIR, ".env");
|
||||
|
||||
function persistSSLEnv(sslPort: string, certPath: string, keyPath: string) {
|
||||
export function persistSSLEnv(
|
||||
sslPort: string,
|
||||
certPath: string,
|
||||
keyPath: string,
|
||||
) {
|
||||
const vars: Record<string, string> = {
|
||||
ENABLE_SSL: "true",
|
||||
SSL_PORT: sslPort,
|
||||
@@ -1,8 +1,11 @@
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { execSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { systemLogger } from "./logger.js";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import { readCertificateInfo } from "./certificate.js";
|
||||
|
||||
const RENEW_WINDOW_MS = 30 * 86_400_000;
|
||||
|
||||
export class AutoSSLSetup {
|
||||
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
|
||||
@@ -28,29 +31,12 @@ export class AutoSSLSetup {
|
||||
}
|
||||
|
||||
try {
|
||||
if (await this.isSSLConfigured()) {
|
||||
await this.logCertificateInfo();
|
||||
await this.setupEnvironmentVariables();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
systemLogger.info("SSL certificates found from entrypoint script", {
|
||||
operation: "ssl_cert_found_entrypoint",
|
||||
cert_path: this.CERT_FILE,
|
||||
key_path: this.KEY_FILE,
|
||||
});
|
||||
|
||||
await this.logCertificateInfo();
|
||||
await this.setupEnvironmentVariables();
|
||||
return;
|
||||
} catch {
|
||||
if (await this.needsSelfSigned()) {
|
||||
await this.generateSSLCertificates();
|
||||
await this.setupEnvironmentVariables();
|
||||
} else {
|
||||
await this.logCertificateInfo();
|
||||
}
|
||||
await this.setupEnvironmentVariables();
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to initialize SSL configuration", error, {
|
||||
operation: "ssl_auto_init_failed",
|
||||
@@ -62,40 +48,44 @@ export class AutoSSLSetup {
|
||||
}
|
||||
}
|
||||
|
||||
private static async isSSLConfigured(): Promise<boolean> {
|
||||
/**
|
||||
* Only core's own certificate is ever replaced here. One a CA issued keeps
|
||||
* being served until it expires, even when nothing renews it.
|
||||
*/
|
||||
private static async needsSelfSigned(): Promise<boolean> {
|
||||
let pem: string;
|
||||
try {
|
||||
await fs.access(this.CERT_FILE);
|
||||
await fs.access(this.KEY_FILE);
|
||||
|
||||
execSync(
|
||||
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
pem = await fs.readFile(this.CERT_FILE, "utf8");
|
||||
} catch {
|
||||
systemLogger.info("SSL certificate not found, will generate one", {
|
||||
operation: "ssl_cert_missing",
|
||||
cert_path: this.CERT_FILE,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("checkend")) {
|
||||
systemLogger.warn(
|
||||
"SSL certificate is expired or expiring soon, will regenerate",
|
||||
{
|
||||
operation: "ssl_cert_expired",
|
||||
cert_path: this.CERT_FILE,
|
||||
error: error.message,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
systemLogger.info(
|
||||
"SSL certificate not found or invalid, will generate new one",
|
||||
{
|
||||
operation: "ssl_cert_missing",
|
||||
cert_path: this.CERT_FILE,
|
||||
},
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const info = readCertificateInfo(pem);
|
||||
if (!info) {
|
||||
systemLogger.warn("SSL certificate is unreadable, will generate one", {
|
||||
operation: "ssl_cert_invalid",
|
||||
cert_path: this.CERT_FILE,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
info.selfSigned &&
|
||||
new Date(info.notAfter).getTime() - Date.now() < RENEW_WINDOW_MS
|
||||
) {
|
||||
systemLogger.warn(
|
||||
"Self-signed SSL certificate is expiring, will regenerate",
|
||||
{ operation: "ssl_cert_expired", cert_path: this.CERT_FILE },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async generateSSLCertificates(): Promise<void> {
|
||||
@@ -210,7 +200,6 @@ IP.3 = 0.0.0.0
|
||||
issuer: issuer.replace("issuer=", ""),
|
||||
valid_from: notBefore.replace("notBefore=", ""),
|
||||
valid_until: notAfter.replace("notAfter=", ""),
|
||||
note: "Certificate will auto-renew 30 days before expiration",
|
||||
});
|
||||
} catch (error) {
|
||||
systemLogger.warn("Could not retrieve certificate information", {
|
||||
@@ -270,14 +259,4 @@ IP.3 = 0.0.0.0
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
static getSSLConfig() {
|
||||
return {
|
||||
enabled: process.env.ENABLE_SSL === "true",
|
||||
port: parseInt(process.env.SSL_PORT || "8443"),
|
||||
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
|
||||
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
|
||||
domain: process.env.SSL_DOMAIN || "localhost",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Core's side of HTTPS: where the certificate lives, replacing it, and
|
||||
* serving a new one without a restart. Getting a certificate (ACME) is a
|
||||
* plugin's job; it reaches this through ctx.system.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import https from "https";
|
||||
import path from "path";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import {
|
||||
readCertificateInfo,
|
||||
validateCertificatePair,
|
||||
type TlsCertificateInfo,
|
||||
} from "./certificate.js";
|
||||
import { persistSSLEnv, reloadNginxWithSSL } from "./nginx-reload.js";
|
||||
|
||||
export interface TlsConfig {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
certPath: string;
|
||||
keyPath: string;
|
||||
domain: string;
|
||||
/** In Docker nginx serves HTTPS and the backend only speaks HTTP. */
|
||||
terminatedByNginx: boolean;
|
||||
}
|
||||
|
||||
export interface TlsRenewer {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export interface TlsStatus {
|
||||
enabled: boolean;
|
||||
certificate: TlsCertificateInfo | null;
|
||||
renewal: TlsRenewer | null;
|
||||
}
|
||||
|
||||
export interface TlsReloadResult {
|
||||
applied: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function getTlsConfig(env: NodeJS.ProcessEnv = process.env): TlsConfig {
|
||||
const sslDir = path.join(env.DATA_DIR || "./db/data", "ssl");
|
||||
return {
|
||||
enabled: env.ENABLE_SSL === "true",
|
||||
port: parseInt(env.SSL_PORT || "8443"),
|
||||
certPath: env.SSL_CERT_PATH || path.join(sslDir, "termix.crt"),
|
||||
keyPath: env.SSL_KEY_PATH || path.join(sslDir, "termix.key"),
|
||||
domain: env.SSL_DOMAIN || "localhost",
|
||||
terminatedByNginx: env.TERMIX_SSL_TERMINATED_BY_NGINX === "true",
|
||||
};
|
||||
}
|
||||
|
||||
type HttpsFactory = (options: https.ServerOptions) => https.Server;
|
||||
|
||||
let directServer: https.Server | null = null;
|
||||
let directFactory: HttpsFactory | null = null;
|
||||
|
||||
/**
|
||||
* Called once by the backend with how to build its direct HTTPS server. Starts
|
||||
* it now when HTTPS is on and nginx is not the one serving it.
|
||||
*/
|
||||
export async function configureDirectHttps(
|
||||
factory: HttpsFactory,
|
||||
): Promise<https.Server | null> {
|
||||
directFactory = factory;
|
||||
const config = getTlsConfig();
|
||||
if (!config.enabled || config.terminatedByNginx) return null;
|
||||
return startDirectServer(config);
|
||||
}
|
||||
|
||||
async function readPair(
|
||||
config: TlsConfig,
|
||||
): Promise<{ cert: Buffer; key: Buffer }> {
|
||||
const [cert, key] = await Promise.all([
|
||||
fs.readFile(config.certPath),
|
||||
fs.readFile(config.keyPath),
|
||||
]);
|
||||
return { cert, key };
|
||||
}
|
||||
|
||||
async function startDirectServer(
|
||||
config: TlsConfig,
|
||||
): Promise<https.Server | null> {
|
||||
if (!directFactory) return null;
|
||||
try {
|
||||
directServer = directFactory(await readPair(config));
|
||||
return directServer;
|
||||
} catch (error) {
|
||||
systemLogger.error(
|
||||
"Failed to start HTTPS server with configured SSL certificate",
|
||||
error,
|
||||
{
|
||||
operation: "https_server_start_failed",
|
||||
cert_path: config.certPath,
|
||||
key_path: config.keyPath,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only for tests: forget the server and factory. */
|
||||
export function resetTlsServiceForTests(): void {
|
||||
directServer = null;
|
||||
directFactory = null;
|
||||
renewers.clear();
|
||||
}
|
||||
|
||||
const renewers = new Map<string, { renewer: TlsRenewer; count: number }>();
|
||||
|
||||
/** Records that a plugin renews the certificate. Returns the undo. */
|
||||
export function registerTlsRenewer(renewer: TlsRenewer): () => void {
|
||||
const entry = renewers.get(renewer.pluginId) ?? { renewer, count: 0 };
|
||||
entry.count++;
|
||||
renewers.set(renewer.pluginId, entry);
|
||||
let live = true;
|
||||
return () => {
|
||||
if (!live) return;
|
||||
live = false;
|
||||
const current = renewers.get(renewer.pluginId);
|
||||
if (!current) return;
|
||||
current.count--;
|
||||
if (current.count <= 0) renewers.delete(renewer.pluginId);
|
||||
};
|
||||
}
|
||||
|
||||
export function currentTlsRenewer(): TlsRenewer | null {
|
||||
const first = renewers.values().next();
|
||||
return first.done ? null : first.value.renewer;
|
||||
}
|
||||
|
||||
export async function getTlsStatus(): Promise<TlsStatus> {
|
||||
const config = getTlsConfig();
|
||||
let certificate: TlsCertificateInfo | null = null;
|
||||
try {
|
||||
certificate = readCertificateInfo(
|
||||
await fs.readFile(config.certPath, "utf8"),
|
||||
);
|
||||
} catch {
|
||||
certificate = null;
|
||||
}
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
certificate,
|
||||
renewal: currentTlsRenewer(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the pair, then swaps both files in with a rename so nginx or the
|
||||
* HTTPS server never reads half a certificate. Does not reload.
|
||||
*/
|
||||
export async function writeTlsCertificate(
|
||||
certificatePem: string,
|
||||
privateKeyPem: string,
|
||||
): Promise<TlsCertificateInfo> {
|
||||
const info = validateCertificatePair(certificatePem, privateKeyPem);
|
||||
const config = getTlsConfig();
|
||||
|
||||
await fs.mkdir(path.dirname(config.certPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(config.keyPath), { recursive: true });
|
||||
|
||||
const certTmp = `${config.certPath}.tmp`;
|
||||
const keyTmp = `${config.keyPath}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(certTmp, certificatePem, { mode: 0o644 });
|
||||
await fs.writeFile(keyTmp, privateKeyPem, { mode: 0o600 });
|
||||
await fs.rename(keyTmp, config.keyPath);
|
||||
await fs.rename(certTmp, config.certPath);
|
||||
await fs.chmod(config.keyPath, 0o600).catch(() => undefined);
|
||||
await fs.chmod(config.certPath, 0o644).catch(() => undefined);
|
||||
} finally {
|
||||
await fs.rm(certTmp, { force: true });
|
||||
await fs.rm(keyTmp, { force: true });
|
||||
}
|
||||
|
||||
systemLogger.info("TLS certificate replaced", {
|
||||
operation: "tls_certificate_written",
|
||||
subject: info.subject,
|
||||
not_after: info.notAfter,
|
||||
});
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Serves the certificate on disk now, through nginx or the direct server. */
|
||||
export async function reloadTls(): Promise<TlsReloadResult> {
|
||||
const config = getTlsConfig();
|
||||
|
||||
if (config.terminatedByNginx) {
|
||||
return reloadNginxWithSSL();
|
||||
}
|
||||
|
||||
try {
|
||||
const pair = await readPair(config);
|
||||
if (directServer) {
|
||||
directServer.setSecureContext(pair);
|
||||
return { applied: true, message: "The new certificate is being served." };
|
||||
}
|
||||
|
||||
process.env.ENABLE_SSL = "true";
|
||||
const server = await startDirectServer(getTlsConfig());
|
||||
if (!server) {
|
||||
return {
|
||||
applied: false,
|
||||
message:
|
||||
"Certificate installed, but HTTPS could not be started. Restart Termix with ENABLE_SSL=true to apply it.",
|
||||
};
|
||||
}
|
||||
persistSSLEnv(String(config.port), config.certPath, config.keyPath);
|
||||
return {
|
||||
applied: true,
|
||||
message: `HTTPS is now active on port ${config.port}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
systemLogger.error("Failed to reload the TLS certificate", error, {
|
||||
operation: "tls_reload_failed",
|
||||
});
|
||||
return {
|
||||
applied: false,
|
||||
message:
|
||||
"Certificate installed, but it could not be loaded. Restart Termix to apply it.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
|
||||
*
|
||||
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
|
||||
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
|
||||
* uploaded certificate, which core still handles), so it turns renewal off.
|
||||
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
|
||||
* then removed from the old row. The rest of the row stays for one release.
|
||||
*
|
||||
* Idempotent: a field already set in plugin_settings is never overwritten.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { encryptSystemSecret } from "../system-secret-crypto.js";
|
||||
|
||||
const PLUGIN_ID = "acme-ssl";
|
||||
const LEGACY_KEY = "acme_ssl_settings";
|
||||
|
||||
export interface AcmeSslSettingsMigrationResult {
|
||||
moved: string[];
|
||||
}
|
||||
|
||||
interface LegacyAcmeSettings {
|
||||
enabled?: unknown;
|
||||
domain?: unknown;
|
||||
email?: unknown;
|
||||
challengeType?: unknown;
|
||||
cloudflareToken?: unknown;
|
||||
}
|
||||
|
||||
const text = (value: unknown) =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
|
||||
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
|
||||
const result: AcmeSslSettingsMigrationResult = { moved: [] };
|
||||
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const raw = await settings.get(LEGACY_KEY);
|
||||
if (!raw) return result;
|
||||
|
||||
let legacy: LegacyAcmeSettings;
|
||||
try {
|
||||
legacy = JSON.parse(raw) as LegacyAcmeSettings;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
if (!legacy || typeof legacy !== "object") return result;
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const writeIfUnset = async (
|
||||
field: string,
|
||||
value: unknown,
|
||||
secret = false,
|
||||
) => {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) return;
|
||||
await pluginSettings.set(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
JSON.stringify(value),
|
||||
secret,
|
||||
);
|
||||
result.moved.push(field);
|
||||
};
|
||||
|
||||
const challenge = legacy.challengeType;
|
||||
const manual = challenge === "manual";
|
||||
|
||||
const domain = text(legacy.domain);
|
||||
if (domain) await writeIfUnset("domain", domain);
|
||||
const email = text(legacy.email);
|
||||
if (email) await writeIfUnset("email", email);
|
||||
if (challenge === "dns-cloudflare") {
|
||||
await writeIfUnset("challengeType", "dns-cloudflare");
|
||||
} else if (challenge === "http-webroot") {
|
||||
await writeIfUnset("challengeType", "http-01");
|
||||
}
|
||||
if (typeof legacy.enabled === "boolean") {
|
||||
await writeIfUnset("autoRenew", legacy.enabled && !manual);
|
||||
}
|
||||
|
||||
const token = text(legacy.cloudflareToken);
|
||||
if (token) {
|
||||
await writeIfUnset(
|
||||
"cloudflareToken",
|
||||
await encryptSystemSecret(token),
|
||||
true,
|
||||
);
|
||||
const { cloudflareToken: _dropped, ...rest } = legacy;
|
||||
await settings.set(LEGACY_KEY, JSON.stringify(rest));
|
||||
}
|
||||
|
||||
if (result.moved.length > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
|
||||
{
|
||||
operation: "acme_ssl_settings_migration",
|
||||
moved: result.moved.join(", "),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Must not stop the backend: the plugin then shows "not configured".
|
||||
databaseLogger.warn("ACME settings migration failed", {
|
||||
operation: "acme_ssl_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in new issue
Block a user