mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-16 01:09:40 +02:00
Move certificates
This commit is contained in:
@@ -1,240 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 privateConfig from "./config";
|
||||
import config from "@server/lib/config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import { regionalCache as cache } from "#private/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// 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>> {
|
||||
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 = await cache.get<CertificateResult>(cacheKey);
|
||||
if (cachedCert) {
|
||||
finalResults.push(cachedCert); // Valid cache hit
|
||||
} else {
|
||||
// Also check for a wildcard cache entry covering this domain's parent
|
||||
const parts = domain.split(".");
|
||||
let wildcardHit = false;
|
||||
if (parts.length > 1) {
|
||||
const parentDomain = parts.slice(1).join(".");
|
||||
const wildcardCacheKey = `cert:*.${parentDomain}`;
|
||||
const cachedWildcard =
|
||||
await cache.get<CertificateResult>(wildcardCacheKey);
|
||||
if (cachedWildcard) {
|
||||
// Re-stamp queriedDomain so callers see the originally requested domain
|
||||
finalResults.push({
|
||||
...cachedWildcard,
|
||||
queriedDomain: domain
|
||||
});
|
||||
wildcardHit = true;
|
||||
}
|
||||
}
|
||||
if (!wildcardHit) {
|
||||
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,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
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);
|
||||
|
||||
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
|
||||
const wildcardPrefixedArray =
|
||||
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
|
||||
|
||||
// 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 (stored as "example.com" or "*.example.com")
|
||||
parentDomainsArray.length > 0
|
||||
? and(
|
||||
inArray(certificates.domain, [
|
||||
...parentDomainsArray,
|
||||
...wildcardPrefixedArray
|
||||
]),
|
||||
eq(certificates.wildcard, true)
|
||||
)
|
||||
: // If there are no possible parent domains, this condition is false
|
||||
sql`false`
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
|
||||
const normalizeWildcardDomain = (domain: string): string =>
|
||||
domain.startsWith("*.") ? domain.slice(2) : domain;
|
||||
|
||||
// 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) {
|
||||
// Normalize to bare parent domain so lookups are consistent regardless of storage format
|
||||
wildcardMatches.set(normalizeWildcardDomain(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 whose normalized domain equals the queried domain
|
||||
else {
|
||||
const normalizedDomain = normalizeWildcardDomain(domain);
|
||||
if (wildcardMatches.has(normalizedDomain)) {
|
||||
foundCert = wildcardMatches.get(normalizedDomain);
|
||||
}
|
||||
// Priority 3: Check for a wildcard match on the parent domain
|
||||
else {
|
||||
const parts = normalizedDomain.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}`;
|
||||
await cache.set(cacheKey, resultCert, 180);
|
||||
|
||||
// Also cache wildcard certs under a pattern key so other subdomains
|
||||
// can find them without a DB round-trip
|
||||
if (resultCert.wildcard) {
|
||||
const normalizedCertDomain = normalizeWildcardDomain(
|
||||
resultCert.domain
|
||||
);
|
||||
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
|
||||
await cache.set(wildcardCacheKey, resultCert, 180);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(
|
||||
finalResults,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
function decryptFinalResults(
|
||||
finalResults: CertificateResult[],
|
||||
secret: string
|
||||
): CertificateResult[] {
|
||||
const validCertsDecrypted = finalResults.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
secret
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
...cert,
|
||||
certFile: decryptedCert,
|
||||
keyFile: decryptedKey
|
||||
};
|
||||
});
|
||||
|
||||
return validCertsDecrypted;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||
import {
|
||||
CertificateResult,
|
||||
getValidCertificatesForDomains
|
||||
} from "#private/lib/certificates";
|
||||
} from "@server/lib/certificates";
|
||||
import { build } from "@server/build";
|
||||
import regionalCache from "#private/lib/cache";
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./verifyCertificateAccess";
|
||||
export * from "./verifyRemoteExitNodeAccess";
|
||||
export * from "./verifyIdpAccess";
|
||||
export * from "./verifyLoginPageAccess";
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express";
|
||||
import { db, domainNamespaces } from "@server/db";
|
||||
import { certificates } from "@server/db";
|
||||
import { domains, orgDomains } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyCertificateAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Assume user/org access is already verified
|
||||
const orgId = getFirstString(req.params.orgId);
|
||||
|
||||
const certIdFromParams = getFirstString(req.params?.certId);
|
||||
const certIdFromBody = getFirstString(req.body?.certId);
|
||||
|
||||
if (
|
||||
certIdFromParams &&
|
||||
certIdFromBody &&
|
||||
certIdFromParams !== certIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Certificate ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const certId = certIdFromParams || certIdFromBody;
|
||||
|
||||
const domainIdFromParams = getFirstString(req.params?.domainId);
|
||||
const domainIdFromBody = getFirstString(req.body?.domainId);
|
||||
|
||||
if (
|
||||
domainIdFromParams &&
|
||||
domainIdFromBody &&
|
||||
domainIdFromParams !== domainIdFromBody
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Domain ID provided in both URL and body with different values"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let domainId = domainIdFromParams || domainIdFromBody;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
if (!certId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Must provide either certId or domainId"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the certificate and its domainId
|
||||
const [cert] = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.certId, Number(certId)))
|
||||
.limit(1);
|
||||
|
||||
if (!cert) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Certificate with ID ${certId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
domainId = cert.domainId ?? undefined;
|
||||
if (!domainId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Certificate with ID ${certId} does not have a domain`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Must provide either certId or domainId"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the domain is a namespace domain
|
||||
const [namespaceDomain] = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (namespaceDomain) {
|
||||
// If it's a namespace domain, we can skip the org check
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if the domain is associated with the org
|
||||
const [orgDomain] = await db
|
||||
.select()
|
||||
.from(orgDomains)
|
||||
.where(
|
||||
and(
|
||||
eq(orgDomains.orgId, orgId),
|
||||
eq(orgDomains.domainId, domainId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!orgDomain) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization does not have access to this certificate"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying certificate access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
export default verifyCertificateAccess;
|
||||
@@ -1,115 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { Certificate, certificates, db, domains } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { Transaction } from "@server/db";
|
||||
import { eq, or, and, like } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Checks if a certificate exists for the given domain.
|
||||
* If not, creates a new certificate in 'pending' state.
|
||||
* Wildcard certs cover subdomains.
|
||||
*/
|
||||
export async function createCertificate(
|
||||
domainId: string,
|
||||
domain: string,
|
||||
trx: Transaction | typeof db
|
||||
) {
|
||||
const [domainRecord] = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domainRecord) {
|
||||
throw new Error(`Domain with ID ${domainId} not found`);
|
||||
}
|
||||
|
||||
let existing: Certificate[] = [];
|
||||
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
existing = await trx
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
or(
|
||||
eq(certificates.domain, domain),
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
eq(certificates.domain, domainLevelDown),
|
||||
eq(certificates.domain, wildcardPrefixed)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// For non-NS domains, we only match exact domain names
|
||||
existing = await trx
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
eq(certificates.domain, domain) // exact match for non-NS domains
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.info(`Certificate already exists for domain ${domain}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let domainToWrite = domain;
|
||||
if (
|
||||
domainRecord.type == "wildcard" && // this is to fix the wildcard certs for traefik in self hosted NOT ON THE CLOUD
|
||||
domainRecord.preferWildcardCert &&
|
||||
!domain.startsWith("*.")
|
||||
) {
|
||||
// in this case traefik is going to generate a domain one level down so we need to store it that way
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
domainToWrite = `*.${domainToWrite}`;
|
||||
}
|
||||
} else if (domainRecord.type == "ns") {
|
||||
if (domain == domainRecord.baseDomain) {
|
||||
domainToWrite = domainRecord.baseDomain;
|
||||
} else {
|
||||
const parts = domain.split(".");
|
||||
if (parts.length > 2) {
|
||||
domainToWrite = parts.slice(1).join(".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No cert found, create a new one in pending state
|
||||
await trx.insert(certificates).values({
|
||||
domain: domainToWrite,
|
||||
domainId,
|
||||
wildcard:
|
||||
domainRecord.type == "ns" ||
|
||||
(domainRecord.type == "wildcard" &&
|
||||
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
|
||||
status: "pending",
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
createdAt: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { certificates, db, domainNamespaces, domains, orgDomains } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { type GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getCertificateParamSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const getCertificateQuerySchema = z.object({
|
||||
domains: z.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z.array(z.string().min(1).max(255))
|
||||
)
|
||||
});
|
||||
|
||||
async function query(orgId: string, domainList: string[]) {
|
||||
// Try to get CNAME certificates first
|
||||
const existingCertificates = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
wildcard: certificates.wildcard,
|
||||
status: certificates.status,
|
||||
expiresAt: certificates.expiresAt,
|
||||
lastRenewalAttempt: certificates.lastRenewalAttempt,
|
||||
createdAt: certificates.createdAt,
|
||||
updatedAt: certificates.updatedAt,
|
||||
errorMessage: certificates.errorMessage,
|
||||
renewalCount: certificates.renewalCount,
|
||||
domainId: domains.domainId,
|
||||
domainType: domains.type
|
||||
})
|
||||
.from(certificates)
|
||||
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(
|
||||
eq(domains.domainId, orgDomains.domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domains.domainId, domainNamespaces.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(certificates.domain, domainList),
|
||||
// Namespace domains are shared across all orgs, so they skip
|
||||
// the org-ownership check (mirrors verifyCertificateAccess).
|
||||
or(
|
||||
isNotNull(orgDomains.orgId),
|
||||
isNotNull(domainNamespaces.domainNamespaceId)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// All non resolved domain certificates might be `ns` or `wildcard`,
|
||||
// which means exact domain certificates do not exist
|
||||
const foundDomains = new Set(
|
||||
existingCertificates.map((cert) => cert.domain)
|
||||
);
|
||||
const domainsWithMissingCertificates = domainList.filter(
|
||||
(domain) => !foundDomains.has(domain)
|
||||
);
|
||||
|
||||
if (domainsWithMissingCertificates.length > 0) {
|
||||
const domainLevelDownSet = new Set<string>();
|
||||
const wildcardDomainSet = new Set<string>();
|
||||
|
||||
for (const domain of domainsWithMissingCertificates) {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
domainLevelDownSet.add(domainLevelDown);
|
||||
wildcardDomainSet.add(wildcardPrefixed);
|
||||
}
|
||||
|
||||
// Need to map the certificates to each domain
|
||||
const wildcardCertificates = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
wildcard: certificates.wildcard,
|
||||
status: certificates.status,
|
||||
expiresAt: certificates.expiresAt,
|
||||
lastRenewalAttempt: certificates.lastRenewalAttempt,
|
||||
createdAt: certificates.createdAt,
|
||||
updatedAt: certificates.updatedAt,
|
||||
errorMessage: certificates.errorMessage,
|
||||
renewalCount: certificates.renewalCount,
|
||||
domainId: domains.domainId,
|
||||
domainType: domains.type
|
||||
})
|
||||
.from(certificates)
|
||||
.innerJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||
.leftJoin(
|
||||
orgDomains,
|
||||
and(
|
||||
eq(domains.domainId, orgDomains.domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domains.domainId, domainNamespaces.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
inArray(certificates.domain, [...domainLevelDownSet]),
|
||||
inArray(certificates.domain, [...wildcardDomainSet])
|
||||
),
|
||||
or(
|
||||
isNotNull(orgDomains.orgId),
|
||||
isNotNull(domainNamespaces.domainNamespaceId)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
existingCertificates.push(...wildcardCertificates);
|
||||
}
|
||||
|
||||
const certificateMap: Record<string, any> = {};
|
||||
for (const domain of domainList) {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
certificateMap[domain] =
|
||||
existingCertificates.find(
|
||||
(cert) =>
|
||||
cert.domain === domain ||
|
||||
cert.domain === domainLevelDown ||
|
||||
cert.domain === wildcardPrefixed
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
return certificateMap;
|
||||
}
|
||||
|
||||
export async function getBatchedCertificates(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getCertificateParamSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const parsedQuery = getCertificateQuerySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domains } = parsedQuery.data;
|
||||
|
||||
const cert = await query(orgId, domains);
|
||||
|
||||
return response<GetBatchedCertificateResponse>(res, {
|
||||
data: cert,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificates retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { certificates, db, domains } from "@server/db";
|
||||
import { eq, and, or, like } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { registry } from "@server/openApi";
|
||||
import { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||
|
||||
const getCertificateSchema = z.strictObject({
|
||||
domainId: z.string(),
|
||||
domain: z.string().min(1).max(255),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
async function query(domainId: string, domain: string) {
|
||||
const [domainRecord] = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (!domainRecord) {
|
||||
throw new Error(`Domain with ID ${domainId} not found`);
|
||||
}
|
||||
|
||||
const domainType = domainRecord.type;
|
||||
|
||||
let existing: any[] = [];
|
||||
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
|
||||
const domainLevelDown = domain.split(".").slice(1).join(".");
|
||||
const wildcardPrefixed = `*.${domainLevelDown}`;
|
||||
|
||||
existing = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
wildcard: certificates.wildcard,
|
||||
status: certificates.status,
|
||||
expiresAt: certificates.expiresAt,
|
||||
lastRenewalAttempt: certificates.lastRenewalAttempt,
|
||||
createdAt: certificates.createdAt,
|
||||
updatedAt: certificates.updatedAt,
|
||||
errorMessage: certificates.errorMessage,
|
||||
renewalCount: certificates.renewalCount
|
||||
})
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
or(
|
||||
eq(certificates.domain, domain),
|
||||
and(
|
||||
eq(certificates.wildcard, true),
|
||||
or(
|
||||
eq(certificates.domain, domainLevelDown),
|
||||
eq(certificates.domain, wildcardPrefixed)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// For non-NS domains, we only match exact domain names
|
||||
existing = await db
|
||||
.select({
|
||||
certId: certificates.certId,
|
||||
domain: certificates.domain,
|
||||
wildcard: certificates.wildcard,
|
||||
status: certificates.status,
|
||||
expiresAt: certificates.expiresAt,
|
||||
lastRenewalAttempt: certificates.lastRenewalAttempt,
|
||||
createdAt: certificates.createdAt,
|
||||
updatedAt: certificates.updatedAt,
|
||||
errorMessage: certificates.errorMessage,
|
||||
renewalCount: certificates.renewalCount
|
||||
})
|
||||
.from(certificates)
|
||||
.where(
|
||||
and(
|
||||
eq(certificates.domainId, domainId),
|
||||
eq(certificates.domain, domain) // exact match for non-NS domains
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return existing.length > 0 ? { ...existing[0], domainType } : null;
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/certificate/{domainId}/{domain}",
|
||||
description: "Get a certificate by domain.",
|
||||
tags: ["Certificate"],
|
||||
request: {
|
||||
params: z.object({
|
||||
domainId: z.string(),
|
||||
domain: z.string().min(1).max(255),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getCertificateSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domainId, domain } = parsedParams.data;
|
||||
|
||||
const cert = await query(domainId, domain);
|
||||
|
||||
if (!cert) {
|
||||
logger.warn(`Certificate not found for domain: ${domainId}`);
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetCertificateResponse>(res, {
|
||||
data: cert,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificate retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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.
|
||||
*/
|
||||
|
||||
export * from "./getCertificate";
|
||||
export * from "./restartCertificate";
|
||||
export * from "./syncCertToNewts";
|
||||
export * from "./getBatchedCertificates";
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { certificates, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const restartCertificateParamsSchema = z.strictObject({
|
||||
certId: z.coerce.number().int().positive(),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/certificate/{certId}",
|
||||
description: "Restart a certificate by ID.",
|
||||
tags: ["Certificate"],
|
||||
request: {
|
||||
params: z.object({
|
||||
certId: z.coerce.number().int().positive(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function restartCertificate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = restartCertificateParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { certId } = parsedParams.data;
|
||||
|
||||
// get the certificate by ID
|
||||
const [cert] = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.certId, certId))
|
||||
.limit(1);
|
||||
|
||||
if (!cert) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Certificate not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (cert.status != "failed" && cert.status != "expired") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Certificate is already valid, no need to restart"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// update the certificate status to 'pending'
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
status: "pending",
|
||||
errorMessage: null,
|
||||
lastRenewalAttempt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.where(eq(certificates.certId, certId));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Certificate restarted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const bodySchema = z.object({
|
||||
domain: z.string().min(1),
|
||||
domainId: z.string().nullable().optional().default(null)
|
||||
});
|
||||
|
||||
export async function syncCertToNewts(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const parsed = bodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsed.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { domain, domainId } = parsed.data;
|
||||
|
||||
logger.debug(
|
||||
`syncCertToNewts: received request to push cert update for domain "${domain}" (domainId: ${domainId ?? "none"})`
|
||||
);
|
||||
|
||||
try {
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
|
||||
res.status(HttpCode.OK).json({
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: `Certificate update pushed to affected newts for domain "${domain}"`
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`syncCertToNewts: error pushing cert update for domain "${domain}": ${err}`
|
||||
);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to push certificate update to affected newts"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import { createStore } from "#private/lib/rateLimitStore";
|
||||
import * as billing from "#private/routers/billing";
|
||||
import * as remoteExitNode from "#private/routers/remoteExitNode";
|
||||
@@ -50,7 +49,6 @@ import {
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
logActionAudit,
|
||||
verifyCertificateAccess,
|
||||
verifyIdpAccess,
|
||||
verifyLoginPageAccess,
|
||||
verifyRemoteExitNodeAccess,
|
||||
@@ -164,32 +162,6 @@ authenticated.get(
|
||||
orgIdp.listUserAdminOrgIdps
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/certificate/:domainId/:domain",
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||
certificates.getCertificate
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/batched-certificates",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getCertificate),
|
||||
certificates.getBatchedCertificates
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/certificate/:certId/restart",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.restartCertificate),
|
||||
logActionAudit(ActionsEnum.restartCertificate),
|
||||
certificates.restartCertificate
|
||||
);
|
||||
|
||||
if (build === "saas") {
|
||||
authenticated.post(
|
||||
"/org/:orgId/billing/create-checkout-session",
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import * as certificates from "@server/routers/certificates";
|
||||
import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as policy from "#private/routers/policy";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
|
||||
@@ -29,7 +29,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
|
||||
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { createCertificate } from "#private/routers/certificates/createCertificate";
|
||||
import { createCertificate } from "@server/routers/certificates/createCertificate";
|
||||
|
||||
import { UpdateLoginPageResponse } from "@server/routers/loginPage/types";
|
||||
|
||||
@@ -85,7 +85,6 @@ export async function updateLoginPage(
|
||||
|
||||
const { loginPageId, orgId } = parsedParams.data;
|
||||
|
||||
|
||||
const [existingLoginPage] = await db
|
||||
.select()
|
||||
.from(loginPage)
|
||||
|
||||
Reference in New Issue
Block a user