From 89b6b1fb569926947dcb957a053f35a5f1386c06 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 12 Apr 2026 16:49:49 -0700 Subject: [PATCH] Placeholder screen and certs are working --- server/lib/billing/tierMatrix.ts | 6 ++- server/lib/encryption.ts | 39 --------------- server/lib/ip.ts | 2 +- server/private/lib/acmeCertSync.ts | 47 +++++++------------ server/private/lib/certificates.ts | 29 +++++------- server/private/lib/readConfigFile.ts | 6 +-- .../private/lib/traefik/getTraefikConfig.ts | 43 ++++++++++------- server/private/routers/hybrid.ts | 38 ++++----------- .../siteResource/createSiteResource.ts | 17 ++++++- .../siteResource/updateSiteResource.ts | 19 +++++++- .../CreateInternalResourceDialog.tsx | 4 +- src/components/EditInternalResourceDialog.tsx | 4 +- src/components/InternalResourceForm.tsx | 19 +++++++- 13 files changed, 127 insertions(+), 146 deletions(-) delete mode 100644 server/lib/encryption.ts diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c76dcd95b..b83e56077 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -19,7 +19,8 @@ export enum TierFeature { SshPam = "sshPam", FullRbac = "fullRbac", SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed - SIEM = "siem" // handle downgrade by disabling SIEM integrations + SIEM = "siem", // handle downgrade by disabling SIEM integrations + HTTPPrivateResources = "httpPrivateResources" // handle downgrade by disabling HTTP private resources } export const tierMatrix: Record = { @@ -56,5 +57,6 @@ export const tierMatrix: Record = { [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], - [TierFeature.SIEM]: ["enterprise"] + [TierFeature.SIEM]: ["enterprise"], + [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"] }; diff --git a/server/lib/encryption.ts b/server/lib/encryption.ts deleted file mode 100644 index 79caecd1a..000000000 --- a/server/lib/encryption.ts +++ /dev/null @@ -1,39 +0,0 @@ -import crypto from "crypto"; - -export function encryptData(data: string, key: Buffer): string { - const algorithm = "aes-256-gcm"; - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv(algorithm, key, iv); - - let encrypted = cipher.update(data, "utf8", "hex"); - encrypted += cipher.final("hex"); - - const authTag = cipher.getAuthTag(); - - // Combine IV, auth tag, and encrypted data - return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted; -} - -// Helper function to decrypt data (you'll need this to read certificates) -export function decryptData(encryptedData: string, key: Buffer): string { - const algorithm = "aes-256-gcm"; - const parts = encryptedData.split(":"); - - if (parts.length !== 3) { - throw new Error("Invalid encrypted data format"); - } - - const iv = Buffer.from(parts[0], "hex"); - const authTag = Buffer.from(parts[1], "hex"); - const encrypted = parts[2]; - - const decipher = crypto.createDecipheriv(algorithm, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(encrypted, "hex", "utf8"); - decrypted += decipher.final("utf8"); - - return decrypted; -} - -// openssl rand -hex 32 > config/encryption.key diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 13d35834b..ce9435983 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -5,7 +5,7 @@ import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; import semver from "semver"; -import { getValidCertificatesForDomains } from "#private/lib/certificates"; +import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; interface IPRange { start: bigint; diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 04d40809c..052488f0f 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -15,9 +15,10 @@ import fs from "fs"; import crypto from "crypto"; import { certificates, domains, db } from "@server/db"; import { and, eq } from "drizzle-orm"; -import { encryptData, decryptData } from "@server/lib/encryption"; +import { encrypt, decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; -import config from "#private/lib/config"; +import privateConfig from "#private/lib/config"; +import config from "@server/lib/config"; interface AcmeCert { domain: { main: string; sans?: string[] }; @@ -32,14 +33,6 @@ interface AcmeJson { }; } -function getEncryptionKey(): Buffer { - const keyHex = config.getRawPrivateConfig().server.encryption_key; - if (!keyHex) { - throw new Error("acmeCertSync: encryption key is not configured"); - } - return Buffer.from(keyHex, "hex"); -} - async function findDomainId(certDomain: string): Promise { // Strip wildcard prefix before lookup (*.example.com -> example.com) const lookupDomain = certDomain.startsWith("*.") @@ -99,9 +92,7 @@ async function syncAcmeCerts( try { raw = fs.readFileSync(acmeJsonPath, "utf8"); } catch (err) { - logger.debug( - `acmeCertSync: could not read ${acmeJsonPath}: ${err}` - ); + logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`); return; } @@ -121,15 +112,13 @@ async function syncAcmeCerts( return; } - const encryptionKey = getEncryptionKey(); + for (const cert of resolverData.Certificates) { const domain = cert.domain?.main; if (!domain) { - logger.debug( - `acmeCertSync: skipping cert with missing domain` - ); + logger.debug(`acmeCertSync: skipping cert with missing domain`); continue; } @@ -161,9 +150,9 @@ async function syncAcmeCerts( if (existing.length > 0 && existing[0].certFile) { try { - const storedCertPem = decryptData( + const storedCertPem = decrypt( existing[0].certFile, - encryptionKey + config.getRawConfig().server.secret! ); if (storedCertPem === certPem) { logger.debug( @@ -185,9 +174,7 @@ async function syncAcmeCerts( if (firstCertPem) { try { const x509 = new crypto.X509Certificate(firstCertPem); - expiresAt = Math.floor( - new Date(x509.validTo).getTime() / 1000 - ); + expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000); } catch (err) { logger.debug( `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` @@ -196,8 +183,8 @@ async function syncAcmeCerts( } const wildcard = domain.startsWith("*."); - const encryptedCert = encryptData(certPem, encryptionKey); - const encryptedKey = encryptData(keyPem, encryptionKey); + const encryptedCert = encrypt(certPem, config.getRawConfig().server.secret!); + const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!); const now = Math.floor(Date.now() / 1000); const domainId = await findDomainId(domain); @@ -249,16 +236,16 @@ async function syncAcmeCerts( } export function initAcmeCertSync(): void { - const privateConfig = config.getRawPrivateConfig(); + const privateConfigData = privateConfig.getRawPrivateConfig(); - if (!privateConfig.flags?.enable_acme_cert_sync) { + if (!privateConfigData.flags?.enable_acme_cert_sync) { return; } const acmeJsonPath = - privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; - const resolver = privateConfig.acme?.resolver ?? "letsencrypt"; - const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000; + privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; + const resolver = privateConfigData.acme?.resolver ?? "letsencrypt"; + const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; logger.info( `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms` @@ -274,4 +261,4 @@ export function initAcmeCertSync(): void { logger.error(`acmeCertSync: error during sync: ${err}`); }); }, intervalMs); -} \ No newline at end of file +} diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 1ec524bb0..ac1a831c1 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -11,23 +11,15 @@ * This file is not licensed under the AGPLv3. */ -import config from "./config"; +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 { decryptData } from "@server/lib/encryption"; +import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import cache from "#private/lib/cache"; -let encryptionKeyHex = ""; -let encryptionKey: Buffer; -function loadEncryptData() { - if (encryptionKey) { - return; // already loaded - } - encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key; - encryptionKey = Buffer.from(encryptionKeyHex, "hex"); -} // Define the return type for clarity and type safety export type CertificateResult = { @@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true ): Promise> { - loadEncryptData(); // Ensure encryption key is loaded + const finalResults: CertificateResult[] = []; const domainsToQuery = new Set(); @@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains( // 2. If all domains were resolved from the cache, return early if (domainsToQuery.size === 0) { - const decryptedResults = decryptFinalResults(finalResults); + const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); return decryptedResults; } @@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains( } } - const decryptedResults = decryptFinalResults(finalResults); + const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); return decryptedResults; } function decryptFinalResults( - finalResults: CertificateResult[] + finalResults: CertificateResult[], + secret: string ): CertificateResult[] { const validCertsDecrypted = finalResults.map((cert) => { // Decrypt and save certificate file - const decryptedCert = decryptData( + const decryptedCert = decrypt( cert.certFile!, // is not null from query - encryptionKey + secret ); // Decrypt and save key file - const decryptedKey = decryptData(cert.keyFile!, encryptionKey); + const decryptedKey = decrypt(cert.keyFile!, secret); // Return only the certificate data without org information return { diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index a755e9fc3..02ee24f88 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({ }), server: z .object({ - encryption_key: z - .string() - .optional() - .transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")), reo_client_id: z .string() .optional() @@ -96,7 +92,7 @@ export const privateConfigSchema = z.object({ enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional(), - enable_acme_cert_sync: z.boolean().optional().default(false) + enable_acme_cert_sync: z.boolean().optional().default(true) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index e82f0bdc7..5b9c2da8a 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -268,10 +268,10 @@ export async function getTraefikConfig( }); // Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge - const siteResourcesWithAliases = await db + const siteResourcesWithFullDomain = await db .select({ siteResourceId: siteResources.siteResourceId, - alias: siteResources.alias, + fullDomain: siteResources.fullDomain, mode: siteResources.mode }) .from(siteResources) @@ -279,7 +279,7 @@ export async function getTraefikConfig( .where( and( eq(siteResources.enabled, true), - isNotNull(siteResources.alias), + isNotNull(siteResources.fullDomain), eq(siteResources.mode, "http"), eq(siteResources.ssl, true), or( @@ -305,9 +305,9 @@ export async function getTraefikConfig( } } // Include siteResource aliases so pangolin-dns also fetches certs for them - for (const sr of siteResourcesWithAliases) { - if (sr.alias) { - domains.add(sr.alias); + for (const sr of siteResourcesWithFullDomain) { + if (sr.fullDomain) { + domains.add(sr.fullDomain); } } // get the valid certs for these domains @@ -904,7 +904,7 @@ export async function getTraefikConfig( // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that // Traefik generates TLS certificates for those domains even when no // matching resource exists yet. - if (siteResourcesWithAliases.length > 0) { + if (siteResourcesWithFullDomain.length > 0) { // Build a set of domains already covered by normal resources const existingFullDomains = new Set(); for (const resource of resourcesMap.values()) { @@ -913,13 +913,13 @@ export async function getTraefikConfig( } } - for (const sr of siteResourcesWithAliases) { - if (!sr.alias) continue; + for (const sr of siteResourcesWithFullDomain) { + if (!sr.fullDomain) continue; // Skip if this alias is already handled by a resource router - if (existingFullDomains.has(sr.alias)) continue; + if (existingFullDomains.has(sr.fullDomain)) continue; - const alias = sr.alias; + const fullDomain = sr.fullDomain; const srKey = `site-resource-cert-${sr.siteResourceId}`; const siteResourceServiceName = `${srKey}-service`; const siteResourceRouterName = `${srKey}-router`; @@ -970,7 +970,7 @@ export async function getTraefikConfig( ], middlewares: [redirectHttpsMiddlewareName], service: siteResourceServiceName, - rule: `Host(\`${alias}\`)`, + rule: `Host(\`${fullDomain}\`)`, priority: 100 }; @@ -979,7 +979,7 @@ export async function getTraefikConfig( if ( !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns ) { - const domainParts = alias.split("."); + const domainParts = fullDomain.split("."); const wildCard = domainParts.length <= 2 ? `*.${domainParts.join(".")}` @@ -999,11 +999,11 @@ export async function getTraefikConfig( } else { // pangolin-dns: only add route if we already have a valid cert const matchingCert = validCerts.find( - (cert) => cert.queriedDomain === alias + (cert) => cert.queriedDomain === fullDomain ); if (!matchingCert) { logger.debug( - `No matching certificate found for siteResource alias: ${alias}` + `No matching certificate found for siteResource alias: ${fullDomain}` ); continue; } @@ -1016,10 +1016,21 @@ export async function getTraefikConfig( ], service: siteResourceServiceName, middlewares: [siteResourceRewriteMiddlewareName], - rule: `Host(\`${alias}\`)`, + rule: `Host(\`${fullDomain}\`)`, priority: 100, tls }; + + // Assets bypass router — lets Next.js static files load without rewrite + config_output.http.routers[`${siteResourceRouterName}-assets`] = { + entryPoints: [ + config.getRawConfig().traefik.https_entrypoint + ], + service: siteResourceServiceName, + rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + priority: 101, + tls + }; } } diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 13a6f70e0..7a848cc02 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -24,14 +24,8 @@ import { User, certificates, exitNodeOrgs, - RemoteExitNode, - olms, - newts, - clients, - sites, domains, orgDomains, - targets, loginPage, loginPageOrg, LoginPage, @@ -70,12 +64,9 @@ import { updateAndGenerateEndpointDestinations, updateSiteBandwidth } from "@server/routers/gerbil"; -import * as gerbil from "@server/routers/gerbil"; import logger from "@server/logger"; -import { decryptData } from "@server/lib/encryption"; +import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import privateConfig from "#private/lib/config"; -import * as fs from "fs"; import { exchangeSession } from "@server/routers/badger"; import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; @@ -298,25 +289,11 @@ hybridRouter.get( } ); -let encryptionKeyHex = ""; -let encryptionKey: Buffer; -function loadEncryptData() { - if (encryptionKey) { - return; // already loaded - } - - encryptionKeyHex = - privateConfig.getRawPrivateConfig().server.encryption_key; - encryptionKey = Buffer.from(encryptionKeyHex, "hex"); -} - // Get valid certificates for given domains (supports wildcard certs) hybridRouter.get( "/certificates/domains", async (req: Request, res: Response, next: NextFunction) => { try { - loadEncryptData(); // Ensure encryption key is loaded - const parsed = getCertificatesByDomainsQuerySchema.safeParse( req.query ); @@ -447,13 +424,13 @@ hybridRouter.get( const result = filtered.map((cert) => { // Decrypt and save certificate file - const decryptedCert = decryptData( + const decryptedCert = decrypt( cert.certFile!, // is not null from query - encryptionKey + config.getRawConfig().server.secret! ); // Decrypt and save key file - const decryptedKey = decryptData(cert.keyFile!, encryptionKey); + const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!); // Return only the certificate data without org information return { @@ -833,9 +810,12 @@ hybridRouter.get( ) ); - logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); + logger.debug( + `User ${userId} has roles in org ${orgId}:`, + userOrgRoleRows + ); - return response<{ roleId: number, roleName: string }[]>(res, { + return response<{ roleId: number; roleName: string }[]>(res, { data: userOrgRoleRows, success: true, error: false, diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index ec2eda527..b5cd64656 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -17,7 +17,7 @@ import { portRangeStringSchema } from "@server/lib/ip"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -201,6 +201,21 @@ export async function createSiteResource( subdomain } = parsedBody.data; + if (mode == "http") { + const hasHttpFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.HTTPPrivateResources] + ); + if (!hasHttpFeature) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "HTTP private resources are not included in your current plan. Please upgrade." + ) + ); + } + } + // Verify the site exists and belongs to the org const [site] = await db .select() diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 6e253d0e3..980116cdb 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,4 +1,3 @@ -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { clientSiteResources, clientSiteResourcesAssociationsCache, @@ -13,7 +12,8 @@ import { Transaction, userSiteResources } from "@server/db"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { generateAliasConfig, @@ -237,6 +237,21 @@ export async function updateSiteResource( ); } + if (mode == "http") { + const hasHttpFeature = await isLicensedOrSubscribed( + existingSiteResource.orgId, + tierMatrix[TierFeature.HTTPPrivateResources] + ); + if (!hasHttpFeature) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "HTTP private resources are not included in your current plan. Please upgrade." + ) + ); + } + } + const isLicensedSshPam = await isLicensedOrSubscribed( existingSiteResource.orgId, tierMatrix.sshPam diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 177571dff..7346a2397 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -45,6 +45,7 @@ export default function CreateInternalResourceDialog({ const t = useTranslations(); const api = createApiClient(useEnvContext()); const [isSubmitting, setIsSubmitting] = useState(false); + const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false); async function handleSubmit(values: InternalResourceFormValues) { setIsSubmitting(true); @@ -159,6 +160,7 @@ export default function CreateInternalResourceDialog({ orgId={orgId} formId="create-internal-resource-form" onSubmit={handleSubmit} + onSubmitDisabledChange={setIsHttpModeDisabled} /> @@ -174,7 +176,7 @@ export default function CreateInternalResourceDialog({