From 28a38252da6d382cdf8d556728e2365073b89c57 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 14 Feb 2026 14:27:58 +0000 Subject: [PATCH] feat(certs): persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting --- changelog.md | 9 ++++ package.json | 2 +- pnpm-lock.yaml | 10 ++--- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 33 ++++++++++++-- ts/classes.storage-cert-manager.ts | 46 ++++++++++++++++++++ ts/opsserver/handlers/certificate.handler.ts | 21 +++++++++ ts_web/00_commitinfo_data.ts | 2 +- 8 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 ts/classes.storage-cert-manager.ts diff --git a/changelog.md b/changelog.md index c2c7acb..bebbde1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-14 - 5.5.0 - feat(certs) +persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting + +- Add StorageBackedCertManager to persist SmartAcme certificates under /certs/ via StorageManager +- Default storage to filesystem path (dcrouterHomeDir/storage) when options.storage is not provided +- Wire SmartAcme to use StorageBackedCertManager and provide SmartProxy certStore handlers that load/save/remove certs under /proxy-certs/ +- Ops server certificate handler reads persisted cert data to report expiry/issued dates and treats acme/provision-function routes with no cert data as provisioning +- Bump @push.rocks/smartproxy dependency to ^25.3.0 + ## 2026-02-14 - 5.4.6 - fix(deps) bump @push.rocks/smartproxy dependency to ^25.2.2 diff --git a/package.json b/package.json index ace688b..96f525b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpromise": "^4.2.3", - "@push.rocks/smartproxy": "^25.2.2", + "@push.rocks/smartproxy": "^25.3.0", "@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrx": "^3.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f80818..4b34c63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,8 +75,8 @@ importers: specifier: ^4.2.3 version: 4.2.3 '@push.rocks/smartproxy': - specifier: ^25.2.2 - version: 25.2.2(@push.rocks/smartserve@2.0.1)(socks@2.8.7) + specifier: ^25.3.0 + version: 25.3.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) '@push.rocks/smartradius': specifier: ^1.1.1 version: 1.1.1 @@ -1040,8 +1040,8 @@ packages: '@push.rocks/smartpromise@4.2.3': resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} - '@push.rocks/smartproxy@25.2.2': - resolution: {integrity: sha512-a9ztUkT2N904O3/MrLKNCqlcznDXgJf7qS7N+1Aw+QeBzxl26ofvwDcffePOSRm6BKo+q6Df9wWJ4gHoAZURLw==} + '@push.rocks/smartproxy@25.3.0': + resolution: {integrity: sha512-ie0jP6dCSZFvrdRmlo5NTufA6AJeQdGsgVQv6M9okQ4IXBkm3LVN+u6t9T2nHalnopMJXLb+qAuq0Y2T5mxIJg==} '@push.rocks/smartpuppeteer@2.0.5': resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} @@ -6441,7 +6441,7 @@ snapshots: '@push.rocks/smartpromise@4.2.3': {} - '@push.rocks/smartproxy@25.2.2(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': + '@push.rocks/smartproxy@25.3.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)': dependencies: '@push.rocks/lik': 6.2.2 '@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7) diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 96eddfa..9c5a0c2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '5.4.6', + version: '5.5.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 68d3682..e03bfc3 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -13,6 +13,7 @@ import { import { logger } from './logger.js'; // Import storage manager import { StorageManager, type IStorageConfig } from './storage/index.js'; +import { StorageBackedCertManager } from './classes.storage-cert-manager.js'; // Import cache system import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js'; @@ -204,7 +205,14 @@ export class DcRouter { this.options = { ...optionsArg }; - + + // Default storage to filesystem if not configured + if (!this.options.storage) { + this.options.storage = { + fsPath: plugins.path.join(paths.dcrouterHomeDir, 'storage'), + }; + } + // Initialize storage manager this.storageManager = new StorageManager(this.options.storage); } @@ -437,14 +445,33 @@ export class DcRouter { const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = { ...this.options.smartProxyConfig, routes, - acme: acmeConfig + acme: acmeConfig, + certStore: { + loadAll: async () => { + const keys = await this.storageManager.list('/proxy-certs/'); + const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = []; + for (const key of keys) { + const data = await this.storageManager.getJSON(key); + if (data) certs.push(data); + } + return certs; + }, + save: async (domain: string, publicKey: string, privateKey: string, ca?: string) => { + await this.storageManager.setJSON(`/proxy-certs/${domain}`, { + domain, publicKey, privateKey, ca, + }); + }, + remove: async (domain: string) => { + await this.storageManager.delete(`/proxy-certs/${domain}`); + }, + }, }; // If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction if (challengeHandlers.length > 0) { this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com', - certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), + certManager: new StorageBackedCertManager(this.storageManager), environment: 'production', challengeHandlers: challengeHandlers, challengePriority: ['dns-01'], diff --git a/ts/classes.storage-cert-manager.ts b/ts/classes.storage-cert-manager.ts new file mode 100644 index 0000000..e241fcd --- /dev/null +++ b/ts/classes.storage-cert-manager.ts @@ -0,0 +1,46 @@ +import * as plugins from './plugins.js'; +import { StorageManager } from './storage/index.js'; + +/** + * ICertManager implementation backed by StorageManager. + * Persists SmartAcme certificates under a /certs/ key prefix so they + * survive process restarts without re-hitting ACME. + */ +export class StorageBackedCertManager implements plugins.smartacme.ICertManager { + private keyPrefix = '/certs/'; + + constructor(private storageManager: StorageManager) {} + + async init(): Promise {} + + async retrieveCertificate(domainName: string): Promise { + const data = await this.storageManager.getJSON(this.keyPrefix + domainName); + if (!data) return null; + return new plugins.smartacme.Cert(data); + } + + async storeCertificate(cert: plugins.smartacme.Cert): Promise { + await this.storageManager.setJSON(this.keyPrefix + cert.domainName, { + id: cert.id, + domainName: cert.domainName, + created: cert.created, + privateKey: cert.privateKey, + publicKey: cert.publicKey, + csr: cert.csr, + validUntil: cert.validUntil, + }); + } + + async deleteCertificate(domainName: string): Promise { + await this.storageManager.delete(this.keyPrefix + domainName); + } + + async close(): Promise {} + + async wipe(): Promise { + const keys = await this.storageManager.list(this.keyPrefix); + for (const key of keys) { + await this.storageManager.delete(key); + } + } +} diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts index 74f78fc..d733d94 100644 --- a/ts/opsserver/handlers/certificate.handler.ts +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -104,6 +104,22 @@ export class CertificateHandler { } } + // Check persisted cert data from StorageManager + if (status === 'unknown' && routeDomains.length > 0) { + for (const domain of routeDomains) { + if (expiryDate) break; + const cleanDomain = domain.replace(/^\*\.?/, ''); + const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`); + if (certData?.validUntil) { + expiryDate = new Date(certData.validUntil).toISOString(); + if (certData.created) { + issuedAt = new Date(certData.created).toISOString(); + } + issuer = 'smartacme-dns-01'; + } + } + } + // Compute status from expiry date if we have one and status is still valid/unknown if (expiryDate && (status === 'valid' || status === 'unknown')) { const expiry = new Date(expiryDate); @@ -124,6 +140,11 @@ export class CertificateHandler { status = 'valid'; } + // ACME/provision-function routes with no cert data are still provisioning + if (status === 'unknown' && (source === 'acme' || source === 'provision-function')) { + status = 'provisioning'; + } + const canReprovision = source === 'acme' || source === 'provision-function'; certificates.push({ diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 96eddfa..9c5a0c2 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '5.4.6', + version: '5.5.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' }