From 39e35bc1d6e5e1f64ce7640f95deaff22a9a72ab Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 12 Aug 2025 16:27:41 -0700 Subject: [PATCH] Add traefik config management --- server/lib/readConfigFile.ts | 34 +- server/lib/remoteTraefikConfig.ts | 582 ++++++++++++++++++++++++++++++ 2 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 server/lib/remoteTraefikConfig.ts diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index e6e7c548..5fb7b955 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -26,13 +26,15 @@ export const configSchema = z .optional() .default("info"), save_logs: z.boolean().optional().default(false), - log_failed_attempts: z.boolean().optional().default(false), + log_failed_attempts: z.boolean().optional().default(false) }), - hybrid: z.object({ - id: z.string().optional(), - secret: z.string().optional(), - endpoint: z.string().optional() - }).optional(), + hybrid: z + .object({ + id: z.string().optional(), + secret: z.string().optional(), + endpoint: z.string().optional() + }) + .optional(), domains: z .record( z.string(), @@ -136,7 +138,18 @@ export const configSchema = z https_entrypoint: z.string().optional().default("websecure"), additional_middlewares: z.array(z.string()).optional(), cert_resolver: z.string().optional().default("letsencrypt"), - prefer_wildcard_cert: z.boolean().optional().default(false) + prefer_wildcard_cert: z.boolean().optional().default(false), + certificates_path: z.string().default("./certificates"), + monitor_interval: z.number().default(5000), + dynamic_cert_config_path: z + .string() + .optional() + .default("./dynamic/cert_config.yml"), + dynamic_router_config_path: z + .string() + .optional() + .default("./dynamic/router_config.yml"), + staticDomains: z.array(z.string()).optional().default([]) }) .optional() .default({}), @@ -219,7 +232,10 @@ export const configSchema = z smtp_host: z.string().optional(), smtp_port: portSchema.optional(), smtp_user: z.string().optional(), - smtp_pass: z.string().optional().transform(getEnvOrYaml("EMAIL_SMTP_PASS")), + smtp_pass: z + .string() + .optional() + .transform(getEnvOrYaml("EMAIL_SMTP_PASS")), smtp_secure: z.boolean().optional(), smtp_tls_reject_unauthorized: z.boolean().optional(), no_reply: z.string().email().optional() @@ -235,7 +251,7 @@ export const configSchema = z disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(), - enable_clients: z.boolean().optional().default(true), + enable_clients: z.boolean().optional().default(true) }) .optional(), dns: z diff --git a/server/lib/remoteTraefikConfig.ts b/server/lib/remoteTraefikConfig.ts new file mode 100644 index 00000000..755a14ae --- /dev/null +++ b/server/lib/remoteTraefikConfig.ts @@ -0,0 +1,582 @@ +import * as fs from "fs"; +import * as path from "path"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import * as yaml from "js-yaml"; +import axios from "axios"; +import { db, exitNodes } from "@server/db"; + +export class TraefikConfigManager { + private intervalId: NodeJS.Timeout | null = null; + private isRunning = false; + private activeDomains = new Set(); + private timeoutId: NodeJS.Timeout | null = null; + + constructor() {} + + /** + * Start monitoring certificates + */ + private scheduleNextExecution(): void { + const intervalMs = config.getRawConfig().traefik.monitor_interval; + const now = Date.now(); + const nextExecution = Math.ceil(now / intervalMs) * intervalMs; + const delay = nextExecution - now; + + this.timeoutId = setTimeout(async () => { + try { + await this.HandleTraefikConfig(); + } catch (error) { + logger.error("Error during certificate monitoring:", error); + } + + if (this.isRunning) { + this.scheduleNextExecution(); // Schedule the next execution + } + }, delay); + } + + async start(): Promise { + if (this.isRunning) { + logger.info("Certificate monitor is already running"); + return; + } + this.isRunning = true; + logger.info(`Starting certificate monitor for exit node`); + + // Ensure certificates directory exists + await this.ensureDirectoryExists( + config.getRawConfig().traefik.certificates_path + ); + + // Run initial check + await this.HandleTraefikConfig(); + + // Start synchronized scheduling + this.scheduleNextExecution(); + + logger.info( + `Certificate monitor started with synchronized ${ + config.getRawConfig().traefik.monitor_interval + }ms interval` + ); + } + /** + * Stop monitoring certificates + */ + stop(): void { + if (!this.isRunning) { + logger.info("Certificate monitor is not running"); + return; + } + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + this.isRunning = false; + logger.info("Certificate monitor stopped"); + } + + /** + * Main monitoring logic + */ + lastActiveDomains: Set = new Set(); + public async HandleTraefikConfig(): Promise { + try { + // Get all active domains for this exit node via HTTP call + const getActiveDomainsFromTraefik = + await this.getActiveDomainsFromTraefik(); + + if (!getActiveDomainsFromTraefik) { + logger.error( + "Failed to fetch active domains from traefik config" + ); + return; + } + + const { domains, traefikConfig } = getActiveDomainsFromTraefik; + + // Add static domains from config + // const staticDomains = [config.getRawConfig().app.dashboard_url]; + // staticDomains.forEach((domain) => domains.add(domain)); + + // Log if domains changed + if ( + this.lastActiveDomains.size !== domains.size || + !Array.from(this.lastActiveDomains).every((domain) => + domains.has(domain) + ) + ) { + logger.info( + `Active domains changed for exit node: ${Array.from(domains).join(", ")}` + ); + this.lastActiveDomains = new Set(domains); + } + + // Get valid certificates for active domains + const validCertificates = + await this.getValidCertificatesForDomains(domains); + + // Download and decrypt new certificates + await this.processValidCertificates(validCertificates); + + // Clean up certificates for domains no longer in use + await this.cleanupUnusedCertificates(domains); + + // wait 1 second for traefik to pick up the new certificates + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Write traefik config as YAML to a second dynamic config file if changed + await this.writeTraefikDynamicConfig(traefikConfig); + + // Send domains to SNI proxy + try { + const [exitNode] = await db.select().from(exitNodes).limit(1); + if (exitNode) { + logger.error("No exit node found"); + await axios.post( + `${exitNode.reachableAt}/full-domains`, + { fullDomains: Array.from(domains) }, + { headers: { "Content-Type": "application/json" } } + ); + } + } catch (err) { + logger.error("Failed to post domains to SNI proxy:", err); + } + + // Update active domains tracking + this.activeDomains = domains; + } catch (error) { + logger.error("Error in certificate monitoring cycle:", error); + } + } + + /** + * Get all domains currently in use from traefik config API + */ + private async getActiveDomainsFromTraefik(): Promise<{ + domains: Set; + traefikConfig: any; + } | null> { + try { + const resp = await axios.get( + `${config.getRawConfig().hybrid?.endpoint}/get-traefik-config` + ); + + if (resp.status !== 200) { + logger.error( + `Failed to fetch traefik config: ${resp.status} ${resp.statusText}` + ); + return null; + } + + const traefikConfig = resp.data; + const domains = new Set(); + + if (traefikConfig?.http?.routers) { + for (const router of Object.values( + traefikConfig.http.routers + )) { + if (router.rule && typeof router.rule === "string") { + // Match Host(`domain`) + const match = router.rule.match(/Host\(`([^`]+)`\)/); + if (match && match[1]) { + domains.add(match[1]); + } + } + } + } + return { domains, traefikConfig }; + } catch (err) { + logger.error("Failed to fetch traefik config:", err); + return null; + } + } + + /** + * Write traefik config as YAML to a second dynamic config file if changed + */ + private async writeTraefikDynamicConfig(traefikConfig: any): Promise { + const traefikDynamicConfigPath = + config.getRawConfig().traefik.dynamic_router_config_path; + let shouldWrite = false; + let oldJson = ""; + if (fs.existsSync(traefikDynamicConfigPath)) { + try { + const oldContent = fs.readFileSync( + traefikDynamicConfigPath, + "utf8" + ); + // Try to parse as YAML then JSON.stringify for comparison + const oldObj = yaml.load(oldContent); + oldJson = JSON.stringify(oldObj); + } catch { + oldJson = ""; + } + } + const newJson = JSON.stringify(traefikConfig); + if (oldJson !== newJson) { + shouldWrite = true; + } + if (shouldWrite) { + try { + fs.writeFileSync( + traefikDynamicConfigPath, + yaml.dump(traefikConfig, { noRefs: true }), + "utf8" + ); + logger.info("Traefik dynamic config updated"); + } catch (err) { + logger.error("Failed to write traefik dynamic config:", err); + } + } + } + + /** + * Get valid certificates for the specified domains + */ + private async getValidCertificatesForDomains(domains: Set): Promise< + Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> + > { + if (domains.size === 0) { + return []; + } + + const domainArray = Array.from(domains); + + try { + const response = await axios.get( + `${config.getRawConfig().hybrid?.endpoint}/certificates/domains`, + { + params: { + domains: domainArray + } + } + ); + return response.data; + } catch (error) { + console.error("Error fetching resource by domain:", error); + return []; + } + } + + /** + * Process valid certificates - download and decrypt them + */ + private async processValidCertificates( + validCertificates: Array<{ + id: number; + domain: string; + certFile: string | null; + keyFile: string | null; + expiresAt: Date | null; + updatedAt?: Date | null; + }> + ): Promise { + const dynamicConfigPath = + config.getRawConfig().traefik.dynamic_cert_config_path; + + // Load existing dynamic config if it exists, otherwise initialize + let dynamicConfig: any = { tls: { certificates: [] } }; + if (fs.existsSync(dynamicConfigPath)) { + try { + const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); + dynamicConfig = yaml.load(fileContent) || dynamicConfig; + if (!dynamicConfig.tls) + dynamicConfig.tls = { certificates: [] }; + if (!Array.isArray(dynamicConfig.tls.certificates)) { + dynamicConfig.tls.certificates = []; + } + } catch (err) { + logger.error("Failed to load existing dynamic config:", err); + } + } + + // Keep a copy of the original config for comparison + const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); + + for (const cert of validCertificates) { + try { + if (!cert.certFile || !cert.keyFile) { + logger.warn( + `Certificate for domain ${cert.domain} is missing cert or key file` + ); + continue; + } + + const domainDir = path.join( + config.getRawConfig().traefik.certificates_path, + cert.domain + ); + await this.ensureDirectoryExists(domainDir); + + const certPath = path.join(domainDir, "cert.pem"); + const keyPath = path.join(domainDir, "key.pem"); + const lastUpdatePath = path.join(domainDir, ".last_update"); + + // Check if we need to update the certificate + const shouldUpdate = await this.shouldUpdateCertificate( + cert, + certPath, + keyPath, + lastUpdatePath + ); + + if (shouldUpdate) { + logger.info( + `Processing certificate for domain: ${cert.domain}` + ); + + fs.writeFileSync(certPath, cert.certFile, "utf8"); + fs.writeFileSync(keyPath, cert.keyFile, "utf8"); + + // Set appropriate permissions (readable by owner only for key file) + fs.chmodSync(certPath, 0o644); + fs.chmodSync(keyPath, 0o600); + + // Write/update .last_update file with current timestamp + fs.writeFileSync( + lastUpdatePath, + new Date().toISOString(), + "utf8" + ); + + logger.info( + `Certificate updated for domain: ${cert.domain}` + ); + } + + // Always ensure the config entry exists and is up to date + const certEntry = { + certFile: `/var/${certPath}`, + keyFile: `/var/${keyPath}` + }; + // Remove any existing entry for this cert/key path + dynamicConfig.tls.certificates = + dynamicConfig.tls.certificates.filter( + (entry: any) => + entry.certFile !== certEntry.certFile || + entry.keyFile !== certEntry.keyFile + ); + dynamicConfig.tls.certificates.push(certEntry); + } catch (error) { + logger.error( + `Error processing certificate for domain ${cert.domain}:`, + error + ); + } + } + + // Only write the config if it has changed + const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); + if (newConfigYaml !== originalConfigYaml) { + fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); + logger.info("Dynamic cert config updated"); + } + } + + /** + * Check if certificate should be updated + */ + private async shouldUpdateCertificate( + cert: { + id: number; + domain: string; + expiresAt: Date | null; + updatedAt?: Date | null; + }, + certPath: string, + keyPath: string, + lastUpdatePath: string + ): Promise { + try { + // If files don't exist, we need to create them + const certExists = await this.fileExists(certPath); + const keyExists = await this.fileExists(keyPath); + const lastUpdateExists = await this.fileExists(lastUpdatePath); + + if (!certExists || !keyExists || !lastUpdateExists) { + return true; + } + + // Read last update time from .last_update file + let lastUpdateTime: Date | null = null; + try { + const lastUpdateStr = fs + .readFileSync(lastUpdatePath, "utf8") + .trim(); + lastUpdateTime = new Date(lastUpdateStr); + } catch { + lastUpdateTime = null; + } + + // Use updatedAt from cert, fallback to expiresAt if not present + const dbUpdateTime = cert.updatedAt ?? cert.expiresAt; + + if (!dbUpdateTime) { + // If no update time in DB, always update + return true; + } + + // If DB updatedAt is newer than last update file, update + if (!lastUpdateTime || dbUpdateTime > lastUpdateTime) { + return true; + } + + return false; + } catch (error) { + logger.error( + `Error checking certificate update status for ${cert.domain}:`, + error + ); + return true; // When in doubt, update + } + } + + /** + * Clean up certificates for domains no longer in use + */ + private async cleanupUnusedCertificates( + currentActiveDomains: Set + ): Promise { + try { + const certsPath = config.getRawConfig().traefik.certificates_path; + const dynamicConfigPath = + config.getRawConfig().traefik.dynamic_cert_config_path; + + // Load existing dynamic config if it exists + let dynamicConfig: any = { tls: { certificates: [] } }; + if (fs.existsSync(dynamicConfigPath)) { + try { + const fileContent = fs.readFileSync( + dynamicConfigPath, + "utf8" + ); + dynamicConfig = yaml.load(fileContent) || dynamicConfig; + if (!dynamicConfig.tls) + dynamicConfig.tls = { certificates: [] }; + if (!Array.isArray(dynamicConfig.tls.certificates)) { + dynamicConfig.tls.certificates = []; + } + } catch (err) { + logger.error( + "Failed to load existing dynamic config:", + err + ); + } + } + + const certDirs = fs.readdirSync(certsPath, { + withFileTypes: true + }); + + let configChanged = false; + + for (const dirent of certDirs) { + if (!dirent.isDirectory()) continue; + + const dirName = dirent.name; + // Only delete if NO current domain is exactly the same or ends with `.${dirName}` + const shouldDelete = !Array.from(currentActiveDomains).some( + (domain) => + domain === dirName || domain.endsWith(`.${dirName}`) + ); + + if (shouldDelete) { + const domainDir = path.join(certsPath, dirName); + logger.info( + `Cleaning up unused certificate directory: ${dirName}` + ); + fs.rmSync(domainDir, { recursive: true, force: true }); + + // Remove from dynamic config + const certFilePath = `/var/${path.join( + domainDir, + "cert.pem" + )}`; + const keyFilePath = `/var/${path.join( + domainDir, + "key.pem" + )}`; + const before = dynamicConfig.tls.certificates.length; + dynamicConfig.tls.certificates = + dynamicConfig.tls.certificates.filter( + (entry: any) => + entry.certFile !== certFilePath && + entry.keyFile !== keyFilePath + ); + if (dynamicConfig.tls.certificates.length !== before) { + configChanged = true; + } + } + } + + if (configChanged) { + try { + fs.writeFileSync( + dynamicConfigPath, + yaml.dump(dynamicConfig, { noRefs: true }), + "utf8" + ); + logger.info("Dynamic config updated after cleanup"); + } catch (err) { + logger.error( + "Failed to update dynamic config after cleanup:", + err + ); + } + } + } catch (error) { + logger.error("Error during certificate cleanup:", error); + } + } + + /** + * Ensure directory exists + */ + private async ensureDirectoryExists(dirPath: string): Promise { + try { + fs.mkdirSync(dirPath, { recursive: true }); + } catch (error) { + logger.error(`Error creating directory ${dirPath}:`, error); + throw error; + } + } + + /** + * Check if file exists + */ + private async fileExists(filePath: string): Promise { + try { + fs.accessSync(filePath); + return true; + } catch { + return false; + } + } + + /** + * Get current status + */ + getStatus(): { + isRunning: boolean; + activeDomains: string[]; + monitorInterval: number; + } { + return { + isRunning: this.isRunning, + activeDomains: Array.from(this.activeDomains), + monitorInterval: + config.getRawConfig().traefik.monitor_interval || 5000 + }; + } +}