Compare commits

...

16 Commits

Author SHA1 Message Date
9060d26f3a v6.4.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 11:25:16 +00:00
c889141ec3 feat(remoteingress): add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI 2026-02-16 11:25:16 +00:00
fb472f353c v6.3.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:52:38 +00:00
090bd747e1 feat(dcrouter): add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS 2026-02-16 09:52:38 +00:00
4d77a94bbb v6.2.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 09:02:57 +00:00
7f5284b10f fix(deps): bump @push.rocks/smartproxy to ^25.5.0 2026-02-16 09:02:57 +00:00
9cd5db2d81 v6.2.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 02:50:25 +00:00
de0b7d1fe0 fix(dcrouter): persist proxy certificate validity dates and improve certificate status initialization 2026-02-16 02:50:25 +00:00
4e32745a8f v6.2.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 01:58:39 +00:00
121573de2f fix(certs): Populate certificate status for cert-store-loaded certificates after SmartProxy startup and check proxy-certs in opsserver certificate handler 2026-02-16 01:58:39 +00:00
cd957526e2 v6.2.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:56:41 +00:00
7aa5f07731 fix(smartacme,storage): Respect wildcard domain requests when retrieving certificates and treat empty/whitespace storage values as null in getJSON 2026-02-16 00:56:41 +00:00
5b6f7b30c3 v6.2.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:26:35 +00:00
18cc21a49e feat(ts_web): add Certificate Management documentation and ops-view-certificates reference 2026-02-16 00:26:35 +00:00
46fa2f6ade v6.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-16 00:22:23 +00:00
0a6315f177 feat(certs): integrate smartacme v9 for ACME certificate provisioning and add certificate management features, docs, dashboard views, API endpoints, and per-domain backoff scheduler 2026-02-16 00:22:23 +00:00
31 changed files with 1580 additions and 280 deletions

View File

@@ -1,5 +1,72 @@
# Changelog
## 2026-02-16 - 6.4.0 - feat(remoteingress)
add Remote Ingress hub and management for edge tunnel nodes, including backend managers, tunnel hub integration, opsserver handlers, typedrequest APIs, and web UI
- Introduce RemoteIngressManager for CRUD and persistent storage of edge registrations
- Introduce TunnelManager to run the RemoteIngressHub, track connected edge statuses, and sync allowed edges to the hub
- Integrate remote ingress into DcRouter (options.remoteIngressConfig, setupRemoteIngress, startup/shutdown handling, and startup summary)
- Add OpsServer RemoteIngressHandler exposing typedrequest APIs (create/update/delete/regenerate/get/status)
- Add web UI: Remote Ingress view, app state parts, actions and components to manage edges and display runtime statuses
- Add typedrequest and data interfaces for remoteingress and export the remoteingress module; add @serve.zone/remoteingress dependency in package.json
## 2026-02-16 - 6.3.0 - feat(dcrouter)
add configurable baseDir and centralized path resolution; use resolved data paths for storage, cache and DNS
- Introduce IDcRouterOptions.baseDir to allow configuring base directory for dcrouter data (defaults to ~/.serve.zone/dcrouter).
- Add DcRouter.resolvedPaths and resolvePaths(baseDir) in ts/paths.ts to centralize computation of dcrouterHomeDir, dataDir, defaultTsmDbPath, defaultStoragePath and dnsRecordsDir.
- Use resolvedPaths throughout DcRouter: default filesystem storage fsPath, CacheDb storagePath, and DNS records loading now reference resolved paths.
- Replace ensureDirectories() behavior with ensureDataDirectories(resolvedPaths) to only create data-related directories; keep legacy ensureDirectories wrapper delegating to the new function.
- Simplify paths module by removing unused legacy path constants and adding a focused API for path resolution and directory creation.
- Remove an unused import (paths) in contentscanner, cleaning up imports.
## 2026-02-16 - 6.2.4 - fix(deps)
bump @push.rocks/smartproxy to ^25.5.0
- Updated @push.rocks/smartproxy from ^25.4.0 to ^25.5.0 in package.json
## 2026-02-16 - 6.2.3 - fix(dcrouter)
persist proxy certificate validity dates and improve certificate status initialization
- Bump @push.rocks/smartacme dependency from ^9.0.0 to ^9.1.3
- Store validFrom and validUntil alongside proxy cert entries (/proxy-certs) when saving, extracting values by parsing PEM where possible
- Use stored cert entries (domain, publicKey, validUntil, validFrom) to populate certificateStatusMap at startup
- Fallback to SmartAcme /certs/ metadata and finally to parsing X.509 from stored PEM to determine expiry/issuedAt when initializing status
- Update opsserver certificate handler to parse publicKey PEM from cert-store and set expiry/issuedAt and issuer accordingly
- Adjust variable names and logging to reflect stored cert entry usage
## 2026-02-16 - 6.2.2 - fix(certs)
Populate certificate status for cert-store-loaded certificates after SmartProxy startup and check proxy-certs in opsserver certificate handler
- Track domains loaded from storageManager '/proxy-certs/' and populate certificateStatusMap with status, routeNames, expiryDate and issuedAt (when available) after SmartProxy starts
- Opsserver certificate handler now falls back to '/proxy-certs/{domain}' if '/certs/{cleanDomain}' is missing and marks cert-store-only entries as valid with issuer 'cert-store'
- Bump @push.rocks/smartproxy dependency from ^25.3.1 to ^25.4.0
## 2026-02-16 - 6.2.1 - fix(smartacme,storage)
Respect wildcard domain requests when retrieving certificates and treat empty/whitespace storage values as null in getJSON
- Pass includeWildcard flag to smartAcme.getCertificateForDomain to avoid incorrectly including/excluding wildcard certificates based on whether the requested domain itself is a wildcard
- Detect wildcard domains via domain.startsWith('*.') and set includeWildcard to false for wildcard requests
- Treat empty or whitespace-only stored values as null in StorageManager.getJSON to avoid parsing empty strings as JSON and potential errors
## 2026-02-16 - 6.2.0 - feat(ts_web)
add Certificate Management documentation and ops-view-certificates reference
- Adds a new 'Certificate Management' section to ts_web/readme.md describing domain-centric overview, certificate sources (ACME/provision/static), expiry monitoring, per-domain backoff, and one-click reprovisioning
- Adds ops-view-certificates.ts entry to the ops UI file list
- Documents new route mapping '/certificates' in the readme navigation
## 2026-02-16 - 6.1.0 - feat(certs)
integrate smartacme v9 for ACME certificate provisioning and add certificate management features, docs, dashboard views, API endpoints, and per-domain backoff scheduler
- Bump dependency: @push.rocks/smartacme -> ^9.0.0
- Add Certificate Management documentation, examples, and a new Certificates view in the OpsServer dashboard (status, source, expiry, backoff, oneclick reprovision)
- Integrate smartacme v9 features: per-domain deduplication, global concurrency control, account rate limiting, structured errors, and clean shutdown behavior
- Introduce per-domain exponential backoff persisted via StorageManager (CertProvisionScheduler) and remove the older serial stagger queue (smartacme v9 handles concurrency/deduping)
- Expose new typedrequest API methods: getCertificateOverview, reprovisionCertificate (legacy), reprovisionCertificateDomain (preferred)
- DcRouter now surfaces smartAcme, certProvisionScheduler, and certificateStatusMap; cert provisioning paths call smartAcme directly and clear backoff on success
- Docs updated to note parallel shutdown/cleanup of HTTP agents and DNS clients
## 2026-02-15 - 6.0.0 - BREAKING CHANGE(certs)
Introduce domain-centric certificate provisioning with per-domain exponential backoff and a staggered serial scheduler; add domain-based reprovision API and UI backoff display; change certificate overview API to be domain-first and include backoff info; bump related deps.

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "6.0.0",
"version": "6.4.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -36,7 +36,7 @@
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^8.0.0",
"@push.rocks/smartacme": "^9.1.3",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdns": "^7.8.1",
"@push.rocks/smartfile": "^13.1.2",
@@ -49,13 +49,14 @@
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^25.3.1",
"@push.rocks/smartproxy": "^25.5.0",
"@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.0.30",
"@push.rocks/smartunique": "^3.0.9",
"@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^3.0.1",
"@tsclass/tsclass": "^9.3.0",
"lru-cache": "^11.2.6",
"uuid": "^13.0.0"

210
pnpm-lock.yaml generated
View File

