better pki
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
||||
} from "./actions";
|
||||
import type { AcmeHost, CaCertificate, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
|
||||
import { useState } from "react";
|
||||
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog } from "@/src/components/ca-certificates/CaCertDialogs";
|
||||
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog } from "@/src/components/ca-certificates/CaCertDialogs";
|
||||
|
||||
type Props = {
|
||||
acmeHosts: AcmeHost[];
|
||||
@@ -67,6 +67,7 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
const [createCaOpen, setCreateCaOpen] = useState(false);
|
||||
const [editCaCert, setEditCaCert] = useState<CaCertificate | null>(null);
|
||||
const [deleteCaCert, setDeleteCaCert] = useState<CaCertificate | null>(null);
|
||||
const [issueCaCert, setIssueCaCert] = useState<CaCertificate | null>(null);
|
||||
const acmeColumns = [
|
||||
{
|
||||
id: 'name',
|
||||
@@ -482,6 +483,13 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
label: "",
|
||||
render: (ca: CaCertificate) => (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
{ca.has_private_key && (
|
||||
<Tooltip title="Issue Client Certificate">
|
||||
<Button size="small" variant="outlined" onClick={() => setIssueCaCert(ca)}>
|
||||
Issue Cert
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => setEditCaCert(ca)}>
|
||||
<EditIcon fontSize="small" />
|
||||
@@ -521,6 +529,13 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
onClose={() => setDeleteCaCert(null)}
|
||||
/>
|
||||
)}
|
||||
{issueCaCert && (
|
||||
<IssueClientCertDialog
|
||||
open={!!issueCaCert}
|
||||
cert={issueCaCert}
|
||||
onClose={() => setIssueCaCert(null)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
import { createCaCertificate, deleteCaCertificate, updateCaCertificate } from "@/src/lib/models/ca-certificates";
|
||||
import { createCaCertificate, deleteCaCertificate, updateCaCertificate, getCaCertificatePrivateKey } from "@/src/lib/models/ca-certificates";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import forge from "node-forge";
|
||||
|
||||
function validatePem(pem: string): void {
|
||||
try {
|
||||
@@ -50,3 +51,90 @@ export async function deleteCaCertificateAction(id: number) {
|
||||
await deleteCaCertificate(id, userId);
|
||||
revalidatePath("/certificates");
|
||||
}
|
||||
|
||||
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, certificate_pem: certificatePem, private_key_pem: privateKeyPem }, userId);
|
||||
revalidatePath("/certificates");
|
||||
return { id: record.id };
|
||||
}
|
||||
|
||||
export type IssuedClientCert = {
|
||||
certificatePem: string;
|
||||
privateKeyPem: string;
|
||||
};
|
||||
|
||||
export async function issueClientCertificateAction(
|
||||
caCertId: number,
|
||||
formData: FormData
|
||||
): Promise<IssuedClientCert> {
|
||||
await requireAdmin();
|
||||
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));
|
||||
|
||||
if (!commonName) throw new Error("Common name 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.certificate_pem);
|
||||
|
||||
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());
|
||||
|
||||
return {
|
||||
certificatePem: forge.pki.certificateToPem(cert),
|
||||
privateKeyPem: forge.pki.privateKeyToPem(keypair.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
1
drizzle/0012_ca_private_key.sql
Normal file
1
drizzle/0012_ca_private_key.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `ca_certificates` ADD `private_key_pem` text;
|
||||
@@ -85,6 +85,13 @@
|
||||
"when": 1772300000000,
|
||||
"tag": "0011_mtls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1772400000000,
|
||||
"tag": "0012_ca_private_key",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"maxmind": "^5.0.5",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"node-forge": "^1.3.3",
|
||||
"react": "^19.2.4",
|
||||
"react-apexcharts": "^2.1.0",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
@@ -2630,6 +2632,16 @@
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@@ -4655,6 +4667,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
|
||||
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"maxmind": "^5.0.5",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"node-forge": "^1.3.3",
|
||||
"react": "^19.2.4",
|
||||
"react-apexcharts": "^2.1.0",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -40,6 +41,7 @@
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -8,18 +9,34 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
InputAdornment,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useTransition, useRef, useState } from "react";
|
||||
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
|
||||
import {
|
||||
createCaCertificateAction,
|
||||
deleteCaCertificateAction,
|
||||
generateCaCertificateAction,
|
||||
issueClientCertificateAction,
|
||||
updateCaCertificateAction,
|
||||
} from "@/app/(dashboard)/certificates/ca-actions";
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function CreateCaCertDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -27,52 +44,117 @@ export function CreateCaCertDialog({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"import" | "generate">("generate");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const importFormRef = useRef<HTMLFormElement>(null);
|
||||
const generateFormRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
function handleClose() {
|
||||
setTab("generate");
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleImport(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(formRef.current!);
|
||||
const formData = new FormData(importFormRef.current!);
|
||||
startTransition(async () => {
|
||||
await createCaCertificateAction(formData);
|
||||
onClose();
|
||||
handleClose();
|
||||
});
|
||||
}
|
||||
|
||||
function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(generateFormRef.current!);
|
||||
startTransition(async () => {
|
||||
await generateCaCertificateAction(formData);
|
||||
handleClose();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Add Client CA Certificate</DialogTitle>
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} mt={1}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
/>
|
||||
<TextField
|
||||
name="certificate_pem"
|
||||
label="Certificate PEM"
|
||||
required
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
<DialogContent>
|
||||
{true && (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab value="generate" label="Generate" />
|
||||
<Tab value="import" label="Import PEM" />
|
||||
</Tabs>
|
||||
|
||||
{tab === "generate" && (
|
||||
<form ref={generateFormRef} onSubmit={handleGenerate}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
helperText="Display name in this UI"
|
||||
/>
|
||||
<TextField
|
||||
name="common_name"
|
||||
label="Common Name (CN)"
|
||||
fullWidth
|
||||
placeholder="My Client CA"
|
||||
helperText="CN field in the certificate. Defaults to the name above if left blank."
|
||||
/>
|
||||
<TextField
|
||||
name="validity_days"
|
||||
label="Validity"
|
||||
type="number"
|
||||
fullWidth
|
||||
defaultValue={3650}
|
||||
inputProps={{ min: 1, max: 3650 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
|
||||
/>
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Generating..." : "Generate CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{tab === "import" && (
|
||||
<form ref={importFormRef} onSubmit={handleImport}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
/>
|
||||
<TextField
|
||||
name="certificate_pem"
|
||||
label="Certificate PEM"
|
||||
required
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
|
||||
/>
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -135,6 +217,105 @@ export function EditCaCertDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueClientCertDialog({
|
||||
open,
|
||||
cert,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
cert: CaCertificate;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [issued, setIssued] = useState<{ certificatePem: string; privateKeyPem: string; name: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleClose() {
|
||||
setIssued(null);
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(formRef.current!);
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await issueClientCertificateAction(cert.id, formData);
|
||||
setIssued({ ...result, name: String(formData.get("common_name") ?? "client") });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to issue certificate");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Issue Client Certificate</DialogTitle>
|
||||
<DialogContent>
|
||||
{issued ? (
|
||||
<Stack spacing={2} mt={1}>
|
||||
<Alert severity="success">
|
||||
Client certificate issued. Download the certificate and key — the private key will not be stored.
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => downloadText(`${issued.name}.crt`, issued.certificatePem)}
|
||||
>
|
||||
Download Certificate (.crt)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => downloadText(`${issued.name}.key`, issued.privateKeyPem)}
|
||||
>
|
||||
Download Private Key (.key)
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<Stack spacing={2} mt={1}>
|
||||
<TextField
|
||||
name="common_name"
|
||||
label="Common Name (CN)"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="alice"
|
||||
helperText="Identifies this client (e.g. a username or device name)"
|
||||
/>
|
||||
<TextField
|
||||
name="validity_days"
|
||||
label="Validity"
|
||||
type="number"
|
||||
fullWidth
|
||||
defaultValue={365}
|
||||
inputProps={{ min: 1, max: 3650 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
|
||||
/>
|
||||
{error && <Typography color="error" variant="body2">{error}</Typography>}
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Issuing..." : "Issue Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
{issued && (
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={handleClose}>Done</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteCaCertDialog({
|
||||
open,
|
||||
cert,
|
||||
|
||||
@@ -132,6 +132,7 @@ export const caCertificates = sqliteTable("ca_certificates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
certificatePem: text("certificate_pem").notNull(),
|
||||
privateKeyPem: text("private_key_pem"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
|
||||
@@ -17,6 +17,7 @@ export type CaCertificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
has_private_key: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -24,6 +25,7 @@ export type CaCertificate = {
|
||||
export type CaCertificateInput = {
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
private_key_pem?: string;
|
||||
};
|
||||
|
||||
type CaCertificateRow = typeof caCertificates.$inferSelect;
|
||||
@@ -33,6 +35,7 @@ function parseCaCertificate(row: CaCertificateRow): CaCertificate {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
certificate_pem: row.certificatePem,
|
||||
has_private_key: !!row.privateKeyPem,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
};
|
||||
@@ -43,6 +46,13 @@ export async function listCaCertificates(): Promise<CaCertificate[]> {
|
||||
return rows.map(parseCaCertificate);
|
||||
}
|
||||
|
||||
export async function getCaCertificatePrivateKey(id: number): Promise<string | null> {
|
||||
const cert = await db.query.caCertificates.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id)
|
||||
});
|
||||
return cert?.privateKeyPem ?? null;
|
||||
}
|
||||
|
||||
export async function getCaCertificate(id: number): Promise<CaCertificate | null> {
|
||||
const cert = await db.query.caCertificates.findFirst({
|
||||
where: (table, { eq }) => eq(table.id, id)
|
||||
@@ -57,6 +67,7 @@ export async function createCaCertificate(input: CaCertificateInput, actorUserId
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
certificatePem: input.certificate_pem.trim(),
|
||||
privateKeyPem: input.private_key_pem?.trim() ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -90,6 +101,7 @@ export async function updateCaCertificate(id: number, input: Partial<CaCertifica
|
||||
.set({
|
||||
name: input.name?.trim() ?? existing.name,
|
||||
certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem,
|
||||
...(input.private_key_pem !== undefined ? { privateKeyPem: input.private_key_pem?.trim() ?? null } : {}),
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(caCertificates.id, id));
|
||||
|
||||
Reference in New Issue
Block a user