2026-02-13 17:05:33 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
|
|
|
|
import type { OpsServer } from '../classes.opsserver.js';
|
|
|
|
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
|
|
|
|
|
|
|
|
export class CertificateHandler {
|
|
|
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
|
|
|
|
|
|
|
|
constructor(private opsServerRef: OpsServer) {
|
|
|
|
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
|
|
|
this.registerHandlers();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private registerHandlers(): void {
|
|
|
|
|
// Get Certificate Overview
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
|
|
|
|
'getCertificateOverview',
|
|
|
|
|
async (dataArg) => {
|
2026-02-13 21:37:52 +00:00
|
|
|
const certificates = await this.buildCertificateOverview();
|
2026-02-13 17:05:33 +00:00
|
|
|
const summary = this.buildSummary(certificates);
|
|
|
|
|
return { certificates, summary };
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
// Legacy route-based reprovision (backward compat)
|
2026-02-13 17:05:33 +00:00
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
|
|
|
|
'reprovisionCertificate',
|
|
|
|
|
async (dataArg) => {
|
2026-02-15 16:03:13 +00:00
|
|
|
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Domain-based reprovision (preferred)
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
|
|
|
|
'reprovisionCertificateDomain',
|
|
|
|
|
async (dataArg) => {
|
|
|
|
|
return this.reprovisionCertificateDomain(dataArg.domain);
|
2026-02-13 17:05:33 +00:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-02-17 16:28:33 +00:00
|
|
|
|
|
|
|
|
// Delete certificate
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
|
|
|
|
'deleteCertificate',
|
|
|
|
|
async (dataArg) => {
|
|
|
|
|
return this.deleteCertificate(dataArg.domain);
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Export certificate
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
|
|
|
|
'exportCertificate',
|
|
|
|
|
async (dataArg) => {
|
|
|
|
|
return this.exportCertificate(dataArg.domain);
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Import certificate
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
|
|
|
|
'importCertificate',
|
|
|
|
|
async (dataArg) => {
|
|
|
|
|
return this.importCertificate(dataArg.cert);
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-02-13 17:05:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
/**
|
|
|
|
|
* Build domain-centric certificate overview.
|
|
|
|
|
* Instead of one row per route, we produce one row per unique domain.
|
|
|
|
|
*/
|
2026-02-13 21:37:52 +00:00
|
|
|
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
|
2026-02-13 17:05:33 +00:00
|
|
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
|
|
|
const smartProxy = dcRouter.smartProxy;
|
|
|
|
|
if (!smartProxy) return [];
|
|
|
|
|
|
|
|
|
|
const routes = smartProxy.routeManager.getRoutes();
|
2026-02-15 16:03:13 +00:00
|
|
|
|
|
|
|
|
// Phase 1: Collect unique domains with their associated route info
|
|
|
|
|
const domainMap = new Map<string, {
|
|
|
|
|
routeNames: string[];
|
|
|
|
|
source: interfaces.requests.TCertificateSource;
|
|
|
|
|
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
|
|
|
|
canReprovision: boolean;
|
|
|
|
|
}>();
|
2026-02-13 17:05:33 +00:00
|
|
|
|
|
|
|
|
for (const route of routes) {
|
|
|
|
|
if (!route.name) continue;
|
|
|
|
|
|
|
|
|
|
const tls = route.action?.tls;
|
|
|
|
|
if (!tls) continue;
|
|
|
|
|
|
|
|
|
|
// Skip passthrough routes - they don't manage certificates
|
|
|
|
|
if (tls.mode === 'passthrough') continue;
|
|
|
|
|
|
|
|
|
|
const routeDomains = route.match.domains
|
|
|
|
|
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
// Determine source
|
|
|
|
|
let source: interfaces.requests.TCertificateSource = 'none';
|
|
|
|
|
if (tls.certificate === 'auto') {
|
|
|
|
|
if ((smartProxy.settings as any).certProvisionFunction) {
|
|
|
|
|
source = 'provision-function';
|
|
|
|
|
} else {
|
|
|
|
|
source = 'acme';
|
|
|
|
|
}
|
|
|
|
|
} else if (tls.certificate && typeof tls.certificate === 'object') {
|
|
|
|
|
source = 'static';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
const canReprovision = source === 'acme' || source === 'provision-function';
|
|
|
|
|
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
|
|
|
|
|
|
|
|
|
for (const domain of routeDomains) {
|
|
|
|
|
const existing = domainMap.get(domain);
|
|
|
|
|
if (existing) {
|
|
|
|
|
// Add this route name to the existing domain entry
|
|
|
|
|
if (!existing.routeNames.includes(route.name)) {
|
|
|
|
|
existing.routeNames.push(route.name);
|
|
|
|
|
}
|
|
|
|
|
// Upgrade source if more specific
|
|
|
|
|
if (existing.source === 'none' && source !== 'none') {
|
|
|
|
|
existing.source = source;
|
|
|
|
|
existing.canReprovision = canReprovision;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
domainMap.set(domain, {
|
|
|
|
|
routeNames: [route.name],
|
|
|
|
|
source,
|
|
|
|
|
tlsMode,
|
|
|
|
|
canReprovision,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 2: Resolve status for each unique domain
|
|
|
|
|
const certificates: interfaces.requests.ICertificateInfo[] = [];
|
|
|
|
|
|
|
|
|
|
for (const [domain, info] of domainMap) {
|
2026-02-13 17:05:33 +00:00
|
|
|
let status: interfaces.requests.TCertificateStatus = 'unknown';
|
|
|
|
|
let expiryDate: string | undefined;
|
|
|
|
|
let issuedAt: string | undefined;
|
|
|
|
|
let issuer: string | undefined;
|
|
|
|
|
let error: string | undefined;
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
// Check event-based status from certificateStatusMap (now keyed by domain)
|
|
|
|
|
const eventStatus = dcRouter.certificateStatusMap.get(domain);
|
2026-02-13 17:05:33 +00:00
|
|
|
if (eventStatus) {
|
|
|
|
|
status = eventStatus.status;
|
|
|
|
|
expiryDate = eventStatus.expiryDate;
|
|
|
|
|
issuedAt = eventStatus.issuedAt;
|
|
|
|
|
error = eventStatus.error;
|
2026-02-13 21:37:52 +00:00
|
|
|
if (eventStatus.source) {
|
|
|
|
|
issuer = eventStatus.source;
|
|
|
|
|
}
|
2026-02-13 17:05:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
// Try SmartProxy certificate status if no event data
|
|
|
|
|
if (status === 'unknown' && info.routeNames.length > 0) {
|
2026-02-13 21:37:52 +00:00
|
|
|
try {
|
2026-02-15 16:03:13 +00:00
|
|
|
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
2026-02-13 21:37:52 +00:00
|
|
|
if (rustStatus) {
|
|
|
|
|
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
|
|
|
|
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
|
|
|
|
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
|
|
|
|
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
|
|
|
|
status = rustStatus.status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Rust bridge may not support this command yet — ignore
|
|
|
|
|
}
|
2026-02-13 17:05:33 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:27:58 +00:00
|
|
|
// Check persisted cert data from StorageManager
|
2026-02-15 16:03:13 +00:00
|
|
|
if (status === 'unknown') {
|
|
|
|
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
2026-02-16 01:58:39 +00:00
|
|
|
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
|
|
|
|
if (!certData) {
|
|
|
|
|
// Also check certStore path (proxy-certs)
|
|
|
|
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
|
|
|
|
}
|
2026-02-15 16:03:13 +00:00
|
|
|
if (certData?.validUntil) {
|
|
|
|
|
expiryDate = new Date(certData.validUntil).toISOString();
|
|
|
|
|
if (certData.created) {
|
|
|
|
|
issuedAt = new Date(certData.created).toISOString();
|
2026-02-14 14:27:58 +00:00
|
|
|
}
|
2026-02-15 16:03:13 +00:00
|
|
|
issuer = 'smartacme-dns-01';
|
2026-02-16 02:50:25 +00:00
|
|
|
} else if (certData?.publicKey) {
|
|
|
|
|
// certStore has the cert — parse PEM for expiry
|
|
|
|
|
try {
|
|
|
|
|
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
|
|
|
|
|
expiryDate = new Date(x509.validTo).toISOString();
|
|
|
|
|
issuedAt = new Date(x509.validFrom).toISOString();
|
|
|
|
|
} catch { /* PEM parsing failed */ }
|
|
|
|
|
status = 'valid';
|
|
|
|
|
issuer = 'cert-store';
|
2026-02-16 01:58:39 +00:00
|
|
|
} else if (certData) {
|
|
|
|
|
status = 'valid';
|
|
|
|
|
issuer = 'cert-store';
|
2026-02-14 14:27:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
// Compute status from expiry date
|
2026-02-13 17:05:33 +00:00
|
|
|
if (expiryDate && (status === 'valid' || status === 'unknown')) {
|
|
|
|
|
const expiry = new Date(expiryDate);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
|
|
|
|
|
|
|
|
|
if (daysUntilExpiry < 0) {
|
|
|
|
|
status = 'expired';
|
|
|
|
|
} else if (daysUntilExpiry < 30) {
|
|
|
|
|
status = 'expiring';
|
|
|
|
|
} else {
|
|
|
|
|
status = 'valid';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Static certs with no other info default to 'valid'
|
2026-02-15 16:03:13 +00:00
|
|
|
if (info.source === 'static' && status === 'unknown') {
|
2026-02-13 17:05:33 +00:00
|
|
|
status = 'valid';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 14:27:58 +00:00
|
|
|
// ACME/provision-function routes with no cert data are still provisioning
|
2026-02-15 16:03:13 +00:00
|
|
|
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
|
2026-02-14 14:27:58 +00:00
|
|
|
status = 'provisioning';
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
// Phase 3: Attach backoff info
|
|
|
|
|
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
|
|
|
|
|
if (dcRouter.certProvisionScheduler) {
|
|
|
|
|
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
|
|
|
|
|
if (bi) {
|
|
|
|
|
backoffInfo = bi;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 17:05:33 +00:00
|
|
|
|
|
|
|
|
certificates.push({
|
2026-02-15 16:03:13 +00:00
|
|
|
domain,
|
|
|
|
|
routeNames: info.routeNames,
|
2026-02-13 17:05:33 +00:00
|
|
|
status,
|
2026-02-15 16:03:13 +00:00
|
|
|
source: info.source,
|
|
|
|
|
tlsMode: info.tlsMode,
|
2026-02-13 17:05:33 +00:00
|
|
|
expiryDate,
|
|
|
|
|
issuer,
|
|
|
|
|
issuedAt,
|
|
|
|
|
error,
|
2026-02-15 16:03:13 +00:00
|
|
|
canReprovision: info.canReprovision,
|
|
|
|
|
backoffInfo,
|
2026-02-13 17:05:33 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return certificates;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildSummary(certificates: interfaces.requests.ICertificateInfo[]): {
|
|
|
|
|
total: number;
|
|
|
|
|
valid: number;
|
|
|
|
|
expiring: number;
|
|
|
|
|
expired: number;
|
|
|
|
|
failed: number;
|
|
|
|
|
unknown: number;
|
|
|
|
|
} {
|
|
|
|
|
const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 };
|
|
|
|
|
summary.total = certificates.length;
|
|
|
|
|
for (const cert of certificates) {
|
|
|
|
|
switch (cert.status) {
|
|
|
|
|
case 'valid': summary.valid++; break;
|
|
|
|
|
case 'expiring': summary.expiring++; break;
|
|
|
|
|
case 'expired': summary.expired++; break;
|
|
|
|
|
case 'failed': summary.failed++; break;
|
|
|
|
|
case 'provisioning': // count as unknown
|
|
|
|
|
case 'unknown': summary.unknown++; break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return summary;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 16:03:13 +00:00
|
|
|
/**
|
|
|
|
|
* Legacy route-based reprovisioning
|
|
|
|
|
*/
|
|
|
|
|
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
2026-02-13 17:05:33 +00:00
|
|
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
|
|
|
const smartProxy = dcRouter.smartProxy;
|
|
|
|
|
|
|
|
|
|
if (!smartProxy) {
|
|
|
|
|
return { success: false, message: 'SmartProxy is not running' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await smartProxy.provisionCertificate(routeName);
|
2026-02-15 16:03:13 +00:00
|
|
|
// Clear event-based status for domains in this route
|
|
|
|
|
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
|
|
|
|
if (entry.routeNames.includes(routeName)) {
|
|
|
|
|
dcRouter.certificateStatusMap.delete(domain);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-13 17:05:33 +00:00
|
|
|
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 16:03:13 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
|
|
|
|
*/
|
|
|
|
|
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
|
|
|
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
|
|
|
const smartProxy = dcRouter.smartProxy;
|
|
|
|
|
|
|
|
|
|
if (!smartProxy) {
|
|
|
|
|
return { success: false, message: 'SmartProxy is not running' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear backoff for this domain (user override)
|
|
|
|
|
if (dcRouter.certProvisionScheduler) {
|
|
|
|
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear status map entry so it gets refreshed
|
|
|
|
|
dcRouter.certificateStatusMap.delete(domain);
|
|
|
|
|
|
|
|
|
|
// Try to provision via SmartAcme directly
|
|
|
|
|
if (dcRouter.smartAcme) {
|
|
|
|
|
try {
|
|
|
|
|
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
|
|
|
|
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: try provisioning via the first matching route
|
|
|
|
|
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
|
|
|
|
if (routeNames.length > 0) {
|
|
|
|
|
try {
|
|
|
|
|
await smartProxy.provisionCertificate(routeNames[0]);
|
|
|
|
|
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: false, message: `No routes found for domain '${domain}'` };
|
|
|
|
|
}
|
2026-02-17 16:28:33 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete certificate data for a domain from storage
|
|
|
|
|
*/
|
|
|
|
|
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
|
|
|
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
|
|
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
|
|
|
|
|
|
|
|
|
// Delete from all known storage paths
|
|
|
|
|
const paths = [
|
|
|
|
|
`/proxy-certs/${domain}`,
|
|
|
|
|
`/proxy-certs/${cleanDomain}`,
|
|
|
|
|
`/certs/${cleanDomain}`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const path of paths) {
|
|
|
|
|
try {
|
|
|
|
|
await dcRouter.storageManager.delete(path);
|
|
|
|
|
} catch {
|
|
|
|
|
// Path may not exist — ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear from in-memory status map
|
|
|
|
|
dcRouter.certificateStatusMap.delete(domain);
|
|
|
|
|
|
|
|
|
|
// Clear backoff info
|
|
|
|
|
if (dcRouter.certProvisionScheduler) {
|
|
|
|
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: true, message: `Certificate data deleted for '${domain}'` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Export certificate data for a domain as ICert-shaped JSON
|
|
|
|
|
*/
|
|
|
|
|
private async exportCertificate(domain: string): Promise<{
|
|
|
|
|
success: boolean;
|
|
|
|
|
cert?: {
|
|
|
|
|
id: string;
|
|
|
|
|
domainName: string;
|
|
|
|
|
created: number;
|
|
|
|
|
validUntil: number;
|
|
|
|
|
privateKey: string;
|
|
|
|
|
publicKey: string;
|
|
|
|
|
csr: string;
|
|
|
|
|
};
|
|
|
|
|
message?: string;
|
|
|
|
|
}> {
|
|
|
|
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
|
|
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
|
|
|
|
|
|
|
|
|
// Try SmartAcme /certs/ path first (has full ICert fields)
|
|
|
|
|
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
|
|
|
|
if (certData && certData.publicKey && certData.privateKey) {
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
cert: {
|
|
|
|
|
id: certData.id || plugins.crypto.randomUUID(),
|
|
|
|
|
domainName: certData.domainName || domain,
|
|
|
|
|
created: certData.created || Date.now(),
|
|
|
|
|
validUntil: certData.validUntil || 0,
|
|
|
|
|
privateKey: certData.privateKey,
|
|
|
|
|
publicKey: certData.publicKey,
|
|
|
|
|
csr: certData.csr || '',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: try /proxy-certs/ with original domain
|
|
|
|
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
|
|
|
|
if (!certData || !certData.publicKey) {
|
|
|
|
|
// Try with clean domain
|
|
|
|
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (certData && certData.publicKey && certData.privateKey) {
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
cert: {
|
|
|
|
|
id: plugins.crypto.randomUUID(),
|
|
|
|
|
domainName: domain,
|
|
|
|
|
created: certData.validFrom || Date.now(),
|
|
|
|
|
validUntil: certData.validUntil || 0,
|
|
|
|
|
privateKey: certData.privateKey,
|
|
|
|
|
publicKey: certData.publicKey,
|
|
|
|
|
csr: '',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { success: false, message: `No certificate data found for '${domain}'` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Import a certificate from ICert-shaped JSON
|
|
|
|
|
*/
|
|
|
|
|
private async importCertificate(cert: {
|
|
|
|
|
id: string;
|
|
|
|
|
domainName: string;
|
|
|
|
|
created: number;
|
|
|
|
|
validUntil: number;
|
|
|
|
|
privateKey: string;
|
|
|
|
|
publicKey: string;
|
|
|
|
|
csr: string;
|
|
|
|
|
}): Promise<{ success: boolean; message?: string }> {
|
|
|
|
|
// Validate PEM content
|
|
|
|
|
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
|
|
|
|
|
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
|
|
|
|
|
}
|
|
|
|
|
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
|
|
|
|
|
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
|
|
|
|
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
|
|
|
|
|
|
|
|
|
// Save to /certs/ (SmartAcme-compatible path)
|
|
|
|
|
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
|
|
|
|
id: cert.id,
|
|
|
|
|
domainName: cert.domainName,
|
|
|
|
|
created: cert.created,
|
|
|
|
|
validUntil: cert.validUntil,
|
|
|
|
|
privateKey: cert.privateKey,
|
|
|
|
|
publicKey: cert.publicKey,
|
|
|
|
|
csr: cert.csr || '',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Also save to /proxy-certs/ (proxy-cert format)
|
|
|
|
|
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
|
|
|
|
domain: cert.domainName,
|
|
|
|
|
publicKey: cert.publicKey,
|
|
|
|
|
privateKey: cert.privateKey,
|
|
|
|
|
ca: undefined,
|
|
|
|
|
validUntil: cert.validUntil,
|
|
|
|
|
validFrom: cert.created,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update in-memory status map
|
|
|
|
|
dcRouter.certificateStatusMap.set(cert.domainName, {
|
|
|
|
|
status: 'valid',
|
|
|
|
|
source: 'static',
|
|
|
|
|
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
|
|
|
|
|
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
|
|
|
|
|
routeNames: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
|
|
|
|
|
}
|
2026-02-13 17:05:33 +00:00
|
|
|
}
|