Added issued-client-cert tracking and revocation for mTLS
This commit is contained in:
@@ -27,15 +27,15 @@ import {
|
||||
deleteCertificateAction,
|
||||
updateCertificateAction,
|
||||
} from "./actions";
|
||||
import type { AcmeHost, CaCertificate, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
|
||||
import type { AcmeHost, CaCertificateView, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
|
||||
import { useState } from "react";
|
||||
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog } from "@/src/components/ca-certificates/CaCertDialogs";
|
||||
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog, ManageIssuedClientCertsDialog } from "@/src/components/ca-certificates/CaCertDialogs";
|
||||
|
||||
type Props = {
|
||||
acmeHosts: AcmeHost[];
|
||||
importedCerts: ImportedCertView[];
|
||||
managedCerts: ManagedCertView[];
|
||||
caCertificates: CaCertificate[];
|
||||
caCertificates: CaCertificateView[];
|
||||
acmePagination: { total: number; page: number; perPage: number };
|
||||
};
|
||||
|
||||
@@ -65,9 +65,10 @@ function ExpiryChip({
|
||||
|
||||
export default function CertificatesClient({ acmeHosts, importedCerts, managedCerts, caCertificates, acmePagination }: Props) {
|
||||
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 [editCaCert, setEditCaCert] = useState<CaCertificateView | null>(null);
|
||||
const [deleteCaCert, setDeleteCaCert] = useState<CaCertificateView | null>(null);
|
||||
const [issueCaCert, setIssueCaCert] = useState<CaCertificateView | null>(null);
|
||||
const [manageIssuedCaCert, setManageIssuedCaCert] = useState<CaCertificateView | null>(null);
|
||||
const acmeColumns = [
|
||||
{
|
||||
id: 'name',
|
||||
@@ -445,7 +446,8 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
CA certificates used for mTLS — clients must present a certificate signed by one of these CAs.
|
||||
CA certificates used for mTLS. New client certificates issued from this UI are tracked here and can be
|
||||
revoked individually; previously issued certificates are not backfilled.
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
@@ -467,21 +469,43 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
render: (ca: CaCertificate) => <Typography fontWeight={600}>{ca.name}</Typography>,
|
||||
render: (ca: CaCertificateView) => <Typography fontWeight={600}>{ca.name}</Typography>,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
label: "Added",
|
||||
render: (ca: CaCertificate) => (
|
||||
render: (ca: CaCertificateView) => (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{new Date(ca.created_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
label: "Issued Client Certs",
|
||||
render: (ca: CaCertificateView) => {
|
||||
const activeCount = ca.issuedCerts.filter((issued) => !issued.revoked_at).length;
|
||||
if (ca.issuedCerts.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
None
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={`${activeCount}/${ca.issuedCerts.length} active`}
|
||||
size="small"
|
||||
color={activeCount > 0 ? "success" : "default"}
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: "",
|
||||
render: (ca: CaCertificate) => (
|
||||
render: (ca: CaCertificateView) => (
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
{ca.has_private_key && (
|
||||
<Tooltip title="Issue Client Certificate">
|
||||
@@ -490,6 +514,14 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => setManageIssuedCaCert(ca)}
|
||||
disabled={ca.issuedCerts.length === 0}
|
||||
>
|
||||
Issued
|
||||
</Button>
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => setEditCaCert(ca)}>
|
||||
<EditIcon fontSize="small" />
|
||||
@@ -536,6 +568,14 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
|
||||
onClose={() => setIssueCaCert(null)}
|
||||
/>
|
||||
)}
|
||||
{manageIssuedCaCert && (
|
||||
<ManageIssuedClientCertsDialog
|
||||
open={!!manageIssuedCaCert}
|
||||
cert={manageIssuedCaCert}
|
||||
issuedCerts={manageIssuedCaCert.issuedCerts}
|
||||
onClose={() => setManageIssuedCaCert(null)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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";
|
||||
|
||||
@@ -101,7 +102,8 @@ export async function issueClientCertificateAction(
|
||||
caCertId: number,
|
||||
formData: FormData
|
||||
): Promise<IssuedClientCert> {
|
||||
await requireAdmin();
|
||||
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") ?? "");
|
||||
@@ -137,6 +139,22 @@ export async function issueClientCertificateAction(
|
||||
]);
|
||||
|
||||
cert.sign(caKey, forge.md.sha256.create());
|
||||
const certificatePem = forge.pki.certificateToPem(cert);
|
||||
const certificate = new X509Certificate(certificatePem);
|
||||
|
||||
await createIssuedClientCertificate(
|
||||
{
|
||||
ca_certificate_id: caCertId,
|
||||
common_name: commonName,
|
||||
serial_number: cert.serialNumber.toUpperCase(),
|
||||
fingerprint_sha256: certificate.fingerprint256,
|
||||
certificate_pem: certificatePem,
|
||||
valid_from: new Date(certificate.validFrom).toISOString(),
|
||||
valid_to: new Date(certificate.validTo).toISOString()
|
||||
},
|
||||
userId
|
||||
);
|
||||
revalidatePath("/certificates");
|
||||
|
||||
const pkcs12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||
keypair.privateKey,
|
||||
@@ -155,3 +173,11 @@ export async function issueClientCertificateAction(
|
||||
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.revoked_at! };
|
||||
}
|
||||
|
||||
@@ -6,8 +6,14 @@ import { requireAdmin } from '@/src/lib/auth';
|
||||
import CertificatesClient from './CertificatesClient';
|
||||
import { scanAcmeCerts } from '@/src/lib/acme-certs';
|
||||
import { listCaCertificates, type CaCertificate } from '@/src/lib/models/ca-certificates';
|
||||
import { listIssuedClientCertificates, type IssuedClientCertificate } from '@/src/lib/models/issued-client-certificates';
|
||||
|
||||
export type { CaCertificate };
|
||||
export type { IssuedClientCertificate };
|
||||
|
||||
export type CaCertificateView = CaCertificate & {
|
||||
issuedCerts: IssuedClientCertificate[];
|
||||
};
|
||||
|
||||
export type CertExpiryStatus = 'ok' | 'expiring_soon' | 'expired';
|
||||
|
||||
@@ -82,7 +88,10 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
const offset = (page - 1) * PER_PAGE;
|
||||
const acmeCertMap = scanAcmeCerts();
|
||||
|
||||
const caCerts = await listCaCertificates();
|
||||
const [caCerts, issuedClientCerts] = await Promise.all([
|
||||
listCaCertificates(),
|
||||
listIssuedClientCertificates()
|
||||
]);
|
||||
|
||||
const [acmeRows, acmeTotal, certRows, usageRows] = await Promise.all([
|
||||
db
|
||||
@@ -149,6 +158,16 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
|
||||
const importedCerts: ImportedCertView[] = [];
|
||||
const managedCerts: ManagedCertView[] = [];
|
||||
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
|
||||
const current = map.get(cert.ca_certificate_id) ?? [];
|
||||
current.push(cert);
|
||||
map.set(cert.ca_certificate_id, current);
|
||||
return map;
|
||||
}, new Map());
|
||||
const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({
|
||||
...cert,
|
||||
issuedCerts: issuedByCa.get(cert.id) ?? [],
|
||||
}));
|
||||
|
||||
for (const cert of certRows) {
|
||||
const domainNames = JSON.parse(cert.domainNames) as string[];
|
||||
@@ -174,7 +193,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
acmeHosts={acmeHosts}
|
||||
importedCerts={importedCerts}
|
||||
managedCerts={managedCerts}
|
||||
caCertificates={caCerts}
|
||||
caCertificates={caCertificateViews}
|
||||
acmePagination={{ total: acmeTotal, page, perPage: PER_PAGE }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -111,6 +111,37 @@ function isAccessList(value: unknown): value is SyncPayload["data"]["accessLists
|
||||
);
|
||||
}
|
||||
|
||||
function isCaCertificate(value: unknown): value is SyncPayload["data"]["caCertificates"][number] {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isNumber(value.id) &&
|
||||
isString(value.name) &&
|
||||
isString(value.certificatePem) &&
|
||||
isNullableString(value.privateKeyPem) &&
|
||||
isNullableNumber(value.createdBy) &&
|
||||
isString(value.createdAt) &&
|
||||
isString(value.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function isIssuedClientCertificate(value: unknown): value is SyncPayload["data"]["issuedClientCertificates"][number] {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isNumber(value.id) &&
|
||||
isNumber(value.caCertificateId) &&
|
||||
isString(value.commonName) &&
|
||||
isString(value.serialNumber) &&
|
||||
isString(value.fingerprintSha256) &&
|
||||
isString(value.certificatePem) &&
|
||||
isString(value.validFrom) &&
|
||||
isString(value.validTo) &&
|
||||
isNullableString(value.revokedAt) &&
|
||||
isNullableNumber(value.createdBy) &&
|
||||
isString(value.createdAt) &&
|
||||
isString(value.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function isAccessListEntry(value: unknown): value is SyncPayload["data"]["accessListEntries"][number] {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
@@ -183,6 +214,8 @@ function isValidSyncPayload(payload: unknown): payload is SyncPayload {
|
||||
|
||||
return (
|
||||
validateArray(d.certificates, isCertificate) &&
|
||||
validateArray(d.caCertificates, isCaCertificate) &&
|
||||
validateArray(d.issuedClientCertificates, isIssuedClientCertificate) &&
|
||||
validateArray(d.accessLists, isAccessList) &&
|
||||
validateArray(d.accessListEntries, isAccessListEntry) &&
|
||||
validateArray(d.proxyHosts, isProxyHost)
|
||||
@@ -223,7 +256,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Sync payload too large" }, { status: 413 });
|
||||
}
|
||||
payload = JSON.parse(bodyText);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
16
drizzle/0013_issued_client_certificates.sql
Normal file
16
drizzle/0013_issued_client_certificates.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `issued_client_certificates` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`ca_certificate_id` integer NOT NULL REFERENCES `ca_certificates`(`id`) ON DELETE cascade,
|
||||
`common_name` text NOT NULL,
|
||||
`serial_number` text NOT NULL,
|
||||
`fingerprint_sha256` text NOT NULL,
|
||||
`certificate_pem` text NOT NULL,
|
||||
`valid_from` text NOT NULL,
|
||||
`valid_to` text NOT NULL,
|
||||
`revoked_at` text,
|
||||
`created_by` integer REFERENCES `users`(`id`) ON DELETE set null,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
CREATE INDEX `issued_client_certificates_ca_idx` ON `issued_client_certificates` (`ca_certificate_id`);
|
||||
CREATE INDEX `issued_client_certificates_revoked_at_idx` ON `issued_client_certificates` (`revoked_at`);
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1772400000000,
|
||||
"tag": "0012_ca_private_key",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1772500000000,
|
||||
"tag": "0013_issued_client_certificates",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
@@ -19,13 +22,16 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useTransition, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
|
||||
import type { IssuedClientCertificate } from "@/src/lib/models/issued-client-certificates";
|
||||
import {
|
||||
createCaCertificateAction,
|
||||
deleteCaCertificateAction,
|
||||
generateCaCertificateAction,
|
||||
issueClientCertificateAction,
|
||||
revokeIssuedClientCertificateAction,
|
||||
updateCaCertificateAction,
|
||||
} from "@/app/(dashboard)/certificates/ca-actions";
|
||||
|
||||
@@ -53,6 +59,14 @@ function sanitizeFilenameSegment(value: string): string {
|
||||
return value.trim().replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, "") || "client";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatFingerprint(value: string): string {
|
||||
return value.match(/.{1,2}/g)?.join(":") ?? value;
|
||||
}
|
||||
|
||||
export function CreateCaCertDialog({
|
||||
open,
|
||||
onClose,
|
||||
@@ -240,6 +254,7 @@ export function IssueClientCertDialog({
|
||||
cert: CaCertificate;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [issued, setIssued] = useState<{
|
||||
pkcs12Base64: string;
|
||||
@@ -267,6 +282,7 @@ export function IssueClientCertDialog({
|
||||
...result,
|
||||
name: sanitizeFilenameSegment(String(formData.get("common_name") ?? "client")),
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to issue certificate");
|
||||
}
|
||||
@@ -360,6 +376,133 @@ export function IssueClientCertDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function ManageIssuedClientCertsDialog({
|
||||
open,
|
||||
cert,
|
||||
issuedCerts,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
cert: CaCertificate;
|
||||
issuedCerts: IssuedClientCertificate[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [items, setItems] = useState<IssuedClientCertificate[]>(issuedCerts);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setItems(issuedCerts);
|
||||
setError(null);
|
||||
}, [issuedCerts, open]);
|
||||
|
||||
function handleRevoke(id: number) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await revokeIssuedClientCertificateAction(id);
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id ? { ...item, revoked_at: result.revokedAt, updated_at: result.revokedAt } : item
|
||||
)
|
||||
);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to revoke certificate");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Issued Client Certificates</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} mt={1}>
|
||||
<Alert severity="info">
|
||||
Revoking a client certificate removes it from the trusted mTLS client certificate pool for hosts using{" "}
|
||||
<strong>{cert.name}</strong>.
|
||||
</Alert>
|
||||
{error && <Typography color="error" variant="body2">{error}</Typography>}
|
||||
{items.length === 0 ? (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
No issued client certificates are currently tracked for this CA. Certificates issued from this UI will
|
||||
appear here and can then be revoked individually.
|
||||
</Typography>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const expired = new Date(item.valid_to).getTime() < Date.now();
|
||||
return (
|
||||
<Card key={item.id} variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{item.common_name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Serial {item.serial_number}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="flex-end">
|
||||
<Chip
|
||||
label={item.revoked_at ? "Revoked" : "Active"}
|
||||
color={item.revoked_at ? "default" : "success"}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={expired ? `Expired ${formatDateTime(item.valid_to)}` : `Expires ${formatDateTime(item.valid_to)}`}
|
||||
color={expired ? "error" : "default"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Issued {formatDateTime(item.created_at)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: "monospace", wordBreak: "break-all" }}
|
||||
>
|
||||
SHA-256 {formatFingerprint(item.fingerprint_sha256)}
|
||||
</Typography>
|
||||
{item.revoked_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Revoked {formatDateTime(item.revoked_at)}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={isPending}
|
||||
onClick={() => handleRevoke(item.id)}
|
||||
>
|
||||
{isPending ? "Revoking..." : "Revoke"}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isPending}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteCaCertDialog({
|
||||
open,
|
||||
cert,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { Resolver } from "node:dns/promises";
|
||||
import { join } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
@@ -6,6 +6,7 @@ import crypto from "node:crypto";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import db, { nowIso } from "./db";
|
||||
import { isNull } from "drizzle-orm";
|
||||
import { config } from "./config";
|
||||
import {
|
||||
getCloudflareSettings,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
accessListEntries,
|
||||
certificates,
|
||||
caCertificates,
|
||||
issuedClientCertificates,
|
||||
proxyHosts
|
||||
} from "./db/schema";
|
||||
import { type GeoBlockMode, type WafHostConfig, type MtlsConfig } from "./models/proxy-hosts";
|
||||
@@ -677,17 +679,6 @@ async function resolveUpstreamDials(
|
||||
};
|
||||
}
|
||||
|
||||
function writeCertificateFiles(cert: CertificateRow) {
|
||||
if (cert.type !== "imported" || !cert.certificate_pem || !cert.private_key_pem) {
|
||||
return null;
|
||||
}
|
||||
const certPath = join(CERTS_DIR, `certificate-${cert.id}.pem`);
|
||||
const keyPath = join(CERTS_DIR, `certificate-${cert.id}.key.pem`);
|
||||
writeFileSync(certPath, cert.certificate_pem, { encoding: "utf-8", mode: 0o600 });
|
||||
writeFileSync(keyPath, cert.private_key_pem, { encoding: "utf-8", mode: 0o600 });
|
||||
return { certificate_file: certPath, key_file: keyPath };
|
||||
}
|
||||
|
||||
function pemToBase64Der(pem: string): string {
|
||||
// Strip PEM headers/footers and whitespace — what remains is the base64-encoded DER
|
||||
return pem
|
||||
@@ -1039,7 +1030,7 @@ async function buildProxyRoutes(
|
||||
let outpostRoute: CaddyHttpRoute | null = null;
|
||||
if (authentik) {
|
||||
// Parse the outpost upstream URL to extract host:port for Caddy's dial field
|
||||
let outpostDial = authentik.outpostUpstream;
|
||||
let outpostDial: string;
|
||||
try {
|
||||
const url = new URL(authentik.outpostUpstream);
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
@@ -1113,7 +1104,7 @@ async function buildProxyRoutes(
|
||||
if (loadBalancing) {
|
||||
reverseProxyHandler.load_balancing = loadBalancing;
|
||||
}
|
||||
const healthChecks = buildHealthChecksConfig(lbConfig, dnsConfig);
|
||||
const healthChecks = buildHealthChecksConfig(lbConfig);
|
||||
if (healthChecks) {
|
||||
reverseProxyHandler.health_checks = healthChecks;
|
||||
}
|
||||
@@ -1323,9 +1314,12 @@ async function buildProxyRoutes(
|
||||
function buildClientAuthentication(
|
||||
domains: string[],
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>
|
||||
): Record<string, unknown> | null {
|
||||
// Collect all CA cert IDs for any domain in this policy that has mTLS
|
||||
// Collect all CA cert IDs for any domain in this policy that has mTLS.
|
||||
// If a CA has managed issued client certs, trust only the active leaf certs
|
||||
// for that CA so revocation can be enforced by removing them from the pool.
|
||||
const caCertIds = new Set<number>();
|
||||
for (const domain of domains) {
|
||||
const ids = mTlsDomainMap.get(domain.toLowerCase());
|
||||
@@ -1337,6 +1331,14 @@ function buildClientAuthentication(
|
||||
|
||||
const derCerts: string[] = [];
|
||||
for (const id of caCertIds) {
|
||||
const issuedLeafCerts = issuedClientCertMap.get(id) ?? [];
|
||||
if (issuedLeafCerts.length > 0) {
|
||||
for (const certPem of issuedLeafCerts) {
|
||||
derCerts.push(pemToBase64Der(certPem));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const ca = caCertMap.get(id);
|
||||
if (!ca) continue;
|
||||
derCerts.push(pemToBase64Der(ca.certificatePem));
|
||||
@@ -1354,7 +1356,8 @@ function buildTlsConnectionPolicies(
|
||||
managedCertificatesWithAutomation: Set<number>,
|
||||
autoManagedDomains: Set<string>,
|
||||
mTlsDomainMap: Map<string, number[]>,
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>
|
||||
caCertMap: Map<number, { id: number; certificatePem: string }>,
|
||||
issuedClientCertMap: Map<number, string[]>
|
||||
) {
|
||||
const policies: Record<string, unknown>[] = [];
|
||||
const readyCertificates = new Set<number>();
|
||||
@@ -1363,7 +1366,7 @@ function buildTlsConnectionPolicies(
|
||||
// Add policy for auto-managed domains (certificate_id = null)
|
||||
if (autoManagedDomains.size > 0) {
|
||||
const domains = Array.from(autoManagedDomains);
|
||||
const clientAuth = buildClientAuthentication(domains, mTlsDomainMap, caCertMap);
|
||||
const clientAuth = buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap);
|
||||
|
||||
if (clientAuth) {
|
||||
// Split: mTLS domains get their own policy, non-mTLS get another
|
||||
@@ -1371,7 +1374,7 @@ function buildTlsConnectionPolicies(
|
||||
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
|
||||
|
||||
if (mTlsDomains.length > 0) {
|
||||
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap);
|
||||
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap, issuedClientCertMap);
|
||||
policies.push({
|
||||
match: { sni: mTlsDomains },
|
||||
client_authentication: mTlsAuth
|
||||
@@ -1406,7 +1409,7 @@ function buildTlsConnectionPolicies(
|
||||
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
|
||||
|
||||
if (mTlsDomains.length > 0) {
|
||||
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap);
|
||||
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap, issuedClientCertMap);
|
||||
policies.push({
|
||||
match: { sni: mTlsDomains },
|
||||
...(mTlsAuth ? { client_authentication: mTlsAuth } : {})
|
||||
@@ -1429,7 +1432,7 @@ function buildTlsConnectionPolicies(
|
||||
const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d));
|
||||
|
||||
if (mTlsDomains.length > 0) {
|
||||
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap);
|
||||
const mTlsAuth = buildClientAuthentication(mTlsDomains, mTlsDomainMap, caCertMap, issuedClientCertMap);
|
||||
policies.push({
|
||||
match: { sni: mTlsDomains },
|
||||
...(mTlsAuth ? { client_authentication: mTlsAuth } : {})
|
||||
@@ -1587,7 +1590,7 @@ async function buildTlsAutomation(
|
||||
}
|
||||
|
||||
async function buildCaddyDocument() {
|
||||
const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows] = await Promise.all([
|
||||
const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows, issuedClientCertRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: proxyHosts.id,
|
||||
@@ -1630,7 +1633,14 @@ async function buildCaddyDocument() {
|
||||
id: caCertificates.id,
|
||||
certificatePem: caCertificates.certificatePem
|
||||
})
|
||||
.from(caCertificates)
|
||||
.from(caCertificates),
|
||||
db
|
||||
.select({
|
||||
caCertificateId: issuedClientCertificates.caCertificateId,
|
||||
certificatePem: issuedClientCertificates.certificatePem
|
||||
})
|
||||
.from(issuedClientCertificates)
|
||||
.where(isNull(issuedClientCertificates.revokedAt))
|
||||
]);
|
||||
|
||||
const proxyHostRows: ProxyHostRow[] = proxyHostRecords.map((h) => ({
|
||||
@@ -1669,6 +1679,12 @@ async function buildCaddyDocument() {
|
||||
|
||||
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
|
||||
const caCertMap = new Map(caCertRows.map((ca) => [ca.id, ca]));
|
||||
const issuedClientCertMap = issuedClientCertRows.reduce<Map<number, string[]>>((map, record) => {
|
||||
const current = map.get(record.caCertificateId) ?? [];
|
||||
current.push(record.certificatePem);
|
||||
map.set(record.caCertificateId, current);
|
||||
return map;
|
||||
}, new Map());
|
||||
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
|
||||
if (!map.has(entry.access_list_id)) {
|
||||
map.set(entry.access_list_id, []);
|
||||
@@ -1706,7 +1722,8 @@ async function buildCaddyDocument() {
|
||||
managedCertificateIds,
|
||||
autoManagedDomains,
|
||||
mTlsDomainMap,
|
||||
caCertMap
|
||||
caCertMap,
|
||||
issuedClientCertMap
|
||||
);
|
||||
|
||||
const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes(
|
||||
@@ -1863,7 +1880,10 @@ export async function applyCaddyConfig() {
|
||||
const causeCode = err?.cause?.code;
|
||||
|
||||
if (causeCode === "ENOTFOUND" || causeCode === "ECONNREFUSED") {
|
||||
throw new Error(`Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`);
|
||||
throw new Error(
|
||||
`Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -2017,7 +2037,7 @@ type DnsResolverRouteConfig = {
|
||||
timeout: string | null;
|
||||
};
|
||||
|
||||
function buildHealthChecksConfig(config: LoadBalancerRouteConfig, dnsConfig: DnsResolverRouteConfig | null): Record<string, unknown> | null {
|
||||
function buildHealthChecksConfig(config: LoadBalancerRouteConfig): Record<string, unknown> | null {
|
||||
const healthChecks: Record<string, unknown> = {};
|
||||
|
||||
// Active health checks
|
||||
|
||||
@@ -138,6 +138,30 @@ export const caCertificates = sqliteTable("ca_certificates", {
|
||||
updatedAt: text("updated_at").notNull()
|
||||
});
|
||||
|
||||
export const issuedClientCertificates = sqliteTable(
|
||||
"issued_client_certificates",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
caCertificateId: integer("ca_certificate_id")
|
||||
.references(() => caCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
commonName: text("common_name").notNull(),
|
||||
serialNumber: text("serial_number").notNull(),
|
||||
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||
certificatePem: text("certificate_pem").notNull(),
|
||||
validFrom: text("valid_from").notNull(),
|
||||
validTo: text("valid_to").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
caCertificateIdx: index("issued_client_certificates_ca_idx").on(table.caCertificateId),
|
||||
revokedAtIdx: index("issued_client_certificates_revoked_at_idx").on(table.revokedAt)
|
||||
})
|
||||
);
|
||||
|
||||
export const proxyHosts = sqliteTable("proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db, { nowIso } from "./db";
|
||||
import { accessListEntries, accessLists, caCertificates, certificates, proxyHosts } from "./db/schema";
|
||||
import { accessListEntries, accessLists, caCertificates, certificates, issuedClientCertificates, proxyHosts } from "./db/schema";
|
||||
import { getSetting, setSetting } from "./settings";
|
||||
import { recordInstanceSyncResult, updateInstance } from "./models/instances";
|
||||
import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret";
|
||||
@@ -24,6 +24,7 @@ export type SyncPayload = {
|
||||
data: {
|
||||
certificates: Array<typeof certificates.$inferSelect>;
|
||||
caCertificates: Array<typeof caCertificates.$inferSelect>;
|
||||
issuedClientCertificates: Array<typeof issuedClientCertificates.$inferSelect>;
|
||||
accessLists: Array<typeof accessLists.$inferSelect>;
|
||||
accessListEntries: Array<typeof accessListEntries.$inferSelect>;
|
||||
proxyHosts: Array<typeof proxyHosts.$inferSelect>;
|
||||
@@ -232,9 +233,10 @@ export async function clearSyncedSetting(key: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function buildSyncPayload(): Promise<SyncPayload> {
|
||||
const [certRows, caCertRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([
|
||||
const [certRows, caCertRows, issuedClientCertRows, accessListRows, accessEntryRows, proxyRows] = await Promise.all([
|
||||
db.select().from(certificates),
|
||||
db.select().from(caCertificates),
|
||||
db.select().from(issuedClientCertificates),
|
||||
db.select().from(accessLists),
|
||||
db.select().from(accessListEntries),
|
||||
db.select().from(proxyHosts)
|
||||
@@ -267,6 +269,11 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
|
||||
createdBy: null
|
||||
}));
|
||||
|
||||
const sanitizedIssuedClientCertificates = issuedClientCertRows.map((row) => ({
|
||||
...row,
|
||||
createdBy: null
|
||||
}));
|
||||
|
||||
const sanitizedProxyHosts = proxyRows.map((row) => ({
|
||||
...row,
|
||||
ownerUserId: null
|
||||
@@ -278,6 +285,7 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
|
||||
data: {
|
||||
certificates: sanitizedCertificates,
|
||||
caCertificates: sanitizedCaCertificates,
|
||||
issuedClientCertificates: sanitizedIssuedClientCertificates,
|
||||
accessLists: sanitizedAccessLists,
|
||||
accessListEntries: accessEntryRows,
|
||||
proxyHosts: sanitizedProxyHosts
|
||||
@@ -305,7 +313,6 @@ export async function syncInstances(): Promise<{ total: number; success: number;
|
||||
|
||||
const httpAllowed = isHttpSyncAllowed();
|
||||
const payload = await buildSyncPayload();
|
||||
let skippedHttp = 0;
|
||||
|
||||
// Sync database-configured instances
|
||||
const dbResults = await Promise.all(
|
||||
@@ -396,7 +403,7 @@ export async function syncInstances(): Promise<{ total: number; success: number;
|
||||
|
||||
const allResults = [...dbResults, ...envResults];
|
||||
const success = allResults.filter((r) => r.ok).length;
|
||||
skippedHttp = allResults.filter((r) => r.skippedHttp).length;
|
||||
const skippedHttp = allResults.filter((r) => r.skippedHttp).length;
|
||||
const failed = allResults.length - success - skippedHttp;
|
||||
|
||||
return { total: allResults.length, success, failed, skippedHttp };
|
||||
@@ -418,6 +425,7 @@ export async function applySyncPayload(payload: SyncPayload) {
|
||||
tx.delete(proxyHosts).run();
|
||||
tx.delete(accessListEntries).run();
|
||||
tx.delete(accessLists).run();
|
||||
tx.delete(issuedClientCertificates).run();
|
||||
tx.delete(certificates).run();
|
||||
tx.delete(caCertificates).run();
|
||||
|
||||
@@ -427,6 +435,9 @@ export async function applySyncPayload(payload: SyncPayload) {
|
||||
if (payload.data.caCertificates && payload.data.caCertificates.length > 0) {
|
||||
tx.insert(caCertificates).values(payload.data.caCertificates).run();
|
||||
}
|
||||
if (payload.data.issuedClientCertificates && payload.data.issuedClientCertificates.length > 0) {
|
||||
tx.insert(issuedClientCertificates).values(payload.data.issuedClientCertificates).run();
|
||||
}
|
||||
if (payload.data.accessLists.length > 0) {
|
||||
tx.insert(accessLists).values(payload.data.accessLists).run();
|
||||
}
|
||||
|
||||
138
src/lib/models/issued-client-certificates.ts
Normal file
138
src/lib/models/issued-client-certificates.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { logAuditEvent } from "../audit";
|
||||
import { applyCaddyConfig } from "../caddy";
|
||||
import { issuedClientCertificates } from "../db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
export type IssuedClientCertificate = {
|
||||
id: number;
|
||||
ca_certificate_id: number;
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
fingerprint_sha256: string;
|
||||
certificate_pem: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IssuedClientCertificateInput = {
|
||||
ca_certificate_id: number;
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
fingerprint_sha256: string;
|
||||
certificate_pem: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
};
|
||||
|
||||
type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
|
||||
|
||||
function parseIssuedClientCertificate(row: IssuedClientCertificateRow): IssuedClientCertificate {
|
||||
return {
|
||||
id: row.id,
|
||||
ca_certificate_id: row.caCertificateId,
|
||||
common_name: row.commonName,
|
||||
serial_number: row.serialNumber,
|
||||
fingerprint_sha256: row.fingerprintSha256,
|
||||
certificate_pem: row.certificatePem,
|
||||
valid_from: toIso(row.validFrom)!,
|
||||
valid_to: toIso(row.validTo)!,
|
||||
revoked_at: toIso(row.revokedAt),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
export async function listIssuedClientCertificates(): Promise<IssuedClientCertificate[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issuedClientCertificates)
|
||||
.orderBy(desc(issuedClientCertificates.createdAt));
|
||||
return rows.map(parseIssuedClientCertificate);
|
||||
}
|
||||
|
||||
export async function getIssuedClientCertificate(id: number): Promise<IssuedClientCertificate | null> {
|
||||
const record = await db.query.issuedClientCertificates.findFirst({
|
||||
where: (table, { eq: compareEq }) => compareEq(table.id, id)
|
||||
});
|
||||
return record ? parseIssuedClientCertificate(record) : null;
|
||||
}
|
||||
|
||||
export async function createIssuedClientCertificate(
|
||||
input: IssuedClientCertificateInput,
|
||||
actorUserId: number
|
||||
): Promise<IssuedClientCertificate> {
|
||||
const now = nowIso();
|
||||
const [record] = await db
|
||||
.insert(issuedClientCertificates)
|
||||
.values({
|
||||
caCertificateId: input.ca_certificate_id,
|
||||
commonName: input.common_name.trim(),
|
||||
serialNumber: input.serial_number.trim(),
|
||||
fingerprintSha256: input.fingerprint_sha256.trim(),
|
||||
certificatePem: input.certificate_pem.trim(),
|
||||
validFrom: input.valid_from,
|
||||
validTo: input.valid_to,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!record) {
|
||||
throw new Error("Failed to store issued client certificate");
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "create",
|
||||
entityType: "issued_client_certificate",
|
||||
entityId: record.id,
|
||||
summary: `Issued client certificate ${input.common_name}`,
|
||||
data: {
|
||||
caCertificateId: input.ca_certificate_id,
|
||||
serialNumber: input.serial_number
|
||||
}
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return (await getIssuedClientCertificate(record.id))!;
|
||||
}
|
||||
|
||||
export async function revokeIssuedClientCertificate(
|
||||
id: number,
|
||||
actorUserId: number
|
||||
): Promise<IssuedClientCertificate> {
|
||||
const existing = await getIssuedClientCertificate(id);
|
||||
if (!existing) {
|
||||
throw new Error("Issued client certificate not found");
|
||||
}
|
||||
if (existing.revoked_at) {
|
||||
throw new Error("Issued client certificate is already revoked");
|
||||
}
|
||||
|
||||
const revokedAt = nowIso();
|
||||
await db
|
||||
.update(issuedClientCertificates)
|
||||
.set({
|
||||
revokedAt,
|
||||
updatedAt: revokedAt
|
||||
})
|
||||
.where(eq(issuedClientCertificates.id, id));
|
||||
|
||||
logAuditEvent({
|
||||
userId: actorUserId,
|
||||
action: "revoke",
|
||||
entityType: "issued_client_certificate",
|
||||
entityId: id,
|
||||
summary: `Revoked client certificate ${existing.common_name}`,
|
||||
data: {
|
||||
caCertificateId: existing.ca_certificate_id,
|
||||
serialNumber: existing.serial_number
|
||||
}
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
return (await getIssuedClientCertificate(id))!;
|
||||
}
|
||||
Reference in New Issue
Block a user