mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-13 05:16:37 +00:00
Push down certs when they are detected
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user