diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 06b427955..beeeef39d 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -274,6 +274,216 @@ function detectWildcard( return { wildcard: false, wildcardSan: null }; } +interface HttpCert { + wildcard: boolean; + altName: string; + certName: string; + commonName: string; + certFile: string; + keyFile: string; +} + +async function syncAcmeCertsFromHttp(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint); + } catch (err) { + logger.debug( + `acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}` + ); + return; + } + + if (!response.ok) { + logger.debug( + `acmeCertSync: HTTP endpoint returned status ${response.status}` + ); + return; + } + + let httpCerts: HttpCert[]; + try { + httpCerts = await response.json(); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse JSON from HTTP endpoint: ${err}` + ); + return; + } + + if (!Array.isArray(httpCerts) || httpCerts.length === 0) { + logger.debug( + `acmeCertSync: no certificates returned from HTTP endpoint` + ); + return; + } + + for (const cert of httpCerts) { + const domain = cert?.certName; + + if (!domain || typeof domain !== "string") { + logger.debug( + `acmeCertSync: skipping HTTP cert with missing certName` + ); + continue; + } + + const certPem = cert.certFile; + const keyPem = cert.keyFile; + + if (!certPem?.trim() || !keyPem?.trim()) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile` + ); + continue; + } + + const firstCertPemForValidation = extractFirstCert(certPem); + if (!firstCertPemForValidation) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found` + ); + continue; + } + + let validatedX509: crypto.X509Certificate; + try { + validatedX509 = new crypto.X509Certificate( + firstCertPemForValidation + ); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}` + ); + continue; + } + + try { + crypto.createPrivateKey(keyPem); + } catch (err) { + logger.debug( + `acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}` + ); + continue; + } + + const wildcard = cert.wildcard ?? false; + + const existing = await db + .select() + .from(certificates) + .where(eq(certificates.domain, domain)) + .limit(1); + + let oldCertPem: string | null = null; + let oldKeyPem: string | null = null; + + if (existing.length > 0 && existing[0].certFile) { + try { + const storedCertPem = decrypt( + existing[0].certFile, + config.getRawConfig().server.secret! + ); + const wildcardUnchanged = existing[0].wildcard === wildcard; + if (storedCertPem === certPem && wildcardUnchanged) { + continue; + } + 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) { + logger.debug( + `acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}` + ); + } + } + + let expiresAt: number | null = null; + try { + expiresAt = Math.floor( + new Date(validatedX509.validTo).getTime() / 1000 + ); + } catch (err) { + logger.debug( + `acmeCertSync: could not parse cert expiry for ${domain}: ${err}` + ); + } + + 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); + if (domainId) { + logger.debug( + `acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"` + ); + } else { + logger.debug( + `acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"` + ); + } + + if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db + .update(certificates) + .set({ + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + updatedAt: now, + wildcard, + ...(domainId !== null && { domainId }) + }) + .where(eq(certificates.domain, domain)); + + await pushCertUpdateToAffectedNewts( + domain, + domainId, + oldCertPem, + oldKeyPem + ); + } else { + logger.debug( + `acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); + await db.insert(certificates).values({ + domain, + domainId, + certFile: encryptedCert, + keyFile: encryptedKey, + status: "valid", + expiresAt, + createdAt: now, + updatedAt: now, + wildcard + }); + + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + } + } +} + async function syncAcmeCerts(acmeJsonPath: string): Promise { let raw: string; try { @@ -389,11 +599,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { const existing = await db .select() .from(certificates) - .where( - and( - eq(certificates.domain, domain) - ) - ) + .where(and(eq(certificates.domain, domain))) .limit(1); let oldCertPem: string | null = null; @@ -408,7 +614,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { const wildcardUnchanged = existing[0].wildcard === wildcard; if (storedCertPem === certPem && wildcardUnchanged) { // logger.debug( - // `acmeCertSync: cert for ${domain} is unchanged, skipping` + // `acmeCertSync: cert for ${domain} is unchanged, skipping` // ); continue; } @@ -547,19 +753,32 @@ export function initAcmeCertSync(): void { privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json"; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; + const httpEndpoint = privateConfigData.acme?.acme_http_endpoint; logger.debug( `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` ); + if (httpEndpoint) { + logger.debug( + `acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms` + ); + } + + const runSync = () => { + if (httpEndpoint) { + syncAcmeCertsFromHttp(httpEndpoint).catch((err) => { + logger.error(`acmeCertSync: error during HTTP sync: ${err}`); + }); + } else { + // only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up + syncAcmeCerts(acmeJsonPath).catch((err) => { + logger.error(`acmeCertSync: error during sync: ${err}`); + }); + } + }; // Run immediately on init, then on the configured interval - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during initial sync: ${err}`); - }); + runSync(); - setInterval(() => { - syncAcmeCerts(acmeJsonPath).catch((err) => { - logger.error(`acmeCertSync: error during sync: ${err}`); - }); - }, intervalMs); + setInterval(runSync, intervalMs); } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 056624159..63ca0b068 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -21,173 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); -export const privateConfigSchema = z.object({ - app: z - .object({ - region: z.string().optional().default("default"), - base_domain: z.string().optional(), - identity_provider_mode: z.enum(["global", "org"]).optional() - }) - .optional() - .default({ - region: "default" - }), - server: z - .object({ - reo_client_id: z - .string() - .optional() - .transform(getEnvOrYaml("REO_CLIENT_ID")), - fossorial_api: z - .string() - .optional() - .default("https://api.fossorial.io"), - fossorial_api_key: z - .string() - .optional() - .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) - }) - .optional() - .prefault({}), - redis: z - .object({ - host: z.string(), - port: portSchema, - password: z - .string() - .optional() - .transform(getEnvOrYaml("REDIS_PASSWORD")), - db: z.int().nonnegative().optional().default(0), - replicas: z - .array( - z.object({ - host: z.string(), - port: portSchema, - password: z.string().optional(), - db: z.int().nonnegative().optional().default(0) +export const privateConfigSchema = z + .object({ + app: z + .object({ + region: z.string().optional().default("default"), + base_domain: z.string().optional(), + identity_provider_mode: z.enum(["global", "org"]).optional() + }) + .optional() + .default({ + region: "default" + }), + server: z + .object({ + reo_client_id: z + .string() + .optional() + .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), + fossorial_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) + }) + .optional() + .prefault({}), + redis: z + .object({ + host: z.string(), + port: portSchema, + password: z + .string() + .optional() + .transform(getEnvOrYaml("REDIS_PASSWORD")), + db: z.int().nonnegative().optional().default(0), + replicas: z + .array( + z.object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.int().nonnegative().optional().default(0) + }) + ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z.boolean().optional().default(true) }) - ) - .optional(), - tls: z - .object({ - rejectUnauthorized: z - .boolean() - .optional() - .default(true) - }) - .optional() - }) - .optional(), - gerbil: z - .object({ - local_exit_node_reachable_at: z - .string() - .optional() - .default("http://gerbil:3004") - }) - .optional() - .prefault({}), - flags: 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(true) - }) - .optional() - .prefault({}), - acme: z - .object({ - acme_json_path: z - .string() - .optional() - .default("config/letsencrypt/acme.json"), - sync_interval_ms: z.number().optional().default(5000) - }) - .optional(), - branding: z - .object({ - app_name: z.string().optional(), - background_image_path: z.string().optional(), - colors: z - .object({ - light: colorsSchema.optional(), - dark: colorsSchema.optional() - }) - .optional(), - logo: z - .object({ - light_path: z.string().optional(), - dark_path: z.string().optional(), - auth_page: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional(), - navbar: z - .object({ - width: z.number().optional(), - height: z.number().optional() - }) - .optional() - }) - .optional(), - footer: z - .array( - z.object({ - text: z.string(), - href: z.string().optional() + .optional() + }) + .optional(), + gerbil: z + .object({ + local_exit_node_reachable_at: z + .string() + .optional() + .default("http://gerbil:3004") + }) + .optional() + .prefault({}), + flags: 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(true) + }) + .optional() + .prefault({}), + acme: z + .object({ + acme_json_path: z + .string() + .optional() + .default("config/letsencrypt/acme.json"), + acme_http_endpoint: z.string().optional(), + sync_interval_ms: z.number().optional().default(5000) + }) + .optional(), + branding: z + .object({ + app_name: z.string().optional(), + background_image_path: z.string().optional(), + colors: z + .object({ + light: colorsSchema.optional(), + dark: colorsSchema.optional() }) - ) - .optional(), - hide_auth_layout_footer: z.boolean().optional().default(false), - login_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - signup_page: z - .object({ - subtitle_text: z.string().optional() - }) - .optional(), - resource_auth_page: z - .object({ - show_logo: z.boolean().optional(), - hide_powered_by: z.boolean().optional(), - title_text: z.string().optional(), - subtitle_text: z.string().optional() - }) - .optional(), - emails: z - .object({ - signature: z.string().optional(), - colors: z - .object({ - primary: z.string().optional() + .optional(), + logo: z + .object({ + light_path: z.string().optional(), + dark_path: z.string().optional(), + auth_page: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional(), + navbar: z + .object({ + width: z.number().optional(), + height: z.number().optional() + }) + .optional() + }) + .optional(), + footer: z + .array( + z.object({ + text: z.string(), + href: z.string().optional() }) - .optional() - }) - .optional() - }) - .optional(), - stripe: z - .object({ - secret_key: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), - webhook_secret: z - .string() - .optional() - .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), - // s3Bucket: z.string(), - // s3Region: z.string().default("us-east-1"), - // localFilePath: z.string().optional() - }) - .optional() -}) + ) + .optional(), + hide_auth_layout_footer: z.boolean().optional().default(false), + login_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + signup_page: z + .object({ + subtitle_text: z.string().optional() + }) + .optional(), + resource_auth_page: z + .object({ + show_logo: z.boolean().optional(), + hide_powered_by: z.boolean().optional(), + title_text: z.string().optional(), + subtitle_text: z.string().optional() + }) + .optional(), + emails: z + .object({ + signature: z.string().optional(), + colors: z + .object({ + primary: z.string().optional() + }) + .optional() + }) + .optional() + }) + .optional(), + stripe: z + .object({ + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + webhook_secret: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")) + // s3Bucket: z.string(), + // s3Region: z.string().default("us-east-1"), + // localFilePath: z.string().optional() + }) + .optional() + }) .transform((data) => { // this to maintain backwards compatibility with the old config file const identityProviderMode = data.app?.identity_provider_mode;