diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 61381d2b0..ac8463ebf 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import * as path from "path"; +import { createHash } from "crypto"; import config from "@server/lib/config"; import logger from "@server/logger"; import * as yaml from "js-yaml"; @@ -16,6 +17,10 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import license from "#dynamic/license/license"; +// Flat files in the certificates dir; the per-domain directory scan and +// cleanup only look at directories, so these don't collide with them. +const MTLS_CA_FILE_PREFIX = "mtls-ca-"; + export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; private isRunning = false; @@ -466,8 +471,18 @@ export class TraefikConfigManager { await new Promise((resolve) => setTimeout(resolve, 500)); } + // Swap inline mTLS CA PEMs for files in the certificates dir + const mtlsCaFiles = this.writeMtlsCaFiles(traefikConfig); + // Write traefik config as YAML to a second dynamic config file if changed - await this.writeTraefikDynamicConfig(traefikConfig); + const dynamicConfigWritten = + await this.writeTraefikDynamicConfig(traefikConfig); + + // Only remove stale CA files once the config on disk no longer + // references them + if (dynamicConfigWritten) { + this.cleanupUnusedMtlsCaFiles(mtlsCaFiles); + } // Send domains to SNI proxy let exitNodeForSni: typeof exitNodes.$inferSelect | undefined; @@ -698,7 +713,9 @@ export class TraefikConfigManager { /** * Write traefik config as YAML to a second dynamic config file if changed */ - private async writeTraefikDynamicConfig(traefikConfig: any): Promise { + private async writeTraefikDynamicConfig( + traefikConfig: any + ): Promise { const traefikDynamicConfigPath = config.getRawConfig().traefik.dynamic_router_config_path; let shouldWrite = false; @@ -729,8 +746,102 @@ export class TraefikConfigManager { logger.info("Traefik dynamic config updated"); } catch (err) { logger.error("Failed to write traefik dynamic config:", err); + return false; } } + return true; + } + + /** + * Replace inline PEM entries in tls.options[*].clientAuth.caFiles with + * paths to files in the certificates directory. Files are named by a + * hash of their content so a changed CA gets a new path, which changes + * the dynamic config and makes Traefik reload it. Returns the set of + * file paths the config now references. + */ + private writeMtlsCaFiles(traefikConfig: any): Set { + const referenced = new Set(); + const tlsOptions = traefikConfig?.tls?.options; + if (!tlsOptions) { + return referenced; + } + + const certsPath = config.getRawConfig().traefik.certificates_path; + + for (const [optionsName, options] of Object.entries( + tlsOptions + )) { + const caFiles = options?.clientAuth?.caFiles; + if (!Array.isArray(caFiles)) continue; + + const paths: string[] = []; + for (const pem of caFiles) { + if (typeof pem !== "string" || !pem.includes("-----BEGIN")) { + // Already a file path (or not something we can handle) + paths.push(pem); + continue; + } + + const hash = createHash("sha256") + .update(pem) + .digest("hex") + .slice(0, 32); + const caPath = path.join( + certsPath, + `${MTLS_CA_FILE_PREFIX}${hash}.pem` + ); + try { + if (!fs.existsSync(caPath)) { + this.atomicWriteFileSync(caPath, pem, 0o644); + logger.info( + `Wrote mTLS CA file for TLS options ${optionsName}` + ); + } + paths.push(caPath); + referenced.add(caPath); + } catch (err) { + logger.error( + `Failed to write mTLS CA file for TLS options ${optionsName}, falling back to inline PEM:`, + err + ); + paths.push(pem); + } + } + + options.clientAuth.caFiles = paths; + } + + return referenced; + } + + /** + * Remove mTLS CA files from the certificates directory that the current + * config no longer references + */ + private cleanupUnusedMtlsCaFiles(referenced: Set): void { + const certsPath = config.getRawConfig().traefik.certificates_path; + try { + if (!fs.existsSync(certsPath)) return; + + for (const dirent of fs.readdirSync(certsPath, { + withFileTypes: true + })) { + if ( + !dirent.isFile() || + !dirent.name.startsWith(MTLS_CA_FILE_PREFIX) || + !dirent.name.endsWith(".pem") + ) { + continue; + } + const filePath = path.join(certsPath, dirent.name); + if (!referenced.has(filePath)) { + fs.rmSync(filePath, { force: true }); + logger.info(`Removed unused mTLS CA file ${dirent.name}`); + } + } + } catch (error) { + logger.error("Error cleaning up mTLS CA files:", error); + } } /**