Added issued-client-cert tracking and revocation for mTLS
This commit is contained in:
+46
-26
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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