From c23f16149cf8760c9f8b4eb3f0752eb2f579cba5 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 17 Feb 2026 16:28:33 +0000 Subject: [PATCH] feat(certificates): add certificate import, export, and deletion support (server handlers, request types, and UI) --- changelog.md | 9 + ts/00_commitinfo_data.ts | 2 +- ts/opsserver/handlers/certificate.handler.ts | 180 +++++++++++++++++++ ts_interfaces/requests/certificate.ts | 65 +++++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 74 ++++++++ ts_web/elements/ops-view-certificates.ts | 125 +++++++++++++ 7 files changed, 455 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index bce33f8..75d8382 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-02-17 - 6.9.0 - feat(certificates) +add certificate import, export, and deletion support (server handlers, request types, and UI) + +- Add typed request handlers in opsserver: deleteCertificate, exportCertificate, importCertificate (ts/opsserver/handlers/certificate.handler.ts) +- Implement deleteCertificate/exportCertificate/importCertificate functions handling storage paths, in-memory status map updates, backoff clearing, validation, and SmartAcme-compatible /certs/ and /proxy-certs/ formats +- Add request interfaces IReq_DeleteCertificate, IReq_ExportCertificate, IReq_ImportCertificate (ts_interfaces/requests/certificate.ts) +- Add web app actions deleteCertificateAction, importCertificateAction and fetchCertificateExport to call new typed requests (ts_web/appstate.ts) +- Update certificates UI to support Import, Export, and Delete actions and add downloadJsonFile helper (ts_web/elements/ops-view-certificates.ts) + ## 2026-02-17 - 6.8.0 - feat(remote-ingress) support auto-deriving ports for remote ingress edges and expose manual/derived port breakdown in API and UI diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 62881c7..f1b1dc6 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: '6.8.0', + version: '6.9.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts index 3a53da3..aceca95 100644 --- a/ts/opsserver/handlers/certificate.handler.ts +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -42,6 +42,36 @@ export class CertificateHandler { } ) ); + + // Delete certificate + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteCertificate', + async (dataArg) => { + return this.deleteCertificate(dataArg.domain); + } + ) + ); + + // Export certificate + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'exportCertificate', + async (dataArg) => { + return this.exportCertificate(dataArg.domain); + } + ) + ); + + // Import certificate + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'importCertificate', + async (dataArg) => { + return this.importCertificate(dataArg.cert); + } + ) + ); } /** @@ -324,4 +354,154 @@ export class CertificateHandler { return { success: false, message: `No routes found for domain '${domain}'` }; } + + /** + * 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}'` }; + } } diff --git a/ts_interfaces/requests/certificate.ts b/ts_interfaces/requests/certificate.ts index 6930cc0..f44bddc 100644 --- a/ts_interfaces/requests/certificate.ts +++ b/ts_interfaces/requests/certificate.ts @@ -74,3 +74,68 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI message?: string; }; } + +// Delete a certificate by domain +export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteCertificate +> { + method: 'deleteCertificate'; + request: { + identity?: authInterfaces.IIdentity; + domain: string; + }; + response: { + success: boolean; + message?: string; + }; +} + +// Export a certificate as ICert JSON +export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ExportCertificate +> { + method: 'exportCertificate'; + request: { + identity?: authInterfaces.IIdentity; + domain: string; + }; + response: { + success: boolean; + cert?: { + id: string; + domainName: string; + created: number; + validUntil: number; + privateKey: string; + publicKey: string; + csr: string; + }; + message?: string; + }; +} + +// Import a certificate from ICert JSON +export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_ImportCertificate +> { + method: 'importCertificate'; + request: { + identity?: authInterfaces.IIdentity; + cert: { + id: string; + domainName: string; + created: number; + validUntil: number; + privateKey: string; + publicKey: string; + csr: string; + }; + }; + response: { + success: boolean; + message?: string; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 62881c7..f1b1dc6 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: '6.8.0', + version: '6.9.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 655596b..7ff7662 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -780,6 +780,80 @@ export const reprovisionCertificateAction = certificateStatePart.createAction( + async (statePartArg, domain) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteCertificate + >('/typedrequest', 'deleteCertificate'); + + await request.fire({ + identity: context.identity, + domain, + }); + + // Re-fetch overview after deletion + await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete certificate', + }; + } + } +); + +export const importCertificateAction = certificateStatePart.createAction<{ + id: string; + domainName: string; + created: number; + validUntil: number; + privateKey: string; + publicKey: string; + csr: string; +}>( + async (statePartArg, cert) => { + const context = getActionContext(); + const currentState = statePartArg.getState(); + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ImportCertificate + >('/typedrequest', 'importCertificate'); + + await request.fire({ + identity: context.identity, + cert, + }); + + // Re-fetch overview after import + await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); + return statePartArg.getState(); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to import certificate', + }; + } + } +); + +export async function fetchCertificateExport(domain: string) { + const context = getActionContext(); + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_ExportCertificate + >('/typedrequest', 'exportCertificate'); + + return request.fire({ + identity: context.identity, + domain, + }); +} + // ============================================================================ // Remote Ingress Actions // ============================================================================ diff --git a/ts_web/elements/ops-view-certificates.ts b/ts_web/elements/ops-view-certificates.ts index f58464d..f92d23a 100644 --- a/ts_web/elements/ops-view-certificates.ts +++ b/ts_web/elements/ops-view-certificates.ts @@ -241,6 +241,61 @@ export class OpsViewCertificates extends DeesElement { : '', })} .dataActions=${[ + { + name: 'Import Certificate', + iconName: 'lucide:upload', + type: ['header'], + actionFunc: async () => { + const { DeesModal } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: 'Import Certificate', + content: html` + + + + `, + menuOptions: [ + { + name: 'Import', + iconName: 'lucide:upload', + action: async (modal) => { + const { DeesToast } = await import('@design.estate/dees-catalog'); + try { + const form = modal.shadowRoot.querySelector('dees-form') as any; + const formData = await form.collectFormData(); + const files = formData.certJsonFile; + if (!files || files.length === 0) { + DeesToast.show({ message: 'Please select a JSON file.', type: 'warning', duration: 3000 }); + return; + } + const file = files[0]; + const text = await file.text(); + const cert = JSON.parse(text); + if (!cert.domainName || !cert.publicKey || !cert.privateKey) { + DeesToast.show({ message: 'Invalid cert JSON: missing domainName, publicKey, or privateKey.', type: 'error', duration: 4000 }); + return; + } + await appstate.certificateStatePart.dispatchAction( + appstate.importCertificateAction, + cert, + ); + DeesToast.show({ message: `Certificate imported for ${cert.domainName}`, type: 'success', duration: 3000 }); + modal.destroy(); + } catch (err) { + DeesToast.show({ message: `Import failed: ${err.message}`, type: 'error', duration: 4000 }); + } + }, + }, + ], + }); + }, + }, { name: 'Reprovision', iconName: 'lucide:RefreshCw', @@ -268,6 +323,63 @@ export class OpsViewCertificates extends DeesElement { }); }, }, + { + name: 'Export', + iconName: 'lucide:download', + type: ['contextmenu'], + actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { + const { DeesToast } = await import('@design.estate/dees-catalog'); + const cert = actionData.item; + try { + const response = await appstate.fetchCertificateExport(cert.domain); + if (response.success && response.cert) { + const safeDomain = cert.domain.replace(/\*/g, '_wildcard'); + this.downloadJsonFile(`${safeDomain}.tsclass.cert.json`, response.cert); + DeesToast.show({ message: `Certificate exported for ${cert.domain}`, type: 'success', duration: 3000 }); + } else { + DeesToast.show({ message: response.message || 'Export failed', type: 'error', duration: 4000 }); + } + } catch (err) { + DeesToast.show({ message: `Export failed: ${err.message}`, type: 'error', duration: 4000 }); + } + }, + }, + { + name: 'Delete', + iconName: 'lucide:trash-2', + type: ['contextmenu'], + actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => { + const cert = actionData.item; + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + await DeesModal.createAndShow({ + heading: `Delete Certificate: ${cert.domain}`, + content: html` +
+

Are you sure you want to delete the certificate data for ${cert.domain}?

+

Note: The certificate may remain in proxy memory until the next restart or reprovisioning.

+
+ `, + menuOptions: [ + { + name: 'Delete', + iconName: 'lucide:trash-2', + action: async (modal) => { + try { + await appstate.certificateStatePart.dispatchAction( + appstate.deleteCertificateAction, + cert.domain, + ); + DeesToast.show({ message: `Certificate deleted for ${cert.domain}`, type: 'success', duration: 3000 }); + modal.destroy(); + } catch (err) { + DeesToast.show({ message: `Delete failed: ${err.message}`, type: 'error', duration: 4000 }); + } + }, + }, + ], + }); + }, + }, { name: 'View Details', iconName: 'lucide:Search', @@ -309,6 +421,19 @@ export class OpsViewCertificates extends DeesElement { `; } + private downloadJsonFile(filename: string, data: any): void { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + private renderRoutePills(routeNames: string[]): TemplateResult { const maxShow = 3; const visible = routeNames.slice(0, maxShow);