Push down certs when they are detected

This commit is contained in:
Owen
2026-04-12 17:31:51 -07:00
parent 89b6b1fb56
commit 9b271950d2

View File

@@ -13,12 +13,24 @@
import fs from "fs"; import fs from "fs";
import crypto from "crypto"; import crypto from "crypto";
import { certificates, domains, db } from "@server/db"; import {
certificates,
clients,
clientSiteResourcesAssociationsCache,
db,
domains,
newts,
SiteResource,
siteResources
} from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { encrypt, decrypt } from "@server/lib/crypto"; import { encrypt, decrypt } from "@server/lib/crypto";
import logger from "@server/logger"; import logger from "@server/logger";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip";
import { updateTargets } from "@server/routers/client/targets";
import cache from "#private/lib/cache";
interface AcmeCert { interface AcmeCert {
domain: { main: string; sans?: string[] }; domain: { main: string; sans?: string[] };
@@ -33,6 +45,138 @@ interface AcmeJson {
}; };
} }
async function pushCertUpdateToAffectedNewts(
domain: string,
domainId: string | null,
oldCertPem: string | null,
oldKeyPem: string | null
): Promise<void> {
// Find all SSL-enabled HTTP site resources that use this cert's domain
let affectedResources: SiteResource[] = [];
if (domainId) {
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.domainId, domainId),
eq(siteResources.ssl, true)
)
);
} else {
// Fallback: match by exact fullDomain when no domainId is available
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.fullDomain, domain),
eq(siteResources.ssl, true)
)
);
}
if (affectedResources.length === 0) {
logger.debug(
`acmeCertSync: no affected site resources for cert domain "${domain}"`
);
return;
}
logger.info(
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
);
for (const resource of affectedResources) {
try {
// Get the newt for this site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, resource.siteId))
.limit(1);
if (!newt) {
logger.debug(
`acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}`
);
continue;
}
// Get all clients with access to this resource
const resourceClients = await db
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clients.clientId,
clientSiteResourcesAssociationsCache.clientId
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
)
);
if (resourceClients.length === 0) {
logger.debug(
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
if (resource.fullDomain) {
await cache.del(`cert:${resource.fullDomain}`);
}
// Generate the new target (will read the freshly updated cert from DB)
const newTarget = await generateSubnetProxyTargetV2(
resource,
resourceClients
);
if (!newTarget) {
logger.debug(
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Construct the old target — same routing shape but with the previous cert/key.
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
// semantics correct so the update message accurately reflects what changed.
const oldTarget: SubnetProxyTargetV2 = {
...newTarget,
tlsCert: oldCertPem ?? undefined,
tlsKey: oldKeyPem ?? undefined
};
await updateTargets(
newt.newtId,
{ oldTargets: [oldTarget], newTargets: [newTarget] },
newt.version
);
logger.info(
`acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}`
);
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
);
}
}
}
async function findDomainId(certDomain: string): Promise<string | null> { async function findDomainId(certDomain: string): Promise<string | null> {
// Strip wildcard prefix before lookup (*.example.com -> example.com) // Strip wildcard prefix before lookup (*.example.com -> example.com)
const lookupDomain = certDomain.startsWith("*.") const lookupDomain = certDomain.startsWith("*.")
@@ -148,6 +292,9 @@ async function syncAcmeCerts(
.where(eq(certificates.domain, domain)) .where(eq(certificates.domain, domain))
.limit(1); .limit(1);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) { if (existing.length > 0 && existing[0].certFile) {
try { try {
const storedCertPem = decrypt( const storedCertPem = decrypt(
@@ -160,6 +307,21 @@ async function syncAcmeCerts(
); );
continue; continue;
} }
// Cert has changed; capture old values so we can send a correct
// update message to the newt after the DB write.
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) { } catch (err) {
// Decryption failure means we should proceed with the update // Decryption failure means we should proceed with the update
logger.debug( logger.debug(
@@ -215,6 +377,8 @@ async function syncAcmeCerts(
logger.info( logger.info(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` `acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
); );
await pushCertUpdateToAffectedNewts(domain, domainId, oldCertPem, oldKeyPem);
} else { } else {
await db.insert(certificates).values({ await db.insert(certificates).values({
domain, domain,
@@ -231,6 +395,9 @@ async function syncAcmeCerts(
logger.info( logger.info(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` `acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
); );
// For a brand-new cert, push to any SSL resources that were waiting for it
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
} }
} }
} }