Compare commits

..

2 Commits

Author SHA1 Message Date
93d6c7d341 v13.0.6
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-05 11:29:47 +00:00
b2ccd54079 fix(certificates): resolve base-domain certificate lookups and route profile list inputs 2026-04-05 11:29:47 +00:00
8 changed files with 114 additions and 90 deletions

View File

@@ -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

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.0.5",
"version": "13.0.6",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -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",

27
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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.'
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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.'
}

View File

@@ -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`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form>
`,
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`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form>
`,
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,