feat(certs): persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting
This commit is contained in:
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
46
ts/classes.storage-cert-manager.ts
Normal file
46
ts/classes.storage-cert-manager.ts
Normal file
@@ -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<void> {}
|
||||
|
||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async wipe(): Promise<void> {
|
||||
const keys = await this.storageManager.list(this.keyPrefix);
|
||||
for (const key of keys) {
|
||||
await this.storageManager.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user