mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
573 lines
17 KiB
TypeScript
573 lines
17 KiB
TypeScript
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 {
|
|
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 { 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, ...(await getAcmeSettings()) });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
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 { 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, ...(await getAcmeSettings()) });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
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}` });
|
|
}
|
|
});
|
|
}
|