From b2ccd5407905c1ceff4d0ee5e63916bbec3c7fac Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 5 Apr 2026 11:29:47 +0000 Subject: [PATCH] fix(certificates): resolve base-domain certificate lookups and route profile list inputs --- changelog.md | 7 ++ package.json | 4 +- pnpm-lock.yaml | 27 +++--- ts/00_commitinfo_data.ts | 2 +- ts/classes.dcrouter.ts | 5 +- ts/opsserver/handlers/certificate.handler.ts | 58 +++++++----- ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/ops-view-targetprofiles.ts | 97 ++++++++++---------- 8 files changed, 113 insertions(+), 89 deletions(-) diff --git a/changelog.md b/changelog.md index 8a890c9..584835c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-05 - 13.0.6 - fix(certificates) +resolve base-domain certificate lookups and route profile list inputs + +- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably. +- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling. +- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references. + ## 2026-04-05 - 13.0.5 - fix(ts_web) replace custom section heading component with dees-heading across ops views diff --git a/package.json b/package.json index 8d39d12..bc24d1f 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "@api.global/typedserver": "^8.4.6", "@api.global/typedsocket": "^4.1.2", "@apiclient.xyz/cloudflare": "^7.1.0", - "@design.estate/dees-catalog": "^3.61.0", + "@design.estate/dees-catalog": "^3.61.1", "@design.estate/dees-element": "^2.2.4", "@push.rocks/lik": "^6.4.0", "@push.rocks/projectinfo": "^5.1.0", "@push.rocks/qenv": "^6.1.3", - "@push.rocks/smartacme": "^9.4.0", + "@push.rocks/smartacme": "^9.5.0", "@push.rocks/smartdata": "^7.1.6", "@push.rocks/smartdb": "^2.5.9", "@push.rocks/smartdns": "^7.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2306319..3829e34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^7.1.0 version: 7.1.0 '@design.estate/dees-catalog': - specifier: ^3.61.0 - version: 3.61.0(@tiptap/pm@2.27.2) + specifier: ^3.61.1 + version: 3.61.1(@tiptap/pm@2.27.2) '@design.estate/dees-element': specifier: ^2.2.4 version: 2.2.4 @@ -39,8 +39,8 @@ importers: specifier: ^6.1.3 version: 6.1.3 '@push.rocks/smartacme': - specifier: ^9.4.0 - version: 9.4.0(socks@2.8.7) + specifier: ^9.5.0 + version: 9.5.0(socks@2.8.7) '@push.rocks/smartdata': specifier: ^7.1.6 version: 7.1.6(socks@2.8.7) @@ -350,8 +350,8 @@ packages: '@configvault.io/interfaces@1.0.17': resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} - '@design.estate/dees-catalog@3.61.0': - resolution: {integrity: sha512-gBcNotstwnapGuf/DSapVu+R8F1ITp1wypDOw4NLFak0FwOmPb7ao5pALUbcz+MZmZmB0VuBuqN5GcTyIGIX3Q==} + '@design.estate/dees-catalog@3.61.1': + resolution: {integrity: sha512-RA85O87pRM3QPlncBNB27wJTl+UVGaGtx8l5DaeOhru78agu4+y+ByAdUgS9Ahdpr/ZZVYSAADkZETsf/l08UQ==} '@design.estate/dees-comms@1.0.30': resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} @@ -1108,8 +1108,8 @@ packages: '@push.rocks/qenv@6.1.3': resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==} - '@push.rocks/smartacme@9.4.0': - resolution: {integrity: sha512-mSqsI859mHI9fCZxLfayzPf/WvukDFzVHOh02vXq3ujxbb5M+ArMnXe0MmC2egR9GeXmQTm3DTENaETX5ffMtw==} + '@push.rocks/smartacme@9.5.0': + resolution: {integrity: sha512-soOjER2c4umKaOSsB6uq/k08aA9rfd7Dicm6DNX3XB16LjCjldVHpizeOGqRBkFga+VroDQ/rEYecHT5tFiWvg==} '@push.rocks/smartarchive@4.2.4': resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==} @@ -4358,7 +4358,7 @@ snapshots: '@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3) '@cloudflare/workers-types': 4.20260317.1 - '@design.estate/dees-catalog': 3.61.0(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.61.1(@tiptap/pm@2.27.2) '@design.estate/dees-comms': 1.0.30 '@push.rocks/lik': 6.4.0 '@push.rocks/smartdelay': 3.0.5 @@ -4887,7 +4887,7 @@ snapshots: dependencies: '@api.global/typedrequest-interfaces': 3.0.19 - '@design.estate/dees-catalog@3.61.0(@tiptap/pm@2.27.2)': + '@design.estate/dees-catalog@3.61.1(@tiptap/pm@2.27.2)': dependencies: '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 @@ -5977,7 +5977,7 @@ snapshots: '@push.rocks/smartlog': 3.2.1 '@push.rocks/smartpath': 6.0.0 - '@push.rocks/smartacme@9.4.0(socks@2.8.7)': + '@push.rocks/smartacme@9.5.0(socks@2.8.7)': dependencies: '@apiclient.xyz/cloudflare': 7.1.0 '@peculiar/x509': 2.0.0 @@ -5997,11 +5997,14 @@ snapshots: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' - '@nuxt/kit' + - bare-abort-controller + - bare-buffer - encoding - gcp-metadata - kerberos - mongodb-client-encryption - react + - react-native-b4a - snappy - socks - supports-color @@ -6965,7 +6968,7 @@ snapshots: '@serve.zone/catalog@2.11.2(@tiptap/pm@2.27.2)': dependencies: - '@design.estate/dees-catalog': 3.61.0(@tiptap/pm@2.27.2) + '@design.estate/dees-catalog': 3.61.1(@tiptap/pm@2.27.2) '@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-element': 2.2.4 '@design.estate/dees-wcctools': 3.8.0 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a3ffb42..dde9958 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: '13.0.5', + version: '13.0.6', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 266dbf1..30f9123 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -1076,7 +1076,10 @@ export class DcRouter { if (!expiryDate) { try { const cleanDomain = entry.domain.replace(/^\*\.?/, ''); - const certDoc = await AcmeCertDoc.findByDomain(cleanDomain); + const domParts = cleanDomain.split('.'); + const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain; + const certDoc = await AcmeCertDoc.findByDomain(baseDomain) + || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null); if (certDoc?.validUntil) { expiryDate = new Date(certDoc.validUntil).toISOString(); } diff --git a/ts/opsserver/handlers/certificate.handler.ts b/ts/opsserver/handlers/certificate.handler.ts index eb85464..1281079 100644 --- a/ts/opsserver/handlers/certificate.handler.ts +++ b/ts/opsserver/handlers/certificate.handler.ts @@ -191,7 +191,11 @@ export class CertificateHandler { // Check persisted cert data from smartdata document classes if (status === 'unknown') { const cleanDomain = domain.replace(/^\*\.?/, ''); - const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain); + // SmartAcme stores certs under the base domain (e.g. example.com for api.example.com) + const parts = cleanDomain.split('.'); + const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain; + const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain) + || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null); const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null; if (acmeDoc?.validUntil) { @@ -331,31 +335,32 @@ export class CertificateHandler { await dcRouter.certProvisionScheduler.clearBackoff(domain); } - // Clear status map entry so it gets refreshed + // Find routes matching this domain — needed to provision through SmartProxy + const routeNames = dcRouter.findRouteNamesForDomain(domain); + if (routeNames.length === 0) { + return { success: false, message: `No routes found for domain '${domain}'` }; + } + + // If forceRenew, invalidate SmartAcme's cache so the next provision gets a fresh cert + if (forceRenew && dcRouter.smartAcme) { + try { + await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true }); + } catch { + // Cache invalidation failed — proceed with provisioning anyway + } + } + + // Clear status map entry so it gets refreshed by the certificate-issued event dcRouter.certificateStatusMap.delete(domain); - // Try to provision via SmartAcme directly - if (dcRouter.smartAcme) { - try { - await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false }); - return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` }; - } catch (err: unknown) { - return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` }; - } + // Provision through SmartProxy — this triggers the full pipeline: + // certProvisionFunction → bridge.loadCertificate → certificate-issued event → status map updated + try { + await smartProxy.provisionCertificate(routeNames[0]); + return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` }; + } catch (err: unknown) { + return { success: false, message: (err as Error).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: unknown) { - return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` }; - } - } - - return { success: false, message: `No routes found for domain '${domain}'` }; } /** @@ -364,9 +369,12 @@ export class CertificateHandler { private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> { const dcRouter = this.opsServerRef.dcRouterRef; const cleanDomain = domain.replace(/^\*\.?/, ''); + const parts = cleanDomain.split('.'); + const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain; - // Delete from smartdata document classes - const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain); + // Delete from smartdata document classes (try base domain first, then exact) + const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain) + || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null); if (acmeDoc) { await acmeDoc.delete(); } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index a3ffb42..dde9958 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: '13.0.5', + version: '13.0.6', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/ops-view-targetprofiles.ts b/ts_web/elements/ops-view-targetprofiles.ts index 2af1f2f..4a8585c 100644 --- a/ts_web/elements/ops-view-targetprofiles.ts +++ b/ts_web/elements/ops-view-targetprofiles.ts @@ -148,17 +148,27 @@ export class OpsViewTargetProfiles extends DeesElement { `; } + private getRouteCandidates() { + const routeState = appstate.routeManagementStatePart.getState(); + const routes = routeState?.mergedRoutes || []; + return routes + .filter((mr) => mr.route.name) + .map((mr) => ({ viewKey: mr.route.name! })); + } + private async showCreateProfileDialog() { const { DeesModal } = await import('@design.estate/dees-catalog'); + const routeCandidates = this.getRouteCandidates(); + DeesModal.createAndShow({ heading: 'Create Target Profile', content: html` - - - + + + `, menuOptions: [ @@ -172,30 +182,26 @@ export class OpsViewTargetProfiles extends DeesElement { const data = await form.collectFormData(); if (!data.name) return; - const domains = data.domains - ? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean) - : undefined; - const targets = data.targets - ? String(data.targets).split(',').map((s: string) => { - const trimmed = s.trim(); - const lastColon = trimmed.lastIndexOf(':'); - if (lastColon === -1) return null; - return { - host: trimmed.substring(0, lastColon), - port: parseInt(trimmed.substring(lastColon + 1), 10), - }; - }).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)) - : undefined; - const routeRefs = data.routeRefs - ? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean) - : undefined; + const domains: string[] = Array.isArray(data.domains) ? data.domains : []; + const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : []; + const targets = targetStrings + .map((s: string) => { + const lastColon = s.lastIndexOf(':'); + if (lastColon === -1) return null; + return { + host: s.substring(0, lastColon), + port: parseInt(s.substring(lastColon + 1), 10), + }; + }) + .filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)); + const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : []; await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, { name: String(data.name), description: data.description ? String(data.description) : undefined, - domains, - targets, - routeRefs, + domains: domains.length > 0 ? domains : undefined, + targets: targets.length > 0 ? targets : undefined, + routeRefs: routeRefs.length > 0 ? routeRefs : undefined, }); modalArg.destroy(); }, @@ -205,20 +211,22 @@ export class OpsViewTargetProfiles extends DeesElement { } private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) { - const currentDomains = profile.domains?.join(', ') ?? ''; - const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? ''; - const currentRouteRefs = profile.routeRefs?.join(', ') ?? ''; + const currentDomains = profile.domains || []; + const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`) || []; + const currentRouteRefs = profile.routeRefs || []; const { DeesModal } = await import('@design.estate/dees-catalog'); + const routeCandidates = this.getRouteCandidates(); + DeesModal.createAndShow({ heading: `Edit Profile: ${profile.name}`, content: html` - - - + + + `, menuOptions: [ @@ -231,24 +239,19 @@ export class OpsViewTargetProfiles extends DeesElement { if (!form) return; const data = await form.collectFormData(); - const domains = data.domains - ? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean) - : []; - const targets = data.targets - ? String(data.targets).split(',').map((s: string) => { - const trimmed = s.trim(); - if (!trimmed) return null; - const lastColon = trimmed.lastIndexOf(':'); - if (lastColon === -1) return null; - return { - host: trimmed.substring(0, lastColon), - port: parseInt(trimmed.substring(lastColon + 1), 10), - }; - }).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)) - : []; - const routeRefs = data.routeRefs - ? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean) - : []; + const domains: string[] = Array.isArray(data.domains) ? data.domains : []; + const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : []; + const targets = targetStrings + .map((s: string) => { + const lastColon = s.lastIndexOf(':'); + if (lastColon === -1) return null; + return { + host: s.substring(0, lastColon), + port: parseInt(s.substring(lastColon + 1), 10), + }; + }) + .filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port)); + const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : []; await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, { id: profile.id,