diff --git a/changelog.md b/changelog.md index f995cb8..2f8df47 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-13 - 23.1.6 - fix(smart-proxy) +disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow + +- Pass an optional ACME override into buildRustConfig so Rust ACME can be disabled per-run +- Disable Rust ACME when certProvisionFunction is configured to avoid provisioning race conditions +- Normalize routing glob patterns into concrete domain identifiers for certificate provisioning (expand leading-star globs and warn on unsupported patterns) +- Deduplicate domains during provisioning to avoid repeated attempts +- When the callback returns 'http01', explicitly trigger Rust ACME for the route via bridge.provisionCertificate and log success/failure + ## 2026-02-13 - 23.1.5 - fix(smart-proxy) provision certificates for wildcard domains instead of skipping them diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 5d59466..32eeedf 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '23.1.5', + version: '23.1.6', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index 802d36b..0500a7c 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -13,7 +13,7 @@ import { RouteValidator } from './utils/route-validator.js'; import { Mutex } from './utils/mutex.js'; // Types -import type { ISmartProxyOptions, TSmartProxyCertProvisionObject } from './models/interfaces.js'; +import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions } from './models/interfaces.js'; import type { IRouteConfig } from './models/route-types.js'; import type { IMetrics } from './models/metrics-types.js'; @@ -146,8 +146,16 @@ export class SmartProxy extends plugins.EventEmitter { // Preprocess routes (strip JS functions, convert socket-handler routes) const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes); + // When certProvisionFunction handles cert provisioning, + // disable Rust's built-in ACME to prevent race condition. + let acmeForRust = this.settings.acme; + if (this.settings.certProvisionFunction && acmeForRust?.enabled) { + acmeForRust = { ...acmeForRust, enabled: false }; + logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' }); + } + // Build Rust config - const config = this.buildRustConfig(rustRoutes); + const config = this.buildRustConfig(rustRoutes, acmeForRust); // Start the Rust proxy await this.bridge.startProxy(config); @@ -334,20 +342,21 @@ export class SmartProxy extends plugins.EventEmitter { /** * Build the Rust configuration object from TS settings. */ - private buildRustConfig(routes: IRouteConfig[]): any { + private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any { + const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme; return { routes, defaults: this.settings.defaults, - acme: this.settings.acme + acme: acme ? { - enabled: this.settings.acme.enabled, - email: this.settings.acme.email, - useProduction: this.settings.acme.useProduction, - port: this.settings.acme.port, - renewThresholdDays: this.settings.acme.renewThresholdDays, - autoRenew: this.settings.acme.autoRenew, - certificateStore: this.settings.acme.certificateStore, - renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours, + enabled: acme.enabled, + email: acme.email, + useProduction: acme.useProduction, + port: acme.port, + renewThresholdDays: acme.renewThresholdDays, + autoRenew: acme.autoRenew, + certificateStore: acme.certificateStore, + renewCheckIntervalHours: acme.renewCheckIntervalHours, } : undefined, connectionTimeout: this.settings.connectionTimeout, @@ -374,18 +383,31 @@ export class SmartProxy extends plugins.EventEmitter { const provisionFn = this.settings.certProvisionFunction; if (!provisionFn) return; + const provisionedDomains = new Set(); + for (const route of this.settings.routes) { if (route.action.tls?.certificate !== 'auto') continue; if (!route.match.domains) continue; - const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; + const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; + const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains); - for (const domain of domains) { + for (const domain of certDomains) { + if (provisionedDomains.has(domain)) continue; + provisionedDomains.add(domain); try { const result: TSmartProxyCertProvisionObject = await provisionFn(domain); if (result === 'http01') { - // Rust handles ACME for this domain + // Callback wants HTTP-01 for this domain — trigger Rust ACME explicitly + if (route.name) { + try { + await this.bridge.provisionCertificate(route.name); + logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' }); + } catch (provisionErr: any) { + logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}`, { component: 'smart-proxy' }); + } + } continue; } @@ -411,6 +433,43 @@ export class SmartProxy extends plugins.EventEmitter { } } + /** + * Normalize routing glob patterns into valid domain identifiers for cert provisioning. + * - `*nevermind.cloud` → `['nevermind.cloud', '*.nevermind.cloud']` + * - `*.lossless.digital` → `['*.lossless.digital']` (already valid wildcard) + * - `code.foss.global` → `['code.foss.global']` (plain domain) + * - `*mid*.example.com` → skipped with warning (unsupported glob) + */ + private normalizeDomainsForCertProvisioning(rawDomains: string[]): string[] { + const result: string[] = []; + for (const raw of rawDomains) { + // Plain domain — no glob characters + if (!raw.includes('*')) { + result.push(raw); + continue; + } + + // Valid wildcard: *.example.com + if (raw.startsWith('*.') && !raw.slice(2).includes('*')) { + result.push(raw); + continue; + } + + // Routing glob like *example.com (leading star, no dot after it) + // Convert to bare domain + wildcard pair + if (raw.startsWith('*') && !raw.startsWith('*.') && !raw.slice(1).includes('*')) { + const baseDomain = raw.slice(1); // Remove leading * + result.push(baseDomain); + result.push(`*.${baseDomain}`); + continue; + } + + // Unsupported glob pattern (e.g. *mid*.example.com) + logger.log('warn', `Skipping unsupported glob pattern for cert provisioning: ${raw}`, { component: 'smart-proxy' }); + } + return result; + } + private isValidDomain(domain: string): boolean { if (!domain || domain.length === 0) return false; if (domain.includes('*')) return false;