Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b75709161 | |||
| c5e2c262b7 | |||
| d10896196d | |||
| 8be1e87bdc | |||
| 96cefe984a | |||
| ca112c3e42 |
27
changelog.md
27
changelog.md
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-13 - 5.3.0 - feat(certificates)
|
||||||
|
add certificate overview and reprovisioning in ops UI and API; track SmartProxy certificate events
|
||||||
|
|
||||||
|
- Add CertificateHandler with typedrequest endpoints: getCertificateOverview and reprovisionCertificate
|
||||||
|
- Introduce ICertificateInfo and request/response interfaces for certificate operations
|
||||||
|
- Frontend: add certificate state part, actions (fetchCertificateOverview, reprovisionCertificate), router view, and ops-view-certificates component
|
||||||
|
- DcRouter: add certificateStatusMap, listen to SmartProxy certificate-issued/renewed/failed events, and add findRouteNameForDomain helper
|
||||||
|
- Bump dependency @push.rocks/smartproxy to ^24.0.0
|
||||||
|
|
||||||
|
## 2026-02-13 - 5.2.0 - feat(monitoring)
|
||||||
|
add throughput metrics and expose them in ops UI
|
||||||
|
|
||||||
|
- MetricsManager now reports bytesInPerSecond and bytesOutPerSecond as part of throughput
|
||||||
|
- Extended IServerStats with requestsPerSecond and throughput {bytesIn, bytesOut, bytesInPerSecond, bytesOutPerSecond}
|
||||||
|
- Stats handler updated to include requestsPerSecond and throughput; fallback stats initialize throughput fields to zero
|
||||||
|
- Web UI ops overview displays Throughput In/Out (bits/s) and total bytes with new formatting helper
|
||||||
|
- Bumped dependency @push.rocks/smartproxy to ^23.1.6
|
||||||
|
|
||||||
|
## 2026-02-13 - 5.1.0 - feat(acme)
|
||||||
|
Integrate SmartAcme DNS-01 handling and add certificate provisioning for SmartProxy
|
||||||
|
|
||||||
|
- Add smartAcme property and lifecycle management (start/stop) in DcRouter
|
||||||
|
- Create SmartAcme instance when DNS challenge handlers are present and wire certProvisionFunction to SmartProxy to return certificates for domains
|
||||||
|
- Fall back to http-01 provisioning on SmartAcme errors for a domain
|
||||||
|
- Stop SmartAcme during shutdown sequence to clean up resources
|
||||||
|
- Bump dependency @push.rocks/smartproxy to ^23.1.5
|
||||||
|
|
||||||
## 2026-02-13 - 5.0.7 - fix(deps)
|
## 2026-02-13 - 5.0.7 - fix(deps)
|
||||||
bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2
|
bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "5.0.7",
|
"version": "5.3.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^23.1.4",
|
"@push.rocks/smartproxy": "^24.0.0",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -37,7 +37,7 @@ importers:
|
|||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
'@push.rocks/smartacme':
|
'@push.rocks/smartacme':
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0(socks@2.8.7)
|
version: 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
|
||||||
'@push.rocks/smartdata':
|
'@push.rocks/smartdata':
|
||||||
specifier: ^7.0.15
|
specifier: ^7.0.15
|
||||||
version: 7.0.15(socks@2.8.7)
|
version: 7.0.15(socks@2.8.7)
|
||||||
@@ -75,8 +75,8 @@ importers:
|
|||||||
specifier: ^4.2.3
|
specifier: ^4.2.3
|
||||||
version: 4.2.3
|
version: 4.2.3
|
||||||
'@push.rocks/smartproxy':
|
'@push.rocks/smartproxy':
|
||||||
specifier: ^23.1.4
|
specifier: ^24.0.0
|
||||||
version: 23.1.4(socks@2.8.7)
|
version: 24.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
|
||||||
'@push.rocks/smartradius':
|
'@push.rocks/smartradius':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -116,7 +116,7 @@ importers:
|
|||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
'@git.zone/tstest':
|
'@git.zone/tstest':
|
||||||
specifier: ^3.1.8
|
specifier: ^3.1.8
|
||||||
version: 3.1.8(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
|
version: 3.1.8(socks@2.8.7)(typescript@5.9.3)
|
||||||
'@git.zone/tswatch':
|
'@git.zone/tswatch':
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.1.0(@tiptap/pm@2.27.2)
|
version: 3.1.0(@tiptap/pm@2.27.2)
|
||||||
@@ -1040,8 +1040,8 @@ packages:
|
|||||||
'@push.rocks/smartpromise@4.2.3':
|
'@push.rocks/smartpromise@4.2.3':
|
||||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@23.1.4':
|
'@push.rocks/smartproxy@24.0.0':
|
||||||
resolution: {integrity: sha512-VzpXVw3VsA7muhqkEB95pxCFtKtNLLjNCQcdvf0s49TVPXy/wHcmMqOPmExacLZEuvzXYiRM5poUqX4+em/8zw==}
|
resolution: {integrity: sha512-xSz6mrV59xmuiuaBgej6Fq611r9+Ay0ad2XiZAP/XGrkWykgQNeDZqzAq8dadmaCqO/3bVfH/mXlEYaKrDyTYA==}
|
||||||
|
|
||||||
'@push.rocks/smartpuppeteer@2.0.5':
|
'@push.rocks/smartpuppeteer@2.0.5':
|
||||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||||
@@ -5308,7 +5308,7 @@ snapshots:
|
|||||||
'@push.rocks/smartshell': 3.3.0
|
'@push.rocks/smartshell': 3.3.0
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
|
|
||||||
'@git.zone/tstest@3.1.8(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)':
|
'@git.zone/tstest@3.1.8(socks@2.8.7)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
|
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
|
||||||
'@git.zone/tsbundle': 2.8.3
|
'@git.zone/tsbundle': 2.8.3
|
||||||
@@ -5339,7 +5339,6 @@ snapshots:
|
|||||||
- '@aws-sdk/credential-providers'
|
- '@aws-sdk/credential-providers'
|
||||||
- '@mongodb-js/zstd'
|
- '@mongodb-js/zstd'
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
- '@push.rocks/smartserve'
|
|
||||||
- '@swc/helpers'
|
- '@swc/helpers'
|
||||||
- aws-crt
|
- aws-crt
|
||||||
- bare-abort-controller
|
- bare-abort-controller
|
||||||
@@ -5833,7 +5832,7 @@ snapshots:
|
|||||||
'@push.rocks/smartlog': 3.1.10
|
'@push.rocks/smartlog': 3.1.10
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
|
|
||||||
'@push.rocks/smartacme@8.0.0(socks@2.8.7)':
|
'@push.rocks/smartacme@8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
|
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
|
||||||
'@apiclient.xyz/cloudflare': 6.4.3
|
'@apiclient.xyz/cloudflare': 6.4.3
|
||||||
@@ -5855,7 +5854,9 @@ snapshots:
|
|||||||
- '@aws-sdk/credential-providers'
|
- '@aws-sdk/credential-providers'
|
||||||
- '@mongodb-js/zstd'
|
- '@mongodb-js/zstd'
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
|
- '@push.rocks/smartserve'
|
||||||
- bare-abort-controller
|
- bare-abort-controller
|
||||||
|
- bufferutil
|
||||||
- encoding
|
- encoding
|
||||||
- gcp-metadata
|
- gcp-metadata
|
||||||
- kerberos
|
- kerberos
|
||||||
@@ -5865,6 +5866,7 @@ snapshots:
|
|||||||
- snappy
|
- snappy
|
||||||
- socks
|
- socks
|
||||||
- supports-color
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@push.rocks/smartarchive@4.2.4':
|
'@push.rocks/smartarchive@4.2.4':
|
||||||
@@ -6439,10 +6441,10 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smartpromise@4.2.3': {}
|
'@push.rocks/smartpromise@4.2.3': {}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@23.1.4(socks@2.8.7)':
|
'@push.rocks/smartproxy@24.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartacme': 8.0.0(socks@2.8.7)
|
'@push.rocks/smartacme': 8.0.0(@push.rocks/smartserve@2.0.1)(socks@2.8.7)
|
||||||
'@push.rocks/smartcrypto': 2.0.4
|
'@push.rocks/smartcrypto': 2.0.4
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartfile': 13.1.2
|
'@push.rocks/smartfile': 13.1.2
|
||||||
@@ -6464,6 +6466,7 @@ snapshots:
|
|||||||
- '@aws-sdk/credential-providers'
|
- '@aws-sdk/credential-providers'
|
||||||
- '@mongodb-js/zstd'
|
- '@mongodb-js/zstd'
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
|
- '@push.rocks/smartserve'
|
||||||
- bare-abort-controller
|
- bare-abort-controller
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- encoding
|
- encoding
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '5.0.7',
|
version: '5.3.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Core services
|
// Core services
|
||||||
public smartProxy?: plugins.smartproxy.SmartProxy;
|
public smartProxy?: plugins.smartproxy.SmartProxy;
|
||||||
|
public smartAcme?: plugins.smartacme.SmartAcme;
|
||||||
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
|
||||||
public emailServer?: UnifiedEmailServer;
|
public emailServer?: UnifiedEmailServer;
|
||||||
public radiusServer?: RadiusServer;
|
public radiusServer?: RadiusServer;
|
||||||
@@ -182,6 +183,15 @@ export class DcRouter {
|
|||||||
public cacheDb?: CacheDb;
|
public cacheDb?: CacheDb;
|
||||||
public cacheCleaner?: CacheCleaner;
|
public cacheCleaner?: CacheCleaner;
|
||||||
|
|
||||||
|
// Certificate status tracking from SmartProxy events
|
||||||
|
public certificateStatusMap = new Map<string, {
|
||||||
|
status: 'valid' | 'failed';
|
||||||
|
domain: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
issuedAt?: string;
|
||||||
|
error?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
// TypedRouter for API endpoints
|
// TypedRouter for API endpoints
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
@@ -429,12 +439,34 @@ export class DcRouter {
|
|||||||
acme: acmeConfig
|
acme: acmeConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
// If we have DNS challenge handlers, enhance the config
|
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
|
||||||
if (challengeHandlers.length > 0) {
|
if (challengeHandlers.length > 0) {
|
||||||
// We'll need to pass this to SmartProxy somehow
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||||
// For now, we'll set it as a property
|
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
||||||
(smartProxyConfig as any).acmeChallengeHandlers = challengeHandlers;
|
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
||||||
(smartProxyConfig as any).acmeChallengePriority = ['dns-01', 'http-01'];
|
environment: 'production',
|
||||||
|
challengeHandlers: challengeHandlers,
|
||||||
|
challengePriority: ['dns-01'],
|
||||||
|
});
|
||||||
|
await this.smartAcme.start();
|
||||||
|
|
||||||
|
smartProxyConfig.certProvisionFunction = async (domain: string) => {
|
||||||
|
try {
|
||||||
|
const cert = await this.smartAcme.getCertificateForDomain(domain);
|
||||||
|
return {
|
||||||
|
id: cert.id,
|
||||||
|
domainName: cert.domainName,
|
||||||
|
created: cert.created,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
csr: cert.csr,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[DcRouter] SmartAcme DNS-01 failed for ${domain}, falling back to http-01:`, err.message);
|
||||||
|
return 'http01';
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create SmartProxy instance
|
// Create SmartProxy instance
|
||||||
@@ -456,14 +488,34 @@ export class DcRouter {
|
|||||||
if (acmeConfig) {
|
if (acmeConfig) {
|
||||||
this.smartProxy.on('certificate-issued', (event) => {
|
this.smartProxy.on('certificate-issued', (event) => {
|
||||||
console.log(`[DcRouter] Certificate issued for ${event.domain}, expires ${event.expiryDate}`);
|
console.log(`[DcRouter] Certificate issued for ${event.domain}, expires ${event.expiryDate}`);
|
||||||
|
const routeName = this.findRouteNameForDomain(event.domain);
|
||||||
|
if (routeName) {
|
||||||
|
this.certificateStatusMap.set(routeName, {
|
||||||
|
status: 'valid', domain: event.domain,
|
||||||
|
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.smartProxy.on('certificate-renewed', (event) => {
|
this.smartProxy.on('certificate-renewed', (event) => {
|
||||||
console.log(`[DcRouter] Certificate renewed for ${event.domain}, expires ${event.expiryDate}`);
|
console.log(`[DcRouter] Certificate renewed for ${event.domain}, expires ${event.expiryDate}`);
|
||||||
|
const routeName = this.findRouteNameForDomain(event.domain);
|
||||||
|
if (routeName) {
|
||||||
|
this.certificateStatusMap.set(routeName, {
|
||||||
|
status: 'valid', domain: event.domain,
|
||||||
|
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.smartProxy.on('certificate-failed', (event) => {
|
this.smartProxy.on('certificate-failed', (event) => {
|
||||||
console.error(`[DcRouter] Certificate failed for ${event.domain}:`, event.error);
|
console.error(`[DcRouter] Certificate failed for ${event.domain}:`, event.error);
|
||||||
|
const routeName = this.findRouteNameForDomain(event.domain);
|
||||||
|
if (routeName) {
|
||||||
|
this.certificateStatusMap.set(routeName, {
|
||||||
|
status: 'failed', domain: event.domain, error: event.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +687,21 @@ export class DcRouter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the route name that matches a given domain
|
||||||
|
*/
|
||||||
|
private findRouteNameForDomain(domain: string): string | undefined {
|
||||||
|
if (!this.smartProxy) return undefined;
|
||||||
|
for (const route of this.smartProxy.routeManager.getRoutes()) {
|
||||||
|
if (!route.match.domains || !route.name) continue;
|
||||||
|
const routeDomains = Array.isArray(route.match.domains)
|
||||||
|
? route.match.domains
|
||||||
|
: [route.match.domains];
|
||||||
|
if (routeDomains.includes(domain)) return route.name;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
console.log('Stopping DcRouter services...');
|
console.log('Stopping DcRouter services...');
|
||||||
|
|
||||||
@@ -652,6 +719,9 @@ export class DcRouter {
|
|||||||
// Stop unified email server if running
|
// Stop unified email server if running
|
||||||
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(),
|
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(),
|
||||||
|
|
||||||
|
// Stop SmartAcme if running
|
||||||
|
this.smartAcme ? this.smartAcme.stop().catch(err => console.error('Error stopping SmartAcme:', err)) : Promise.resolve(),
|
||||||
|
|
||||||
// Stop HTTP SmartProxy if running
|
// Stop HTTP SmartProxy if running
|
||||||
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
|
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
|
||||||
|
|
||||||
|
|||||||
@@ -147,8 +147,10 @@ export class MetricsManager {
|
|||||||
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
|
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
|
||||||
throughput: proxyMetrics ? {
|
throughput: proxyMetrics ? {
|
||||||
bytesIn: proxyMetrics.totals.bytesIn(),
|
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||||
bytesOut: proxyMetrics.totals.bytesOut()
|
bytesOut: proxyMetrics.totals.bytesOut(),
|
||||||
} : { bytesIn: 0, bytesOut: 0 },
|
bytesInPerSecond: proxyMetrics.throughput.instant().in,
|
||||||
|
bytesOutPerSecond: proxyMetrics.throughput.instant().out,
|
||||||
|
} : { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class OpsServer {
|
|||||||
private statsHandler: handlers.StatsHandler;
|
private statsHandler: handlers.StatsHandler;
|
||||||
private radiusHandler: handlers.RadiusHandler;
|
private radiusHandler: handlers.RadiusHandler;
|
||||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||||
|
private certificateHandler: handlers.CertificateHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -57,6 +58,7 @@ export class OpsServer {
|
|||||||
this.statsHandler = new handlers.StatsHandler(this);
|
this.statsHandler = new handlers.StatsHandler(this);
|
||||||
this.radiusHandler = new handlers.RadiusHandler(this);
|
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||||
|
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
174
ts/opsserver/handlers/certificate.handler.ts
Normal file
174
ts/opsserver/handlers/certificate.handler.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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) => {
|
||||||
|
const certificates = this.buildCertificateOverview();
|
||||||
|
const summary = this.buildSummary(certificates);
|
||||||
|
return { certificates, summary };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reprovision Certificate
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||||
|
'reprovisionCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.reprovisionCertificate(dataArg.routeName);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCertificateOverview(): interfaces.requests.ICertificateInfo[] {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
if (!smartProxy) return [];
|
||||||
|
|
||||||
|
const routes = smartProxy.routeManager.getRoutes();
|
||||||
|
const certificates: interfaces.requests.ICertificateInfo[] = [];
|
||||||
|
|
||||||
|
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') {
|
||||||
|
// Check if a certProvisionFunction is configured
|
||||||
|
if ((smartProxy.settings as any).certProvisionFunction) {
|
||||||
|
source = 'provision-function';
|
||||||
|
} else {
|
||||||
|
source = 'acme';
|
||||||
|
}
|
||||||
|
} else if (tls.certificate && typeof tls.certificate === 'object') {
|
||||||
|
source = 'static';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with unknown status
|
||||||
|
let status: interfaces.requests.TCertificateStatus = 'unknown';
|
||||||
|
let expiryDate: string | undefined;
|
||||||
|
let issuedAt: string | undefined;
|
||||||
|
let issuer: string | undefined;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
|
// Check event-based status from DcRouter's certificateStatusMap
|
||||||
|
const eventStatus = dcRouter.certificateStatusMap.get(route.name);
|
||||||
|
if (eventStatus) {
|
||||||
|
status = eventStatus.status;
|
||||||
|
expiryDate = eventStatus.expiryDate;
|
||||||
|
issuedAt = eventStatus.issuedAt;
|
||||||
|
error = eventStatus.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get Rust-side certificate data
|
||||||
|
try {
|
||||||
|
// getCertificateStatus is async but we're in a sync context
|
||||||
|
// We'll rely on event-based data primarily
|
||||||
|
} catch {
|
||||||
|
// Ignore errors from Rust bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute status from expiry date if we have one and status is still valid/unknown
|
||||||
|
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'
|
||||||
|
if (source === 'static' && status === 'unknown') {
|
||||||
|
status = 'valid';
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReprovision = source === 'acme' || source === 'provision-function';
|
||||||
|
|
||||||
|
certificates.push({
|
||||||
|
routeName: route.name,
|
||||||
|
domains: routeDomains,
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
tlsMode: tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough',
|
||||||
|
expiryDate,
|
||||||
|
issuer,
|
||||||
|
issuedAt,
|
||||||
|
error,
|
||||||
|
canReprovision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reprovisionCertificate(routeName: 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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smartProxy.provisionCertificate(routeName);
|
||||||
|
// Clear event-based status so it gets refreshed
|
||||||
|
dcRouter.certificateStatusMap.delete(routeName);
|
||||||
|
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from './security.handler.js';
|
|||||||
export * from './stats.handler.js';
|
export * from './stats.handler.js';
|
||||||
export * from './radius.handler.js';
|
export * from './radius.handler.js';
|
||||||
export * from './email-ops.handler.js';
|
export * from './email-ops.handler.js';
|
||||||
|
export * from './certificate.handler.js';
|
||||||
@@ -27,6 +27,8 @@ export class StatsHandler {
|
|||||||
cpuUsage: stats.cpuUsage,
|
cpuUsage: stats.cpuUsage,
|
||||||
activeConnections: stats.activeConnections,
|
activeConnections: stats.activeConnections,
|
||||||
totalConnections: stats.totalConnections,
|
totalConnections: stats.totalConnections,
|
||||||
|
requestsPerSecond: stats.requestsPerSecond,
|
||||||
|
throughput: stats.throughput,
|
||||||
},
|
},
|
||||||
history: dataArg.includeHistory ? stats.history : undefined,
|
history: dataArg.includeHistory ? stats.history : undefined,
|
||||||
};
|
};
|
||||||
@@ -191,6 +193,8 @@ export class StatsHandler {
|
|||||||
cpuUsage: stats.cpuUsage,
|
cpuUsage: stats.cpuUsage,
|
||||||
activeConnections: stats.activeConnections,
|
activeConnections: stats.activeConnections,
|
||||||
totalConnections: stats.totalConnections,
|
totalConnections: stats.totalConnections,
|
||||||
|
requestsPerSecond: stats.requestsPerSecond,
|
||||||
|
throughput: stats.throughput,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -301,6 +305,7 @@ export class StatsHandler {
|
|||||||
requestsPerSecond: number;
|
requestsPerSecond: number;
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
totalConnections: number;
|
totalConnections: number;
|
||||||
|
throughput: interfaces.data.IServerStats['throughput'];
|
||||||
history: Array<{
|
history: Array<{
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -316,6 +321,7 @@ export class StatsHandler {
|
|||||||
requestsPerSecond: serverStats.requestsPerSecond,
|
requestsPerSecond: serverStats.requestsPerSecond,
|
||||||
activeConnections: serverStats.activeConnections,
|
activeConnections: serverStats.activeConnections,
|
||||||
totalConnections: serverStats.totalConnections,
|
totalConnections: serverStats.totalConnections,
|
||||||
|
throughput: serverStats.throughput,
|
||||||
history: [], // TODO: Implement history tracking
|
history: [], // TODO: Implement history tracking
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -340,6 +346,7 @@ export class StatsHandler {
|
|||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
activeConnections: 0,
|
activeConnections: 0,
|
||||||
totalConnections: 0,
|
totalConnections: 0,
|
||||||
|
throughput: { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
history: [],
|
history: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ export interface IServerStats {
|
|||||||
};
|
};
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
totalConnections: number;
|
totalConnections: number;
|
||||||
|
requestsPerSecond: number;
|
||||||
|
throughput: {
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
bytesInPerSecond: number;
|
||||||
|
bytesOutPerSecond: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IEmailStats {
|
export interface IEmailStats {
|
||||||
|
|||||||
54
ts_interfaces/requests/certificate.ts
Normal file
54
ts_interfaces/requests/certificate.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
|
export type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
|
||||||
|
export type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
|
||||||
|
|
||||||
|
export interface ICertificateInfo {
|
||||||
|
routeName: string;
|
||||||
|
domains: string[];
|
||||||
|
status: TCertificateStatus;
|
||||||
|
source: TCertificateSource;
|
||||||
|
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||||
|
expiryDate?: string; // ISO string
|
||||||
|
issuer?: string;
|
||||||
|
issuedAt?: string; // ISO string
|
||||||
|
error?: string; // if status === 'failed'
|
||||||
|
canReprovision: boolean; // true for acme/provision-function routes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetCertificateOverview
|
||||||
|
> {
|
||||||
|
method: 'getCertificateOverview';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
certificates: ICertificateInfo[];
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
expiring: number;
|
||||||
|
expired: number;
|
||||||
|
failed: number;
|
||||||
|
unknown: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ReprovisionCertificate
|
||||||
|
> {
|
||||||
|
method: 'reprovisionCertificate';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
routeName: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export * from './stats.js';
|
|||||||
export * from './combined.stats.js';
|
export * from './combined.stats.js';
|
||||||
export * from './radius.js';
|
export * from './radius.js';
|
||||||
export * from './email-ops.js';
|
export * from './email-ops.js';
|
||||||
|
export * from './certificate.js';
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '5.0.7',
|
version: '5.3.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export interface INetworkState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ICertificateState {
|
||||||
|
certificates: interfaces.requests.ICertificateInfo[];
|
||||||
|
summary: { total: number; valid: number; expiring: number; expired: number; failed: number; unknown: number };
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IEmailOpsState {
|
export interface IEmailOpsState {
|
||||||
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||||
queuedEmails: interfaces.requests.IEmailQueueItem[];
|
queuedEmails: interfaces.requests.IEmailQueueItem[];
|
||||||
@@ -103,7 +111,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
|||||||
// Determine initial view from URL path
|
// Determine initial view from URL path
|
||||||
const getInitialView = (): string => {
|
const getInitialView = (): string => {
|
||||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||||
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security'];
|
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'];
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
return validViews.includes(view) ? view : 'overview';
|
return validViews.includes(view) ? view : 'overview';
|
||||||
@@ -162,6 +170,18 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
|||||||
'soft'
|
'soft'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const certificateStatePart = await appState.getStatePart<ICertificateState>(
|
||||||
|
'certificates',
|
||||||
|
{
|
||||||
|
certificates: [],
|
||||||
|
summary: { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft'
|
||||||
|
);
|
||||||
|
|
||||||
// Actions for state management
|
// Actions for state management
|
||||||
interface IActionContext {
|
interface IActionContext {
|
||||||
identity: interfaces.data.IIdentity | null;
|
identity: interfaces.data.IIdentity | null;
|
||||||
@@ -341,6 +361,13 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If switching to certificates view, ensure we fetch certificate data
|
||||||
|
if (viewName === 'certificates' && currentState.activeView !== 'certificates') {
|
||||||
|
setTimeout(() => {
|
||||||
|
certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
activeView: viewName,
|
activeView: viewName,
|
||||||
@@ -641,6 +668,66 @@ export const removeFromSuppressionListAction = emailOpsStatePart.createAction<st
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Certificate Actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetCertificateOverview
|
||||||
|
>('/typedrequest', 'getCertificateOverview');
|
||||||
|
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificates: response.certificates,
|
||||||
|
summary: response.summary,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch certificate overview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
||||||
|
async (statePartArg, routeName) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ReprovisionCertificate
|
||||||
|
>('/typedrequest', 'reprovisionCertificate');
|
||||||
|
|
||||||
|
await request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
routeName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fetch overview after reprovisioning
|
||||||
|
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
|
return statePartArg.getState();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to reprovision certificate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Combined refresh action for efficient polling
|
// Combined refresh action for efficient polling
|
||||||
async function dispatchCombinedRefreshAction() {
|
async function dispatchCombinedRefreshAction() {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
@@ -725,6 +812,15 @@ async function dispatchCombinedRefreshAction() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh certificate data if on certificates view
|
||||||
|
if (currentView === 'certificates') {
|
||||||
|
try {
|
||||||
|
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Certificate refresh failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Combined refresh failed:', error);
|
console.error('Combined refresh failed:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export * from './ops-view-emails.js';
|
|||||||
export * from './ops-view-logs.js';
|
export * from './ops-view-logs.js';
|
||||||
export * from './ops-view-config.js';
|
export * from './ops-view-config.js';
|
||||||
export * from './ops-view-security.js';
|
export * from './ops-view-security.js';
|
||||||
|
export * from './ops-view-certificates.js';
|
||||||
export * from './shared/index.js';
|
export * from './shared/index.js';
|
||||||
@@ -19,6 +19,7 @@ import { OpsViewEmails } from './ops-view-emails.js';
|
|||||||
import { OpsViewLogs } from './ops-view-logs.js';
|
import { OpsViewLogs } from './ops-view-logs.js';
|
||||||
import { OpsViewConfig } from './ops-view-config.js';
|
import { OpsViewConfig } from './ops-view-config.js';
|
||||||
import { OpsViewSecurity } from './ops-view-security.js';
|
import { OpsViewSecurity } from './ops-view-security.js';
|
||||||
|
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||||
|
|
||||||
@customElement('ops-dashboard')
|
@customElement('ops-dashboard')
|
||||||
export class OpsDashboard extends DeesElement {
|
export class OpsDashboard extends DeesElement {
|
||||||
@@ -61,6 +62,10 @@ export class OpsDashboard extends DeesElement {
|
|||||||
name: 'Security',
|
name: 'Security',
|
||||||
element: OpsViewSecurity,
|
element: OpsViewSecurity,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Certificates',
|
||||||
|
element: OpsViewCertificates,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
355
ts_web/elements/ops-view-certificates.ts
Normal file
355
ts_web/elements/ops-view-certificates.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from './shared/css.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-certificates': OpsViewCertificates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-certificates')
|
||||||
|
export class OpsViewCertificates extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.certificateStatePart.state.subscribe((newState) => {
|
||||||
|
this.certState = newState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.certificatesContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.valid {
|
||||||
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.expiring {
|
||||||
|
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
|
||||||
|
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.expired,
|
||||||
|
.statusBadge.failed {
|
||||||
|
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.provisioning {
|
||||||
|
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
|
||||||
|
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.unknown {
|
||||||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||||
|
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||||
|
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.domainPills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.domainPill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||||
|
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.moreCount {
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiryInfo {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiryInfo .daysLeft {
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiryInfo .daysLeft.warn {
|
||||||
|
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiryInfo .daysLeft.danger {
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { summary } = this.certState;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ops-sectionheading>Certificates</ops-sectionheading>
|
||||||
|
|
||||||
|
<div class="certificatesContainer">
|
||||||
|
${this.renderStatsTiles(summary)}
|
||||||
|
${this.renderCertificateTable()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
title: 'Total Certificates',
|
||||||
|
value: summary.total,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'shieldHalved',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'valid',
|
||||||
|
title: 'Valid',
|
||||||
|
value: summary.valid,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'check',
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expiring',
|
||||||
|
title: 'Expiring Soon',
|
||||||
|
value: summary.expiring,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'clock',
|
||||||
|
color: '#f59e0b',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'problems',
|
||||||
|
title: 'Failed / Expired',
|
||||||
|
value: summary.failed + summary.expired,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'triangleExclamation',
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${tiles}
|
||||||
|
.minTileWidth=${200}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'arrowsRotate',
|
||||||
|
action: async () => {
|
||||||
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
|
appstate.fetchCertificateOverviewAction,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCertificateTable(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<dees-table
|
||||||
|
.data=${this.certState.certificates}
|
||||||
|
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
||||||
|
Route: cert.routeName,
|
||||||
|
Domains: this.renderDomainPills(cert.domains),
|
||||||
|
Status: this.renderStatusBadge(cert.status),
|
||||||
|
Source: this.renderSourceBadge(cert.source),
|
||||||
|
Expires: this.renderExpiry(cert.expiryDate),
|
||||||
|
Error: cert.error
|
||||||
|
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
|
||||||
|
: '',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Reprovision',
|
||||||
|
iconName: 'arrowsRotate',
|
||||||
|
type: ['inRow'],
|
||||||
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
|
const cert = actionData.item;
|
||||||
|
if (!cert.canReprovision) {
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.show({
|
||||||
|
message: 'This certificate source does not support reprovisioning.',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
|
appstate.reprovisionCertificateAction,
|
||||||
|
cert.routeName,
|
||||||
|
);
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.show({
|
||||||
|
message: `Reprovisioning triggered for ${cert.routeName}`,
|
||||||
|
type: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'View Details',
|
||||||
|
iconName: 'magnifyingGlass',
|
||||||
|
type: ['doubleClick', 'contextmenu'],
|
||||||
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
|
const cert = actionData.item;
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Certificate: ${cert.routeName}`,
|
||||||
|
content: html`
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<dees-dataview-codebox
|
||||||
|
.heading=${'Certificate Details'}
|
||||||
|
progLang="json"
|
||||||
|
.codeToDisplay=${JSON.stringify(cert, null, 2)}
|
||||||
|
></dees-dataview-codebox>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Copy Route Name',
|
||||||
|
iconName: 'copy',
|
||||||
|
action: async () => {
|
||||||
|
await navigator.clipboard.writeText(cert.routeName);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
heading1="Certificate Status"
|
||||||
|
heading2="TLS certificates across all routes"
|
||||||
|
searchable
|
||||||
|
.pagination=${true}
|
||||||
|
.paginationSize=${50}
|
||||||
|
dataName="certificate"
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDomainPills(domains: string[]): TemplateResult {
|
||||||
|
const maxShow = 3;
|
||||||
|
const visible = domains.slice(0, maxShow);
|
||||||
|
const remaining = domains.length - maxShow;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<span class="domainPills">
|
||||||
|
${visible.map((d) => html`<span class="domainPill">${d}</span>`)}
|
||||||
|
${remaining > 0 ? html`<span class="moreCount">+${remaining} more</span>` : ''}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderStatusBadge(status: interfaces.requests.TCertificateStatus): TemplateResult {
|
||||||
|
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSourceBadge(source: interfaces.requests.TCertificateSource): TemplateResult {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
acme: 'ACME',
|
||||||
|
'provision-function': 'Custom',
|
||||||
|
static: 'Static',
|
||||||
|
none: 'None',
|
||||||
|
};
|
||||||
|
return html`<span class="sourceBadge">${labels[source] || source}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderExpiry(expiryDate?: string): TemplateResult {
|
||||||
|
if (!expiryDate) {
|
||||||
|
return html`<span style="color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}">--</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = new Date(expiryDate);
|
||||||
|
const now = new Date();
|
||||||
|
const daysLeft = Math.ceil((expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
const dateStr = expiry.toLocaleDateString();
|
||||||
|
let daysClass = '';
|
||||||
|
let daysText = '';
|
||||||
|
|
||||||
|
if (daysLeft < 0) {
|
||||||
|
daysClass = 'danger';
|
||||||
|
daysText = `(expired)`;
|
||||||
|
} else if (daysLeft < 30) {
|
||||||
|
daysClass = 'warn';
|
||||||
|
daysText = `(${daysLeft}d left)`;
|
||||||
|
} else {
|
||||||
|
daysText = `(${daysLeft}d left)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<span class="expiryInfo">
|
||||||
|
${dateStr} <span class="daysLeft ${daysClass}">${daysText}</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,20 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatBitsPerSecond(bytesPerSecond: number): string {
|
||||||
|
const bitsPerSecond = bytesPerSecond * 8;
|
||||||
|
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
||||||
|
let size = bitsPerSecond;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1000;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderServerStats(): TemplateResult {
|
private renderServerStats(): TemplateResult {
|
||||||
if (!this.statsState.serverStats) return html``;
|
if (!this.statsState.serverStats) return html``;
|
||||||
|
|
||||||
@@ -162,6 +176,24 @@ export class OpsViewOverview extends DeesElement {
|
|||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
description: `Total: ${this.statsState.serverStats.totalConnections}`,
|
description: `Total: ${this.statsState.serverStats.totalConnections}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'throughputIn',
|
||||||
|
title: 'Throughput In',
|
||||||
|
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesInPerSecond || 0),
|
||||||
|
type: 'text',
|
||||||
|
icon: 'download',
|
||||||
|
color: '#22c55e',
|
||||||
|
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesIn || 0)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'throughputOut',
|
||||||
|
title: 'Throughput Out',
|
||||||
|
value: this.formatBitsPerSecond(this.statsState.serverStats.throughput?.bytesOutPerSecond || 0),
|
||||||
|
type: 'text',
|
||||||
|
icon: 'upload',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
description: `Total: ${this.formatBytes(this.statsState.serverStats.throughput?.bytesOut || 0)}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'cpu',
|
id: 'cpu',
|
||||||
title: 'CPU Usage',
|
title: 'CPU Usage',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
|
|||||||
|
|
||||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||||
|
|
||||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security'] as const;
|
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'] as const;
|
||||||
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
||||||
|
|
||||||
export type TValidView = typeof validViews[number];
|
export type TValidView = typeof validViews[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user