@@ -36,8 +36,8 @@ importers:
specifier: ^6.1.3
version: 6.1.3
'@push.rocks/smartacme':
specifier: ^8.0.0
version: 8.0.0(socks@2.8.7)
specifier: ^9.1.3
version: 9.1.3(socks@2.8.7)
'@push.rocks/smartdata':
specifier: ^7.0.15
version: 7.0.15(socks@2.8.7)
@@ -75,8 +75,8 @@ importers:
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartproxy':
specifier: ^25.3.1
version: 25.3.1
specifier: ^25.5.0
version: 25.5.0
'@push.rocks/smartradius':
specifier: ^1.1.1
version: 1.1.1
@@ -95,6 +95,9 @@ importers:
'@serve.zone/interfaces':
specifier: ^5.3.0
version: 5.3.0
'@serve.zone/remoteingress':
specifier: ^3.0.1
version: 3.0.1
'@tsclass/tsclass':
specifier: ^9.3.0
version: 9.3.0
@@ -154,9 +157,6 @@ packages:
peerDependencies:
'@push.rocks/smartserve': '>=1.1.0'
'@apiclient.xyz/cloudflare@6.4.3':
resolution: {integrity: sha512-ztegUdUO3Zd4mUoTSylKlCEKPBMHEcggrLelR+7CiblM4beHMwopMVlryBmiCY7bOVbUSPoK0xsVTF7VIy3p/A==}
'@apiclient.xyz/cloudflare@7.1.0':
resolution: {integrity: sha512-qb+PWcE5OjOCPO0+4rexOgtEf9Q1VOIHfrGmav/gXAtkdNL5omifSxPbUseyFKsZrxnRv4rLzvjckUCj0hkvFw==}
@@ -651,9 +651,6 @@ packages:
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'}
'@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@lit-labs/ssr-dom-shim@1.5.1':
resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==}
@@ -858,8 +855,8 @@ packages:
'@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
'@push.rocks/smartacme@8.0.0':
resolution: {integrity: sha512-Oq+m+LX4IG0p4qCGZLEwa6UlMo5Hfq7paRjpREwQNsaGSKl23xsjsEJLxjxkePwaXnaIkHEwU/5MtrEkg2uKEQ==}
'@push.rocks/smartacme@9.1.3':
resolution: {integrity: sha512-rxb4zGZQvcR7l8cb8SvLy+zkCgXKg8rO7b12zaE9ZBe5Q+khoInxscC0eKjmNZ7BOUFFDOxDKoQhgeqwHGOqZQ==}
'@push.rocks/smartarchive@4.2.4':
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
@@ -901,9 +898,6 @@ packages:
'@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
'@push.rocks/smartdns@6.2.2':
resolution: {integrity: sha512-MhJcHujbyIuwIIFdnXb2OScGtRjNsliLUS8GoAurFsKtcCOaA0ytfP+PNzkukyBufjb1nMiJF3rjhswXdHakAQ==}
'@push.rocks/smartdns@7.8.1':
resolution: {integrity: sha512-qEizM9dFzhq4XGICDC8Im7JLjwdokHdDZ6wLufBInaEOupq+8XOa9bC6EGlBQVsCXFUyrKzsFk6eBa9BSZMKPw==}
@@ -1040,8 +1034,8 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@25.3.1':
resolution: {integrity: sha512-kGJGpx3KBUz+qWU2L9B2gbZoUbQEG2BFe6ZzK0b68Y32nHoSIMjol14hzc3sRgW1p/loWy+Gj+5j0KuVytKWmA==}
'@push.rocks/smartproxy@25.5.0':
resolution: {integrity: sha512-ePjxuwplEWpvvK0Xnb3q/8BVmE4xrBJl1mSoKBcZOzizF2T6ZmwuQKIvjnDJ13Q/KHLSIqMFS61CWVJHwtOUfA==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1100,6 +1094,9 @@ packages:
'@push.rocks/smarttime@4.1.1':
resolution: {integrity: sha512-Ha/3J/G+zfTl4ahpZgF6oUOZnUjpLhrBja0OQ2cloFxF9sKT8I1COaSqIfBGDtoK2Nly4UD4aTJ3JcJNOg/kgA==}
'@push.rocks/smarttime@4.2.3':
resolution: {integrity: sha512-8gMg8RUkrCG4p9NcEUZV7V6KpL24+jAMK02g7qyhfA6giz/JJWD0+8w8xjSR+G7qe16KVQ2y3RbvAL9TxmO36g==}
'@push.rocks/smartunique@3.0.9':
resolution: {integrity: sha512-q6DYQgT7/dqdWi9HusvtWCjdsFzLFXY9LTtaZV6IYNJt6teZOonoygxTdNt9XLn6niBSbLYrHSKvJNTRH/uK+g==}
@@ -1128,6 +1125,9 @@ packages:
'@push.rocks/taskbuffer@4.2.0':
resolution: {integrity: sha512-ttoBe5y/WXkAo5/wSMcC/Y4Zbyw4XG8kwAsEaqnAPCxa3M9MI1oV/yM1e9gU1IH97HVPidzbTxRU5/PcHDdUsg==}
'@push.rocks/taskbuffer@6.1.2':
resolution: {integrity: sha512-sdqKd8N/GidztQ1k3r8A86rLvD8Afyir5FjYCNJXDD9837JLoqzHaOKGltUSBsCGh2gjsZn6GydsY6HhXQgvZQ==}
'@push.rocks/webrequest@3.0.37':
resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==}
@@ -1340,6 +1340,9 @@ packages:
'@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
'@serve.zone/remoteingress@3.0.1':
resolution: {integrity: sha512-B2TEjW9GF80QA2MhWFKO9a8k0VYY2rkoY7pX5AoAcuqVJjvOP2Izq/HfL4dYkKEmDkhRUd89W0/WXkHUJLfp8Q==}
'@sindresorhus/is@5.6.0':
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
engines: {node: '>=14.16'}
@@ -1757,18 +1760,12 @@ packages:
'@tsclass/tsclass@4.4.4':
resolution: {integrity: sha512-YZOAF+u+r4u5rCev2uUd1KBTBdfyFdtDmcv4wuN+864lMccbdfRICR3SlJwCfYS1lbeV3QNLYGD30wjRXgvCJA==}
'@tsclass/tsclass@5.0.0':
resolution: {integrity: sha512-2X66VCk0Oe1L01j6GQHC6F9Gj7lpZPPSUTDNax7e29lm4OqBTyAzTR3ePR8coSbWBwsmRV8awLRSrSI+swlqWA==}
'@tsclass/tsclass@9.3.0':
resolution: {integrity: sha512-KD3oTUN3RGu67tgjNHgWWZGsdYipr1RUDxQ9MMKSgIJ6oNZ4q5m2rg0ibrgyHWkAjTPlHVa6kHP3uVOY+8bnHw==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/bn.js@5.2.0':
resolution: {integrity: sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -1787,12 +1784,6 @@ packages:
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dns-packet@5.6.5':
resolution: {integrity: sha512-qXOC7XLOEe43ehtWJCMnQXvgcIpv6rPmQ1jXT98Ad8A3TB1Ue50jsCbSSSyuazScEuZ/Q026vHbrOTVkmwA+7Q==}
'@types/elliptic@6.4.18':
resolution: {integrity: sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==}
'@types/express-serve-static-core@5.1.1':
resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
@@ -2096,9 +2087,6 @@ packages:
bintrees@1.0.2:
resolution: {integrity: sha1-SfiW1uhYpKSZ34XDj7OZua/4QPg=}
bn.js@4.12.2:
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -2119,9 +2107,6 @@ packages:
broadcast-channel@7.3.0:
resolution: {integrity: sha512-UHPhLBQKfQ8OmMFMpmPfO5dRakyA1vsfiDGWTYNvChYol65tbuhivPEGgZZiuetorvExdvxaWiBy/ym1Ty08yA==}
brorand@1.1.0:
resolution: {integrity: sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=}
bson@6.10.4:
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
engines: {node: '>=16.20.1'}
@@ -2281,6 +2266,10 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
croner@10.0.1:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
croner@9.1.0:
resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==}
engines: {node: '>=18.0'}
@@ -2374,10 +2363,6 @@ packages:
devtools-protocol@0.0.1566079:
resolution: {integrity: sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==}
dns-packet@5.6.1:
resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==}
engines: {node: '>=6'}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -2407,9 +2392,6 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
elliptic@6.6.1:
resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2742,9 +2724,6 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2766,9 +2745,6 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hmac-drbg@1.0.1:
resolution: {integrity: sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=}
html-minifier@4.0.0:
resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==}
engines: {node: '>=6'}
@@ -3279,12 +3255,6 @@ packages:
mingo@7.2.0:
resolution: {integrity: sha512-UeX942qZpofn5L97h295SkS7j/ADf7Qac8gdRCMBPxi0/1m70aeB2owLFvWbyuMj1dowonlivlVRQVDx+6h+7Q==}
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
minimatch@10.1.2:
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==}
engines: {node: 20 || >=22}
@@ -4491,18 +4461,6 @@ snapshots:
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarturl': 3.1.0
'@apiclient.xyz/cloudflare@6.4.3':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartstring': 4.1.0
'@tsclass/tsclass': 9.3.0
cloudflare: 5.2.0
transitivePeerDependencies:
- encoding
'@apiclient.xyz/cloudflare@7.1.0':
dependencies:
'@push.rocks/smartdelay': 3.0.5
@@ -5500,8 +5458,6 @@ snapshots:
'@isaacs/cliui@9.0.0': {}
'@leichtgewicht/ip-codec@2.0.5': {}
'@lit-labs/ssr-dom-shim@1.5.1': {}
'@lit/reactive-element@2.1.2':
@@ -5832,24 +5788,21 @@ snapshots:
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartacme@8.0.0(socks@2.8.7)':
'@push.rocks/smartacme@9.1.3(socks@2.8.7)':
dependencies:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@apiclient.xyz/cloudflare': 6.4.3
'@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 1.14.3
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdata': 5.16.7(socks@2.8.7)
'@push.rocks/smartdata': 7.0.15(socks@2.8.7)
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 6.2.2
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartdns': 7.8.1
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
'@push.rocks/taskbuffer': 6.1.2
'@tsclass/tsclass': 9.3.0
acme-client: 5.4.0
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
@@ -6036,22 +5989,6 @@ snapshots:
dependencies:
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartdns@6.2.2':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
'@tsclass/tsclass': 5.0.0
'@types/dns-packet': 5.6.5
'@types/elliptic': 6.4.18
acme-client: 5.4.0
dns-packet: 5.6.1
elliptic: 6.6.1
minimatch: 10.1.2
transitivePeerDependencies:
- supports-color
'@push.rocks/smartdns@7.8.1':
dependencies:
'@push.rocks/smartdelay': 3.0.5
@@ -6438,7 +6375,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@25.3.1':
'@push.rocks/smartproxy@25.5.0':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.1.11
@@ -6621,6 +6558,17 @@ snapshots:
is-nan: 1.3.2
pretty-ms: 9.3.0
'@push.rocks/smarttime@4.2.3':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartpromise': 4.2.3
croner: 10.0.1
date-fns: 4.1.0
dayjs: 1.11.19
is-nan: 1.3.2
pretty-ms: 9.3.0
'@push.rocks/smartunique@3.0.9':
dependencies:
'@types/uuid': 9.0.8
@@ -6688,6 +6636,22 @@ snapshots:
- supports-color
- vue
'@push.rocks/taskbuffer@6.1.2':
dependencies:
'@design.estate/dees-element': 2.1.6
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/webrequest@3.0.37':
dependencies:
'@push.rocks/smartdelay': 3.0.5
@@ -6889,6 +6853,11 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.3.0
'@serve.zone/remoteingress@3.0.1':
dependencies:
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartrust': 1.2.1
'@sindresorhus/is@5.6.0': {}
'@smithy/abort-controller@4.2.8':
@@ -7423,10 +7392,6 @@ snapshots:
dependencies:
type-fest: 4.41.0
'@tsclass/tsclass@5.0.0':
dependencies:
type-fest: 4.41.0
'@tsclass/tsclass@9.3.0':
dependencies:
type-fest: 4.41.0
@@ -7436,10 +7401,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/bn.js@5.2.0':
dependencies:
'@types/node': 25.2.3
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -7464,14 +7425,6 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/dns-packet@5.6.5':
dependencies:
'@types/node': 25.2.3
'@types/elliptic@6.4.18':
dependencies:
'@types/bn.js': 5.2.0
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 25.2.3
@@ -7788,8 +7741,6 @@ snapshots:
bintrees@1.0.2: {}
bn.js@4.12.2: {}
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -7826,8 +7777,6 @@ snapshots:
p-queue: 6.6.2
unload: 2.4.1
brorand@1.1.0: {}
bson@6.10.4: {}
bson@7.2.0: {}
@@ -7978,6 +7927,8 @@ snapshots:
crelt@1.0.6: {}
croner@10.0.1: {}
croner@9.1.0: {}
cross-spawn@7.0.6:
@@ -8050,10 +8001,6 @@ snapshots:
devtools-protocol@0.0.1566079: {}
dns-packet@5.6.1:
dependencies:
'@leichtgewicht/ip-codec': 2.0.5
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -8090,16 +8037,6 @@ snapshots:
ee-first@1.1.1: {}
elliptic@6.6.1:
dependencies:
bn.js: 4.12.2
brorand: 1.1.0
hash.js: 1.1.7
hmac-drbg: 1.0.1
inherits: 2.0.4
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -8529,11 +8466,6 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
minimalistic-assert: 1.0.1
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -8566,12 +8498,6 @@ snapshots:
highlight.js@11.11.1: {}
hmac-drbg@1.0.1:
dependencies:
hash.js: 1.1.7
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
html-minifier@4.0.0:
dependencies:
camel-case: 3.0.0
@@ -9280,10 +9206,6 @@ snapshots:
mingo@7.2.0: {}
minimalistic-assert@1.0.1: {}
minimalistic-crypto-utils@1.0.1: {}
minimatch@10.1.2:
dependencies:
'@isaacs/brace-expansion': 5.0.1

