feat(certificates): add certificate import, export, and deletion support (server handlers, request types, and UI)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -42,6 +42,36 @@ export class CertificateHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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}'` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -780,6 +780,80 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteCertificateAction = certificateStatePart.createAction<string>(
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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`
|
||||
<dees-form>
|
||||
<dees-input-fileupload
|
||||
key="certJsonFile"
|
||||
label="Certificate JSON (.tsclass.cert.json)"
|
||||
accept=".json"
|
||||
.multiple=${false}
|
||||
required
|
||||
></dees-input-fileupload>
|
||||
</dees-form>
|
||||
`,
|
||||
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`
|
||||
<div style="padding: 20px; font-size: 14px;">
|
||||
<p>Are you sure you want to delete the certificate data for <strong>${cert.domain}</strong>?</p>
|
||||
<p style="color: #f59e0b; margin-top: 12px;">Note: The certificate may remain in proxy memory until the next restart or reprovisioning.</p>
|
||||
</div>
|
||||
`,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user