Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
189 lines
7.1 KiB
TypeScript
Executable File
189 lines
7.1 KiB
TypeScript
Executable File
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { requireAdmin } from "@/src/lib/auth";
|
|
import { createCaCertificate, deleteCaCertificate, updateCaCertificate, getCaCertificatePrivateKey } from "@/src/lib/models/ca-certificates";
|
|
import { createIssuedClientCertificate, revokeIssuedClientCertificate } from "@/src/lib/models/issued-client-certificates";
|
|
import { X509Certificate } from "node:crypto";
|
|
import forge from "node-forge";
|
|
|
|
function validatePem(pem: string): void {
|
|
try {
|
|
new X509Certificate(pem);
|
|
} catch {
|
|
throw new Error("Invalid certificate PEM: could not parse as X.509 certificate");
|
|
}
|
|
}
|
|
|
|
export async function createCaCertificateAction(formData: FormData) {
|
|
const session = await requireAdmin();
|
|
const userId = Number(session.user.id);
|
|
const name = String(formData.get("name") ?? "").trim();
|
|
const certificatePem = String(formData.get("certificate_pem") ?? "").trim();
|
|
|
|
if (!name) throw new Error("Name is required");
|
|
if (!certificatePem) throw new Error("Certificate PEM is required");
|
|
validatePem(certificatePem);
|
|
|
|
await createCaCertificate({ name, certificatePem: certificatePem }, userId);
|
|
revalidatePath("/certificates");
|
|
}
|
|
|
|
export async function updateCaCertificateAction(id: number, formData: FormData) {
|
|
const session = await requireAdmin();
|
|
const userId = Number(session.user.id);
|
|
const name = formData.get("name") ? String(formData.get("name")).trim() : undefined;
|
|
const certificatePem = formData.get("certificate_pem") ? String(formData.get("certificate_pem")).trim() : undefined;
|
|
|
|
if (certificatePem) {
|
|
validatePem(certificatePem);
|
|
}
|
|
|
|
await updateCaCertificate(id, {
|
|
...(name ? { name } : {}),
|
|
...(certificatePem ? { certificatePem: certificatePem } : {})
|
|
}, userId);
|
|
revalidatePath("/certificates");
|
|
}
|
|
|
|
export async function deleteCaCertificateAction(id: number): Promise<{ success: boolean; error?: string }> {
|
|
const session = await requireAdmin();
|
|
const userId = Number(session.user.id);
|
|
try {
|
|
await deleteCaCertificate(id, userId);
|
|
revalidatePath("/certificates");
|
|
return { success: true };
|
|
} catch (e) {
|
|
return { success: false, error: e instanceof Error ? e.message : "Failed to delete CA certificate" };
|
|
}
|
|
}
|
|
|
|
export async function generateCaCertificateAction(formData: FormData): Promise<{ id: number }> {
|
|
const session = await requireAdmin();
|
|
const userId = Number(session.user.id);
|
|
const name = String(formData.get("name") ?? "").trim();
|
|
const commonName = String(formData.get("common_name") ?? name).trim() || name;
|
|
const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "3650"), 10) || 3650));
|
|
|
|
if (!name) throw new Error("Name is required");
|
|
|
|
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
|
|
const cert = forge.pki.createCertificate();
|
|
cert.publicKey = keypair.publicKey;
|
|
cert.serialNumber = "01";
|
|
cert.validity.notBefore = new Date();
|
|
cert.validity.notAfter = new Date();
|
|
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays);
|
|
|
|
const attrs = [
|
|
{ name: "commonName", value: commonName },
|
|
{ name: "organizationName", value: "Caddy Proxy Manager" },
|
|
];
|
|
cert.setSubject(attrs);
|
|
cert.setIssuer(attrs);
|
|
cert.setExtensions([
|
|
{ name: "basicConstraints", cA: true, critical: true },
|
|
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
|
|
{ name: "subjectKeyIdentifier" },
|
|
]);
|
|
|
|
cert.sign(keypair.privateKey, forge.md.sha256.create());
|
|
|
|
const certificatePem = forge.pki.certificateToPem(cert);
|
|
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
|
|
|
|
const record = await createCaCertificate({ name, certificatePem: certificatePem, privateKeyPem: privateKeyPem }, userId);
|
|
revalidatePath("/certificates");
|
|
return { id: record.id };
|
|
}
|
|
|
|
export type IssuedClientCert = {
|
|
pkcs12Base64: string;
|
|
passwordProtected: boolean;
|
|
exportAlgorithm: "3des" | "aes256";
|
|
};
|
|
|
|
export async function issueClientCertificateAction(
|
|
caCertId: number,
|
|
formData: FormData
|
|
): Promise<IssuedClientCert> {
|
|
const session = await requireAdmin();
|
|
const userId = Number(session.user.id);
|
|
const commonName = String(formData.get("common_name") ?? "").trim();
|
|
const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "365"), 10) || 365));
|
|
const exportPassword = String(formData.get("export_password") ?? "");
|
|
const compatibilityMode = formData.get("compatibility_mode") === "on";
|
|
const exportAlgorithm: IssuedClientCert["exportAlgorithm"] = compatibilityMode ? "3des" : "aes256";
|
|
|
|
if (!commonName) throw new Error("Common name is required");
|
|
if (!exportPassword) throw new Error("Export password is required");
|
|
|
|
const caPrivateKeyPem = await getCaCertificatePrivateKey(caCertId);
|
|
if (!caPrivateKeyPem) throw new Error("This CA has no stored private key — cannot issue client certificates");
|
|
|
|
const caCertRecord = await import("@/src/lib/models/ca-certificates").then(m => m.getCaCertificate(caCertId));
|
|
if (!caCertRecord) throw new Error("CA certificate not found");
|
|
|
|
const caKey = forge.pki.privateKeyFromPem(caPrivateKeyPem);
|
|
const caCert = forge.pki.certificateFromPem(caCertRecord.certificatePem);
|
|
|
|
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
|
|
const cert = forge.pki.createCertificate();
|
|
cert.publicKey = keypair.publicKey;
|
|
cert.serialNumber = Date.now().toString(16);
|
|
cert.validity.notBefore = new Date();
|
|
cert.validity.notAfter = new Date();
|
|
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays);
|
|
|
|
cert.setSubject([{ name: "commonName", value: commonName }]);
|
|
cert.setIssuer(caCert.subject.attributes);
|
|
cert.setExtensions([
|
|
{ name: "basicConstraints", cA: false },
|
|
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
|
|
{ name: "extKeyUsage", clientAuth: true },
|
|
]);
|
|
|
|
cert.sign(caKey, forge.md.sha256.create());
|
|
const certificatePem = forge.pki.certificateToPem(cert);
|
|
const certificate = new X509Certificate(certificatePem);
|
|
|
|
await createIssuedClientCertificate(
|
|
{
|
|
caCertificateId: caCertId,
|
|
commonName: commonName,
|
|
serialNumber: cert.serialNumber.toUpperCase(),
|
|
fingerprintSha256: certificate.fingerprint256,
|
|
certificatePem: certificatePem,
|
|
validFrom: new Date(certificate.validFrom).toISOString(),
|
|
validTo: new Date(certificate.validTo).toISOString()
|
|
},
|
|
userId
|
|
);
|
|
revalidatePath("/certificates");
|
|
|
|
const pkcs12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
|
keypair.privateKey,
|
|
[cert, caCert],
|
|
exportPassword,
|
|
{
|
|
algorithm: exportAlgorithm,
|
|
friendlyName: commonName,
|
|
}
|
|
);
|
|
const pkcs12Der = forge.asn1.toDer(pkcs12Asn1).getBytes();
|
|
|
|
return {
|
|
pkcs12Base64: forge.util.encode64(pkcs12Der),
|
|
passwordProtected: true,
|
|
exportAlgorithm,
|
|
};
|
|
}
|
|
|
|
export async function revokeIssuedClientCertificateAction(id: number): Promise<{ revokedAt: string }> {
|
|
const session = await requireAdmin();
|
|
const userId = Number(session.user.id);
|
|
const record = await revokeIssuedClientCertificate(id, userId);
|
|
revalidatePath("/certificates");
|
|
return { revokedAt: record.revokedAt! };
|
|
}
|