102
readme.md
View File

@@ -21,6 +21,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- [Email System](#email-system)
- [DNS Server](#dns-server)
- [RADIUS Server](#radius-server)
- [Certificate Management](#certificate-management)
- [Storage & Caching](#storage--caching)
- [Security Features](#security-features)
- [OpsServer Dashboard](#opsserver-dashboard)
@@ -46,7 +47,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Hierarchical rate limiting** — global, per-domain, per-sender
### 🔒 Enterprise Security
- **Automatic TLS certificates** via ACME with Cloudflare DNS-01 challenges
- **Automatic TLS certificates** via ACME (smartacme v9) with Cloudflare DNS-01 challenges
- **Smart certificate scheduling** — per-domain deduplication, controlled parallelism, and account rate limiting handled automatically
- **Per-domain exponential backoff** — failed provisioning attempts are tracked and backed off to avoid hammering ACME servers
- **IP reputation checking** with caching and configurable thresholds
- **Content scanning** for spam, viruses, and malicious attachments
- **Security event logging** with structured audit trails
@@ -73,7 +76,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
### 🖥️ OpsServer Dashboard
- **Web-based management interface** with real-time monitoring
- **JWT authentication** with session persistence
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, and security events
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, and security events
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
- **Read-only configuration display** — DcRouter is configured through code
## Installation
@@ -250,7 +254,7 @@ graph TB
ES[smartmta Email Server<br/><i>TypeScript + Rust</i>]
DS[SmartDNS Server<br/><i>Rust-powered</i>]
RS[SmartRadius Server]
CM[Certificate Manager]
CM[Certificate Manager<br/><i>smartacme v9</i>]
OS[OpsServer Dashboard]
MM[Metrics Manager]
SM[Storage Manager]
@@ -297,6 +301,7 @@ graph TB
| **SmartProxy** | `@push.rocks/smartproxy` | High-performance HTTP/HTTPS and TCP/SNI proxy with route-based config (Rust engine) |
| **UnifiedEmailServer** | `@push.rocks/smartmta` | Full SMTP server with pattern-based routing, DKIM, queue management (TypeScript + Rust) |
| **DNS Server** | `@push.rocks/smartdns` | Authoritative DNS with dynamic records and DKIM TXT auto-generation (Rust engine) |
| **SmartAcme** | `@push.rocks/smartacme` | ACME certificate management with per-domain dedup, concurrency control, and rate limiting |
| **RADIUS Server** | `@push.rocks/smartradius` | Network authentication with MAB, VLAN assignment, and accounting |
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
@@ -308,8 +313,8 @@ graph TB
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, and SmartRadius based on which configs are provided.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery.
3. **On `stop()`**: All services are gracefully shut down in reverse order.
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
### Rust-Powered Architecture
@@ -584,15 +589,6 @@ match: { sizeRange: { min: 1000, max: 5000000 }, hasAttachments: true }
match: { subject: /invoice|receipt/i }
```
### Socket-Handler Mode 🔌
When `useSocketHandler: true` is set, SmartProxy passes sockets directly to the email server — no internal port binding, lower latency, and fewer open ports:
```
Traditional: External Port → SmartProxy → Internal Port → Email Server
Socket Mode: External Port → SmartProxy → (direct socket) → Email Server
```
### Email Security Stack
- **DKIM** — Automatic key generation, signing, and rotation for all domains
@@ -705,6 +701,73 @@ RADIUS is fully manageable at runtime via the OpsServer API:
- Session monitoring and forced disconnects
- Accounting summaries and statistics
## Certificate Management
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
### How It Works
When a `dnsChallenge` is configured (e.g. with a Cloudflare API key), DcRouter creates a SmartAcme instance that handles DNS-01 challenges for automatic certificate provisioning. SmartProxy calls the `certProvisionFunction` whenever a route needs a TLS certificate, and SmartAcme takes care of the rest.
```typescript
const router = new DcRouter({
smartProxyConfig: {
routes: [
{
name: 'secure-app',
match: { domains: ['app.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '192.168.1.10', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' } // ← triggers ACME provisioning
}
}
],
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
},
tls: { contactEmail: 'admin@example.com' },
dnsChallenge: { cloudflareApiKey: process.env.CLOUDFLARE_API_KEY }
});
```
### smartacme v9 Features
| Feature | Description |
|---------|-------------|
| **Per-domain deduplication** | Concurrent requests for the same domain share a single ACME operation |
| **Global concurrency cap** | Default 5 parallel ACME operations to prevent overload |
| **Account rate limiting** | Sliding window (250 orders / 3 hours) to stay within ACME provider limits |
| **Structured errors** | `AcmeError` with `isRetryable`, `isRateLimited`, `retryAfter` fields |
| **Clean shutdown** | `stop()` properly destroys HTTP agents and DNS clients |
### Per-Domain Backoff
DcRouter's `CertProvisionScheduler` adds **per-domain exponential backoff** on top of smartacme's built-in protections. If a DNS-01 challenge fails for a domain:
1. The failure is recorded (persisted to storage)
2. The domain enters backoff: `min(failures² × 1 hour, 24 hours)`
3. Subsequent requests for that domain are rejected until the backoff expires
4. On success, the backoff is cleared
This prevents hammering ACME servers for domains with persistent issues (e.g. missing DNS delegation).
### Fallback to HTTP-01
If DNS-01 fails, the `certProvisionFunction` returns `'http01'` to tell SmartProxy to fall back to HTTP-01 challenge validation. This provides a safety net for domains where DNS-01 isn't viable.
### Certificate Storage
Certificates are persisted via the `StorageBackedCertManager` which uses DcRouter's `StorageManager`. This means certs survive restarts and don't need to be re-provisioned unless they expire.
### Dashboard
The OpsServer includes a **Certificates** view showing:
- All domains with their certificate status (valid, expiring, expired, failed)
- Certificate source (ACME, provision function, static)
- Expiry dates and issuer information
- Backoff status for failed domains
- One-click reprovisioning per domain
## Storage & Caching
### StorageManager
@@ -725,7 +788,7 @@ storage: {
// Simply omit the storage config
```
Used for: DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs.
Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs, cert backoff state.
### Cache Database
@@ -811,6 +874,7 @@ The OpsServer provides a web-based management interface served on port 3000. It'
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning |
| 📜 **Logs** | Real-time log viewer with level filtering and search |
| ⚙️ **Configuration** | Read-only view of current system configuration |
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
@@ -838,6 +902,11 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
'getBounceRecords' // Bounce records
'removeFromSuppressionList' // Unsuppress an address
// Certificates
'getCertificateOverview' // Domain-centric certificate status
'reprovisionCertificate' // Reprovision by route name (legacy)
'reprovisionCertificateDomain' // Reprovision by domain (preferred)
// Configuration (read-only)
'getConfiguration' // Current system config
@@ -884,6 +953,7 @@ const router = new DcRouter(options: IDcRouterOptions);
|----------|------|-------------|
| `options` | `IDcRouterOptions` | Current configuration |
| `smartProxy` | `SmartProxy` | SmartProxy instance |
| `smartAcme` | `SmartAcme` | SmartAcme v9 certificate manager instance |
| `emailServer` | `UnifiedEmailServer` | Email server instance (from smartmta) |
| `dnsServer` | `DnsServer` | DNS server instance |
| `radiusServer` | `RadiusServer` | RADIUS server instance |
@@ -891,6 +961,8 @@ const router = new DcRouter(options: IDcRouterOptions);
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
| `metricsManager` | `MetricsManager` | Metrics collector |
| `cacheDb` | `CacheDb` | Cache database instance |
| `certProvisionScheduler` | `CertProvisionScheduler` | Per-domain backoff scheduler for cert provisioning |
| `certificateStatusMap` | `Map<string, ...>` | Domain-keyed certificate status from SmartProxy events |
### Re-exported Types

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '6.0.0',
version: '6.4.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -11,31 +11,22 @@ interface IBackoffEntry {
/**
* Manages certificate provisioning scheduling with:
* - Per-domain exponential backoff persisted in StorageManager
* - Serial stagger queue with configurable delay between provisions
*
* Note: Serial stagger queue was removed — smartacme v9 handles
* concurrency, per-domain dedup, and rate limiting internally.
*/
export class CertProvisionScheduler {
private storageManager: StorageManager;
private staggerDelayMs: number;
private maxBackoffHours: number;
// In-memory serial queue
private queue: Array<{
domain: string;
fn: () => Promise<any>;
resolve: (value: any) => void;
reject: (err: any) => void;
}> = [];
private processing = false;
// In-memory backoff cache (mirrors storage for fast lookups)
private backoffCache = new Map<string, IBackoffEntry>();
constructor(
storageManager: StorageManager,
options?: { staggerDelayMs?: number; maxBackoffHours?: number }
options?: { maxBackoffHours?: number }
) {
this.storageManager = storageManager;
this.staggerDelayMs = options?.staggerDelayMs ?? 3000;
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
}
@@ -136,41 +127,4 @@ export class CertProvisionScheduler {
lastError: entry.lastError,
};
}
/**
* Enqueue a provision operation for serial execution with stagger delay.
* Returns the result of the provision function.
*/
enqueueProvision<T>(domain: string, fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ domain, fn, resolve, reject });
this.processQueue();
});
}
/**
* Process the stagger queue serially
*/
private async processQueue(): Promise<void> {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const item = this.queue.shift()!;
try {
logger.log('info', `Processing cert provision for ${item.domain}`);
const result = await item.fn();
item.resolve(result);
} catch (err) {
item.reject(err);
}
// Stagger delay between provisions
if (this.queue.length > 0) {
await new Promise<void>((r) => setTimeout(r, this.staggerDelayMs));
}
}
this.processing = false;
}
}

View File

@@ -21,8 +21,12 @@ import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
baseDir?: string;
/**
* Direct SmartProxy configuration - gives full control over HTTP/HTTPS and TCP/SNI traffic
* This is the preferred way to configure HTTP/HTTPS and general TCP/SNI traffic
@@ -152,6 +156,22 @@ export interface IDcRouterOptions {
* Enables MAC Authentication Bypass (MAB) and VLAN assignment
*/
radiusConfig?: IRadiusServerConfig;
/**
* Remote Ingress configuration for edge tunnel nodes
* Enables edge nodes to accept incoming connections and tunnel them to this DcRouter
*/
remoteIngressConfig?: {
/** Enable remote ingress hub (default: false) */
enabled?: boolean;
/** Port for tunnel connections from edge nodes (default: 8443) */
tunnelPort?: number;
/** TLS configuration for the tunnel server */
tls?: {
certPath?: string;
keyPath?: string;
};
};
}
/**
@@ -170,6 +190,7 @@ export interface PortProxyRuleContext {
export class DcRouter {
public options: IDcRouterOptions;
public resolvedPaths: ReturnType<typeof paths.resolvePaths>;
// Core services
public smartProxy?: plugins.smartproxy.SmartProxy;
@@ -185,6 +206,10 @@ export class DcRouter {
public cacheDb?: CacheDb;
public cacheCleaner?: CacheCleaner;
// Remote Ingress
public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager;
// Certificate status tracking from SmartProxy events (keyed by domain)
public certificateStatusMap = new Map<string, {
status: 'valid' | 'failed';
@@ -195,7 +220,7 @@ export class DcRouter {
error?: string;
}>();
// Certificate provisioning scheduler with backoff + stagger
// Certificate provisioning scheduler with per-domain backoff
public certProvisionScheduler?: CertProvisionScheduler;
// TypedRouter for API endpoints
@@ -210,10 +235,13 @@ export class DcRouter {
...optionsArg
};
// Resolve all data paths from baseDir
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
// Default storage to filesystem if not configured
if (!this.options.storage) {
this.options.storage = {
fsPath: plugins.path.join(paths.dcrouterHomeDir, 'storage'),
fsPath: this.resolvedPaths.defaultStoragePath,
};
}
@@ -259,6 +287,11 @@ export class DcRouter {
await this.setupRadiusServer();
}
// Set up Remote Ingress hub if configured
if (this.options.remoteIngressConfig?.enabled) {
await this.setupRemoteIngress();
}
this.logStartupSummary();
} catch (error) {
console.error('❌ Error starting DcRouter:', error);
@@ -345,6 +378,16 @@ export class DcRouter {
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`);
}
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
console.log('\n🌐 Remote Ingress:');
console.log(` ├─ Tunnel Port: ${this.options.remoteIngressConfig.tunnelPort || 8443}`);
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
const connectedCount = this.tunnelManager.getConnectedCount();
console.log(` ├─ Registered Edges: ${edgeCount}`);
console.log(` └─ Connected Edges: ${connectedCount}`);
}
// Storage summary
if (this.storageManager && this.options.storage) {
console.log('\n💾 Storage:');
@@ -372,7 +415,7 @@ export class DcRouter {
// Initialize CacheDb singleton
this.cacheDb = CacheDb.getInstance({
storagePath: cacheConfig.storagePath || paths.defaultTsmDbPath,
storagePath: cacheConfig.storagePath || this.resolvedPaths.defaultTsmDbPath,
dbName: cacheConfig.dbName || 'dcrouter',
debug: false,
});
@@ -445,6 +488,9 @@ export class DcRouter {
if (routes.length > 0 || this.options.smartProxyConfig) {
console.log('Setting up SmartProxy with combined configuration');
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
// Create SmartProxy configuration
const smartProxyConfig: plugins.smartproxy.ISmartProxyOptions = {
...this.options.smartProxyConfig,
@@ -456,13 +502,23 @@ export class DcRouter {
const certs: Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }> = [];
for (const key of keys) {
const data = await this.storageManager.getJSON(key);
if (data) certs.push(data);
if (data) {
certs.push(data);
loadedCertEntries.push({ domain: data.domain, publicKey: data.publicKey, validUntil: data.validUntil, validFrom: data.validFrom });
}
}
return certs;
},
save: async (domain: string, publicKey: string, privateKey: string, ca?: string) => {
let validUntil: number | undefined;
let validFrom: number | undefined;
try {
const x509 = new plugins.crypto.X509Certificate(publicKey);
validUntil = new Date(x509.validTo).getTime();
validFrom = new Date(x509.validFrom).getTime();
} catch { /* PEM parsing failed */ }
await this.storageManager.setJSON(`/proxy-certs/${domain}`, {
domain, publicKey, privateKey, ca,
domain, publicKey, privateKey, ca, validUntil, validFrom,
});
},
remove: async (domain: string) => {
@@ -496,14 +552,17 @@ export class DcRouter {
}
try {
const result = await scheduler.enqueueProvision(domain, async () => {
// smartacme v9 handles concurrency, per-domain dedup, and rate limiting internally
eventComms.log(`Attempting DNS-01 via SmartAcme for ${domain}`);
eventComms.setSource('smartacme-dns-01');
const cert = await this.smartAcme.getCertificateForDomain(domain);
const isWildcardDomain = domain.startsWith('*.');
const cert = await this.smartAcme.getCertificateForDomain(domain, {
includeWildcard: !isWildcardDomain,
});
if (cert.validUntil) {
eventComms.setExpiryDate(new Date(cert.validUntil));
}
return {
const result = {
id: cert.id,
domainName: cert.domainName,
created: cert.created,
@@ -512,7 +571,6 @@ export class DcRouter {
publicKey: cert.publicKey,
csr: cert.csr,
};
});
// Success — clear any backoff
await scheduler.clearBackoff(domain);
@@ -578,6 +636,59 @@ export class DcRouter {
await this.smartProxy.start();
console.log('[DcRouter] SmartProxy started successfully');
// Populate certificateStatusMap for certs loaded from store at startup
for (const entry of loadedCertEntries) {
if (!this.certificateStatusMap.has(entry.domain)) {
const routeNames = this.findRouteNamesForDomain(entry.domain);
let expiryDate: string | undefined;
let issuedAt: string | undefined;
// Use validUntil/validFrom from stored proxy-certs data if available
if (entry.validUntil) {
expiryDate = new Date(entry.validUntil).toISOString();
}
if (entry.validFrom) {
issuedAt = new Date(entry.validFrom).toISOString();
}
// Try SmartAcme /certs/ metadata as secondary source
if (!expiryDate) {
try {
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
const certMeta = await this.storageManager.getJSON(`/certs/${cleanDomain}`);
if (certMeta?.validUntil) {
expiryDate = new Date(certMeta.validUntil).toISOString();
}
if (certMeta?.created && !issuedAt) {
issuedAt = new Date(certMeta.created).toISOString();
}
} catch { /* no metadata available */ }
}
// Fallback: parse X509 from PEM to get expiry
if (!expiryDate && entry.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(entry.publicKey);
expiryDate = new Date(x509.validTo).toISOString();
if (!issuedAt) {
issuedAt = new Date(x509.validFrom).toISOString();
}
} catch { /* PEM parsing failed */ }
}
this.certificateStatusMap.set(entry.domain, {
status: 'valid',
routeNames,
expiryDate,
issuedAt,
source: 'cert-store',
});
}
}
if (loadedCertEntries.length > 0) {
console.log(`[DcRouter] Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
}
console.log(`SmartProxy started with ${routes.length} routes`);
}
}
@@ -811,6 +922,11 @@ export class DcRouter {
// Stop RADIUS server if running
this.radiusServer ?
this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) :
Promise.resolve(),
// Stop Remote Ingress tunnel manager if running
this.tunnelManager ?
this.tunnelManager.stop().catch(err => console.error('Error stopping TunnelManager:', err)) :
Promise.resolve()
]);
@@ -1238,7 +1354,7 @@ export class DcRouter {
try {
// Ensure paths are imported
const dnsDir = paths.dnsRecordsDir;
const dnsDir = this.resolvedPaths.dnsRecordsDir;
// Check if directory exists
if (!plugins.fs.existsSync(dnsDir)) {
@@ -1302,7 +1418,7 @@ export class DcRouter {
}
// Ensure necessary directories exist
paths.ensureDirectories();
paths.ensureDataDirectories(this.resolvedPaths);
// Generate DKIM keys for each email domain
for (const domainConfig of this.options.emailConfig.domains) {
@@ -1457,6 +1573,31 @@ export class DcRouter {
}
}
/**
* Set up Remote Ingress hub for edge tunnel connections
*/
private async setupRemoteIngress(): Promise<void> {
if (!this.options.remoteIngressConfig?.enabled) {
return;
}
logger.log('info', 'Setting up Remote Ingress hub...');
// Initialize the edge registration manager
this.remoteIngressManager = new RemoteIngressManager(this.storageManager);
await this.remoteIngressManager.initialize();
// Create and start the tunnel manager
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
targetHost: '127.0.0.1',
});
await this.tunnelManager.start();
const edgeCount = this.remoteIngressManager.getAllEdges().length;
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
}
/**
* Set up RADIUS server for network authentication
*/

View File

@@ -10,4 +10,7 @@ export * from './classes.dcrouter.js';
// RADIUS module
export * from './radius/index.js';
// Remote Ingress module
export * from './remoteingress/index.js';
export const runCli = async () => {};

View File

@@ -19,6 +19,7 @@ export class OpsServer {
private radiusHandler: handlers.RadiusHandler;
private emailOpsHandler: handlers.EmailOpsHandler;
private certificateHandler: handlers.CertificateHandler;
private remoteIngressHandler: handlers.RemoteIngressHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -59,6 +60,7 @@ export class OpsServer {
this.radiusHandler = new handlers.RadiusHandler(this);
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -156,13 +156,29 @@ export class CertificateHandler {
// Check persisted cert data from StorageManager
if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, '');
const certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
if (!certData) {
// Also check certStore path (proxy-certs)
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
}
if (certData?.validUntil) {
expiryDate = new Date(certData.validUntil).toISOString();
if (certData.created) {
issuedAt = new Date(certData.created).toISOString();
}
issuer = 'smartacme-dns-01';
} else if (certData?.publicKey) {
// certStore has the cert — parse PEM for expiry
try {
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
expiryDate = new Date(x509.validTo).toISOString();
issuedAt = new Date(x509.validFrom).toISOString();
} catch { /* PEM parsing failed */ }
status = 'valid';
issuer = 'cert-store';
} else if (certData) {
status = 'valid';
issuer = 'cert-store';
}
}

View File

@@ -6,3 +6,4 @@ export * from './stats.handler.js';
export * from './radius.handler.js';
export * from './email-ops.handler.js';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';

View File

@@ -0,0 +1,163 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class RemoteIngressHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Get all remote ingress edges
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
'getRemoteIngresses',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
if (!manager) {
return { edges: [] };
}
// Return edges without secrets
const edges = manager.getAllEdges().map((e) => ({
...e,
secret: '********', // Never expose secrets via API
}));
return { edges };
},
),
);
// Create a new remote ingress edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
'createRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return {
success: false,
edge: null as any,
};
}
const edge = await manager.createEdge(
dataArg.name,
dataArg.listenPorts,
dataArg.tags,
);
// Sync allowed edges with the hub
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge };
},
),
);
// Delete a remote ingress edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
'deleteRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
}
const deleted = await manager.deleteEdge(dataArg.id);
if (deleted && tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return {
success: deleted,
message: deleted ? undefined : 'Edge not found',
};
},
),
);
// Update a remote ingress edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
'updateRemoteIngress',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, edge: null as any };
}
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
enabled: dataArg.enabled,
tags: dataArg.tags,
});
if (!edge) {
return { success: false, edge: null as any };
}
// Sync allowed edges if enabled status changed
if (tunnelManager && dataArg.enabled !== undefined) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge: { ...edge, secret: '********' } };
},
),
);
// Regenerate secret for an edge
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
'regenerateRemoteIngressSecret',
async (dataArg, toolsArg) => {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, secret: '' };
}
const secret = await manager.regenerateSecret(dataArg.id);
if (!secret) {
return { success: false, secret: '' };
}
// Sync allowed edges since secret changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, secret };
},
),
);
// Get runtime status of all edges
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
'getRemoteIngressStatus',
async (dataArg, toolsArg) => {
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!tunnelManager) {
return { statuses: [] };
}
return { statuses: tunnelManager.getEdgeStatuses() };
},
),
);
}
}

View File

@@ -1,7 +1,6 @@
import * as plugins from './plugins.js';
// Base directories
export const baseDir = process.cwd();
// Code/asset paths (not affected by baseDir)
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
@@ -20,35 +19,37 @@ export const dataDir = process.env.DATA_DIR
// Default TsmDB path for CacheDb
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
// MTA directories
export const keysDir = plugins.path.join(dataDir, 'keys');
// DNS records directory (only surviving MTA directory reference)
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
// Email template directories
export const emailTemplatesDir = plugins.path.join(dataDir, 'templates', 'email');
export const MtaAttachmentsDir = plugins.path.join(dataDir, 'attachments'); // For email attachments
// Configuration path
export const configPath = process.env.CONFIG_PATH
? process.env.CONFIG_PATH
: plugins.path.join(baseDir, 'config.json');
// Create directories if they don't exist
export function ensureDirectories() {
// Ensure data directories
plugins.fsUtils.ensureDirSync(dataDir);
plugins.fsUtils.ensureDirSync(keysDir);
plugins.fsUtils.ensureDirSync(dnsRecordsDir);
plugins.fsUtils.ensureDirSync(sentEmailsDir);
plugins.fsUtils.ensureDirSync(receivedEmailsDir);
plugins.fsUtils.ensureDirSync(failedEmailsDir);
plugins.fsUtils.ensureDirSync(logsDir);
// Ensure email template directories
plugins.fsUtils.ensureDirSync(emailTemplatesDir);
plugins.fsUtils.ensureDirSync(MtaAttachmentsDir);
/**
* Resolve all data paths from a given baseDir.
* When no baseDir is provided, falls back to ~/.serve.zone/dcrouter.
* Specific overrides (e.g. DATA_DIR env) take precedence.
*/
export function resolvePaths(baseDir?: string) {
const root = baseDir ?? plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
const resolvedDataDir = process.env.DATA_DIR ?? plugins.path.join(root, 'data');
return {
dcrouterHomeDir: root,
dataDir: resolvedDataDir,
defaultTsmDbPath: plugins.path.join(root, 'tsmdb'),
defaultStoragePath: plugins.path.join(root, 'storage'),
dnsRecordsDir: plugins.path.join(resolvedDataDir, 'dns'),
};
}
/**
* Ensure only the data directories that are actually used exist.
*/
export function ensureDataDirectories(resolvedPaths: ReturnType<typeof resolvePaths>) {
plugins.fsUtils.ensureDirSync(resolvedPaths.dataDir);
plugins.fsUtils.ensureDirSync(resolvedPaths.dnsRecordsDir);
}
/**
* Legacy wrapper — delegates to ensureDataDirectories with module-level defaults.
*/
export function ensureDirectories() {
ensureDataDirectories(resolvePaths());
}

View File

@@ -23,9 +23,11 @@ export {
// @serve.zone scope
import * as servezoneInterfaces from '@serve.zone/interfaces';
import * as remoteingress from '@serve.zone/remoteingress';
export {
servezoneInterfaces
servezoneInterfaces,
remoteingress,
}
// @api.global scope

View File

@@ -0,0 +1,160 @@
import * as plugins from '../plugins.js';
import type { StorageManager } from '../storage/classes.storagemanager.js';
import type { IRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
const STORAGE_PREFIX = '/remote-ingress/';
/**
* Manages CRUD for remote ingress edge registrations.
* Persists edge configs via StorageManager and provides
* the allowed edges list for the Rust hub.
*/
export class RemoteIngressManager {
private storageManager: StorageManager;
private edges: Map<string, IRemoteIngress> = new Map();
constructor(storageManager: StorageManager) {
this.storageManager = storageManager;
}
/**
* Load all edge registrations from storage into memory.
*/
public async initialize(): Promise<void> {
const keys = await this.storageManager.list(STORAGE_PREFIX);
for (const key of keys) {
const edge = await this.storageManager.getJSON<IRemoteIngress>(key);
if (edge) {
this.edges.set(edge.id, edge);
}
}
}
/**
* Create a new edge registration.
*/
public async createEdge(
name: string,
listenPorts: number[],
tags?: string[],
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
const now = Date.now();
const edge: IRemoteIngress = {
id,
name,
secret,
listenPorts,
enabled: true,
tags: tags || [],
createdAt: now,
updatedAt: now,
};
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
this.edges.set(id, edge);
return edge;
}
/**
* Get an edge by ID.
*/
public getEdge(id: string): IRemoteIngress | undefined {
return this.edges.get(id);
}
/**
* Get all edge registrations.
*/
public getAllEdges(): IRemoteIngress[] {
return Array.from(this.edges.values());
}
/**
* Update an edge registration.
*/
public async updateEdge(
id: string,
updates: {
name?: string;
listenPorts?: number[];
enabled?: boolean;
tags?: string[];
},
): Promise<IRemoteIngress | null> {
const edge = this.edges.get(id);
if (!edge) {
return null;
}
if (updates.name !== undefined) edge.name = updates.name;
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
this.edges.set(id, edge);
return edge;
}
/**
* Delete an edge registration.
*/
public async deleteEdge(id: string): Promise<boolean> {
if (!this.edges.has(id)) {
return false;
}
await this.storageManager.delete(`${STORAGE_PREFIX}${id}`);
this.edges.delete(id);
return true;
}
/**
* Regenerate the secret for an edge.
*/
public async regenerateSecret(id: string): Promise<string | null> {
const edge = this.edges.get(id);
if (!edge) {
return null;
}
edge.secret = plugins.crypto.randomBytes(32).toString('hex');
edge.updatedAt = Date.now();
await this.storageManager.setJSON(`${STORAGE_PREFIX}${id}`, edge);
this.edges.set(id, edge);
return edge.secret;
}
/**
* Verify an edge's secret using constant-time comparison.
*/
public verifySecret(id: string, secret: string): boolean {
const edge = this.edges.get(id);
if (!edge) {
return false;
}
const expected = Buffer.from(edge.secret);
const provided = Buffer.from(secret);
if (expected.length !== provided.length) {
return false;
}
return plugins.crypto.timingSafeEqual(expected, provided);
}
/**
* Get the list of allowed edges (enabled only) for the Rust hub.
*/
public getAllowedEdges(): Array<{ id: string; secret: string }> {
const result: Array<{ id: string; secret: string }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
result.push({ id: edge.id, secret: edge.secret });
}
}
return result;
}
}

View File

@@ -0,0 +1,126 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig {
tunnelPort?: number;
targetHost?: string;
}
/**
* Manages the RemoteIngressHub instance and tracks connected edge statuses.
*/
export class TunnelManager {
private hub: InstanceType<typeof plugins.remoteingress.RemoteIngressHub>;
private manager: RemoteIngressManager;
private config: ITunnelManagerConfig;
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager;
this.config = config;
this.hub = new plugins.remoteingress.RemoteIngressHub();
// Listen for edge connect/disconnect events
this.hub.on('edgeConnected', (data: { edgeId: string }) => {
const existing = this.edgeStatuses.get(data.edgeId);
this.edgeStatuses.set(data.edgeId, {
edgeId: data.edgeId,
connected: true,
publicIp: existing?.publicIp ?? null,
activeTunnels: 0,
lastHeartbeat: Date.now(),
connectedAt: Date.now(),
});
});
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing) {
existing.connected = false;
existing.activeTunnels = 0;
}
});
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing) {
existing.activeTunnels++;
existing.lastHeartbeat = Date.now();
}
});
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing && existing.activeTunnels > 0) {
existing.activeTunnels--;
}
});
}
/**
* Start the tunnel hub and load allowed edges.
*/
public async start(): Promise<void> {
await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
});
// Send allowed edges to the hub
await this.syncAllowedEdges();
}
/**
* Stop the tunnel hub.
*/
public async stop(): Promise<void> {
await this.hub.stop();
this.edgeStatuses.clear();
}
/**
* Sync allowed edges from the manager to the hub.
* Call this after creating/deleting/updating edges.
*/
public async syncAllowedEdges(): Promise<void> {
const edges = this.manager.getAllowedEdges();
await this.hub.updateAllowedEdges(edges);
}
/**
* Get runtime statuses for all known edges.
*/
public getEdgeStatuses(): IRemoteIngressStatus[] {
return Array.from(this.edgeStatuses.values());
}
/**
* Get status for a specific edge.
*/
public getEdgeStatus(edgeId: string): IRemoteIngressStatus | undefined {
return this.edgeStatuses.get(edgeId);
}
/**
* Get the count of connected edges.
*/
public getConnectedCount(): number {
let count = 0;
for (const status of this.edgeStatuses.values()) {
if (status.connected) count++;
}
return count;
}
/**
* Get the total number of active tunnels across all edges.
*/
public getTotalActiveTunnels(): number {
let total = 0;
for (const status of this.edgeStatuses.values()) {
total += status.activeTunnels;
}
return total;
}
}

View File

@@ -0,0 +1,2 @@
export * from './classes.remoteingress-manager.js';
export * from './classes.tunnel-manager.js';

View File

@@ -1,5 +1,4 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js';
import { Email, type Core } from '@push.rocks/smartmta';
type IAttachment = Core.IAttachment;

View File

@@ -378,7 +378,7 @@ export class StorageManager {
*/
async getJSON<T = any>(key: string): Promise<T | null> {
const value = await this.get(key);
if (value === null) {
if (value === null || value.trim() === '') {
return null;
}

View File

@@ -1,2 +1,3 @@
export * from './auth.js';
export * from './stats.js';
export * from './remoteingress.js';

View File

@@ -0,0 +1,25 @@
/**
* A stored remote ingress edge registration.
*/
export interface IRemoteIngress {
id: string;
name: string;
secret: string;
listenPorts: number[];
enabled: boolean;
tags?: string[];
createdAt: number;
updatedAt: number;
}
/**
* Runtime status of a remote ingress edge.
*/
export interface IRemoteIngressStatus {
edgeId: string;
connected: boolean;
publicIp: string | null;
activeTunnels: number;
lastHeartbeat: number | null;
connectedAt: number | null;
}

View File

@@ -128,6 +128,37 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
| `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records |
| `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address |
#### 🔐 Certificates
| Interface | Method | Description |
|-----------|--------|-------------|
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
#### Certificate Types
```typescript
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
interface ICertificateInfo {
domain: string;
routeNames: string[];
status: TCertificateStatus;
source: TCertificateSource;
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
expiryDate?: string;
issuer?: string;
issuedAt?: string;
error?: string;
canReprovision: boolean;
backoffInfo?: {
failures: number;
retryAfter?: string;
lastError?: string;
};
}
```
#### 📡 RADIUS
| Interface | Method | Description |
|-----------|--------|-------------|
@@ -173,7 +204,16 @@ console.log('Email:', metrics.emailStats);
console.log('DNS:', metrics.dnsStats);
console.log('Security:', metrics.securityMetrics);
// 3. Check email queues
// 3. Check certificate status
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
'https://your-dcrouter:3000/typedrequest',
'getCertificateOverview'
);
const certs = await certClient.fire({ identity });
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
// 4. Check email queues
const queueClient = new typedrequest.TypedRequest<requests.IReq_GetQueuedEmails>(
'https://your-dcrouter:3000/typedrequest',
'getQueuedEmails'

View File

@@ -6,3 +6,4 @@ export * from './combined.stats.js';
export * from './radius.js';
export * from './email-ops.js';
export * from './certificate.js';
export * from './remoteingress.js';

View File

@@ -0,0 +1,117 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js';
// ============================================================================
// Remote Ingress Edge Management
// ============================================================================
/**
* Create a new remote ingress edge registration.
*/
export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateRemoteIngress
> {
method: 'createRemoteIngress';
request: {
identity?: authInterfaces.IIdentity;
name: string;
listenPorts: number[];
tags?: string[];
};
response: {
success: boolean;
edge: IRemoteIngress;
};
}
/**
* Delete a remote ingress edge registration.
*/
export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteRemoteIngress
> {
method: 'deleteRemoteIngress';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Update a remote ingress edge registration.
*/
export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateRemoteIngress
> {
method: 'updateRemoteIngress';
request: {
identity?: authInterfaces.IIdentity;
id: string;
name?: string;
listenPorts?: number[];
enabled?: boolean;
tags?: string[];
};
response: {
success: boolean;
edge: IRemoteIngress;
};
}
/**
* Regenerate the secret for a remote ingress edge.
*/
export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RegenerateRemoteIngressSecret
> {
method: 'regenerateRemoteIngressSecret';
request: {
identity?: authInterfaces.IIdentity;
id: string;
};
response: {
success: boolean;
secret: string;
};
}
/**
* Get all remote ingress edge registrations.
*/
export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRemoteIngresses
> {
method: 'getRemoteIngresses';
request: {
identity?: authInterfaces.IIdentity;
};
response: {
edges: IRemoteIngress[];
};
}
/**
* Get runtime status of all remote ingress edges.
*/
export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRemoteIngressStatus
> {
method: 'getRemoteIngressStatus';
request: {
identity?: authInterfaces.IIdentity;
};
response: {
statuses: IRemoteIngressStatus[];
};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '6.0.0',
version: '6.4.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -116,7 +116,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
// Determine initial view from URL path
const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'];
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'];
const segments = path.split('/').filter(Boolean);
const view = segments[0];
return validViews.includes(view) ? view : 'overview';
@@ -192,6 +192,34 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
'soft'
);
// ============================================================================
// Remote Ingress State
// ============================================================================
export interface IRemoteIngressState {
edges: interfaces.data.IRemoteIngress[];
statuses: interfaces.data.IRemoteIngressStatus[];
selectedEdgeId: string | null;
newEdgeSecret: string | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngressState>(
'remoteIngress',
{
edges: [],
statuses: [],
selectedEdgeId: null,
newEdgeSecret: null,
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft'
);
// Actions for state management
interface IActionContext {
identity: interfaces.data.IIdentity | null;
@@ -378,6 +406,13 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
}, 100);
}
// If switching to remoteingress view, ensure we fetch edge data
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
setTimeout(() => {
remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
}, 100);
}
return {
...currentState,
activeView: viewName,
@@ -745,6 +780,150 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
}
);
// ============================================================================
// Remote Ingress Actions
// ============================================================================
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRemoteIngresses
>('/typedrequest', 'getRemoteIngresses');
const statusRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRemoteIngressStatus
>('/typedrequest', 'getRemoteIngressStatus');
const [edgesResponse, statusResponse] = await Promise.all([
edgesRequest.fire({ identity: context.identity }),
statusRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
edges: edgesResponse.edges,
statuses: statusResponse.statuses,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch remote ingress data',
};
}
});
export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: string;
listenPorts: number[];
tags?: string[];
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateRemoteIngress
>('/typedrequest', 'createRemoteIngress');
const response = await request.fire({
identity: context.identity,
name: dataArg.name,
listenPorts: dataArg.listenPorts,
tags: dataArg.tags,
});
if (response.success) {
// Refresh the list and store the new secret for display
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
return {
...statePartArg.getState(),
newEdgeSecret: response.edge.secret,
};
}
return currentState;
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to create edge',
};
}
});
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteRemoteIngress
>('/typedrequest', 'deleteRemoteIngress');
await request.fire({
identity: context.identity,
id: edgeId,
});
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
return statePartArg.getState();
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to delete edge',
};
}
}
);
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId) => {
const context = getActionContext();
const currentState = statePartArg.getState();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RegenerateRemoteIngressSecret
>('/typedrequest', 'regenerateRemoteIngressSecret');
const response = await request.fire({
identity: context.identity,
id: edgeId,
});
if (response.success) {
return {
...currentState,
newEdgeSecret: response.secret,
};
}
return currentState;
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to regenerate secret',
};
}
}
);
export const clearNewEdgeSecretAction = remoteIngressStatePart.createAction(
async (statePartArg) => {
return {
...statePartArg.getState(),
newEdgeSecret: null,
};
}
);
// Combined refresh action for efficient polling
async function dispatchCombinedRefreshAction() {
const context = getActionContext();

View File

@@ -6,4 +6,5 @@ export * from './ops-view-logs.js';
export * from './ops-view-config.js';
export * from './ops-view-security.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './shared/index.js';

View File

@@ -20,6 +20,7 @@ import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewConfig } from './ops-view-config.js';
import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -66,6 +67,10 @@ export class OpsDashboard extends DeesElement {
name: 'Certificates',
element: OpsViewCertificates,
},
{
name: 'RemoteIngress',
element: OpsViewRemoteIngress,
},
];
/**

View File

@@ -0,0 +1,290 @@
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-remoteingress': OpsViewRemoteIngress;
}
}
@customElement('ops-view-remoteingress')
export class OpsViewRemoteIngress extends DeesElement {
@state()
accessor riState: appstate.IRemoteIngressState = appstate.remoteIngressStatePart.getState();
constructor() {
super();
const sub = appstate.remoteIngressStatePart.state.subscribe((newState) => {
this.riState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.remoteIngressStatePart.dispatchAction(appstate.fetchRemoteIngressAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.remoteIngressContainer {
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.connected {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.disconnected {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.disabled {
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.secretDialog {
padding: 16px;
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border: 1px solid ${cssManager.bdTheme('#fbbf24', '#92400e')};
border-radius: 8px;
margin-bottom: 16px;
}
.secretDialog code {
display: block;
padding: 8px 12px;
background: ${cssManager.bdTheme('#1f2937', '#111827')};
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
word-break: break-all;
margin: 8px 0;
user-select: all;
}
.secretDialog .warning {
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
margin-top: 8px;
}
.portsDisplay {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.portBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
}
`,
];
render(): TemplateResult {
const totalEdges = this.riState.edges.length;
const connectedEdges = this.riState.statuses.filter(s => s.connected).length;
const disconnectedEdges = totalEdges - connectedEdges;
const activeTunnels = this.riState.statuses.reduce((sum, s) => sum + s.activeTunnels, 0);
const statsTiles: IStatsTile[] = [
{
id: 'totalEdges',
title: 'Total Edges',
type: 'number',
value: totalEdges,
icon: 'lucide:server',
description: 'Registered edge nodes',
color: '#3b82f6',
},
{
id: 'connectedEdges',
title: 'Connected',
type: 'number',
value: connectedEdges,
icon: 'lucide:link',
description: 'Currently connected edges',
color: '#10b981',
},
{
id: 'disconnectedEdges',
title: 'Disconnected',
type: 'number',
value: disconnectedEdges,
icon: 'lucide:unlink',
description: 'Offline edge nodes',
color: disconnectedEdges > 0 ? '#ef4444' : '#6b7280',
},
{
id: 'activeTunnels',
title: 'Active Tunnels',
type: 'number',
value: activeTunnels,
icon: 'lucide:cable',
description: 'Active client connections',
color: '#8b5cf6',
},
];
return html`
<ops-sectionheading>Remote Ingress</ops-sectionheading>
${this.riState.newEdgeSecret ? html`
<div class="secretDialog">
<strong>Edge Secret (copy now - shown only once):</strong>
<code>${this.riState.newEdgeSecret}</code>
<div class="warning">This secret will not be shown again. Save it securely.</div>
<dees-button
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeSecretAction, null)}
>Dismiss</dees-button>
</div>
` : ''}
<div class="remoteIngressContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Edge Nodes'}
.heading2=${'Manage remote ingress edge registrations'}
.data=${this.riState.edges}
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
name: edge.name,
status: this.getEdgeStatusHtml(edge),
publicIp: this.getEdgePublicIp(edge.id),
ports: this.getPortsHtml(edge.listenPorts),
tunnels: this.getEdgeTunnelCount(edge.id),
lastHeartbeat: this.getLastHeartbeat(edge.id),
})}
.dataActions=${[
{
name: 'Regenerate Secret',
iconName: 'lucide:key',
action: async (edge: interfaces.data.IRemoteIngress) => {
await appstate.remoteIngressStatePart.dispatchAction(
appstate.regenerateRemoteIngressSecretAction,
edge.id,
);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
action: async (edge: interfaces.data.IRemoteIngress) => {
await appstate.remoteIngressStatePart.dispatchAction(
appstate.deleteRemoteIngressAction,
edge.id,
);
},
},
]}
.createNewAction=${async () => {
const { DeesModal } = await import('@design.estate/dees-catalog');
const result = await DeesModal.createAndShow({
heading: 'Create Edge Node',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Listen Ports (comma-separated)'} .required=${true} .value=${'443,25'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
</dees-form>
`,
menuOptions: [],
});
if (result) {
const formData = result as any;
const ports = (formData.name ? formData.listenPorts : '443')
.split(',')
.map((p: string) => parseInt(p.trim(), 10))
.filter((p: number) => !isNaN(p));
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.createRemoteIngressAction,
{
name: formData.name,
listenPorts: ports,
tags,
},
);
}
}}
></dees-table>
</div>
`;
}
private getEdgeStatus(edgeId: string): interfaces.data.IRemoteIngressStatus | undefined {
return this.riState.statuses.find(s => s.edgeId === edgeId);
}
private getEdgeStatusHtml(edge: interfaces.data.IRemoteIngress): TemplateResult {
if (!edge.enabled) {
return html`<span class="statusBadge disabled">Disabled</span>`;
}
const status = this.getEdgeStatus(edge.id);
if (status?.connected) {
return html`<span class="statusBadge connected">Connected</span>`;
}
return html`<span class="statusBadge disconnected">Disconnected</span>`;
}
private getEdgePublicIp(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
return status?.publicIp || '-';
}
private getPortsHtml(ports: number[]): TemplateResult {
return html`<div class="portsDisplay">${ports.map(p => html`<span class="portBadge">${p}</span>`)}</div>`;
}
private getEdgeTunnelCount(edgeId: string): number {
const status = this.getEdgeStatus(edgeId);
return status?.activeTunnels || 0;
}
private getLastHeartbeat(edgeId: string): string {
const status = this.getEdgeStatus(edgeId);
if (!status?.lastHeartbeat) return '-';
const ago = Date.now() - status.lastHeartbeat;
if (ago < 60000) return `${Math.floor(ago / 1000)}s ago`;
if (ago < 3600000) return `${Math.floor(ago / 60000)}m ago`;
return `${Math.floor(ago / 3600000)}h ago`;
}
}

View File

@@ -34,6 +34,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
- **Security** — Security incidents from email processing
- Bounce record management and suppression list controls
### 🔐 Certificate Management
- Domain-centric certificate overview with status indicators
- Certificate source tracking (ACME, provision function, static)
- Expiry date monitoring and alerts
- Per-domain backoff status for failed provisions
- One-click reprovisioning per domain
### 📜 Log Viewer
- Real-time log streaming
- Filter by log level (error, warning, info, debug)
@@ -77,6 +84,7 @@ ts_web/
├── ops-view-overview.ts # Overview statistics
├── ops-view-network.ts # Network monitoring
├── ops-view-emails.ts # Email queue management
├── ops-view-certificates.ts # Certificate overview & reprovisioning
├── ops-view-logs.ts # Log viewer
├── ops-view-config.ts # Configuration display
├── ops-view-security.ts # Security dashboard
@@ -132,6 +140,7 @@ removeFromSuppressionAction(email) // Remove from suppression list
/emails/sent → Sent emails
/emails/failed → Failed emails
/emails/security → Security incidents
/certificates → Certificate management
/logs → Log viewer
/configuration → System configuration
/security → Security dashboard

View File

@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates'] as const;
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
export type TValidView = typeof validViews[number];