mirror of
https://github.com/fosrl/pangolin.git
synced 2025-12-16 13:06:27 +00:00
214 lines
7.3 KiB
TypeScript
214 lines
7.3 KiB
TypeScript
/*
|
|
* This file is part of a proprietary work.
|
|
*
|
|
* Copyright (c) 2025 Fossorial, Inc.
|
|
* All rights reserved.
|
|
*
|
|
* This file is licensed under the Fossorial Commercial License.
|
|
* You may not use this file except in compliance with the License.
|
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
*
|
|
* This file is not licensed under the AGPLv3.
|
|
*/
|
|
|
|
import config from "./config";
|
|
import { certificates, db } from "@server/db";
|
|
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
|
import { decryptData } from "@server/lib/encryption";
|
|
import * as fs from "fs";
|
|
import logger from "@server/logger";
|
|
import cache from "@server/lib/cache";
|
|
|
|
let encryptionKeyPath = "";
|
|
let encryptionKeyHex = "";
|
|
let encryptionKey: Buffer;
|
|
function loadEncryptData() {
|
|
if (encryptionKey) {
|
|
return; // already loaded
|
|
}
|
|
|
|
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
|
|
|
|
if (!fs.existsSync(encryptionKeyPath)) {
|
|
throw new Error(
|
|
"Encryption key file not found. Please generate one first."
|
|
);
|
|
}
|
|
|
|
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
|
|
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
|
}
|
|
|
|
// Define the return type for clarity and type safety
|
|
export type CertificateResult = {
|
|
id: number;
|
|
domain: string;
|
|
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
|
|
wildcard: boolean | null;
|
|
certFile: string | null;
|
|
keyFile: string | null;
|
|
expiresAt: number | null;
|
|
updatedAt?: number | null;
|
|
};
|
|
|
|
export async function getValidCertificatesForDomains(
|
|
domains: Set<string>,
|
|
useCache: boolean = true
|
|
): Promise<Array<CertificateResult>> {
|
|
|
|
loadEncryptData(); // Ensure encryption key is loaded
|
|
|
|
const finalResults: CertificateResult[] = [];
|
|
const domainsToQuery = new Set<string>();
|
|
|
|
// 1. Check cache first if enabled
|
|
if (useCache) {
|
|
for (const domain of domains) {
|
|
const cacheKey = `cert:${domain}`;
|
|
const cachedCert = cache.get<CertificateResult>(cacheKey);
|
|
if (cachedCert) {
|
|
finalResults.push(cachedCert); // Valid cache hit
|
|
} else {
|
|
domainsToQuery.add(domain); // Cache miss or expired
|
|
}
|
|
}
|
|
} else {
|
|
// If caching is disabled, add all domains to the query set
|
|
domains.forEach((d) => domainsToQuery.add(d));
|
|
}
|
|
|
|
// 2. If all domains were resolved from the cache, return early
|
|
if (domainsToQuery.size === 0) {
|
|
const decryptedResults = decryptFinalResults(finalResults);
|
|
return decryptedResults;
|
|
}
|
|
|
|
// 3. Prepare domains for the database query
|
|
const domainsToQueryArray = Array.from(domainsToQuery);
|
|
const parentDomainsToQuery = new Set<string>();
|
|
|
|
domainsToQueryArray.forEach((domain) => {
|
|
const parts = domain.split(".");
|
|
// A wildcard can only match a domain with at least two parts (e.g., example.com)
|
|
if (parts.length > 1) {
|
|
parentDomainsToQuery.add(parts.slice(1).join("."));
|
|
}
|
|
});
|
|
|
|
const parentDomainsArray = Array.from(parentDomainsToQuery);
|
|
|
|
// 4. Build and execute a single, efficient Drizzle query
|
|
// This query fetches all potential exact and wildcard matches in one database round-trip.
|
|
const potentialCerts = await db
|
|
.select()
|
|
.from(certificates)
|
|
.where(
|
|
and(
|
|
eq(certificates.status, "valid"),
|
|
isNotNull(certificates.certFile),
|
|
isNotNull(certificates.keyFile),
|
|
or(
|
|
// Condition for exact matches on the requested domains
|
|
inArray(certificates.domain, domainsToQueryArray),
|
|
// Condition for wildcard matches on the parent domains
|
|
parentDomainsArray.length > 0
|
|
? and(
|
|
inArray(certificates.domain, parentDomainsArray),
|
|
eq(certificates.wildcard, true)
|
|
)
|
|
: // If there are no possible parent domains, this condition is false
|
|
sql`false`
|
|
)
|
|
)
|
|
);
|
|
|
|
// 5. Process the database results, prioritizing exact matches over wildcards
|
|
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
|
|
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
|
|
|
|
for (const cert of potentialCerts) {
|
|
if (cert.wildcard) {
|
|
wildcardMatches.set(cert.domain, cert);
|
|
} else {
|
|
exactMatches.set(cert.domain, cert);
|
|
}
|
|
}
|
|
|
|
for (const domain of domainsToQuery) {
|
|
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
|
|
|
|
// Priority 1: Check for an exact match (non-wildcard)
|
|
if (exactMatches.has(domain)) {
|
|
foundCert = exactMatches.get(domain);
|
|
}
|
|
// Priority 2: Check for a wildcard certificate that matches the exact domain
|
|
else {
|
|
if (wildcardMatches.has(domain)) {
|
|
foundCert = wildcardMatches.get(domain);
|
|
}
|
|
// Priority 3: Check for a wildcard match on the parent domain
|
|
else {
|
|
const parts = domain.split(".");
|
|
if (parts.length > 1) {
|
|
const parentDomain = parts.slice(1).join(".");
|
|
if (wildcardMatches.has(parentDomain)) {
|
|
foundCert = wildcardMatches.get(parentDomain);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If a certificate was found, format it, add to results, and cache it
|
|
if (foundCert) {
|
|
logger.debug(
|
|
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
|
|
);
|
|
const resultCert: CertificateResult = {
|
|
id: foundCert.certId,
|
|
domain: foundCert.domain, // The actual domain of the cert record
|
|
queriedDomain: domain, // The domain that was originally requested
|
|
wildcard: foundCert.wildcard,
|
|
certFile: foundCert.certFile,
|
|
keyFile: foundCert.keyFile,
|
|
expiresAt: foundCert.expiresAt,
|
|
updatedAt: foundCert.updatedAt
|
|
};
|
|
|
|
finalResults.push(resultCert);
|
|
|
|
// Add to cache for future requests, using the *requested domain* as the key
|
|
if (useCache) {
|
|
const cacheKey = `cert:${domain}`;
|
|
cache.set(cacheKey, resultCert, 180);
|
|
}
|
|
}
|
|
}
|
|
|
|
const decryptedResults = decryptFinalResults(finalResults);
|
|
return decryptedResults;
|
|
}
|
|
|
|
function decryptFinalResults(
|
|
finalResults: CertificateResult[]
|
|
): CertificateResult[] {
|
|
const validCertsDecrypted = finalResults.map((cert) => {
|
|
// Decrypt and save certificate file
|
|
const decryptedCert = decryptData(
|
|
cert.certFile!, // is not null from query
|
|
encryptionKey
|
|
);
|
|
|
|
// Decrypt and save key file
|
|
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
|
|
|
// Return only the certificate data without org information
|
|
return {
|
|
...cert,
|
|
certFile: decryptedCert,
|
|
keyFile: decryptedKey
|
|
};
|
|
});
|
|
|
|
return validCertsDecrypted;
|
|
}
|