Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21157477b4 | |||
| fcf36e5cd5 | |||
| f5740fa565 | |||
| 4a9fba53a9 | |||
| da61adc9a2 | |||
| 616066ffd0 | |||
| bd5cccb405 | |||
| fbade85cda | |||
| 9060d26f3a | |||
| c889141ec3 | |||
| fb472f353c | |||
| 090bd747e1 | |||
| 4d77a94bbb | |||
| 7f5284b10f | |||
| 9cd5db2d81 | |||
| de0b7d1fe0 | |||
| 4e32745a8f | |||
| 121573de2f | |||
| cd957526e2 | |||
| 7aa5f07731 | |||
| 5b6f7b30c3 | |||
| 18cc21a49e | |||
| 46fa2f6ade | |||
| 0a6315f177 | |||
| 841f99e19d | |||
| 8e9de46cd2 | |||
| 2d44528345 | |||
| 28a38252da | |||
| dfb268bbfc | |||
| 6532c7ff22 | |||
| d2c63cf170 | |||
| 09d66e4528 | |||
| 3078fa9d7b | |||
| 57fbb128e6 | |||
| d73266eeb8 | |||
| 2dbdf2d2b1 | |||
| 383e0adc23 | |||
| d7789f5a44 | |||
| 2638990667 | |||
| c33ecdc26f | |||
| b033d80927 | |||
| cf5d616769 | |||
| 8e722f5ab6 | |||
| 2b75709161 | |||
| c5e2c262b7 | |||
| d10896196d | |||
| 8be1e87bdc | |||
| 96cefe984a | |||
| ca112c3e42 | |||
| 85b6c4fa51 | |||
| ee550e6f25 | |||
| 108a8bb51d | |||
| 3c5b26d1c1 | |||
| 01fbc3db95 | |||
| 8dd9770339 | |||
| 77842647fd | |||
| a309145829 | |||
| 5de8d38b78 | |||
| 2d6dbc552e | |||
| f0fae866dc | |||
| 87c039a63f | |||
| 2c875cbb18 | |||
| 735464e8e6 | |||
| e6a1f50554 | |||
| 530ebbf3e4 | |||
| 048f038e36 | |||
| e375adb80a | |||
| 9d7da5bc25 | |||
| 41fe7a8a47 | |||
| f3f1f58b67 | |||
| 9e0e77737b | |||
| 5de3344905 | |||
| ae34314f54 | |||
| 5b473de354 | |||
| 1a108fa8b7 | |||
| badabe753a | |||
| c2d3ace0dd | |||
| fcea194cf6 | |||
| b90650c660 | |||
| 2206abd04b | |||
| d54831765b | |||
| dd4ac9fa3d | |||
| aed9151998 | |||
| 5d4bf4eff8 | |||
| 9027125520 | |||
| ee561c0823 | |||
| 95cb5d7840 | |||
| 2f46b3c9f3 | |||
| 7bd94884f4 | |||
| 405990563b | |||
| bf9f805c71 | |||
| 28cbf84f97 | |||
| d24e51117d | |||
| 92fde9d0d7 | |||
| b81bda6ce8 | |||
| 9b3f5c458d | |||
| 3ba47f9a71 | |||
| 2ab2e30336 | |||
| 8ce6c88d58 | |||
| facae93e4b | |||
| 0eb4963247 | |||
| 02dd3c77b5 | |||
| 93995d5031 | |||
| 554d245c0c | |||
| e3cb35a036 | |||
| 3a95ea9f4e | |||
| 99f57dba76 | |||
| 415e28038d | |||
| 7bda406624 | |||
| 8282610307 | |||
| 5269c20770 | |||
| f1fb4c8495 | |||
| 5faca8c1b6 | |||
| 61778bdba8 | |||
| ab19130904 | |||
| 646aa7106b | |||
| b0f167f6da | |||
| 4d8d802006 | |||
| 6ee1d6e917 | |||
| f877ad9676 | |||
| fe817dde00 | |||
| 272973702e | |||
| c776dab2c0 | |||
| 74692c4aa5 | |||
| 71183b35c0 | |||
| ae73de19b2 | |||
| a2b413a78f | |||
| 739eeb63aa | |||
| eb26a62a87 | |||
| ad0ab6c103 | |||
| 37e1ecefd2 | |||
| e6251ab655 | |||
| 53b64025f3 | |||
| 40db395591 | |||
| 2c244c4a9a | |||
| 0baf2562b7 | |||
| 64da8d9100 | |||
| b11fea7334 | |||
| 6c8458f63c | |||
| 455b0085ec | |||
| 2b2fe940c4 | |||
| e1a7b3e8f7 | |||
| 191c4160c1 | |||
| 2e75961d1c | |||
| 88099e120a | |||
| 77ff948404 | |||
| 0e610cba16 | |||
| 8d59d617f1 | |||
| 6aa54d974e | |||
| 2aeb52bf13 | |||
| 243a45d24c | |||
| cfea44742a | |||
| 073c8378c7 | |||
| af408d38c9 | |||
| c3b14c0f58 | |||
| 69304dc839 | |||
| a3721f7a74 | |||
| 20583beb35 | |||
| b8ea8f660e | |||
| 5a45d6cd45 | |||
| 84196f9b13 | |||
| 4c9fd22a86 | |||
| 5b33623c2d | |||
| 58f4a123d2 | |||
| 11a2ae6b27 | |||
| 4e4c7df558 | |||
| 3d669ed9dd | |||
| 6e19e30f87 | |||
| dc5c0b2584 | |||
| 35712b18bc | |||
| 9958c036a0 | |||
| 14c9fbdc3c | |||
| 4fd3ec2958 | |||
| f2e9ff0a51 | |||
| cb52446f65 | |||
| 0907949f8a | |||
| 9629329bc2 | |||
| f651cd1c2f | |||
| a7438a7cd6 | |||
| e0f6e3237b | |||
| 1b141ec8f3 | |||
| 7d28d23bbd | |||
| 53f5e30b23 | |||
| 7344bf0f70 | |||
| 4905595cbb | |||
| f058b2d1e7 | |||
| 6fcc3feb73 | |||
| 50350bd78d | |||
| f065a9c952 | |||
| 72898c67b7 | |||
| ca53816b41 | |||
| ac419e7b79 | |||
| 7c0f9b4e44 | |||
| d584f3584c | |||
| a4353b10bb | |||
| b2f25c49b6 | |||
| d3255a7e14 | |||
| 2564d0874b | |||
| ca111f4783 | |||
| b6dd281a54 | |||
| 645790d0c2 | |||
| 535b055664 | |||
| 2eeb731669 | |||
| c3ae995372 | |||
| 15e7a3032c | |||
| 10ab09894b | |||
| 38811dbf23 | |||
| 3f220996ee | |||
| b0a0078ad0 | |||
| ecb913843c | |||
| 162795802f | |||
| b1890f59ee | |||
| 5c85188183 | |||
| f37cddf26d | |||
| f3f06ed06d | |||
| 07f03eb834 | |||
| e7174e8630 | |||
| 186e94c1a2 | |||
| fb424d814c | |||
| 0ad5dfd6ee | |||
| fbaafa909b | |||
| f1cc7fd340 | |||
| deec61da42 | |||
| 190ae11667 | |||
| f4ace3999d | |||
| 8b857e3d1d | |||
| 7aaf8f2595 | |||
| 39b634b6bb | |||
| 4624fdbe10 | |||
| 858794799b | |||
| cb33dd26d0 | |||
| d3d197d9d3 | |||
| 0e914a3366 | |||
| 747478f0f9 | |||
| b61de33ee0 | |||
| 970c0d5c60 | |||
| fe2069c48e | |||
| 63781ab1bd | |||
| 0b155d6925 | |||
| 076aac27ce | |||
| 7f84405279 | |||
| 13ef31c13f | |||
| 5cf4c0f150 | |||
| 04b7552b34 | |||
| 1528d29b0d | |||
| 9d895898b1 | |||
| 45be1e0a42 | |||
| ba39392c1b | |||
| f704dc78aa | |||
| 7e931d6c52 | |||
| 630e911589 | |||
| f6377d1973 | |||
| c852e954c9 | |||
| 2ee66ef967 | |||
| 5ad43470f3 | |||
| efd64d6304 | |||
| a29cff2fc5 | |||
| d161fe4f19 | |||
| df9a8ad14e | |||
| 8ddad6e652 | |||
| 3d36d3d1c5 | |||
| 329320cd40 | |||
| 63ecf60543 | |||
| 87917f68fb | |||
| 018b499010 | |||
| a4d79c2d01 | |||
| 90d3e75963 | |||
| 4887ec9d93 | |||
| 983e6cb623 | |||
| e9b2ec0f59 | |||
| c084de9c78 | |||
| 2b207833ce | |||
| 4dc095e662 | |||
| c1311f493f | |||
| 97cbe6e398 | |||
| 0bb9c5e1e5 | |||
| cf90560243 | |||
| 8def86494a | |||
| db46e01f6e | |||
| 7baf747972 | |||
| 4a17a1073e | |||
| 8997ded81d | |||
| f177d8e9ab | |||
| 808a9cc856 | |||
| be1c8d1164 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ dist/
|
||||
dist_*/
|
||||
|
||||
# custom
|
||||
**/.claude/settings.local.json
|
||||
.nogit/data/
|
||||
readme.plan.md
|
||||
|
||||
573
changelog.md
Normal file
573
changelog.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-16 - 6.4.4 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^25.7.3
|
||||
|
||||
- Updated @push.rocks/smartproxy from ^25.7.2 to ^25.7.3 in package.json
|
||||
|
||||
## 2026-02-16 - 6.4.3 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^25.7.2
|
||||
|
||||
- Updated package.json: @push.rocks/smartproxy ^25.7.1 -> ^25.7.2 (patch dependency update)
|
||||
|
||||
## 2026-02-16 - 6.4.2 - fix(smartproxy)
|
||||
bump @push.rocks/smartproxy to ^25.7.1
|
||||
|
||||
- Updated dependency @push.rocks/smartproxy from ^25.7.0 to ^25.7.1 in package.json
|
||||
- No other source changes; dependency patch bump only
|
||||
|
||||
## 2026-02-16 - 6.4.1 - fix(deps)
|
||||
bump dependencies: @push.rocks/smartproxy to ^25.7.0 and @serve.zone/remoteingress to ^3.0.2
|
||||
|
||||
- Bumped @push.rocks/smartproxy from ^25.5.0 to ^25.7.0
|
||||
- Bumped @serve.zone/remoteingress from ^3.0.1 to ^3.0.2
|
||||
- Package current version is 6.4.0 — recommended patch release
|
||||
|
||||
## 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, one‑click 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.
|
||||
|
||||
- Add CertProvisionScheduler: persistent per-domain exponential backoff, retry calculation, and an in-memory serial stagger queue.
|
||||
- Integrate scheduler with SmartAcme certProvisionFunction: enqueue provisions, clear backoff on success, record failures to drive backoff.
|
||||
- Switch certificate event tracking to be keyed by domain (certificateStatusMap now keyed by domain) and add findRouteNamesForDomain helper.
|
||||
- BREAKING: ICertificateInfo shape changed — replaced routeName/domains with domain and routeNames; added optional backoffInfo (failures, retryAfter, lastError).
|
||||
- Add domain-based reprovision endpoint (reprovisionCertificateDomain) while retaining legacy route-based reprovision for backward compatibility (internal rename to reprovisionCertificateByRoute).
|
||||
- Web UI updated to domain-centric certificate overview, displays route pills, backoff indicator and retry timing, and uses domain-based reprovision action.
|
||||
- Dependency bumps: @push.rocks/smartlog -> ^3.1.11, @push.rocks/smartproxy -> ^25.3.1.
|
||||
|
||||
## 2026-02-14 - 5.5.0 - feat(certs)
|
||||
persist ACME certificates in StorageManager, add storage-backed cert manager, default storage to filesystem, and improve certificate status reporting
|
||||
|
||||
- Add StorageBackedCertManager to persist SmartAcme certificates under /certs/ via StorageManager
|
||||
- Default storage to filesystem path (dcrouterHomeDir/storage) when options.storage is not provided
|
||||
- Wire SmartAcme to use StorageBackedCertManager and provide SmartProxy certStore handlers that load/save/remove certs under /proxy-certs/
|
||||
- Ops server certificate handler reads persisted cert data to report expiry/issued dates and treats acme/provision-function routes with no cert data as provisioning
|
||||
- Bump @push.rocks/smartproxy dependency to ^25.3.0
|
||||
|
||||
## 2026-02-14 - 5.4.6 - fix(deps)
|
||||
bump @push.rocks/smartproxy dependency to ^25.2.2
|
||||
|
||||
- Updated dependency @push.rocks/smartproxy: ^25.2.0 → ^25.2.2
|
||||
- Change is a dependency-only patch update, no source code modifications
|
||||
- Current package version is 5.4.5; recommend a patch release
|
||||
|
||||
## 2026-02-14 - 5.4.5 - fix(dcrouter)
|
||||
bump patch for release pipeline consistency - no code changes
|
||||
|
||||
- current version: 5.4.4 (from package.json)
|
||||
- git diff: no changes detected
|
||||
- recommend patch bump to trigger release artifacts if required
|
||||
|
||||
## 2026-02-14 - 5.4.4 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^25.2.0
|
||||
|
||||
- Updated @push.rocks/smartproxy from ^25.1.0 to ^25.2.0 (patch, non-breaking).
|
||||
- Current package version is 5.4.3; recommend a patch release to 5.4.4.
|
||||
|
||||
## 2026-02-14 - 5.4.3 - fix(dependencies)
|
||||
bump @push.rocks/smartproxy to ^25.1.0
|
||||
|
||||
- Updated @push.rocks/smartproxy from ^25.0.0 to ^25.1.0 in package.json
|
||||
|
||||
## 2026-02-13 - 5.4.2 - fix(dcrouter)
|
||||
improve domain pattern matching to support routing-glob and wildcard patterns and use matching logic when resolving routes
|
||||
|
||||
- Support routing-glob patterns beginning with '*' (e.g. *example.com) to match base domain, wildcard form, and subdomains
|
||||
- Treat standard wildcard patterns ('*.example.com') as matching both the base domain (example.com) and its subdomains
|
||||
- Use isDomainMatch when resolving routes instead of exact array includes to allow pattern matching
|
||||
- Normalize domain and pattern to lowercase and simplify equality checks
|
||||
|
||||
## 2026-02-13 - 5.4.1 - fix(network,dcrouter)
|
||||
Always register SmartProxy certificate event handlers and include total bytes + improved connection metrics in network stats/UI
|
||||
|
||||
- Always register SmartProxy 'certificate-issued', 'certificate-renewed', and 'certificate-failed' handlers (previously only registered when acmeConfig was present) so certificate events are processed regardless of provisioning path.
|
||||
- Add totalBytes (in/out) to network stats and propagate it through ts_interfaces and app state so total data transferred is available to the UI.
|
||||
- Combine metricsManager.getNetworkStats with collectServerStats to compute activeConnections and adjust connectionDetails/TopEndpoints handling.
|
||||
- Update ops UI to display totalBytes in throughput cards and remove a redundant network-specific auto-refresh fetch.
|
||||
- Type and state updates: ts_interfaces/data/stats.ts and ts_web/appstate.ts updated with totalBytes and initialization/default mapping adjusted.
|
||||
|
||||
## 2026-02-13 - 5.4.0 - feat(certificates)
|
||||
include certificate source/issuer and Rust-side status checks; pass eventComms into certProvisionFunction and record expiry information
|
||||
|
||||
- bump @push.rocks/smartproxy dependency to ^25.0.0
|
||||
- add optional 'source' field to certificate status and propagate event.source when certificates are issued, renewed, or failed
|
||||
- change smartProxy.certProvisionFunction signature to accept eventComms; use it to log attempts, set source and expiryDate, and fall back to http-01 on DNS-01 failure
|
||||
- make buildCertificateOverview async and query smartProxy.getCertificateStatus for a route when event-based status is unknown
|
||||
- improve logging to include certificate source and more contextual messages
|
||||
|
||||
## 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)
|
||||
bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2
|
||||
|
||||
- package.json: updated @push.rocks/smartdns from ^7.8.0 to ^7.8.1 (patch)
|
||||
- package.json: updated @push.rocks/smartmta from ^5.2.1 to ^5.2.2 (patch)
|
||||
|
||||
## 2026-02-12 - 5.0.6 - fix(deps)
|
||||
bump @push.rocks/smartproxy to ^23.1.4
|
||||
|
||||
- package.json: @push.rocks/smartproxy ^23.1.2 → ^23.1.4
|
||||
- Dependency-only version bump, no source code changes
|
||||
|
||||
## 2026-02-12 - 5.0.5 - fix(dcrouter)
|
||||
remove legacy handling of emailConfig.routes that added domain-based routes
|
||||
|
||||
- Removed loop that added domain-based email routes from emailConfig.routes into emailRoutes
|
||||
- Previously created match.domains by extracting the recipient domain (split on '@') and defaulted forward target port to 25
|
||||
- Removed creation of TLS passthrough configuration for those forwarded routes
|
||||
- This prevents duplicate or incorrect domain-based routes being appended during email route construction
|
||||
|
||||
## 2026-02-12 - 5.0.4 - fix(cache)
|
||||
use user-writable ~/.serve.zone/dcrouter for TsmDB and centralize data path logic
|
||||
|
||||
- Default TsmDB storage changed from /etc/dcrouter/tsmdb to ~/.serve.zone/dcrouter/tsmdb
|
||||
- Introduced dcrouterHomeDir, dataDir, and defaultTsmDbPath in ts/paths.ts
|
||||
- CacheDb now defaults to defaultTsmDbPath when no storagePath is provided
|
||||
- DcRouter initialization updated to use paths.defaultTsmDbPath; README and readme.hints updated to document the new defaults
|
||||
- Avoids /etc permission issues and prevents starting a real MongoDB process in tests by using a user-writable default path
|
||||
|
||||
## 2026-02-12 - 5.0.3 - fix(packaging)
|
||||
add files whitelist to package.json and remove Playwright-generated screenshots
|
||||
|
||||
- Add a "files" array to package.json to control published package contents (includes ts/, ts_web/, dist/, dist_*/**, dist_ts/, dist_ts_web/, assets/, cli.js, npmextra.json, readme.md).
|
||||
- Remove multiple .playwright-mcp/*.png screenshot files (clean up Playwright test artifacts and reduce repository noise/size).
|
||||
|
||||
## 2026-02-12 - 5.0.2 - fix(docs)
|
||||
update documentation and packaging configuration: document smartmta/smartdns integrations, adjust API method names, and add release registry info
|
||||
|
||||
- README: document SmartDNS as Rust-powered DNS engine and smartmta as TypeScript+Rust MTA; add Rust-powered architecture section and component package table
|
||||
- README: update Node.js requirement from 18+ to 20+; replace embedded cache DB TsmDb with LocalTsmDb and reduce listed cached document types
|
||||
- README & ts_interfaces: rename typedrequest API adminLogin -> adminLoginWithUsernameAndPassword and add/clarify several API methods (logout, suppression management, RADIUS client/VLAN helpers)
|
||||
- README: update test instructions, change test file references and add a test coverage table
|
||||
- npmextra.json: re-key package configs (@git.zone/cli, @ship.zone/szci), tidy watch array formatting, and add release.registries and accessLevel for publishing
|
||||
|
||||
## 2026-02-11 - 5.0.1 - fix(deps/tests)
|
||||
bump two dependencies and disable cache in tests
|
||||
|
||||
- Bumped @api.global/typedrequest from ^3.2.5 to ^3.2.6
|
||||
- Bumped @push.rocks/smartradius from ^1.1.0 to ^1.1.1
|
||||
- Disabled cache in tests by adding cacheConfig: { enabled: false } to DcRouter instantiation in test/test.jwt-auth.ts, test/test.opsserver-api.ts, and test/test.protected-endpoint.ts
|
||||
|
||||
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mta)
|
||||
migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation
|
||||
|
||||
- Replace ~27k LOC custom MTA (ts/mail/, ts/deliverability/) with @push.rocks/smartmta v5.2.1 (TypeScript+Rust hybrid)
|
||||
- Remove many SMTP client/server test suites and test helpers; testing approach and fixtures changed/removed
|
||||
- Upgrade dependencies: @push.rocks/smartproxy -> 23.1.2, @push.rocks/smartdns -> 7.8.0, add @push.rocks/smartmta@5.2.1; bump other minor deps
|
||||
- API differences: updateEmailRoutes() replaces updateRoutes(); UnifiedEmailServer exposes dkimCreator publicly; bounce/suppression APIs moved to emailServer.* helpers; Email class and IAttachment types moved into @push.rocks/smartmta exports
|
||||
- SmartProxy route validation stricter: forward actions must use targets (array) instead of target (singular) — tests/configs updated accordingly
|
||||
- DKIM generation/serving moved to smartmta (dcrouter no longer manages DKIM keys directly)
|
||||
|
||||
## 2026-02-10 - 4.1.1 - fix(smartproxy)
|
||||
upgrade @push.rocks/smartproxy to ^23.1.0 and adapt code/tests for its async getStatistics() API
|
||||
|
||||
- Bumped dependency @push.rocks/smartproxy 22.4.2 → 23.1.0 in package.json
|
||||
- Changed ts/monitoring/classes.metricsmanager.ts to await smartProxy.getStatistics() (was synchronous)
|
||||
- Updated multiple tests to set cacheConfig: { enabled: false } and added socketTimeouts where appropriate
|
||||
- Improved SMTP test servers: handle multi-line input, drop data for packet-loss simulation, and ignore socket errors to make tests more robust
|
||||
- Added migration notes to readme.hints.md documenting SmartProxy v23.1.0 changes (async getStatistics, Rust proxy behavior)
|
||||
|
||||
## 2026-02-10 - 4.1.0 - feat(cache)
|
||||
add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration
|
||||
|
||||
- Introduce CacheDb and CacheCleaner using @push.rocks/smartdata and @push.rocks/smartmongo (LocalTsmDb) for persistent caching
|
||||
- Integrate cache initialization, console summary, and graceful shutdown into DcRouter (options.cacheConfig and setupCacheDb())
|
||||
- Require svDb() decorators on concrete cache document classes; remove decorators from the abstract CachedDocument base class
|
||||
- Switch CacheCleaner to smartdata getInstances() + per-document delete() instead of deleteMany
|
||||
- Adapt to LocalTsmDb API changes (folderPath option and start() returning connectionUri) and initialize SmartdataDb with mongoDbUrl/mongoDbName
|
||||
- Remove experimentalDecorators and emitDecoratorMetadata from tsconfig to use TC39 Stage 3 decorators (smartdata v7+ compatibility)
|
||||
- Add package.json exports mapping (remove main/typings entries) to expose dist entry points
|
||||
- Add README documentation for the Smartdata Cache System and configuration/usage examples
|
||||
|
||||
## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config)
|
||||
convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing
|
||||
|
||||
- Removed server-side 'updateConfiguration' TypedHandler and the private updateConfiguration() method; getConfiguration remains as a read-only handler.
|
||||
- Removed IReq_UpdateConfiguration interface from request typings; IReq_GetConfiguration marked as read-only.
|
||||
- Removed client-side editing functionality: ops-view-config editing state and methods, Edit/Save/Cancel buttons, and updateConfigurationAction; ops-view-config enhanced to display read-only configuration (badges for booleans, array pills, icons, formatted numbers/bytes, empty states, etc.).
|
||||
- Tests updated: replaced configuration update tests with verifyIdentity tests and added a read-only configuration access test.
|
||||
- Documentation updated to reflect configuration is read-only (readme.md, ts_web/readme.md, ts_interfaces/readme.md, readme.hints.md).
|
||||
- Dependencies adjusted: bumped @push.rocks/smartdata to ^7.0.15 and added @push.rocks/smartmongo ^5.1.0; ts/plugins updated to import/export smartmongo.
|
||||
|
||||
## 2026-02-02 - 3.1.0 - feat(web)
|
||||
determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies
|
||||
|
||||
- UI: derive initial active view from window.location.pathname so the dashboard supports deep linking and bookmarks (ts_web/appstate.ts)
|
||||
- UI: pass selectedView to dees-simple-appdash by adding a currentViewTab getter in ops-dashboard (ts_web/elements/ops-dashboard.ts)
|
||||
- Docs: add TypeScript interfaces README for @serve.zone/dcrouter-interfaces (ts_interfaces/readme.md)
|
||||
- Docs: add/update web module README detailing features, routing, and build instructions (ts_web/readme.md) and expand main project README
|
||||
- Deps: bump multiple dependencies in package.json (notable bumps: @api.global/typedrequest -> ^3.2.5, @design.estate/dees-catalog -> ^3.42.0, @design.estate/dees-element -> ^2.1.6, @push.rocks/projectinfo -> ^5.0.2, @push.rocks/smartdata -> ^5.16.7, @push.rocks/smartpromise -> ^4.2.3, @push.rocks/smartradius -> ^1.1.0, @push.rocks/smartstate -> ^2.0.30, mailauth -> ^4.12.1)
|
||||
|
||||
## 2026-02-01 - 3.0.0 - BREAKING CHANGE(deps)
|
||||
upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support
|
||||
|
||||
- Bumped many major dependencies: @api.global/typedserver 3.x → 8.3.0, @api.global/typedsocket 3.x → 4.1.0, @apiclient.xyz/cloudflare 6.x → 7.1.0, @design.estate/dees-catalog 1.x → 3.41.4, @push.rocks/smartpath 5.x → 6.x, @push.rocks/smartproxy 19.x → 22.x, @push.rocks/smartrequest 2.x → 5.x, uuid 11.x → 13.x, @types/node 25.1.0 → 25.2.0
|
||||
|
||||
## 2026-02-01 - 2.13.0 - feat(radius)
|
||||
add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers
|
||||
|
||||
- Introduce full RADIUS module under ts/radius: classes.radius.server, classes.vlan.manager, classes.accounting.manager (authentication, VLAN mapping, OUI patterns, accounting, persistence).
|
||||
- Integrate RADIUS into DcRouter: add radiusConfig option, setupRadiusServer(), updateRadiusConfig(), start/stop lifecycle handling and startup summary output.
|
||||
- Add OpsServer RadiusHandler (ts/opsserver/handlers/radius.handler.ts) exposing TypedRequest endpoints for client management, VLAN mappings, accounting reports and statistics.
|
||||
- Add typed request interfaces for RADIUS under ts_interfaces/requests/radius.ts and export them from the requests index.
|
||||
- Wire smartradius into plugins (ts/plugins.ts) and export the new module; export RADIUS from ts/index.ts and re-export RADIUS types from classes.dcrouter.
|
||||
- Update package.json & npmextra.json: add tswatch script and dev watcher configuration, add @push.rocks/smartradius dependency and a test_watch/devserver.ts dev server entrypoint.
|
||||
- Refactor several web UI components (ops-dashboard, ops-view-*) to use 'accessor' for @state properties (small UI state API adjustments).
|
||||
- Documentation: update readme.hints.md with RADIUS integration notes and examples.
|
||||
|
||||
## 2026-02-01 - 2.12.6 - fix(tests)
|
||||
update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience
|
||||
|
||||
- Email tests: switch to IEmailConfig properties (domains, routes), use router.emailServer (not unifiedEmailServer), change to non-privileged ports (e.g. 2525) and use fs.rmSync for cleanup.
|
||||
- SMTP client helper: add pool and domain options; adjust tests to use STARTTLS (secure: false) and tolerate TLS/cipher negotiation failures with try/catch fallbacks.
|
||||
- DNS tests: replace dnsDomain with dnsNsDomains and dnsScopes; test route generation without starting services, verify route names/domains, and create socket handlers without binding privileged ports.
|
||||
- Socket-handler tests: use high non-standard ports for route/handler tests, verify route naming (email-port-<port>-route), ensure handlers are functions and handle errors gracefully without starting full routers.
|
||||
- Integration/storage/rate-limit tests: add waits for async persistence, create/cleanup test directories, return and manage test server instances, relax strict assertions (memory threshold, rate-limiting enforcement) and make tests tolerant of implementation differences.
|
||||
- Misc: use getAvailablePort in perf test setup, export tap.start() where appropriate, and generally make tests less brittle by adding try/catch, fallbacks and clearer logs for expected non-deterministic behavior.
|
||||
|
||||
## 2026-02-01 - 2.12.5 - fix(mail)
|
||||
migrate filesystem helpers to fsUtils, update DKIM and mail APIs, harden SMTP client, and bump dependencies
|
||||
|
||||
- Introduce plugins.fsUtils compatibility layer and replace usages of plugins.smartfile.* with plugins.fsUtils.* across storage, routing, deliverability, and paths to support newer smartfile behaviour
|
||||
- Update DKIM signing/verifying to new mailauth API: use signingDomain/selector/privateKey and read keys from dkimCreator before signing; adjust verifier fields to use signingDomain
|
||||
- Harden SMTP client CommandHandler: add MAX_BUFFER_SIZE, socket close/error handlers, robust cleanup, clear response buffer, and adjust command/data timeouts; reduce default SOCKET_TIMEOUT to 45s
|
||||
- Use SmartFileFactory for creating SmartFile attachments and update saving/loading to use fsUtils async/sync helpers
|
||||
- Switch test runners to export default tap.start(), relax some memory-test thresholds, and add test helper methods (recordAuthFailure, recordError)
|
||||
- Update package.json: simplify bundle script and bump multiple devDependencies/dependencies to compatible versions
|
||||
|
||||
## 2025-01-29 - 2.13.0 - feat(socket-handler)
|
||||
Implement socket-handler mode for DNS and email services, enabling direct socket passing from SmartProxy
|
||||
|
||||
- Add `dnsDomain` configuration option that automatically sets up DNS server with DNS-over-HTTPS (DoH) support
|
||||
- Implement socket-handler mode for email services with `useSocketHandler` flag in email configuration
|
||||
- Update SmartProxy route generation to create socket-handler actions instead of port forwarding
|
||||
- Add automatic route creation for DNS paths `/dns-query` and `/resolve` when dnsDomain is configured
|
||||
- Enhance UnifiedEmailServer with `handleSocket` method for direct socket processing
|
||||
- Configure DnsServer with `manualHttpsMode: true` to prevent HTTPS port binding while enabling DoH
|
||||
- Improve performance by eliminating internal port forwarding overhead
|
||||
- Update documentation with socket-handler mode configuration and benefits
|
||||
|
||||
## 2025-05-16 - 2.12.0 - feat(smartproxy)
|
||||
Update documentation and configuration guides to adopt new route-based SmartProxy architecture
|
||||
|
||||
- Revise SmartProxy implementation hints in readme.hints.md to describe route-based configuration with glob pattern matching
|
||||
- Add migration examples showing transition from old direct configuration to new route-based style
|
||||
- Update DcRouter and SMTP port configuration to generate SmartProxy routes for email handling (ports 25, 587, 465 mapped to internal services)
|
||||
- Enhance integration documentation with examples for HTTP and email services using the new SmartProxy routes
|
||||
|
||||
## 2025-05-16 - 2.11.2 - fix(dependencies)
|
||||
Update dependency versions and adjust test imports to use new packages
|
||||
|
||||
- Upgraded @git.zone/tsbuild from ^2.3.2 to ^2.5.1
|
||||
- Upgraded @git.zone/tstest/tapbundle from ^1.0.88 to ^1.9.0 and replaced @push.rocks/tapbundle imports in tests
|
||||
- Upgraded @push.rocks/smartlog from ^3.0.3 to ^3.1.2
|
||||
- Upgraded @push.rocks/smartproxy from ^10.2.0 to ^18.1.0
|
||||
- Upgraded mailauth from ^4.8.4 to ^4.8.5
|
||||
|
||||
## 2025-05-08 - 2.11.1 - fix(platform)
|
||||
Update commit info with no functional changes; regenerated commit information.
|
||||
|
||||
|
||||
## 2025-05-08 - 2.11.0 - feat(platformservice)
|
||||
Expose DcRouter and update package visibility. Changed package.json 'private' flag from true to false to allow public publication, and added export of DcRouter in ts/index.ts for improved API accessibility.
|
||||
|
||||
- Changed package.json: set 'private' to false
|
||||
- Added export for DcRouter in ts/index.ts
|
||||
|
||||
## 2025-05-08 - 2.10.0 - feat(config): Implement standardized configuration system
|
||||
Create a comprehensive configuration system with validation, defaults, and documentation
|
||||
|
||||
- Added consistent configuration interfaces across all services
|
||||
- Implemented validation for all configuration objects with detailed error reporting
|
||||
- Added default values for optional configuration parameters
|
||||
- Created an extensive documentation system for configuration options
|
||||
- Added migration helpers for managing configuration format changes
|
||||
- Enhanced platform service to load configuration from multiple sources (file, environment, code)
|
||||
- Updated email and SMS services to use the new configuration system
|
||||
|
||||
## 2025-05-08 - 2.9.0 - feat(errors): Implement comprehensive error handling system
|
||||
Enhance error handling with structured errors, consistent patterns, and improved logging
|
||||
|
||||
- Added domain-specific error classes for better error categorization and handling
|
||||
- Created comprehensive error codes for all service types (email, MTA, security, etc.)
|
||||
- Implemented detailed error context with severity, category, and recoverability classification
|
||||
- Added utilities for error conversion, formatting, and handling with automatic retry mechanisms
|
||||
- Enhanced logging with correlation tracking, context support, and structured data
|
||||
- Created middleware for handling errors in HTTP requests with proper status code mapping
|
||||
- Added retry with exponential backoff for transient failures
|
||||
|
||||
## 2025-05-08 - 2.8.9 - fix(types)
|
||||
Fix TypeScript build errors and improve API type safety across platformservice interfaces
|
||||
|
||||
- Fixed interface placement in EmailService and MtaConnector classes
|
||||
- Aligned DeliveryStatus enum and updated ApiManager handlers with proper type-safe signatures
|
||||
- Added comprehensive TypeScript interfaces for ISendEmailOptions, ITemplateContext, IValidateEmailOptions, IValidationResult, and IEmailServiceStats
|
||||
- Removed circular dependencies in type definitions and added proper type assertions
|
||||
- Improved test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager; external DNS lookups are disabled under test environment
|
||||
|
||||
## 2025-05-08 - 2.8.8 - fix(types): Fix TypeScript build errors and improve API interfaces
|
||||
Fix TypeScript build errors caused by interface placement and improve API type alignment
|
||||
|
||||
- Fixed interface placement in EmailService and MtaConnector classes
|
||||
- Aligned DeliveryStatus enum with EmailSendJob implementation
|
||||
- Added proper method signatures for API endpoint handlers in ApiManager class
|
||||
- Updated getStats and checkEmailStatus methods to conform to API contracts
|
||||
- Implemented type-safe return values for all API methods
|
||||
- Fixed circular dependencies in type definitions
|
||||
- Added proper type assertion where needed to satisfy TypeScript compiler
|
||||
|
||||
## 2025-05-08 - 2.8.7 - feat(types): Add comprehensive TypeScript interfaces for API types
|
||||
Improve type safety across the platform by adding detailed TypeScript interfaces for APIs
|
||||
|
||||
- Added ISendEmailOptions interface with complete documentation for email sending options
|
||||
- Created ITemplateContext interface for email template rendering with full type safety
|
||||
- Added IValidateEmailOptions and IValidationResult interfaces for email validation
|
||||
- Improved IEmailServiceStats interface with detailed statistics types
|
||||
- Added IEmailStatusResponse and IEmailStatusDetails interfaces for MTA status checking
|
||||
- Updated sendEmail and other methods to use these new interfaces instead of 'any'
|
||||
- Removed need for type assertions in various components
|
||||
|
||||
## 2025-05-08 - 2.8.6 - fix(tests)
|
||||
fix: Improve test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager. Disable filesystem operations and external DNS lookups during tests by checking NODE_ENV, add proper cleanup of singleton instances and active timeouts to ensure consistent test environment.
|
||||
|
||||
- Bumped version from 2.8.4 to 2.8.5 in package.json and changelog.md
|
||||
- Improved SenderReputationMonitor to skip filesystem operations and DNS record loading when NODE_ENV is set to test
|
||||
- Added cleanup of singleton instances and active timeouts in test files
|
||||
- Updated readme.plan.md with roadmap items for test stability
|
||||
|
||||
## 2025-05-08 - 2.8.5 - fix(tests): Improve test stability by fixing race conditions
|
||||
Enhance the SenderReputationMonitor tests to prevent race conditions and make tests more reliable
|
||||
|
||||
- Modified SenderReputationMonitor to detect test environment and disable filesystem operations
|
||||
- Added proper cleanup of singleton instances and timeouts between tests
|
||||
- Disabled DNS lookups during tests to prevent external dependencies
|
||||
- Set a consistent test environment using NODE_ENV=test
|
||||
- Made all tests independent of each other to prevent shared state issues
|
||||
|
||||
## 2025-05-08 - 2.8.4 - fix(mail)
|
||||
refactor(mail): Remove Mailgun references from PlatformService. Update keywords, error messages, and documentation to use MTA exclusively.
|
||||
|
||||
- Removed Mailgun integration from keywords in package.json and npmextra.json
|
||||
- Updated EmailService to remove Mailgun API key usage and reference MTA instead
|
||||
- Updated changelog.md and readme.md to reflect removal of Mailgun and update examples
|
||||
- Revised error messages to mention 'MTA not configured' instead of generic provider errors
|
||||
- Updated readme.plan.md to document Mailgun removal
|
||||
|
||||
## 2025-05-08 - 2.8.3 - refactor(mail): Remove Mailgun references
|
||||
Remove all Mailgun references from the codebase since it's no longer used as an email provider
|
||||
|
||||
- Removed "mailgun integration" from keywords in package.json and npmextra.json
|
||||
- Updated comments and documentation in EmailService to remove Mailgun mentions
|
||||
- Updated error messages to reference MTA instead of generic email providers
|
||||
- Updated the readme email example to use PlatformService reference instead of Mailgun API key
|
||||
|
||||
## 2025-05-08 - 2.8.2 - fix(tests)
|
||||
Fix outdated import paths in test files for dcrouter and ratelimiter modules
|
||||
|
||||
- Updated dcrouter import from '../ts/dcrouter/index.js' to '../ts/classes.dcrouter.js'
|
||||
- Updated ratelimiter import from '../ts/mta/classes.ratelimiter.js' to '../ts/mail/delivery/classes.ratelimiter.js'
|
||||
|
||||
## 2025-05-08 - 2.8.1 - fix(readme)
|
||||
Update readme with consolidated email system improvements and modular directory structure
|
||||
|
||||
Clarify that the platform now organizes email functionality into distinct directories (mail/core, mail/delivery, mail/routing, mail/security, mail/services) and update the diagram and key features list accordingly. Adjust code examples to reflect explicit module imports and the use of SzPlatformService.
|
||||
|
||||
- Changed description of consolidated email configuration to include 'streamlined directory structure'.
|
||||
- Updated mermaid diagram to show 'Mail System Structure' with separate components for core, delivery, routing, security, and services.
|
||||
- Modified key features list to document modular directory structure.
|
||||
- Revised code sample imports to use explicit paths and SzPlatformService.
|
||||
|
||||
## 2025-05-08 - 2.8.0 - feat(docs)
|
||||
Update documentation to include consolidated email handling and pattern‑based routing details
|
||||
|
||||
- Extended MTA section to describe the new unified email processing system with forward, MTA, and process modes
|
||||
- Updated system diagram to reflect DcRouter integration with UnifiedEmailServer, DeliveryQueue, DeliverySystem, and RateLimiter
|
||||
- Revised readme.plan.md checklists to mark completed features in core architecture, multi‑modal processing, unified queue, and DcRouter integration
|
||||
|
||||
## 2025-05-08 - 2.7.0 - feat(dcrouter)
|
||||
Implement unified email configuration with pattern‐based routing and consolidated email processing. Migrate SMTP forwarding and store‐and‐forward into a single, configuration-driven system that supports glob pattern matching in domain rules.
|
||||
|
||||
- Introduced IEmailConfig interface to consolidate MTA, forwarding, and processing settings.
|
||||
- Added pattern-based domain routing with glob patterns (e.g., '*@example.com', '*@*.example.net').
|
||||
- Reworked DcRouter integration to expose unified email handling and updated readme.plan.md and changelog.md accordingly.
|
||||
- Removed deprecated SMTP forwarding components in favor of the consolidated approach.
|
||||
|
||||
## 2025-05-08 - 2.7.0 - feat(dcrouter)
|
||||
Implement consolidated email configuration with pattern-based routing
|
||||
|
||||
- Added new pattern-based email routing with glob patterns (e.g., `*@task.vc`, `*@*.example.net`)
|
||||
- Consolidated all email functionality (MTA, forwarding, processing) under a unified `emailConfig` interface
|
||||
- Implemented domain router with pattern specificity calculation for most accurate matching
|
||||
- Removed deprecated components (SMTP forwarding, Store-and-Forward) in favor of the unified approach
|
||||
- Updated DcRouter tests to use the new consolidated email configuration pattern
|
||||
- Enhanced inline documentation with detailed interface definitions and configuration examples
|
||||
- Updated implementation plan with comprehensive component designs for the unified email system
|
||||
|
||||
## 2025-05-07 - 2.6.0 - feat(dcrouter)
|
||||
Implement integrated DcRouter with comprehensive SmartProxy configuration, enhanced SMTP processing, and robust store‐and‐forward email routing
|
||||
|
||||
- Marked completion of tasks in readme.plan.md with [x] flags for SMTP server setup, email processing pipeline, queue management, and delivery system.
|
||||
- Reworked DcRouter to use direct SmartProxy configuration, separating smtpConfig and smtpForwarding approaches.
|
||||
- Added new components for delivery queue and delivery system with persistent storage support.
|
||||
- Improved SMTP server implementation with TLS support, event handlers for connection, authentication, sender/recipient validation, and data processing.
|
||||
- Refined domain-based routing and transformation logic in EmailProcessor with metrics and logging.
|
||||
- Updated exported modules in dcrouter index to include SMTP store‐and‐forward components.
|
||||
- Enhanced inline documentation and code comments for configuration interfaces and integration details.
|
||||
|
||||
## 2025-05-07 - 2.5.0 - feat(dcrouter)
|
||||
Enhance DcRouter configuration and update documentation
|
||||
|
||||
- Added new implementation hints (readme.hints.md) and planning documentation (readme.plan.md) outlining removal of SzPlatformService dependency and improvements in SMTP forwarding, domain routing, and certificate management.
|
||||
- Introduced new interfaces: ISmtpForwardingConfig and IDomainRoutingConfig for precise SMTP and HTTP domain routing configuration.
|
||||
- Refactored DcRouter classes to support direct integration with SmartProxy and enhanced MTA functionality, including SMTP port configuration and improved TLS handling.
|
||||
- Updated supporting modules such as SmtpPortConfig and EmailDomainRouter to provide better routing and security options.
|
||||
- Enhanced test coverage across dcrouter, rate limiter, IP warmup manager, and email authentication, ensuring backward compatibility and improved quality.
|
||||
|
||||
## 2025-05-07 - 2.4.2 - fix(tests)
|
||||
Update test assertions and singleton instance references in DMARC, integration, and IP warmup manager tests
|
||||
|
||||
- In test.emailauth.ts, update expected DMARC policy from 'none' to 'reject' and verify actualPolicy and action accordingly
|
||||
- In test.integration.ts, remove deprecated casting and adjust dedicated policy naming (use 'dedicated' instead of 'dedicatedDomain')
|
||||
- In test.ipwarmupmanager.ts and test.reputationmonitor.ts, replace singleton reset from '_instance' to 'instance' for proper instance access
|
||||
- Update round robin allocation tests to verify IP cycle returns one of the available IPs
|
||||
- Enhance daily limit tests by verifying getBestIPForSending returns null when limit is reached
|
||||
- General refactoring across tests for improved clarity and consistency
|
||||
|
||||
## 2025-05-07 - 2.4.1 - fix(tests)
|
||||
Update test assertions and refine service interfaces
|
||||
|
||||
- Converted outdated chai assertions to use tap's toBeTruthy, toEqual, and toBeGreaterThan methods in multiple test files
|
||||
- Appended tap.stopForcefully() tests to ensure proper cleanup in test suites
|
||||
- Added stop() method to PlatformService for graceful shutdown
|
||||
- Exposed certificate property in MtaService from private to public
|
||||
- Refactored dcrouter smartProxy configuration to better handle MTA service integration and certificate provisioning
|
||||
|
||||
## 2025-05-07 - 2.4.0 - feat(email)
|
||||
Enhance email integration by updating @push.rocks/smartmail to ^2.1.0 and improving the entire email stack including validation, DKIM verification, templating, MIME conversion, and attachment handling.
|
||||
|
||||
- Updated smartmail dependency from ^2.0.1 to ^2.1.0 in package.json
|
||||
- Enhanced EmailValidator with comprehensive checks (syntax, MX, disposable and role validations)
|
||||
- Refactored TemplateManager to support dynamic variable substitution and loading templates from directory
|
||||
- Improved conversion between internal Email and smartmail.Smartmail, streamlining MIME handling and attachment mapping
|
||||
- Augmented DKIM verification with caching and custom header injection for improved security reporting
|
||||
- Updated readme.plan.md with detailed roadmap for further performance, security, analytics, and deliverability enhancements
|
||||
- Expanded test suite to cover smartmail integration, validation, templating, and conversion between formats
|
||||
|
||||
## 2025-05-04 - 1.0.10 to 1.0.8 - core
|
||||
Applied core fixes across several versions on this day.
|
||||
|
||||
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
|
||||
|
||||
## 2024-04-01 - 1.0.7 - core
|
||||
Applied a core fix.
|
||||
|
||||
- Fixed core functionality for version 1.0.7
|
||||
|
||||
## 2024-03-19 - 1.0.6 - core
|
||||
Applied a core fix.
|
||||
|
||||
- Fixed core functionality for version 1.0.6
|
||||
|
||||
## 2024-02-16 - 1.0.5 to 1.0.2 - core
|
||||
Applied multiple core fixes in a contiguous range of versions.
|
||||
|
||||
- Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2
|
||||
|
||||
## 2024-02-15 - 1.0.1 - core
|
||||
Applied a core fix.
|
||||
|
||||
- Fixed core functionality for version 1.0.1
|
||||
|
||||
–––––––––––––––––––––––
|
||||
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.
|
||||
4
cli.child.js
Normal file
4
cli.child.js
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.CLI_CALL = 'true';
|
||||
import * as cliTool from './ts/index.js';
|
||||
cliTool.runCli();
|
||||
121
html/index.html
Normal file
121
html/index.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<!--gitzone default-->
|
||||
<!-- made by Lossless GmbH -->
|
||||
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--Lets set some basic meta tags-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||
/>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!--Lets make sure we recognize this as an PWA-->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||
|
||||
<!--Lets load standard fonts-->
|
||||
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||
|
||||
|
||||
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||
<style>
|
||||
html {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
position: relative;
|
||||
background: #000;
|
||||
margin: 0px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
projectVersion = '';
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<style>
|
||||
body {
|
||||
background: #303f9f;
|
||||
font-family: Inter, Roboto, sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-top: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
background: #4357d9;
|
||||
}
|
||||
.contentHeader {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 25px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
.footer {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<div class="logo">
|
||||
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="contentHeader">We need JavaScript to run properly!</div>
|
||||
<div class="content">
|
||||
This site is being built using lit-element (made by Google). This technology works with
|
||||
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
|
||||
JavaScript.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a href="https://lossless.gmbh">Legal Info</a> |
|
||||
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="text/javascript" async defer>
|
||||
window.revenueEnabled = true;
|
||||
const runRevenueCheck = async () => {
|
||||
var e = document.createElement('div');
|
||||
e.id = '476kjuhzgtr764';
|
||||
e.style.display = 'none';
|
||||
document.body.appendChild(e);
|
||||
if (document.getElementById('476kjuhzgtr764')) {
|
||||
window.revenueEnabled = true;
|
||||
} else {
|
||||
window.revenueEnabled = false;
|
||||
}
|
||||
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||
};
|
||||
|
||||
runRevenueCheck();
|
||||
</script>
|
||||
</body>
|
||||
<script defer type="module" src="/bundle.js"></script>
|
||||
</html>
|
||||
@@ -1,20 +1,75 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [
|
||||
{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": [
|
||||
"ts/**/*.ts",
|
||||
"ts_*/**/*.ts",
|
||||
"test_watch/devserver.ts"
|
||||
],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/tsbundle": {
|
||||
"bundles": [
|
||||
{
|
||||
"from": "./ts_web/index.ts",
|
||||
"to": "./dist_serve/bundle.js",
|
||||
"outputMode": "bundle",
|
||||
"bundler": "esbuild",
|
||||
"production": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
"gitscope": "serve.zone",
|
||||
"gitrepo": "platformservice",
|
||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
||||
"npmPackagename": "@serve.zone/platformservice",
|
||||
"gitrepo": "dcrouter",
|
||||
"description": "A traffic router intended to be gating your datacenter.",
|
||||
"npmPackagename": "@serve.zone/dcrouter",
|
||||
"license": "MIT",
|
||||
"projectDomain": "serve.zone"
|
||||
"projectDomain": "serve.zone",
|
||||
"keywords": [
|
||||
"mail service",
|
||||
"SMS",
|
||||
"letter delivery",
|
||||
"AI services",
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"traffic router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
"DKIM signing",
|
||||
"mail forwarding",
|
||||
"SMTP TLS",
|
||||
"domain management",
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"dockerRegistryRepoMap": {
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
||||
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
|
||||
134
package.json
134
package.json
@@ -1,46 +1,110 @@
|
||||
{
|
||||
"name": "@serve.zone/platformservice",
|
||||
"version": "1.0.5",
|
||||
"description": "contains the platformservice container with mail, sms, letter, ai services.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "6.4.4",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist_ts/index.js",
|
||||
"./interfaces": "./dist_ts_interfaces/index.js"
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/)",
|
||||
"start": "(node --max_old_space_size=100 ./cli.js)",
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild --web --allowimplicitany)"
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
"bundle": "(tsbundle)",
|
||||
"watch": "tswatch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.1.17",
|
||||
"@git.zone/tsrun": "^1.2.8",
|
||||
"@git.zone/tstest": "^1.0.28",
|
||||
"@git.zone/tswatch": "^2.0.1",
|
||||
"@push.rocks/tapbundle": "^5.0.3"
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.0.4",
|
||||
"@api.global/typedserver": "^3.0.20",
|
||||
"@api.global/typedsocket": "^3.0.0",
|
||||
"@apiclient.xyz/cloudflare": "^6.0.3",
|
||||
"@apiclient.xyz/letterxpress": "^1.0.16",
|
||||
"@push.rocks/projectinfo": "^5.0.1",
|
||||
"@push.rocks/qenv": "^6.0.5",
|
||||
"@push.rocks/smartdata": "^5.0.7",
|
||||
"@push.rocks/smartfile": "^11.0.4",
|
||||
"@push.rocks/smartlog": "^3.0.3",
|
||||
"@push.rocks/smartmail": "^1.0.24",
|
||||
"@push.rocks/smartpath": "^5.0.5",
|
||||
"@push.rocks/smartpromise": "^4.0.3",
|
||||
"@push.rocks/smartrequest": "^2.0.21",
|
||||
"@push.rocks/smartrx": "^3.0.7",
|
||||
"@push.rocks/smartstate": "^2.0.0",
|
||||
"@serve.zone/interfaces": "^1.0.34",
|
||||
"@tsclass/tsclass": "^4.0.51",
|
||||
"mailauth": "^4.6.5",
|
||||
"mailparser": "^3.6.7",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
"@api.global/typedrequest": "^3.2.6",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.42.0",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.1.3",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.8.1",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.11",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.2.2",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^25.7.3",
|
||||
"@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.2",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"mail service",
|
||||
"SMS",
|
||||
"letter delivery",
|
||||
"AI services",
|
||||
"SMTP server",
|
||||
"mail parsing",
|
||||
"DKIM",
|
||||
"mail router",
|
||||
"letterXpress",
|
||||
"OpenAI",
|
||||
"Anthropic AI",
|
||||
"DKIM signing",
|
||||
"mail forwarding",
|
||||
"SMTP TLS",
|
||||
"domain management",
|
||||
"email templating",
|
||||
"rule management",
|
||||
"SMTP STARTTLS",
|
||||
"DNS management",
|
||||
"RADIUS",
|
||||
"AAA",
|
||||
"network authentication",
|
||||
"VLAN assignment",
|
||||
"MAC authentication"
|
||||
],
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"mongodb-memory-server",
|
||||
"puppeteer"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
|
||||
15139
pnpm-lock.yaml
generated
15139
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
773
readme.hints.md
Normal file
773
readme.hints.md
Normal file
@@ -0,0 +1,773 @@
|
||||
# Implementation Hints and Learnings
|
||||
|
||||
## smartmta Migration (2026-02-11)
|
||||
|
||||
### Overview
|
||||
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.
|
||||
|
||||
### Architecture
|
||||
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
|
||||
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
|
||||
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer
|
||||
|
||||
### Key API Differences (smartmta vs old custom MTA)
|
||||
- `updateEmailRoutes()` instead of `updateRoutes()`
|
||||
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
|
||||
- `bounceManager` is private, but exposed via public methods:
|
||||
- `emailServer.getSuppressionList()`
|
||||
- `emailServer.getHardBouncedAddresses()`
|
||||
- `emailServer.getBounceHistory(email)`
|
||||
- `emailServer.removeFromSuppressionList(email)`
|
||||
- `Email` class imported from `@push.rocks/smartmta`
|
||||
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`
|
||||
|
||||
### Deleted Directories
|
||||
- `ts/mail/` (60 files) — replaced by smartmta
|
||||
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
|
||||
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
|
||||
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence
|
||||
|
||||
### Remaining Cache Documents
|
||||
- `CachedEmail` — kept (dcrouter-level queue persistence)
|
||||
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)
|
||||
|
||||
### Dependencies Removed
|
||||
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge
|
||||
|
||||
### Pre-existing Test Failures (not caused by migration)
|
||||
- `test/test.jwt-auth.ts` — `response.text is not a function` (webrequest compatibility issue)
|
||||
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts
|
||||
|
||||
### smartmta Location
|
||||
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
|
||||
|
||||
## Dependency Upgrade (2026-02-11)
|
||||
|
||||
### SmartProxy v23.1.2 Route Validation
|
||||
- SmartProxy 23.1.2 enforces stricter route validation
|
||||
- Forward actions MUST use `targets` (array) instead of `target` (singular)
|
||||
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests
|
||||
|
||||
```typescript
|
||||
// WRONG - will fail validation
|
||||
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }
|
||||
|
||||
// CORRECT
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
|
||||
```
|
||||
|
||||
**Files Fixed:**
|
||||
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
|
||||
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`
|
||||
|
||||
## Dependency Upgrade (2026-02-10)
|
||||
|
||||
### SmartProxy v23.1.0 Upgrade
|
||||
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
|
||||
|
||||
**Key Changes:**
|
||||
- Rust-based proxy components for improved performance
|
||||
- Rust binary runs as separate process via IPC
|
||||
- `getStatistics()` now returns `Promise<any>` (was synchronous)
|
||||
- nftables-proxy removed (not used by dcrouter)
|
||||
|
||||
**Code Changes Required:**
|
||||
```typescript
|
||||
// Old (synchronous)
|
||||
const proxyStats = this.dcRouter.smartProxy.getStatistics();
|
||||
|
||||
// New (async)
|
||||
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
|
||||
|
||||
## Dependency Upgrade (2026-02-01)
|
||||
|
||||
### Major Upgrades Completed
|
||||
- `@api.global/typedserver`: 3.0.80 → 8.3.0
|
||||
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
|
||||
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
|
||||
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
|
||||
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
|
||||
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
|
||||
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
|
||||
- `uuid`: 11.1.0 → 13.0.0
|
||||
|
||||
### Breaking Changes Fixed
|
||||
|
||||
1. **SmartProxy v22**: `target` → `targets` (array)
|
||||
```typescript
|
||||
// Old
|
||||
action: { type: 'forward', target: { host: 'x', port: 25 } }
|
||||
// New
|
||||
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
|
||||
```
|
||||
|
||||
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
|
||||
```typescript
|
||||
// Old
|
||||
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
|
||||
const json = resp.body;
|
||||
// New
|
||||
const resp = await plugins.smartrequest.SmartRequest.create()...post();
|
||||
const json = await resp.json();
|
||||
```
|
||||
|
||||
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
|
||||
```typescript
|
||||
// Old (deprecated but supported)
|
||||
<dees-icon iconFA="check"></dees-icon>
|
||||
// New
|
||||
<dees-icon icon="fa:check"></dees-icon>
|
||||
<dees-icon icon="lucide:menu"></dees-icon>
|
||||
```
|
||||
|
||||
### TC39 Decorators
|
||||
- ts_web components updated to use `accessor` keyword for `@state()` decorators
|
||||
- Required for TC39 standard decorator support
|
||||
|
||||
### tswatch Configuration
|
||||
The project now uses tswatch for development:
|
||||
```bash
|
||||
pnpm run watch
|
||||
```
|
||||
Configuration in `npmextra.json`:
|
||||
```json
|
||||
{
|
||||
"@git.zone/tswatch": {
|
||||
"watchers": [{
|
||||
"name": "dcrouter-dev",
|
||||
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||
"restart": true,
|
||||
"debounce": 500,
|
||||
"runOnStart": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## RADIUS Server Integration (2026-02-01)
|
||||
|
||||
### Overview
|
||||
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
|
||||
|
||||
### Key Features
|
||||
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
|
||||
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
|
||||
- **RADIUS Accounting** - Track sessions, data usage, and billing
|
||||
|
||||
### Configuration Example
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
radiusConfig: {
|
||||
authPort: 1812, // Authentication port (default)
|
||||
acctPort: 1813, // Accounting port (default)
|
||||
clients: [
|
||||
{
|
||||
name: 'switch-1',
|
||||
ipRange: '192.168.1.0/24',
|
||||
secret: 'shared-secret',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
vlanAssignment: {
|
||||
defaultVlan: 100, // VLAN for unknown MACs
|
||||
allowUnknownMacs: true,
|
||||
mappings: [
|
||||
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
|
||||
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
|
||||
]
|
||||
},
|
||||
accounting: {
|
||||
enabled: true,
|
||||
retentionDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Components
|
||||
- `RadiusServer` - Main server wrapping smartradius
|
||||
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
|
||||
- `AccountingManager` - Session tracking and billing data
|
||||
|
||||
### OpsServer API Endpoints
|
||||
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
|
||||
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
|
||||
- `testVlanAssignment` - Test what VLAN a MAC would get
|
||||
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
|
||||
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
|
||||
|
||||
### Files
|
||||
- `ts/radius/` - RADIUS module
|
||||
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
|
||||
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
|
||||
|
||||
## Test Fix: test.dcrouter.email.ts (2026-02-01)
|
||||
|
||||
### Issue
|
||||
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
|
||||
|
||||
### Root Cause
|
||||
The test was using outdated email config properties:
|
||||
- Used `domainRules: []` (non-existent property)
|
||||
- Used `defaultMode` (non-existent property)
|
||||
- Missing required `domains: []` property
|
||||
- Missing required `routes: []` property
|
||||
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
|
||||
|
||||
### Fix
|
||||
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
|
||||
```typescript
|
||||
const emailConfig: IEmailConfig = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [], // Required: domain configurations
|
||||
routes: [] // Required: email routing rules
|
||||
};
|
||||
```
|
||||
|
||||
And fixed the property name:
|
||||
```typescript
|
||||
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
|
||||
```
|
||||
|
||||
### Key Learning
|
||||
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
|
||||
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
|
||||
- `routes: IEmailRoute[]` is required (email routing rules)
|
||||
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
|
||||
|
||||
## Network Metrics Implementation (2025-06-23)
|
||||
|
||||
### SmartProxy Metrics API Integration
|
||||
- Updated to use new SmartProxy metrics API (v19.6.7)
|
||||
- Use `getMetrics()` for detailed metrics with grouped methods:
|
||||
```typescript
|
||||
const metrics = smartProxy.getMetrics();
|
||||
metrics.connections.active() // Current active connections
|
||||
metrics.throughput.instant() // Real-time throughput {in, out}
|
||||
metrics.connections.topIPs(10) // Top 10 IPs by connection count
|
||||
```
|
||||
- Use `getStatistics()` for basic stats
|
||||
|
||||
### Network Traffic Display
|
||||
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
|
||||
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
|
||||
- Network graph shows separate lines for inbound (green) and outbound (purple)
|
||||
- Throughput tiles and graph use same data source for consistency
|
||||
|
||||
### Requests/sec vs Connections
|
||||
- Requests/sec shows HTTP request counts (derived from connections)
|
||||
- Single connection can handle multiple requests
|
||||
- Current implementation tracks connections, not individual requests
|
||||
- Trend line shows historical request counts, not throughput
|
||||
|
||||
## DKIM Implementation Status (2025-05-30)
|
||||
|
||||
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
|
||||
|
||||
## SmartProxy Usage
|
||||
|
||||
### New Route-Based Architecture (v18+)
|
||||
- SmartProxy now uses a route-based configuration system
|
||||
- Routes define match criteria and actions instead of simple port-to-port forwarding
|
||||
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
|
||||
|
||||
```typescript
|
||||
// NEW: Route-based SmartProxy configuration
|
||||
const smartProxy = new plugins.smartproxy.SmartProxy({
|
||||
routes: [
|
||||
{
|
||||
name: 'https-traffic',
|
||||
match: {
|
||||
ports: 443,
|
||||
domains: ['example.com', '*.example.com']
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: {
|
||||
host: 'backend.server.com',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
tls: {
|
||||
mode: 'terminate',
|
||||
certificate: 'auto'
|
||||
}
|
||||
}
|
||||
],
|
||||
defaults: {
|
||||
target: {
|
||||
host: 'fallback.server.com',
|
||||
port: 8080
|
||||
}
|
||||
},
|
||||
acme: {
|
||||
accountEmail: 'admin@example.com',
|
||||
enabled: true,
|
||||
useProduction: true
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Migration from Old to New
|
||||
```typescript
|
||||
// OLD configuration style (deprecated)
|
||||
{
|
||||
fromPort: 443,
|
||||
toPort: 8080,
|
||||
targetIP: 'backend.server.com',
|
||||
domainConfigs: [...]
|
||||
}
|
||||
|
||||
// NEW route-based style
|
||||
{
|
||||
routes: [{
|
||||
name: 'main-route',
|
||||
match: { ports: 443 },
|
||||
action: {
|
||||
type: 'forward',
|
||||
target: { host: 'backend.server.com', port: 8080 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Component Usage
|
||||
- Use SmartProxy components directly instead of creating your own wrappers
|
||||
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
||||
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
||||
|
||||
### Certificate Management
|
||||
- SmartProxy has built-in ACME certificate management
|
||||
- Configure it in the `acme` property of SmartProxy options
|
||||
- Use `accountEmail` (not `email`) for the ACME contact email
|
||||
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
|
||||
|
||||
## qenv Usage
|
||||
|
||||
### Direct Usage
|
||||
- Use qenv directly instead of creating environment variable wrappers
|
||||
- Instantiate qenv with appropriate basePath and nogitPath:
|
||||
|
||||
```typescript
|
||||
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
### SmartProxy Interfaces
|
||||
- Always check the interfaces from the node_modules to ensure correct property names
|
||||
- Important interfaces for the new architecture:
|
||||
- `ISmartProxyOptions`: Main configuration with `routes` array
|
||||
- `IRouteConfig`: Individual route configuration
|
||||
- `IRouteMatch`: Match criteria for routes
|
||||
- `IRouteTarget`: Target configuration for forwarding
|
||||
- `IAcmeOptions`: ACME certificate configuration
|
||||
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
|
||||
|
||||
### New Route Configuration
|
||||
```typescript
|
||||
interface IRouteConfig {
|
||||
name: string;
|
||||
match: {
|
||||
ports: number | number[];
|
||||
domains?: string | string[];
|
||||
path?: string;
|
||||
headers?: Record<string, string | RegExp>;
|
||||
};
|
||||
action: {
|
||||
type: 'forward' | 'redirect' | 'block' | 'static';
|
||||
target?: {
|
||||
host: string | string[] | ((context) => string);
|
||||
port: number | 'preserve' | ((context) => number);
|
||||
};
|
||||
};
|
||||
tls?: {
|
||||
mode: TTlsMode;
|
||||
certificate?: 'auto' | { key: string; cert: string; };
|
||||
};
|
||||
security?: {
|
||||
authentication?: IRouteAuthentication;
|
||||
rateLimit?: IRouteRateLimit;
|
||||
ipAllowList?: string[];
|
||||
ipBlockList?: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Required Properties
|
||||
- For `ISmartProxyOptions`, `routes` array is the main configuration
|
||||
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
||||
- Routes must have `name`, `match`, and `action` properties
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Structure
|
||||
- Follow the project's test structure, using `@push.rocks/tapbundle`
|
||||
- Use `expect(value).toEqual(expected)` for equality checks
|
||||
- Use `expect(value).toBeTruthy()` for boolean assertions
|
||||
|
||||
```typescript
|
||||
tap.test('test description', async () => {
|
||||
const result = someFunction();
|
||||
expect(result.property).toEqual('expected value');
|
||||
expect(result.valid).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
- Include a cleanup test to ensure proper test resource handling
|
||||
- Add a `stop` test to forcefully end the test when needed:
|
||||
|
||||
```typescript
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture Principles
|
||||
|
||||
### Simplicity
|
||||
- Prefer direct usage of libraries instead of creating wrappers
|
||||
- Don't reinvent functionality that already exists in dependencies
|
||||
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
|
||||
|
||||
### Component Integration
|
||||
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
||||
- Use parallel operations for performance (like in the `stop()` method)
|
||||
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
||||
|
||||
## Email Integration with SmartProxy
|
||||
|
||||
### Architecture (Post-Migration)
|
||||
- Email traffic is routed through SmartProxy using automatic route generation
|
||||
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
|
||||
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
|
||||
- smartmta's Rust SMTP bridge handles SMTP protocol processing
|
||||
|
||||
### Port Mapping
|
||||
- External port 25 → Internal port 10025 (SMTP)
|
||||
- External port 587 → Internal port 10587 (Submission)
|
||||
- External port 465 → Internal port 10465 (SMTPS)
|
||||
|
||||
### TLS Handling
|
||||
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
|
||||
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
|
||||
|
||||
## SmartMetrics Integration (2025-06-12) - COMPLETED
|
||||
|
||||
### Overview
|
||||
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
|
||||
|
||||
### Key Findings
|
||||
1. **CPU Metrics:**
|
||||
- SmartMetrics provides `cpuUsageText` as a string percentage
|
||||
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
|
||||
- UI was incorrectly dividing by 2, showing half the actual CPU usage
|
||||
|
||||
2. **Memory Metrics:**
|
||||
- SmartMetrics calculates `maxMemoryMB` as minimum of:
|
||||
- V8 heap size limit
|
||||
- System total memory
|
||||
- Docker memory limit (if available)
|
||||
- Provides `memoryUsageBytes` (total process memory including children)
|
||||
- Provides `memoryPercentage` (pre-calculated percentage)
|
||||
- UI was only showing heap usage, missing actual memory constraints
|
||||
|
||||
### Changes Made
|
||||
1. **MetricsManager Enhanced:**
|
||||
- Added `maxMemoryMB` from SmartMetrics instance
|
||||
- Added `actualUsageBytes` from SmartMetrics data
|
||||
- Added `actualUsagePercentage` from SmartMetrics data
|
||||
- Kept existing memory fields for compatibility
|
||||
|
||||
2. **Interface Updated:**
|
||||
- Added optional fields to `IServerStats.memoryUsage`
|
||||
- Fields are optional to maintain backward compatibility
|
||||
|
||||
3. **UI Fixed:**
|
||||
- Removed incorrect CPU division by 2
|
||||
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
|
||||
- Shows actual memory usage vs max memory limit (not just heap)
|
||||
|
||||
### Result
|
||||
- CPU now shows accurate usage percentage
|
||||
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
|
||||
- Better monitoring for containerized environments
|
||||
|
||||
## Network UI Implementation (2025-06-20) - COMPLETED
|
||||
|
||||
### Overview
|
||||
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
|
||||
|
||||
### Architecture
|
||||
1. **MetricsManager Integration:**
|
||||
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
|
||||
- Extended with `getNetworkStats()` method to expose unused metrics:
|
||||
- `getConnectionsByIP()` - Connection counts by IP address
|
||||
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
|
||||
- `getTopIPs()` - Top connecting IPs sorted by connection count
|
||||
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
|
||||
|
||||
2. **Existing Infrastructure Leveraged:**
|
||||
- `getActiveConnections` endpoint already exists in security.handler.ts
|
||||
- Enhanced to include real SmartProxy data via MetricsManager
|
||||
- IConnectionInfo interface already supports network data structures
|
||||
|
||||
3. **State Management:**
|
||||
- Added `INetworkState` interface following existing patterns
|
||||
- Created `networkStatePart` with connections, throughput, and IP data
|
||||
- Integrated with existing auto-refresh mechanism
|
||||
|
||||
4. **UI Changes (Minimal):**
|
||||
- Removed `generateMockData()` method and all mock generation
|
||||
- Connected to real `networkStatePart` state
|
||||
- Added `renderTopIPs()` section to display top connected IPs
|
||||
- Updated traffic chart to show real request data
|
||||
- Kept all existing UI components (DeesTable, DeesChartArea)
|
||||
|
||||
### Implementation Details
|
||||
1. **Data Transformation:**
|
||||
- Converts IConnectionInfo[] to INetworkRequest[] for table display
|
||||
- Calculates traffic buckets based on selected time range
|
||||
- Maps connection data to chart-compatible format
|
||||
|
||||
2. **Real Metrics Displayed:**
|
||||
- Active connections count (from server stats)
|
||||
- Requests per second (calculated from recent connections)
|
||||
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
|
||||
- Top IPs with connection counts and percentages
|
||||
|
||||
3. **TypeScript Fixes:**
|
||||
- SmartProxy methods like `getThroughputRate()` not in base interface
|
||||
- Implemented manual fallbacks for missing methods
|
||||
- Fixed `publicIpv4` → `publicIp` property name
|
||||
|
||||
### Result
|
||||
- Network view now shows real connection activity
|
||||
- Auto-refreshes with other stats every second
|
||||
- Displays actual IPs and connection counts
|
||||
- No more mock/demo data
|
||||
- Minimal code changes (streamlined approach)
|
||||
|
||||
### Throughput Data Fix (2025-06-20)
|
||||
The throughput was showing 0 because:
|
||||
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
|
||||
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
|
||||
3. `getThroughputRate()` only exists in the extended interface
|
||||
|
||||
**Solution implemented:**
|
||||
1. Updated MetricsManager to check if methods exist at runtime and call them
|
||||
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
|
||||
3. Created new `getNetworkStats` endpoint in security.handler.ts
|
||||
4. Updated frontend to call the new endpoint for complete network metrics
|
||||
|
||||
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
|
||||
|
||||
## Email Operations Dashboard (2026-02-01)
|
||||
|
||||
### Overview
|
||||
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
|
||||
|
||||
### New Files Created
|
||||
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
|
||||
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
|
||||
|
||||
### Key Interfaces
|
||||
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
|
||||
- `IReq_GetSentEmails` - Fetch delivered emails
|
||||
- `IReq_GetFailedEmails` - Fetch failed emails
|
||||
- `IReq_ResendEmail` - Re-queue a failed email for retry
|
||||
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
|
||||
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
|
||||
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
|
||||
|
||||
### UI Changes (ops-view-emails.ts)
|
||||
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
|
||||
- **Queued**: Emails pending delivery
|
||||
- **Sent**: Successfully delivered emails
|
||||
- **Failed**: Failed emails with resend capability
|
||||
- **Security**: Security incidents from SecurityLogger
|
||||
- Removed `generateMockEmails()` method
|
||||
- Added state management via `emailOpsStatePart` in appstate.ts
|
||||
- Added resend button for failed emails
|
||||
- Added security incident detail view
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||
```
|
||||
|
||||
### Backend Data Access
|
||||
The handler accesses data from:
|
||||
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
|
||||
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
|
||||
- `emailServer.bounceManager` - Bounce records and suppression list
|
||||
|
||||
## OpsServer UI Fixes (2026-02-02)
|
||||
|
||||
### Configuration Page Fix
|
||||
The configuration page had field name mismatches between frontend and backend:
|
||||
- Frontend expected `server` and `storage` sections
|
||||
- Backend returns `proxy` section (not `server`)
|
||||
- Backend has no `storage` section
|
||||
|
||||
**Fix**: Updated `ops-view-config.ts` to use correct section names:
|
||||
- `proxy` instead of `server`
|
||||
- Removed non-existent `storage` section
|
||||
- Added optional chaining (`?.`) for safety
|
||||
|
||||
### Auth Persistence Fix
|
||||
Login state was using `'soft'` mode in Smartstate which is memory-only:
|
||||
- User login was lost on page refresh
|
||||
- State reset to logged out after browser restart
|
||||
|
||||
**Changes**:
|
||||
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
|
||||
- Now uses IndexedDB to persist across browser sessions
|
||||
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
|
||||
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||
- Validates stored JWT hasn't expired before auto-logging in
|
||||
- Clears expired sessions and shows login form
|
||||
|
||||
## Config UI Read-Only Conversion (2026-02-03)
|
||||
|
||||
### Overview
|
||||
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
|
||||
- Removed `updateConfiguration` handler
|
||||
- Removed `updateConfiguration()` private method
|
||||
- Kept `getConfiguration` handler (read-only)
|
||||
|
||||
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
|
||||
- Removed `IReq_UpdateConfiguration` interface
|
||||
- Kept `IReq_GetConfiguration` interface
|
||||
|
||||
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
|
||||
- Removed `editingSection` and `editedConfig` state properties
|
||||
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
|
||||
- Removed Edit/Save/Cancel buttons
|
||||
- Removed warning banner about immediate changes
|
||||
- Enhanced read-only display with:
|
||||
- Status badges for boolean values (enabled/disabled)
|
||||
- Array display as pills/tags with counts
|
||||
- Section icons (mail, globe, network, shield)
|
||||
- Better formatting for numbers and byte sizes
|
||||
- Empty state handling ("Not configured", "None configured")
|
||||
- Info note explaining configuration is read-only
|
||||
|
||||
4. **State Management (`ts_web/appstate.ts`)**:
|
||||
- Removed `updateConfigurationAction`
|
||||
- Kept `fetchConfigurationAction` (read-only)
|
||||
|
||||
5. **Tests (`test/test.protected-endpoint.ts`)**:
|
||||
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
|
||||
- Added test for read-only config access
|
||||
- Kept auth flow testing with different protected endpoint
|
||||
|
||||
6. **Documentation**:
|
||||
- `readme.md`: Updated API endpoints to show config as read-only
|
||||
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
|
||||
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
|
||||
|
||||
### Visual Display Features
|
||||
- Boolean values shown as colored badges (green=enabled, red=disabled)
|
||||
- Arrays displayed as pills with count summaries
|
||||
- Section headers with relevant Lucide icons
|
||||
- Numbers formatted with locale separators
|
||||
- Byte sizes auto-formatted (B, KB, MB, GB)
|
||||
- Time values shown with "seconds" suffix
|
||||
- Nested objects with visual indentation
|
||||
|
||||
## Smartdata Cache System (2026-02-03)
|
||||
|
||||
### Overview
|
||||
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
|
||||
|
||||
### Technology Stack
|
||||
| Layer | Package | Purpose |
|
||||
|-------|---------|---------|
|
||||
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
|
||||
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
|
||||
|
||||
### TC39 Decorators
|
||||
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
|
||||
- Removed `experimentalDecorators: true`
|
||||
- Removed `emitDecoratorMetadata: true`
|
||||
|
||||
This is required for smartdata v7+ compatibility.
|
||||
|
||||
### Cache Document Classes
|
||||
Located in `ts/cache/documents/`:
|
||||
|
||||
| Class | Purpose | Default TTL |
|
||||
|-------|---------|-------------|
|
||||
| `CachedEmail` | Email queue items | 30 days |
|
||||
| `CachedIPReputation` | IP reputation lookups | 24 hours |
|
||||
|
||||
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
|
||||
|
||||
### Usage Pattern
|
||||
```typescript
|
||||
// Document classes use smartdata decorators
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
// ...
|
||||
}
|
||||
|
||||
// Query examples
|
||||
const email = await CachedEmail.getInstance({ id: 'abc123' });
|
||||
const pending = await CachedEmail.getInstances({ status: 'pending' });
|
||||
await email.save();
|
||||
await email.delete();
|
||||
```
|
||||
|
||||
### Configuration
|
||||
```typescript
|
||||
const dcRouter = new DcRouter({
|
||||
cacheConfig: {
|
||||
enabled: true,
|
||||
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||
dbName: 'dcrouter',
|
||||
cleanupIntervalHours: 1,
|
||||
ttlConfig: {
|
||||
emails: 30, // days
|
||||
ipReputation: 1, // days
|
||||
bounces: 30, // days
|
||||
dkimKeys: 90, // days
|
||||
suppression: 30 // days
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Cache Cleaner
|
||||
- Runs hourly by default (configurable via `cleanupIntervalHours`)
|
||||
- Finds and deletes documents where `expiresAt < now()`
|
||||
- Uses smartdata's `getInstances()` + `delete()` pattern
|
||||
|
||||
### Key Files
|
||||
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
|
||||
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
|
||||
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
|
||||
- `ts/cache/documents/*.ts` - Document class definitions
|
||||
443
test/readme.md
Normal file
443
test/readme.md
Normal file
@@ -0,0 +1,443 @@
|
||||
# DCRouter SMTP Test Suite
|
||||
|
||||
```
|
||||
test/
|
||||
├── readme.md # This file
|
||||
├── helpers/
|
||||
│ ├── server.loader.ts # SMTP server lifecycle management
|
||||
│ ├── utils.ts # Common test utilities
|
||||
│ └── smtp.client.ts # Test SMTP client utilities
|
||||
└── suite/
|
||||
├── smtpserver_commands/ # SMTP command tests (CMD)
|
||||
├── smtpserver_connection/ # Connection management tests (CM)
|
||||
├── smtpserver_edge-cases/ # Edge case tests (EDGE)
|
||||
├── smtpserver_email-processing/ # Email processing tests (EP)
|
||||
├── smtpserver_error-handling/ # Error handling tests (ERR)
|
||||
├── smtpserver_performance/ # Performance tests (PERF)
|
||||
├── smtpserver_reliability/ # Reliability tests (REL)
|
||||
├── smtpserver_rfc-compliance/ # RFC compliance tests (RFC)
|
||||
└── smtpserver_security/ # Security tests (SEC)
|
||||
```
|
||||
|
||||
## Test ID Convention
|
||||
|
||||
All test files follow a strict naming convention: `test.<category-id>.<description>.ts`
|
||||
|
||||
Examples:
|
||||
- `test.cmd-01.ehlo-command.ts` - EHLO command test
|
||||
- `test.cm-01.tls-connection.ts` - TLS connection test
|
||||
- `test.sec-01.authentication.ts` - Authentication test
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Connection Management (CM)
|
||||
|
||||
Tests for validating SMTP connection handling, TLS support, and connection lifecycle management.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|-------|-------------------------------------------|----------|----------------|
|
||||
| CM-01 | TLS Connection Test | High | `suite/smtpserver_connection/test.cm-01.tls-connection.ts` |
|
||||
| CM-02 | Multiple Simultaneous Connections | High | `suite/smtpserver_connection/test.cm-02.multiple-connections.ts` |
|
||||
| CM-03 | Connection Timeout | High | `suite/smtpserver_connection/test.cm-03.connection-timeout.ts` |
|
||||
| CM-04 | Connection Limits | Medium | `suite/smtpserver_connection/test.cm-04.connection-limits.ts` |
|
||||
| CM-05 | Connection Rejection | Medium | `suite/smtpserver_connection/test.cm-05.connection-rejection.ts` |
|
||||
| CM-06 | STARTTLS Connection Upgrade | High | `suite/smtpserver_connection/test.cm-06.starttls-upgrade.ts` |
|
||||
| CM-07 | Abrupt Client Disconnection | Medium | `suite/smtpserver_connection/test.cm-07.abrupt-disconnection.ts` |
|
||||
| CM-08 | TLS Version Compatibility | Medium | `suite/smtpserver_connection/test.cm-08.tls-versions.ts` |
|
||||
| CM-09 | TLS Cipher Configuration | Medium | `suite/smtpserver_connection/test.cm-09.tls-ciphers.ts` |
|
||||
| CM-10 | Plain Connection Test | Low | `suite/smtpserver_connection/test.cm-10.plain-connection.ts` |
|
||||
| CM-11 | TCP Keep-Alive Test | Low | `suite/smtpserver_connection/test.cm-11.keepalive.ts` |
|
||||
|
||||
### 2. SMTP Commands (CMD)
|
||||
|
||||
Tests for validating proper SMTP protocol command implementation.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CMD-01 | EHLO Command | High | `suite/smtpserver_commands/test.cmd-01.ehlo-command.ts` |
|
||||
| CMD-02 | MAIL FROM Command | High | `suite/smtpserver_commands/test.cmd-02.mail-from.ts` |
|
||||
| CMD-03 | RCPT TO Command | High | `suite/smtpserver_commands/test.cmd-03.rcpt-to.ts` |
|
||||
| CMD-04 | DATA Command | High | `suite/smtpserver_commands/test.cmd-04.data-command.ts` |
|
||||
| CMD-05 | NOOP Command | Medium | `suite/smtpserver_commands/test.cmd-05.noop-command.ts` |
|
||||
| CMD-06 | RSET Command | Medium | `suite/smtpserver_commands/test.cmd-06.rset-command.ts` |
|
||||
| CMD-07 | VRFY Command | Low | `suite/smtpserver_commands/test.cmd-07.vrfy-command.ts` |
|
||||
| CMD-08 | EXPN Command | Low | `suite/smtpserver_commands/test.cmd-08.expn-command.ts` |
|
||||
| CMD-09 | SIZE Extension | Medium | `suite/smtpserver_commands/test.cmd-09.size-extension.ts` |
|
||||
| CMD-10 | HELP Command | Low | `suite/smtpserver_commands/test.cmd-10.help-command.ts` |
|
||||
| CMD-11 | Command Pipelining | Medium | `suite/smtpserver_commands/test.cmd-11.command-pipelining.ts` |
|
||||
| CMD-12 | HELO Command | Low | `suite/smtpserver_commands/test.cmd-12.helo-command.ts` |
|
||||
| CMD-13 | QUIT Command | High | `suite/smtpserver_commands/test.cmd-13.quit-command.ts` |
|
||||
|
||||
### 3. Email Processing (EP)
|
||||
|
||||
Tests for validating email content handling, parsing, and delivery.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|-------|-------------------------------------------|----------|----------------|
|
||||
| EP-01 | Basic Email Sending | High | `suite/smtpserver_email-processing/test.ep-01.basic-email-sending.ts` |
|
||||
| EP-02 | Invalid Email Address Handling | High | `suite/smtpserver_email-processing/test.ep-02.invalid-email-addresses.ts` |
|
||||
| EP-03 | Multiple Recipients | Medium | `suite/smtpserver_email-processing/test.ep-03.multiple-recipients.ts` |
|
||||
| EP-04 | Large Email Handling | High | `suite/smtpserver_email-processing/test.ep-04.large-email.ts` |
|
||||
| EP-05 | MIME Handling | High | `suite/smtpserver_email-processing/test.ep-05.mime-handling.ts` |
|
||||
| EP-06 | Attachment Handling | Medium | `suite/smtpserver_email-processing/test.ep-06.attachment-handling.ts` |
|
||||
| EP-07 | Special Character Handling | Medium | `suite/smtpserver_email-processing/test.ep-07.special-character-handling.ts` |
|
||||
| EP-08 | Email Routing | High | `suite/smtpserver_email-processing/test.ep-08.email-routing.ts` |
|
||||
| EP-09 | Delivery Status Notifications | Medium | `suite/smtpserver_email-processing/test.ep-09.delivery-status-notifications.ts` |
|
||||
|
||||
### 4. Security (SEC)
|
||||
|
||||
Tests for validating security features and protections.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| SEC-01 | Authentication | High | `suite/smtpserver_security/test.sec-01.authentication.ts` |
|
||||
| SEC-02 | Authorization | High | `suite/smtpserver_security/test.sec-02.authorization.ts` |
|
||||
| SEC-03 | DKIM Processing | High | `suite/smtpserver_security/test.sec-03.dkim-processing.ts` |
|
||||
| SEC-04 | SPF Checking | High | `suite/smtpserver_security/test.sec-04.spf-checking.ts` |
|
||||
| SEC-05 | DMARC Policy Enforcement | Medium | `suite/smtpserver_security/test.sec-05.dmarc-policy.ts` |
|
||||
| SEC-06 | IP Reputation Checking | High | `suite/smtpserver_security/test.sec-06.ip-reputation.ts` |
|
||||
| SEC-07 | Content Scanning | Medium | `suite/smtpserver_security/test.sec-07.content-scanning.ts` |
|
||||
| SEC-08 | Rate Limiting | High | `suite/smtpserver_security/test.sec-08.rate-limiting.ts` |
|
||||
| SEC-09 | TLS Certificate Validation | High | `suite/smtpserver_security/test.sec-09.tls-certificate-validation.ts` |
|
||||
| SEC-10 | Header Injection Prevention | High | `suite/smtpserver_security/test.sec-10.header-injection-prevention.ts` |
|
||||
| SEC-11 | Bounce Management | Medium | `suite/smtpserver_security/test.sec-11.bounce-management.ts` |
|
||||
|
||||
### 5. Error Handling (ERR)
|
||||
|
||||
Tests for validating proper error handling and recovery.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| ERR-01 | Syntax Error Handling | High | `suite/smtpserver_error-handling/test.err-01.syntax-errors.ts` |
|
||||
| ERR-02 | Invalid Sequence Handling | High | `suite/smtpserver_error-handling/test.err-02.invalid-sequence.ts` |
|
||||
| ERR-03 | Temporary Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-03.temporary-failures.ts` |
|
||||
| ERR-04 | Permanent Failure Handling | Medium | `suite/smtpserver_error-handling/test.err-04.permanent-failures.ts` |
|
||||
| ERR-05 | Resource Exhaustion Handling | High | `suite/smtpserver_error-handling/test.err-05.resource-exhaustion.ts` |
|
||||
| ERR-06 | Malformed MIME Handling | Medium | `suite/smtpserver_error-handling/test.err-06.malformed-mime.ts` |
|
||||
| ERR-07 | Exception Handling | High | `suite/smtpserver_error-handling/test.err-07.exception-handling.ts` |
|
||||
| ERR-08 | Error Logging | Medium | `suite/smtpserver_error-handling/test.err-08.error-logging.ts` |
|
||||
|
||||
### 6. Performance (PERF)
|
||||
|
||||
Tests for validating performance characteristics and benchmarks.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|------------------------------------------|----------|----------------|
|
||||
| PERF-01 | Throughput Testing | Medium | `suite/smtpserver_performance/test.perf-01.throughput.ts` |
|
||||
| PERF-02 | Concurrency Testing | High | `suite/smtpserver_performance/test.perf-02.concurrency.ts` |
|
||||
| PERF-03 | CPU Utilization | Medium | `suite/smtpserver_performance/test.perf-03.cpu-utilization.ts` |
|
||||
| PERF-04 | Memory Usage | Medium | `suite/smtpserver_performance/test.perf-04.memory-usage.ts` |
|
||||
| PERF-05 | Connection Processing Time | Medium | `suite/smtpserver_performance/test.perf-05.connection-processing-time.ts` |
|
||||
| PERF-06 | Message Processing Time | Medium | `suite/smtpserver_performance/test.perf-06.message-processing-time.ts` |
|
||||
| PERF-07 | Resource Cleanup | High | `suite/smtpserver_performance/test.perf-07.resource-cleanup.ts` |
|
||||
|
||||
### 7. Reliability (REL)
|
||||
|
||||
Tests for validating system reliability and stability.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| REL-01 | Long-Running Operation | High | `suite/smtpserver_reliability/test.rel-01.long-running-operation.ts` |
|
||||
| REL-02 | Restart Recovery | High | `suite/smtpserver_reliability/test.rel-02.restart-recovery.ts` |
|
||||
| REL-03 | Resource Leak Detection | High | `suite/smtpserver_reliability/test.rel-03.resource-leak-detection.ts` |
|
||||
| REL-04 | Error Recovery | High | `suite/smtpserver_reliability/test.rel-04.error-recovery.ts` |
|
||||
| REL-05 | DNS Resolution Failure Handling | Medium | `suite/smtpserver_reliability/test.rel-05.dns-resolution-failure.ts` |
|
||||
| REL-06 | Network Interruption Handling | Medium | `suite/smtpserver_reliability/test.rel-06.network-interruption.ts` |
|
||||
|
||||
### 8. Edge Cases (EDGE)
|
||||
|
||||
Tests for validating handling of unusual or extreme scenarios.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| EDGE-01 | Very Large Email | Low | `suite/smtpserver_edge-cases/test.edge-01.very-large-email.ts` |
|
||||
| EDGE-02 | Very Small Email | Low | `suite/smtpserver_edge-cases/test.edge-02.very-small-email.ts` |
|
||||
| EDGE-03 | Invalid Character Handling | Medium | `suite/smtpserver_edge-cases/test.edge-03.invalid-character-handling.ts` |
|
||||
| EDGE-04 | Empty Commands | Low | `suite/smtpserver_edge-cases/test.edge-04.empty-commands.ts` |
|
||||
| EDGE-05 | Extremely Long Lines | Medium | `suite/smtpserver_edge-cases/test.edge-05.extremely-long-lines.ts` |
|
||||
| EDGE-06 | Extremely Long Headers | Medium | `suite/smtpserver_edge-cases/test.edge-06.extremely-long-headers.ts` |
|
||||
| EDGE-07 | Unusual MIME Types | Low | `suite/smtpserver_edge-cases/test.edge-07.unusual-mime-types.ts` |
|
||||
| EDGE-08 | Nested MIME Structures | Low | `suite/smtpserver_edge-cases/test.edge-08.nested-mime-structures.ts` |
|
||||
|
||||
### 9. RFC Compliance (RFC)
|
||||
|
||||
Tests for validating compliance with SMTP-related RFCs.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| RFC-01 | RFC 5321 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-01.rfc5321-compliance.ts` |
|
||||
| RFC-02 | RFC 5322 Compliance | High | `suite/smtpserver_rfc-compliance/test.rfc-02.rfc5322-compliance.ts` |
|
||||
| RFC-03 | RFC 7208 SPF Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-03.rfc7208-spf-compliance.ts` |
|
||||
| RFC-04 | RFC 6376 DKIM Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-04.rfc6376-dkim-compliance.ts` |
|
||||
| RFC-05 | RFC 7489 DMARC Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-05.rfc7489-dmarc-compliance.ts` |
|
||||
| RFC-06 | RFC 8314 TLS Compliance | Medium | `suite/smtpserver_rfc-compliance/test.rfc-06.rfc8314-tls-compliance.ts` |
|
||||
| RFC-07 | RFC 3461 DSN Compliance | Low | `suite/smtpserver_rfc-compliance/test.rfc-07.rfc3461-dsn-compliance.ts` |
|
||||
|
||||
## SMTP Client Test Suite
|
||||
|
||||
The following test categories ensure our SMTP client is production-ready, RFC-compliant, and handles all real-world scenarios properly.
|
||||
|
||||
### Client Test Organization
|
||||
|
||||
```
|
||||
test/
|
||||
└── suite/
|
||||
├── smtpclient_connection/ # Client connection management tests (CCM)
|
||||
├── smtpclient_commands/ # Client command execution tests (CCMD)
|
||||
├── smtpclient_email-composition/ # Email composition tests (CEP)
|
||||
├── smtpclient_security/ # Client security tests (CSEC)
|
||||
├── smtpclient_error-handling/ # Client error handling tests (CERR)
|
||||
├── smtpclient_performance/ # Client performance tests (CPERF)
|
||||
├── smtpclient_reliability/ # Client reliability tests (CREL)
|
||||
├── smtpclient_edge-cases/ # Client edge case tests (CEDGE)
|
||||
└── smtpclient_rfc-compliance/ # Client RFC compliance tests (CRFC)
|
||||
```
|
||||
|
||||
### 10. Client Connection Management (CCM)
|
||||
|
||||
Tests for validating how the SMTP client establishes and manages connections to servers.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CCM-01 | Basic TCP Connection | High | `suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts` |
|
||||
| CCM-02 | TLS Connection Establishment | High | `suite/smtpclient_connection/test.ccm-02.tls-connection.ts` |
|
||||
| CCM-03 | STARTTLS Upgrade | High | `suite/smtpclient_connection/test.ccm-03.starttls-upgrade.ts` |
|
||||
| CCM-04 | Connection Pooling | High | `suite/smtpclient_connection/test.ccm-04.connection-pooling.ts` |
|
||||
| CCM-05 | Connection Reuse | Medium | `suite/smtpclient_connection/test.ccm-05.connection-reuse.ts` |
|
||||
| CCM-06 | Connection Timeout Handling | High | `suite/smtpclient_connection/test.ccm-06.connection-timeout.ts` |
|
||||
| CCM-07 | Automatic Reconnection | High | `suite/smtpclient_connection/test.ccm-07.automatic-reconnection.ts` |
|
||||
| CCM-08 | DNS Resolution & MX Records | High | `suite/smtpclient_connection/test.ccm-08.dns-mx-resolution.ts` |
|
||||
| CCM-09 | IPv4/IPv6 Dual Stack Support | Medium | `suite/smtpclient_connection/test.ccm-09.dual-stack-support.ts` |
|
||||
| CCM-10 | Proxy Support (SOCKS/HTTP) | Low | `suite/smtpclient_connection/test.ccm-10.proxy-support.ts` |
|
||||
| CCM-11 | Keep-Alive Management | Medium | `suite/smtpclient_connection/test.ccm-11.keepalive-management.ts` |
|
||||
|
||||
### 11. Client Command Execution (CCMD)
|
||||
|
||||
Tests for validating how the client sends SMTP commands and processes responses.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CCMD-01 | EHLO/HELO Command Sending | High | `suite/smtpclient_commands/test.ccmd-01.ehlo-helo-sending.ts` |
|
||||
| CCMD-02 | MAIL FROM Command with Parameters | High | `suite/smtpclient_commands/test.ccmd-02.mail-from-parameters.ts` |
|
||||
| CCMD-03 | RCPT TO Command with Multiple Recipients | High | `suite/smtpclient_commands/test.ccmd-03.rcpt-to-multiple.ts` |
|
||||
| CCMD-04 | DATA Command and Content Transmission | High | `suite/smtpclient_commands/test.ccmd-04.data-transmission.ts` |
|
||||
| CCMD-05 | AUTH Command (LOGIN, PLAIN, CRAM-MD5) | High | `suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts` |
|
||||
| CCMD-06 | Command Pipelining | Medium | `suite/smtpclient_commands/test.ccmd-06.command-pipelining.ts` |
|
||||
| CCMD-07 | Response Code Parsing | High | `suite/smtpclient_commands/test.ccmd-07.response-parsing.ts` |
|
||||
| CCMD-08 | Extended Response Handling | Medium | `suite/smtpclient_commands/test.ccmd-08.extended-responses.ts` |
|
||||
| CCMD-09 | QUIT Command and Graceful Disconnect | High | `suite/smtpclient_commands/test.ccmd-09.quit-disconnect.ts` |
|
||||
| CCMD-10 | RSET Command Usage | Medium | `suite/smtpclient_commands/test.ccmd-10.rset-usage.ts` |
|
||||
| CCMD-11 | NOOP Keep-Alive | Low | `suite/smtpclient_commands/test.ccmd-11.noop-keepalive.ts` |
|
||||
|
||||
### 12. Client Email Composition (CEP)
|
||||
|
||||
Tests for validating email composition, formatting, and encoding.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|--------|-------------------------------------------|----------|----------------|
|
||||
| CEP-01 | Basic Email Headers | High | `suite/smtpclient_email-composition/test.cep-01.basic-headers.ts` |
|
||||
| CEP-02 | MIME Multipart Messages | High | `suite/smtpclient_email-composition/test.cep-02.mime-multipart.ts` |
|
||||
| CEP-03 | Attachment Encoding | High | `suite/smtpclient_email-composition/test.cep-03.attachment-encoding.ts` |
|
||||
| CEP-04 | UTF-8 and International Characters | High | `suite/smtpclient_email-composition/test.cep-04.utf8-international.ts` |
|
||||
| CEP-05 | Base64 and Quoted-Printable Encoding | Medium | `suite/smtpclient_email-composition/test.cep-05.content-encoding.ts` |
|
||||
| CEP-06 | HTML Email with Inline Images | Medium | `suite/smtpclient_email-composition/test.cep-06.html-inline-images.ts` |
|
||||
| CEP-07 | Custom Headers | Low | `suite/smtpclient_email-composition/test.cep-07.custom-headers.ts` |
|
||||
| CEP-08 | Message-ID Generation | Medium | `suite/smtpclient_email-composition/test.cep-08.message-id.ts` |
|
||||
| CEP-09 | Date Header Formatting | Medium | `suite/smtpclient_email-composition/test.cep-09.date-formatting.ts` |
|
||||
| CEP-10 | Line Length Limits (RFC 5322) | High | `suite/smtpclient_email-composition/test.cep-10.line-length-limits.ts` |
|
||||
|
||||
### 13. Client Security (CSEC)
|
||||
|
||||
Tests for client-side security features and protections.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CSEC-01 | TLS Certificate Verification | High | `suite/smtpclient_security/test.csec-01.tls-verification.ts` |
|
||||
| CSEC-02 | Authentication Mechanisms | High | `suite/smtpclient_security/test.csec-02.auth-mechanisms.ts` |
|
||||
| CSEC-03 | OAuth2 Support | Medium | `suite/smtpclient_security/test.csec-03.oauth2-support.ts` |
|
||||
| CSEC-04 | Password Security (No Plaintext) | High | `suite/smtpclient_security/test.csec-04.password-security.ts` |
|
||||
| CSEC-05 | DKIM Signing | High | `suite/smtpclient_security/test.csec-05.dkim-signing.ts` |
|
||||
| CSEC-06 | SPF Record Compliance | Medium | `suite/smtpclient_security/test.csec-06.spf-compliance.ts` |
|
||||
| CSEC-07 | Secure Credential Storage | High | `suite/smtpclient_security/test.csec-07.credential-storage.ts` |
|
||||
| CSEC-08 | TLS Version Enforcement | High | `suite/smtpclient_security/test.csec-08.tls-version-enforcement.ts` |
|
||||
| CSEC-09 | Certificate Pinning | Low | `suite/smtpclient_security/test.csec-09.certificate-pinning.ts` |
|
||||
| CSEC-10 | Injection Attack Prevention | High | `suite/smtpclient_security/test.csec-10.injection-prevention.ts` |
|
||||
|
||||
### 14. Client Error Handling (CERR)
|
||||
|
||||
Tests for how the client handles various error conditions.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CERR-01 | 4xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-01.4xx-errors.ts` |
|
||||
| CERR-02 | 5xx Error Response Handling | High | `suite/smtpclient_error-handling/test.cerr-02.5xx-errors.ts` |
|
||||
| CERR-03 | Network Failure Recovery | High | `suite/smtpclient_error-handling/test.cerr-03.network-failures.ts` |
|
||||
| CERR-04 | Timeout Recovery | High | `suite/smtpclient_error-handling/test.cerr-04.timeout-recovery.ts` |
|
||||
| CERR-05 | Retry Logic with Backoff | High | `suite/smtpclient_error-handling/test.cerr-05.retry-backoff.ts` |
|
||||
| CERR-06 | Greylisting Handling | Medium | `suite/smtpclient_error-handling/test.cerr-06.greylisting.ts` |
|
||||
| CERR-07 | Rate Limit Response Handling | High | `suite/smtpclient_error-handling/test.cerr-07.rate-limits.ts` |
|
||||
| CERR-08 | Malformed Server Response | Medium | `suite/smtpclient_error-handling/test.cerr-08.malformed-responses.ts` |
|
||||
| CERR-09 | Connection Drop During Transfer | High | `suite/smtpclient_error-handling/test.cerr-09.connection-drops.ts` |
|
||||
| CERR-10 | Authentication Failure Handling | High | `suite/smtpclient_error-handling/test.cerr-10.auth-failures.ts` |
|
||||
|
||||
### 15. Client Performance (CPERF)
|
||||
|
||||
Tests for client performance characteristics and optimization.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|----------|-------------------------------------------|----------|----------------|
|
||||
| CPERF-01 | Bulk Email Sending | High | `suite/smtpclient_performance/test.cperf-01.bulk-sending.ts` |
|
||||
| CPERF-02 | Connection Pool Efficiency | High | `suite/smtpclient_performance/test.cperf-02.pool-efficiency.ts` |
|
||||
| CPERF-03 | Memory Usage Under Load | High | `suite/smtpclient_performance/test.cperf-03.memory-usage.ts` |
|
||||
| CPERF-04 | CPU Usage Optimization | Medium | `suite/smtpclient_performance/test.cperf-04.cpu-optimization.ts` |
|
||||
| CPERF-05 | Parallel Sending Performance | High | `suite/smtpclient_performance/test.cperf-05.parallel-sending.ts` |
|
||||
| CPERF-06 | Large Attachment Handling | Medium | `suite/smtpclient_performance/test.cperf-06.large-attachments.ts` |
|
||||
| CPERF-07 | Queue Management | High | `suite/smtpclient_performance/test.cperf-07.queue-management.ts` |
|
||||
| CPERF-08 | DNS Caching Efficiency | Medium | `suite/smtpclient_performance/test.cperf-08.dns-caching.ts` |
|
||||
|
||||
### 16. Client Reliability (CREL)
|
||||
|
||||
Tests for client reliability and resilience.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CREL-01 | Long Running Stability | High | `suite/smtpclient_reliability/test.crel-01.long-running.ts` |
|
||||
| CREL-02 | Failover to Backup MX | High | `suite/smtpclient_reliability/test.crel-02.mx-failover.ts` |
|
||||
| CREL-03 | Queue Persistence | High | `suite/smtpclient_reliability/test.crel-03.queue-persistence.ts` |
|
||||
| CREL-04 | Crash Recovery | High | `suite/smtpclient_reliability/test.crel-04.crash-recovery.ts` |
|
||||
| CREL-05 | Memory Leak Prevention | High | `suite/smtpclient_reliability/test.crel-05.memory-leaks.ts` |
|
||||
| CREL-06 | Concurrent Operation Safety | High | `suite/smtpclient_reliability/test.crel-06.concurrency-safety.ts` |
|
||||
| CREL-07 | Resource Cleanup | Medium | `suite/smtpclient_reliability/test.crel-07.resource-cleanup.ts` |
|
||||
|
||||
### 17. Client Edge Cases (CEDGE)
|
||||
|
||||
Tests for unusual scenarios and edge cases.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|----------|-------------------------------------------|----------|----------------|
|
||||
| CEDGE-01 | Extremely Slow Server Response | Medium | `suite/smtpclient_edge-cases/test.cedge-01.slow-server.ts` |
|
||||
| CEDGE-02 | Server Sending Invalid UTF-8 | Low | `suite/smtpclient_edge-cases/test.cedge-02.invalid-utf8.ts` |
|
||||
| CEDGE-03 | Extremely Large Recipients List | Medium | `suite/smtpclient_edge-cases/test.cedge-03.large-recipient-list.ts` |
|
||||
| CEDGE-04 | Zero-Byte Attachments | Low | `suite/smtpclient_edge-cases/test.cedge-04.zero-byte-attachments.ts` |
|
||||
| CEDGE-05 | Server Disconnect Mid-Command | High | `suite/smtpclient_edge-cases/test.cedge-05.mid-command-disconnect.ts` |
|
||||
| CEDGE-06 | Unusual Server Banners | Low | `suite/smtpclient_edge-cases/test.cedge-06.unusual-banners.ts` |
|
||||
| CEDGE-07 | Non-Standard Port Connections | Medium | `suite/smtpclient_edge-cases/test.cedge-07.non-standard-ports.ts` |
|
||||
|
||||
### 18. Client RFC Compliance (CRFC)
|
||||
|
||||
Tests for RFC compliance from the client perspective.
|
||||
|
||||
| ID | Test Description | Priority | Implementation |
|
||||
|---------|-------------------------------------------|----------|----------------|
|
||||
| CRFC-01 | RFC 5321 Client Requirements | High | `suite/smtpclient_rfc-compliance/test.crfc-01.rfc5321-client.ts` |
|
||||
| CRFC-02 | RFC 5322 Message Format | High | `suite/smtpclient_rfc-compliance/test.crfc-02.rfc5322-format.ts` |
|
||||
| CRFC-03 | RFC 2045-2049 MIME Compliance | High | `suite/smtpclient_rfc-compliance/test.crfc-03.mime-compliance.ts` |
|
||||
| CRFC-04 | RFC 4954 AUTH Extension | High | `suite/smtpclient_rfc-compliance/test.crfc-04.auth-extension.ts` |
|
||||
| CRFC-05 | RFC 3207 STARTTLS | High | `suite/smtpclient_rfc-compliance/test.crfc-05.starttls.ts` |
|
||||
| CRFC-06 | RFC 1870 SIZE Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-06.size-extension.ts` |
|
||||
| CRFC-07 | RFC 6152 8BITMIME Extension | Medium | `suite/smtpclient_rfc-compliance/test.crfc-07.8bitmime.ts` |
|
||||
| CRFC-08 | RFC 2920 Command Pipelining | Medium | `suite/smtpclient_rfc-compliance/test.crfc-08.pipelining.ts` |
|
||||
|
||||
## Running SMTP Client Tests
|
||||
|
||||
### Run All Client Tests
|
||||
```bash
|
||||
cd dcrouter
|
||||
pnpm test test/suite/smtpclient_*
|
||||
```
|
||||
|
||||
### Run Specific Client Test Category
|
||||
```bash
|
||||
# Run all client connection tests
|
||||
pnpm test test/suite/smtpclient_connection
|
||||
|
||||
# Run all client security tests
|
||||
pnpm test test/suite/smtpclient_security
|
||||
```
|
||||
|
||||
### Run Single Client Test File
|
||||
```bash
|
||||
# Run basic TCP connection test
|
||||
tsx test/suite/smtpclient_connection/test.ccm-01.basic-tcp-connection.ts
|
||||
|
||||
# Run AUTH mechanisms test
|
||||
tsx test/suite/smtpclient_commands/test.ccmd-05.auth-mechanisms.ts
|
||||
```
|
||||
|
||||
## Client Performance Benchmarks
|
||||
|
||||
Expected performance metrics for production-ready SMTP client:
|
||||
- **Sending Rate**: >100 emails per second (with connection pooling)
|
||||
- **Connection Pool Size**: 10-50 concurrent connections efficiently managed
|
||||
- **Memory Usage**: <500MB for 1000 concurrent email operations
|
||||
- **DNS Cache Hit Rate**: >90% for repeated domains
|
||||
- **Retry Success Rate**: >95% for temporary failures
|
||||
- **Large Attachment Support**: Files up to 25MB without performance degradation
|
||||
- **Queue Processing**: >1000 emails/minute with persistent queue
|
||||
|
||||
## Client Security Requirements
|
||||
|
||||
All client security tests must pass for production deployment:
|
||||
- **TLS Support**: TLS 1.2+ required, TLS 1.3 preferred
|
||||
- **Authentication**: Support for LOGIN, PLAIN, CRAM-MD5, OAuth2
|
||||
- **Certificate Validation**: Proper certificate chain validation
|
||||
- **DKIM Signing**: Automatic DKIM signature generation
|
||||
- **Credential Security**: No plaintext password storage
|
||||
- **Injection Prevention**: Protection against header/command injection
|
||||
|
||||
## Client Production Readiness Criteria
|
||||
|
||||
### Production Gate 1: Core Functionality (>95% tests passing)
|
||||
- Basic connection establishment
|
||||
- Command execution and response parsing
|
||||
- Email composition and sending
|
||||
- Error handling and recovery
|
||||
|
||||
### Production Gate 2: Advanced Features (>90% tests passing)
|
||||
- Connection pooling and reuse
|
||||
- Authentication mechanisms
|
||||
- TLS/STARTTLS support
|
||||
- Retry logic and resilience
|
||||
|
||||
### Production Gate 3: Enterprise Ready (>85% tests passing)
|
||||
- High-volume sending capabilities
|
||||
- Advanced security features
|
||||
- Full RFC compliance
|
||||
- Performance under load
|
||||
|
||||
## Key Differences: Server vs Client Tests
|
||||
|
||||
| Aspect | Server Tests | Client Tests |
|
||||
|--------|--------------|--------------|
|
||||
| **Focus** | Accepting connections, processing commands | Making connections, sending commands |
|
||||
| **Security** | Validating incoming data, enforcing policies | Protecting credentials, validating servers |
|
||||
| **Performance** | Handling many clients concurrently | Efficient bulk sending, connection reuse |
|
||||
| **Reliability** | Staying up under attack/load | Retrying failures, handling timeouts |
|
||||
| **RFC Compliance** | Server MUST requirements | Client MUST requirements |
|
||||
|
||||
## Test Implementation Priority
|
||||
|
||||
1. **Critical** (implement first):
|
||||
- Basic connection and command sending
|
||||
- Authentication mechanisms
|
||||
- Error handling and retry logic
|
||||
- TLS/Security features
|
||||
|
||||
2. **High Priority** (implement second):
|
||||
- Connection pooling
|
||||
- Email composition and MIME
|
||||
- Performance optimization
|
||||
- RFC compliance
|
||||
|
||||
3. **Medium Priority** (implement third):
|
||||
- Advanced features (OAuth2, etc.)
|
||||
- Edge case handling
|
||||
- Extended performance tests
|
||||
- Additional RFC extensions
|
||||
|
||||
4. **Low Priority** (implement last):
|
||||
- Proxy support
|
||||
- Certificate pinning
|
||||
- Unusual scenarios
|
||||
- Optional RFC features
|
||||
|
||||
175
test/test.config.md
Normal file
175
test/test.config.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# DCRouter Test Configuration
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
cd dcrouter
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Run Specific Category
|
||||
```bash
|
||||
# Run all connection tests
|
||||
tsx test/run-category.ts connection
|
||||
|
||||
# Run all security tests
|
||||
tsx test/run-category.ts security
|
||||
|
||||
# Run all performance tests
|
||||
tsx test/run-category.ts performance
|
||||
```
|
||||
|
||||
### Run Individual Test File
|
||||
```bash
|
||||
# Run TLS connection test
|
||||
tsx test/suite/connection/test.tls-connection.ts
|
||||
|
||||
# Run authentication test
|
||||
tsx test/suite/security/test.authentication.ts
|
||||
```
|
||||
|
||||
### Run Tests with Verbose Output
|
||||
```bash
|
||||
# All tests with verbose logging
|
||||
pnpm test -- --verbose
|
||||
|
||||
# Individual test with verbose
|
||||
tsx test/suite/connection/test.tls-connection.ts --verbose
|
||||
```
|
||||
|
||||
## Test Server Configuration
|
||||
|
||||
Each test file starts its own SMTP server with specific configuration. Common configurations:
|
||||
|
||||
### Basic Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost'
|
||||
});
|
||||
```
|
||||
|
||||
### TLS-Enabled Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
tlsEnabled: true
|
||||
});
|
||||
```
|
||||
|
||||
### Authenticated Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
authRequired: true
|
||||
});
|
||||
```
|
||||
|
||||
### High-Performance Server
|
||||
```typescript
|
||||
const testServer = await startTestServer({
|
||||
port: 2525,
|
||||
hostname: 'localhost',
|
||||
maxConnections: 1000,
|
||||
size: 50 * 1024 * 1024 // 50MB
|
||||
});
|
||||
```
|
||||
|
||||
## Port Allocation
|
||||
|
||||
Tests use different ports to avoid conflicts:
|
||||
- Connection tests: 2525-2530
|
||||
- Command tests: 2531-2540
|
||||
- Email processing: 2541-2550
|
||||
- Security tests: 2551-2560
|
||||
- Performance tests: 2561-2570
|
||||
- Edge cases: 2571-2580
|
||||
- RFC compliance: 2581-2590
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Server Lifecycle
|
||||
All tests follow this pattern:
|
||||
```typescript
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
||||
|
||||
let testServer;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
testServer = await startTestServer({ port: 2525 });
|
||||
});
|
||||
|
||||
// Your tests here...
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
await stopTestServer(testServer);
|
||||
});
|
||||
|
||||
tap.start();
|
||||
```
|
||||
|
||||
### SMTP Client Testing
|
||||
```typescript
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
|
||||
const client = createTestSmtpClient({
|
||||
host: 'localhost',
|
||||
port: 2525
|
||||
});
|
||||
```
|
||||
|
||||
### Low-Level SMTP Testing
|
||||
```typescript
|
||||
import { connectToSmtp, sendSmtpCommand } from '../../helpers/test.utils.js';
|
||||
|
||||
const socket = await connectToSmtp('localhost', 2525);
|
||||
const response = await sendSmtpCommand(socket, 'EHLO test.example.com', '250');
|
||||
```
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
Expected minimums for production:
|
||||
- Throughput: >10 emails/second
|
||||
- Concurrent connections: >100
|
||||
- Memory increase: <2% under load
|
||||
- Connection time: <5000ms
|
||||
- Error rate: <5%
|
||||
|
||||
## Debugging Failed Tests
|
||||
|
||||
### Enable Verbose Logging
|
||||
```bash
|
||||
DEBUG=* tsx test/suite/connection/test.tls-connection.ts
|
||||
```
|
||||
|
||||
### Check Server Logs
|
||||
Tests output server logs to console. Look for:
|
||||
- 🚀 Server start messages
|
||||
- 📧 Email processing logs
|
||||
- ❌ Error messages
|
||||
- ✅ Success confirmations
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port Already in Use**
|
||||
- Tests use unique ports
|
||||
- Check for orphaned processes: `lsof -i :2525`
|
||||
- Kill process: `kill -9 <PID>`
|
||||
|
||||
2. **TLS Certificate Errors**
|
||||
- Tests use self-signed certificates
|
||||
- Production should use real certificates
|
||||
|
||||
3. **Timeout Errors**
|
||||
- Increase timeout in test configuration
|
||||
- Check network connectivity
|
||||
- Verify server started successfully
|
||||
|
||||
4. **Authentication Failures**
|
||||
- Test servers may not validate credentials
|
||||
- Check authRequired configuration
|
||||
- Verify AUTH mechanisms supported
|
||||
265
test/test.contentscanner.ts
Normal file
265
test/test.contentscanner.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||
import { Email } from '@push.rocks/smartmta';
|
||||
|
||||
// Test instantiation
|
||||
tap.test('ContentScanner - should be instantiable', async () => {
|
||||
const scanner = ContentScanner.getInstance({
|
||||
scanBody: true,
|
||||
scanSubject: true,
|
||||
scanAttachments: true
|
||||
});
|
||||
|
||||
expect(scanner).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test singleton pattern
|
||||
tap.test('ContentScanner - should use singleton pattern', async () => {
|
||||
const scanner1 = ContentScanner.getInstance();
|
||||
const scanner2 = ContentScanner.getInstance();
|
||||
|
||||
// Both instances should be the same object
|
||||
expect(scanner1 === scanner2).toEqual(true);
|
||||
});
|
||||
|
||||
// Test clean email can be correctly distinguished from high-risk email
|
||||
tap.test('ContentScanner - should distinguish between clean and suspicious emails', async () => {
|
||||
// Create an instance with a higher minimum threat score
|
||||
const scanner = new ContentScanner({
|
||||
minThreatScore: 50 // Higher threshold to consider clean
|
||||
});
|
||||
|
||||
// Create a truly clean email with no potentially sensitive data patterns
|
||||
const cleanEmail = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Project Update',
|
||||
text: 'The project is on track. Let me know if you have questions.',
|
||||
html: '<p>The project is on track. Let me know if you have questions.</p>'
|
||||
});
|
||||
|
||||
// Create a highly suspicious email
|
||||
const suspiciousEmail = new Email({
|
||||
from: 'admin@bank-fake.com',
|
||||
to: 'victim@example.com',
|
||||
subject: 'URGENT: Your account needs verification now!',
|
||||
text: 'Click here to verify your account or it will be suspended: https://bit.ly/12345',
|
||||
html: '<p>Click here to verify your account or it will be suspended: <a href="https://bit.ly/12345">click here</a></p>'
|
||||
});
|
||||
|
||||
// Test both emails
|
||||
const cleanResult = await scanner.scanEmail(cleanEmail);
|
||||
const suspiciousResult = await scanner.scanEmail(suspiciousEmail);
|
||||
|
||||
console.log('Clean vs Suspicious results:', {
|
||||
cleanScore: cleanResult.threatScore,
|
||||
suspiciousScore: suspiciousResult.threatScore
|
||||
});
|
||||
|
||||
// Verify the scanner can distinguish between them
|
||||
// Suspicious email should have a significantly higher score
|
||||
expect(suspiciousResult.threatScore > cleanResult.threatScore + 40).toEqual(true);
|
||||
|
||||
// Verify clean email scans all expected elements
|
||||
expect(cleanResult.scannedElements.length > 0).toEqual(true);
|
||||
});
|
||||
|
||||
// Test phishing detection in subject
|
||||
tap.test('ContentScanner - should detect phishing in subject', async () => {
|
||||
// Create a dedicated scanner for this test
|
||||
const scanner = new ContentScanner({
|
||||
scanSubject: true,
|
||||
scanBody: true,
|
||||
scanAttachments: false,
|
||||
customRules: []
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'security@bank-account-verify.com',
|
||||
to: 'victim@example.com',
|
||||
subject: 'URGENT: Verify your bank account details immediately',
|
||||
text: 'Your account will be suspended. Please verify your details.',
|
||||
html: '<p>Your account will be suspended. Please verify your details.</p>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
console.log('Phishing email scan result:', result);
|
||||
|
||||
// We only care that it detected something suspicious
|
||||
expect(result.threatScore >= 20).toEqual(true);
|
||||
|
||||
// Check if any threat was detected (specific type may vary)
|
||||
expect(result.threatType).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test malware indicators in body
|
||||
tap.test('ContentScanner - should detect malware indicators in body', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'invoice@company.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Your invoice',
|
||||
text: 'Please see the attached invoice. You need to enable macros to view this document properly.',
|
||||
html: '<p>Please see the attached invoice. You need to enable macros to view this document properly.</p>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType === ThreatCategory.MALWARE || result.threatType).toBeTruthy();
|
||||
expect(result.threatScore >= 30).toEqual(true);
|
||||
});
|
||||
|
||||
// Test suspicious link detection
|
||||
tap.test('ContentScanner - should detect suspicious links', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'newsletter@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Weekly Newsletter',
|
||||
text: 'Check our latest offer at https://bit.ly/2x3F5 and https://t.co/abc123',
|
||||
html: '<p>Check our latest offer at <a href="https://bit.ly/2x3F5">here</a> and <a href="https://t.co/abc123">here</a></p>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.SUSPICIOUS_LINK);
|
||||
expect(result.threatScore >= 30).toEqual(true);
|
||||
});
|
||||
|
||||
// Test script injection detection
|
||||
tap.test('ContentScanner - should detect script injection', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'newsletter@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Newsletter',
|
||||
text: 'Check our website',
|
||||
html: '<p>Check our website</p><script>document.cookie="session="+localStorage.getItem("token");</script>'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.XSS);
|
||||
expect(result.threatScore >= 40).toEqual(true);
|
||||
});
|
||||
|
||||
// Test executable attachment detection
|
||||
tap.test('ContentScanner - should detect executable attachments', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Software Update',
|
||||
text: 'Please install the attached software update.',
|
||||
attachments: [{
|
||||
filename: 'update.exe',
|
||||
content: Buffer.from('MZ...fake executable content...'),
|
||||
contentType: 'application/octet-stream'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.EXECUTABLE);
|
||||
expect(result.threatScore >= 70).toEqual(true);
|
||||
});
|
||||
|
||||
// Test macro document detection
|
||||
tap.test('ContentScanner - should detect macro documents', async () => {
|
||||
// Create a mock Office document with macro indicators
|
||||
const fakeDocContent = Buffer.from('Document content...vbaProject.bin...Auto_Open...DocumentOpen...Microsoft VBA...');
|
||||
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Financial Report',
|
||||
text: 'Please review the attached financial report.',
|
||||
attachments: [{
|
||||
filename: 'report.docm',
|
||||
content: fakeDocContent,
|
||||
contentType: 'application/vnd.ms-word.document.macroEnabled.12'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.MALICIOUS_MACRO);
|
||||
expect(result.threatScore >= 60).toEqual(true);
|
||||
});
|
||||
|
||||
// Test compound threat detection (multiple indicators)
|
||||
tap.test('ContentScanner - should detect compound threats', async () => {
|
||||
const scanner = ContentScanner.getInstance();
|
||||
|
||||
const email = new Email({
|
||||
from: 'security@bank-verify.com',
|
||||
to: 'victim@example.com',
|
||||
subject: 'URGENT: Verify your account details immediately',
|
||||
text: 'Your account will be suspended unless you verify your details at https://bit.ly/2x3F5',
|
||||
html: '<p>Your account will be suspended unless you verify your details <a href="https://bit.ly/2x3F5">here</a>.</p>',
|
||||
attachments: [{
|
||||
filename: 'verification.exe',
|
||||
content: Buffer.from('MZ...fake executable content...'),
|
||||
contentType: 'application/octet-stream'
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatScore > 70).toEqual(true); // Should have a high score due to multiple threats
|
||||
});
|
||||
|
||||
// Test custom rules
|
||||
tap.test('ContentScanner - should apply custom rules', async () => {
|
||||
// Create a scanner with custom rules
|
||||
const scanner = new ContentScanner({
|
||||
customRules: [
|
||||
{
|
||||
pattern: /CUSTOM_PATTERN_FOR_TESTING/,
|
||||
type: ThreatCategory.CUSTOM_RULE,
|
||||
score: 50,
|
||||
description: 'Custom pattern detected'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Custom Rule',
|
||||
text: 'This message contains CUSTOM_PATTERN_FOR_TESTING that should be detected.'
|
||||
});
|
||||
|
||||
const result = await scanner.scanEmail(email);
|
||||
|
||||
expect(result.isClean).toEqual(false);
|
||||
expect(result.threatType).toEqual(ThreatCategory.CUSTOM_RULE);
|
||||
expect(result.threatScore >= 50).toEqual(true);
|
||||
});
|
||||
|
||||
// Test threat level classification
|
||||
tap.test('ContentScanner - should classify threat levels correctly', async () => {
|
||||
expect(ContentScanner.getThreatLevel(10)).toEqual('none');
|
||||
expect(ContentScanner.getThreatLevel(25)).toEqual('low');
|
||||
expect(ContentScanner.getThreatLevel(50)).toEqual('medium');
|
||||
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
159
test/test.dcrouter.email.ts
Normal file
159
test/test.dcrouter.email.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
const customPortMapping: Record<number, number> = {
|
||||
25: 11025, // Custom SMTP port mapping
|
||||
587: 11587, // Custom submission port mapping
|
||||
465: 11465, // Custom SMTPS port mapping
|
||||
2525: 12525 // Additional custom port
|
||||
};
|
||||
|
||||
// Create a custom email configuration using smartmta interfaces
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465, 2525],
|
||||
hostname: 'mail.example.com',
|
||||
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||
domains: [
|
||||
{
|
||||
domain: 'example.com',
|
||||
dnsMode: 'external-dns',
|
||||
},
|
||||
{
|
||||
domain: 'example.org',
|
||||
dnsMode: 'external-dns',
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
name: 'forward-example-com',
|
||||
match: {
|
||||
recipients: '*@example.com',
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
forward: {
|
||||
host: 'mail1.example.com',
|
||||
port: 25,
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'deliver-example-org',
|
||||
match: {
|
||||
recipients: '*@example.org',
|
||||
},
|
||||
action: {
|
||||
type: 'deliver',
|
||||
process: {
|
||||
dkim: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Create DcRouter options with custom email port configuration
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
emailPortConfig: {
|
||||
portMapping: customPortMapping,
|
||||
portSettings: {
|
||||
2525: {
|
||||
terminateTls: false,
|
||||
routeName: 'custom-smtp-route'
|
||||
}
|
||||
},
|
||||
},
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Verify the options are correctly set
|
||||
expect(router.options.emailPortConfig).toBeTruthy();
|
||||
expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping);
|
||||
|
||||
// Test the generateEmailRoutes method
|
||||
if (typeof (router as any)['generateEmailRoutes'] === 'function') {
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
|
||||
// Verify that all ports are configured
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
// Check the custom port configuration
|
||||
const customPortRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
|
||||
});
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||
|
||||
// Check standard port mappings
|
||||
const smtpRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
|
||||
});
|
||||
expect(smtpRoute?.action.targets[0].port).toEqual(11025);
|
||||
|
||||
const submissionRoute = routes.find((r: any) => {
|
||||
const ports = r.match.ports;
|
||||
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
|
||||
});
|
||||
expect(submissionRoute?.action.targets[0].port).toEqual(11587);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: []
|
||||
};
|
||||
|
||||
// Create DcRouter options
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig,
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
cacheConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
};
|
||||
|
||||
// Create DcRouter instance
|
||||
const router = new DcRouter(options);
|
||||
|
||||
// Start the router to initialize email services
|
||||
await router.start();
|
||||
|
||||
// Verify unified email server was initialized
|
||||
expect(router.emailServer).toBeTruthy();
|
||||
|
||||
// Stop the router
|
||||
await router.stop();
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
140
test/test.dns-server-config.ts
Normal file
140
test/test.dns-server-config.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Test DNS server configuration and record registration
|
||||
*/
|
||||
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Test DNS configuration
|
||||
const testDnsConfig = {
|
||||
udpPort: 5353, // Use non-privileged port for testing
|
||||
httpsPort: 8443,
|
||||
httpsKey: './test/fixtures/test-key.pem',
|
||||
httpsCert: './test/fixtures/test-cert.pem',
|
||||
dnssecZone: 'test.example.com',
|
||||
records: [
|
||||
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
|
||||
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
|
||||
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
|
||||
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
|
||||
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
|
||||
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
|
||||
]
|
||||
};
|
||||
|
||||
tap.test('DNS server configuration - should extract records correctly', async () => {
|
||||
const { records, ...dnsServerOptions } = testDnsConfig;
|
||||
|
||||
expect(dnsServerOptions.udpPort).toEqual(5353);
|
||||
expect(dnsServerOptions.httpsPort).toEqual(8443);
|
||||
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
|
||||
expect(records).toBeArray();
|
||||
expect(records.length).toEqual(6);
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should handle record parsing', async () => {
|
||||
const parseDnsRecordData = (type: string, value: string): any => {
|
||||
switch (type) {
|
||||
case 'A':
|
||||
return value;
|
||||
case 'MX':
|
||||
const [priority, exchange] = value.split(' ');
|
||||
return { priority: parseInt(priority), exchange };
|
||||
case 'TXT':
|
||||
return value;
|
||||
case 'NS':
|
||||
return value;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Test A record parsing
|
||||
const aRecord = parseDnsRecordData('A', '192.168.1.1');
|
||||
expect(aRecord).toEqual('192.168.1.1');
|
||||
|
||||
// Test MX record parsing
|
||||
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
|
||||
expect(mxRecord).toHaveProperty('priority', 10);
|
||||
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
|
||||
|
||||
// Test TXT record parsing
|
||||
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
|
||||
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should group records by domain', async () => {
|
||||
const records = testDnsConfig.records;
|
||||
const recordsByDomain = new Map<string, typeof records>();
|
||||
|
||||
for (const record of records) {
|
||||
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||
if (!recordsByDomain.has(pattern)) {
|
||||
recordsByDomain.set(pattern, []);
|
||||
}
|
||||
recordsByDomain.get(pattern)!.push(record);
|
||||
}
|
||||
|
||||
// Check grouping
|
||||
expect(recordsByDomain.size).toBeGreaterThan(0);
|
||||
|
||||
// Verify each group has records
|
||||
for (const [pattern, domainRecords] of recordsByDomain) {
|
||||
expect(domainRecords.length).toBeGreaterThan(0);
|
||||
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DNS server configuration - should extract unique record types', async () => {
|
||||
const records = testDnsConfig.records;
|
||||
const recordTypes = [...new Set(records.map(r => r.type))];
|
||||
|
||||
expect(recordTypes).toContain('A');
|
||||
expect(recordTypes).toContain('MX');
|
||||
expect(recordTypes).toContain('TXT');
|
||||
expect(recordTypes).toContain('NS');
|
||||
|
||||
console.log('Unique record types:', recordTypes.join(', '));
|
||||
});
|
||||
|
||||
tap.test('DNS server - mock handler registration', async () => {
|
||||
// Mock DNS server for testing
|
||||
const mockDnsServer = {
|
||||
handlers: new Map<string, any>(),
|
||||
registerHandler: function(pattern: string, types: string[], handler: Function) {
|
||||
this.handlers.set(pattern, { types, handler });
|
||||
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate record registration
|
||||
const records = testDnsConfig.records;
|
||||
const recordsByDomain = new Map<string, typeof records>();
|
||||
|
||||
for (const record of records) {
|
||||
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||
if (!recordsByDomain.has(pattern)) {
|
||||
recordsByDomain.set(pattern, []);
|
||||
}
|
||||
recordsByDomain.get(pattern)!.push(record);
|
||||
}
|
||||
|
||||
// Register handlers
|
||||
for (const [domainPattern, domainRecords] of recordsByDomain) {
|
||||
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
|
||||
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
|
||||
const matchingRecord = domainRecords.find(
|
||||
r => r.name === question.name && r.type === question.type
|
||||
);
|
||||
return matchingRecord || null;
|
||||
});
|
||||
}
|
||||
|
||||
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.start({
|
||||
throwOnError: true
|
||||
});
|
||||
148
test/test.dns-socket-handler.ts
Normal file
148
test/test.dns-socket-handler.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
cacheConfig: { enabled: false }
|
||||
});
|
||||
|
||||
await dcRouter.start();
|
||||
|
||||
// Check that DNS server is not created
|
||||
expect((dcRouter as any).dnsServer).toBeUndefined();
|
||||
|
||||
await dcRouter.stop();
|
||||
});
|
||||
|
||||
tap.test('should generate DNS routes when dnsNsDomains is set', async () => {
|
||||
// This test checks the route generation logic WITHOUT starting the full DcRouter
|
||||
// Starting DcRouter would require DNS port 53 and cause conflicts
|
||||
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||
dnsScopes: ['test.local'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Check routes are generated correctly (without starting)
|
||||
const generatedRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
expect(generatedRoutes.length).toEqual(2); // /dns-query and /resolve
|
||||
|
||||
// Check that routes have socket-handler action
|
||||
generatedRoutes.forEach((route: any) => {
|
||||
expect(route.action.type).toEqual('socket-handler');
|
||||
expect(route.action.socketHandler).toBeDefined();
|
||||
});
|
||||
|
||||
// Verify routes target the primary nameserver
|
||||
const dnsQueryRoute = generatedRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.domains).toContain('ns1.test.local');
|
||||
});
|
||||
|
||||
tap.test('should create DNS routes with correct configuration', async () => {
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Access the private method to generate routes
|
||||
const dnsRoutes = (dcRouter as any).generateDnsRoutes();
|
||||
|
||||
expect(dnsRoutes.length).toEqual(2);
|
||||
|
||||
// Check first route (dns-query) - uses primary nameserver (first in array)
|
||||
const dnsQueryRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-dns-query');
|
||||
expect(dnsQueryRoute).toBeDefined();
|
||||
expect(dnsQueryRoute.match.ports).toContain(443);
|
||||
expect(dnsQueryRoute.match.domains).toContain('ns1.example.com');
|
||||
expect(dnsQueryRoute.match.path).toEqual('/dns-query');
|
||||
|
||||
// Check second route (resolve)
|
||||
const resolveRoute = dnsRoutes.find((r: any) => r.name === 'dns-over-https-resolve');
|
||||
expect(resolveRoute).toBeDefined();
|
||||
expect(resolveRoute.match.ports).toContain(443);
|
||||
expect(resolveRoute.match.domains).toContain('ns1.example.com');
|
||||
expect(resolveRoute.match.path).toEqual('/resolve');
|
||||
});
|
||||
|
||||
tap.test('DNS socket handler should be created correctly', async () => {
|
||||
// This test verifies the socket handler creation WITHOUT starting the full router
|
||||
dcRouter = new DcRouter({
|
||||
dnsNsDomains: ['ns1.test.local', 'ns2.test.local'],
|
||||
dnsScopes: ['test.local'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
// Get the socket handler (this doesn't require DNS server to be started)
|
||||
const socketHandler = (dcRouter as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
|
||||
// Create a mock socket to test the handler behavior without DNS server
|
||||
const mockSocket = new plugins.net.Socket();
|
||||
let socketEnded = false;
|
||||
|
||||
mockSocket.end = () => {
|
||||
socketEnded = true;
|
||||
return mockSocket;
|
||||
};
|
||||
|
||||
// When DNS server is not initialized, the handler should end the socket
|
||||
try {
|
||||
await socketHandler(mockSocket);
|
||||
} catch (error) {
|
||||
// Expected - DNS server not initialized
|
||||
}
|
||||
|
||||
// Socket should be ended because DNS server wasn't started
|
||||
expect(socketEnded).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('DNS routes should only be generated when dnsNsDomains is configured', async () => {
|
||||
// Test without DNS configuration - should return empty routes
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const routesWithoutDns = (dcRouter as any).generateDnsRoutes();
|
||||
expect(routesWithoutDns.length).toEqual(0);
|
||||
|
||||
// Test with DNS configuration - should return routes
|
||||
const dcRouterWithDns = new DcRouter({
|
||||
dnsNsDomains: ['ns1.example.com'],
|
||||
dnsScopes: ['example.com'],
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
}
|
||||
});
|
||||
|
||||
const routesWithDns = (dcRouterWithDns as any).generateDnsRoutes();
|
||||
expect(routesWithDns.length).toEqual(2);
|
||||
|
||||
// Verify socket handler can be created
|
||||
const socketHandler = (dcRouterWithDns as any).createDnsSocketHandler();
|
||||
expect(socketHandler).toBeDefined();
|
||||
expect(typeof socketHandler).toEqual('function');
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
274
test/test.errors.ts
Normal file
274
test/test.errors.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as errors from '../ts/errors/index.js';
|
||||
import {
|
||||
PlatformError,
|
||||
ValidationError,
|
||||
NetworkError,
|
||||
ResourceError,
|
||||
OperationError
|
||||
} from '../ts/errors/base.errors.js';
|
||||
import {
|
||||
ErrorSeverity,
|
||||
ErrorCategory,
|
||||
ErrorRecoverability
|
||||
} from '../ts/errors/error.codes.js';
|
||||
import {
|
||||
ErrorHandler
|
||||
} from '../ts/errors/error-handler.js';
|
||||
|
||||
// Test base error classes
|
||||
tap.test('Base error classes should set properties correctly', async () => {
|
||||
const message = 'Test error message';
|
||||
const code = 'TEST_ERROR_CODE';
|
||||
const context = {
|
||||
component: 'TestComponent',
|
||||
operation: 'testOperation',
|
||||
data: { foo: 'bar' }
|
||||
};
|
||||
|
||||
// Test PlatformError
|
||||
const platformError = new PlatformError(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
|
||||
expect(platformError.message).toEqual(message);
|
||||
expect(platformError.code).toEqual(code);
|
||||
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||||
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
expect(platformError.context?.component).toEqual(context.component);
|
||||
expect(platformError.context?.operation).toEqual(context.operation);
|
||||
expect(platformError.context?.data?.foo).toEqual('bar');
|
||||
expect(platformError.name).toEqual('PlatformError');
|
||||
|
||||
// Test ValidationError
|
||||
const validationError = new ValidationError(message, code, context);
|
||||
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||||
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||||
|
||||
// Test NetworkError
|
||||
const networkError = new NetworkError(message, code, context);
|
||||
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||
|
||||
// Test ResourceError
|
||||
const resourceError = new ResourceError(message, code, context);
|
||||
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||||
});
|
||||
|
||||
// Test error handler utility
|
||||
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||
// Configure error handler
|
||||
ErrorHandler.configure({
|
||||
logErrors: false, // Disable for testing
|
||||
includeStacksInProd: false,
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
baseDelay: 100,
|
||||
maxDelay: 1000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Test converting regular Error to PlatformError
|
||||
const regularError = new Error('Something went wrong');
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
regularError,
|
||||
'PLATFORM_OPERATION_ERROR',
|
||||
{ component: 'TestHandler' }
|
||||
);
|
||||
|
||||
expect(platformError).toBeInstanceOf(PlatformError);
|
||||
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(platformError.context?.component).toEqual('TestHandler');
|
||||
|
||||
// Test formatting error for API response
|
||||
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||||
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||||
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
|
||||
|
||||
// Test executing a function with error handling
|
||||
let executed = false;
|
||||
try {
|
||||
await ErrorHandler.execute(async () => {
|
||||
executed = true;
|
||||
throw new Error('Execution failed');
|
||||
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||
expect(error.context.operation).toEqual('testExecution');
|
||||
}
|
||||
expect(executed).toEqual(true);
|
||||
|
||||
// Test executeWithRetry successful after retries
|
||||
let attempts = 0;
|
||||
const result = await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10, // Use small delay for tests
|
||||
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
|
||||
onRetry: (error, attempt, delay) => {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempt).toBeGreaterThan(0);
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual('success');
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test executeWithRetry that fails after max attempts
|
||||
attempts = 0;
|
||||
try {
|
||||
await ErrorHandler.executeWithRetry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Persistent failure');
|
||||
},
|
||||
'TEST_RETRY_ERROR',
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 10,
|
||||
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(PlatformError);
|
||||
expect(attempts).toEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
// Test retry utilities
|
||||
tap.test('Error retry utilities should work correctly', async () => {
|
||||
let attempts = 0;
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
if (attempts < 3) {
|
||||
throw new Error('Temporary error');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
{
|
||||
maxRetries: 5,
|
||||
initialDelay: 20,
|
||||
backoffFactor: 1.5,
|
||||
retryableErrors: [/Temporary/]
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
}
|
||||
|
||||
expect(attempts).toEqual(3);
|
||||
|
||||
// Test retry with non-retryable error
|
||||
attempts = 0;
|
||||
try {
|
||||
await errors.retry(
|
||||
async () => {
|
||||
attempts++;
|
||||
throw new Error('Critical error');
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error.message).toEqual('Critical error');
|
||||
expect(attempts).toEqual(1); // Should only attempt once
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function that will reject first n times, then resolve
|
||||
interface FlakyFunction {
|
||||
(failTimes: number, result?: any): Promise<any>;
|
||||
counter: number;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const flaky: FlakyFunction = Object.assign(
|
||||
async function (failTimes: number, result: any = 'success'): Promise<any> {
|
||||
if (flaky.counter < failTimes) {
|
||||
flaky.counter++;
|
||||
throw new Error(`Flaky failure ${flaky.counter}`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
counter: 0,
|
||||
reset: () => { flaky.counter = 0; }
|
||||
}
|
||||
);
|
||||
|
||||
// Test error wrapping and retry combination
|
||||
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||||
// Reset counter for the test
|
||||
flaky.reset();
|
||||
|
||||
// Create a wrapped version of the flaky function
|
||||
const wrapped = errors.withErrorHandling(
|
||||
() => flaky(2, 'wrapped success'),
|
||||
'TEST_WRAPPED_ERROR',
|
||||
{ component: 'TestComponent' }
|
||||
);
|
||||
|
||||
// Execute with retry
|
||||
const result = await errors.retry(
|
||||
wrapped,
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/]
|
||||
}
|
||||
);
|
||||
expect(result).toEqual('wrapped success');
|
||||
expect(flaky.counter).toEqual(2);
|
||||
|
||||
// Reset and test failure case
|
||||
flaky.reset();
|
||||
|
||||
try {
|
||||
await errors.retry(
|
||||
() => flaky(5, 'never reached'),
|
||||
{
|
||||
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||||
initialDelay: 10,
|
||||
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
|
||||
}
|
||||
);
|
||||
// Should not reach here
|
||||
expect(false).toEqual(true);
|
||||
} catch (error) {
|
||||
expect(error.message).toContain('Flaky failure');
|
||||
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
179
test/test.ipreputationchecker.ts
Normal file
179
test/test.ipreputationchecker.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
|
||||
// Mock for dns lookup
|
||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
||||
|
||||
// Setup mock DNS resolver with proper typing
|
||||
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
||||
return mockDnsResolveImpl(hostname);
|
||||
};
|
||||
|
||||
// Test instantiation
|
||||
tap.test('IPReputationChecker - should be instantiable', async () => {
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false
|
||||
});
|
||||
|
||||
expect(checker).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test singleton pattern
|
||||
tap.test('IPReputationChecker - should use singleton pattern', async () => {
|
||||
const checker1 = IPReputationChecker.getInstance();
|
||||
const checker2 = IPReputationChecker.getInstance();
|
||||
|
||||
// Both instances should be the same object
|
||||
expect(checker1 === checker2).toEqual(true);
|
||||
});
|
||||
|
||||
// Test IP validation
|
||||
tap.test('IPReputationChecker - should validate IP address format', async () => {
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false
|
||||
});
|
||||
|
||||
// Valid IP should work
|
||||
const result = await checker.checkReputation('192.168.1.1');
|
||||
expect(result.score).toBeGreaterThan(0);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// Invalid IP should fail with error
|
||||
const invalidResult = await checker.checkReputation('invalid.ip');
|
||||
expect(invalidResult.error).toBeTruthy();
|
||||
});
|
||||
|
||||
// Test DNSBL lookups
|
||||
tap.test('IPReputationChecker - should check IP against DNSBL', async () => {
|
||||
try {
|
||||
// Setup mock implementation for DNSBL
|
||||
mockDnsResolveImpl = async (hostname: string) => {
|
||||
// Listed in DNSBL if IP contains 2
|
||||
if (hostname.includes('2.1.168.192') && hostname.includes('zen.spamhaus.org')) {
|
||||
return ['127.0.0.2'];
|
||||
}
|
||||
throw { code: 'ENOTFOUND' };
|
||||
};
|
||||
|
||||
// Create a new instance with specific settings for this test
|
||||
const testInstance = new IPReputationChecker({
|
||||
dnsblServers: ['zen.spamhaus.org'],
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 1 // Small cache for testing
|
||||
});
|
||||
|
||||
// Clean IP should have good score
|
||||
const cleanResult = await testInstance.checkReputation('192.168.1.1');
|
||||
expect(cleanResult.isSpam).toEqual(false);
|
||||
expect(cleanResult.score).toEqual(100);
|
||||
|
||||
// Blacklisted IP should have reduced score
|
||||
const blacklistedResult = await testInstance.checkReputation('192.168.1.2');
|
||||
expect(blacklistedResult.isSpam).toEqual(true);
|
||||
expect(blacklistedResult.score < 100).toEqual(true); // Less than 100
|
||||
expect(blacklistedResult.blacklists).toBeTruthy();
|
||||
expect((blacklistedResult.blacklists || []).length > 0).toEqual(true);
|
||||
} catch (err) {
|
||||
console.error('Test error:', err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Test caching behavior
|
||||
tap.test('IPReputationChecker - should cache reputation results', async () => {
|
||||
// Create a fresh instance for this test
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 10 // Small cache for testing
|
||||
});
|
||||
|
||||
// Check that first look performs a lookup and second uses cache
|
||||
const ip = '192.168.1.10';
|
||||
|
||||
// First check should add to cache
|
||||
const result1 = await testInstance.checkReputation(ip);
|
||||
expect(result1).toBeTruthy();
|
||||
|
||||
// Manually verify it's in cache - access private member for testing
|
||||
const hasInCache = (testInstance as any).reputationCache.has(ip);
|
||||
expect(hasInCache).toEqual(true);
|
||||
|
||||
// Call again, should use cache
|
||||
const result2 = await testInstance.checkReputation(ip);
|
||||
expect(result2).toBeTruthy();
|
||||
|
||||
// Results should be identical
|
||||
expect(result1.score).toEqual(result2.score);
|
||||
});
|
||||
|
||||
// Test risk level classification
|
||||
tap.test('IPReputationChecker - should classify risk levels correctly', async () => {
|
||||
expect(IPReputationChecker.getRiskLevel(10)).toEqual('high');
|
||||
expect(IPReputationChecker.getRiskLevel(30)).toEqual('medium');
|
||||
expect(IPReputationChecker.getRiskLevel(60)).toEqual('low');
|
||||
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
|
||||
});
|
||||
|
||||
// Test IP type detection
|
||||
tap.test('IPReputationChecker - should detect special IP types', async () => {
|
||||
const testInstance = new IPReputationChecker({
|
||||
enableDNSBL: false,
|
||||
enableIPInfo: true,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 5 // Small cache for testing
|
||||
});
|
||||
|
||||
// Test Tor exit node detection
|
||||
const torResult = await testInstance.checkReputation('171.25.1.1');
|
||||
expect(torResult.isTor).toEqual(true);
|
||||
expect(torResult.score < 90).toEqual(true);
|
||||
|
||||
// Test VPN detection
|
||||
const vpnResult = await testInstance.checkReputation('185.156.1.1');
|
||||
expect(vpnResult.isVPN).toEqual(true);
|
||||
expect(vpnResult.score < 90).toEqual(true);
|
||||
|
||||
// Test proxy detection
|
||||
const proxyResult = await testInstance.checkReputation('34.92.1.1');
|
||||
expect(proxyResult.isProxy).toEqual(true);
|
||||
expect(proxyResult.score < 90).toEqual(true);
|
||||
});
|
||||
|
||||
// Test error handling
|
||||
tap.test('IPReputationChecker - should handle DNS lookup errors gracefully', async () => {
|
||||
// Setup mock implementation to simulate error
|
||||
mockDnsResolveImpl = async () => {
|
||||
throw new Error('DNS server error');
|
||||
};
|
||||
|
||||
const checker = IPReputationChecker.getInstance({
|
||||
dnsblServers: ['zen.spamhaus.org'],
|
||||
enableIPInfo: false,
|
||||
enableLocalCache: false,
|
||||
maxCacheSize: 300 // Force new instance
|
||||
});
|
||||
|
||||
// Should return a result despite errors
|
||||
const result = await checker.checkReputation('192.168.1.1');
|
||||
expect(result.score).toEqual(100); // No blacklist hits found due to error
|
||||
expect(result.isSpam).toEqual(false);
|
||||
});
|
||||
|
||||
// Restore original implementation at the end
|
||||
tap.test('Cleanup - restore mocks', async () => {
|
||||
plugins.dns.promises.resolve = originalDnsResolve;
|
||||
});
|
||||
|
||||
tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
131
test/test.jwt-auth.ts
Normal file
131
test/test.jwt-auth.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity).toHaveProperty('jwt');
|
||||
expect(response.identity).toHaveProperty('userId');
|
||||
expect(response.identity).toHaveProperty('name');
|
||||
expect(response.identity).toHaveProperty('expiresAt');
|
||||
expect(response.identity).toHaveProperty('role');
|
||||
expect(response.identity.role).toEqual('admin');
|
||||
|
||||
identity = response.identity;
|
||||
console.log('JWT:', identity.jwt);
|
||||
});
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response).toHaveProperty('identity');
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: {
|
||||
...identity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
// The response should contain the same identity data as the JWT
|
||||
const response = await verifyRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
expect(response.identity.expiresAt).toEqual(identity.expiresAt);
|
||||
expect(response.identity.userId).toEqual(identity.userId);
|
||||
});
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
const response = await logoutRequest.fire({
|
||||
identity
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('success');
|
||||
expect(response.success).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
let errorOccurred = false;
|
||||
try {
|
||||
await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
} catch (error) {
|
||||
errorOccurred = true;
|
||||
// TypedResponseError is thrown
|
||||
expect(error).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(errorOccurred).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
84
test/test.opsserver-api.ts
Normal file
84
test/test.opsserver-api.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
const response = await healthRequest.fire({
|
||||
detailed: false
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
expect(response.health.services).toHaveProperty('OpsServer');
|
||||
});
|
||||
|
||||
tap.test('should respond to server statistics request', async () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({
|
||||
includeHistory: false
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('stats');
|
||||
expect(response.stats).toHaveProperty('uptime');
|
||||
expect(response.stats).toHaveProperty('cpuUsage');
|
||||
expect(response.stats).toHaveProperty('memoryUsage');
|
||||
});
|
||||
|
||||
tap.test('should respond to configuration request', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
});
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
const response = await logsRequest.fire({
|
||||
limit: 10
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('logs');
|
||||
expect(response).toHaveProperty('total');
|
||||
expect(response).toHaveProperty('hasMore');
|
||||
expect(response.logs).toBeArray();
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
120
test/test.protected-endpoint.ts
Normal file
120
test/test.protected-endpoint.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
await testDcRouter.start();
|
||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: 'admin',
|
||||
password: 'admin'
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('identity');
|
||||
adminIdentity = response.identity;
|
||||
console.log('Admin logged in with JWT');
|
||||
});
|
||||
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
const response = await verifyRequest.fire({
|
||||
identity: adminIdentity,
|
||||
});
|
||||
|
||||
expect(response).toHaveProperty('valid');
|
||||
expect(response.valid).toBeTrue();
|
||||
console.log('Admin identity verified successfully');
|
||||
});
|
||||
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await verifyRequest.fire({} as any);
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request without identity');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
try {
|
||||
await verifyRequest.fire({
|
||||
identity: {
|
||||
...adminIdentity,
|
||||
jwt: 'invalid.jwt.token'
|
||||
},
|
||||
});
|
||||
expect(true).toBeFalse(); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error).toBeTruthy();
|
||||
console.log('Successfully rejected request with invalid JWT');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should allow access to public endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
// No identity provided
|
||||
const response = await healthRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('health');
|
||||
expect(response.health.healthy).toBeTrue();
|
||||
console.log('Public endpoint accessible without auth');
|
||||
});
|
||||
|
||||
tap.test('should allow read-only config access', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3000/typedrequest',
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
// Config is read-only and doesn't require auth
|
||||
const response = await configRequest.fire({});
|
||||
|
||||
expect(response).toHaveProperty('config');
|
||||
expect(response.config).toHaveProperty('email');
|
||||
expect(response.config).toHaveProperty('dns');
|
||||
expect(response.config).toHaveProperty('proxy');
|
||||
expect(response.config).toHaveProperty('security');
|
||||
console.log('Configuration read successfully');
|
||||
});
|
||||
|
||||
tap.test('should stop DCRouter', async () => {
|
||||
await testDcRouter.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
289
test/test.storagemanager.ts
Normal file
289
test/test.storagemanager.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as paths from '../ts/paths.js';
|
||||
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Test data
|
||||
const testData = {
|
||||
string: 'Hello, World!',
|
||||
json: { name: 'test', value: 42, nested: { data: true } },
|
||||
largeString: 'x'.repeat(10000)
|
||||
};
|
||||
|
||||
tap.test('Storage Manager - Memory Backend', async () => {
|
||||
// Create StorageManager without config (defaults to memory)
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test basic get/set
|
||||
await storage.set('/test/key', testData.string);
|
||||
const value = await storage.get('/test/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test JSON helpers
|
||||
await storage.setJSON('/test/json', testData.json);
|
||||
const jsonValue = await storage.getJSON('/test/json');
|
||||
expect(jsonValue).toEqual(testData.json);
|
||||
|
||||
// Test exists
|
||||
expect(await storage.exists('/test/key')).toEqual(true);
|
||||
expect(await storage.exists('/nonexistent')).toEqual(false);
|
||||
|
||||
// Test delete
|
||||
await storage.delete('/test/key');
|
||||
expect(await storage.exists('/test/key')).toEqual(false);
|
||||
|
||||
// Test list
|
||||
await storage.set('/items/1', 'one');
|
||||
await storage.set('/items/2', 'two');
|
||||
await storage.set('/other/3', 'three');
|
||||
|
||||
const items = await storage.list('/items');
|
||||
expect(items.length).toEqual(2);
|
||||
expect(items).toContain('/items/1');
|
||||
expect(items).toContain('/items/2');
|
||||
|
||||
// Verify memory backend
|
||||
expect(storage.getBackend()).toEqual('memory');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Filesystem Backend', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage');
|
||||
|
||||
// Clean up test directory if it exists
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
// Create StorageManager with filesystem path
|
||||
const storage = new StorageManager({ fsPath: testDir });
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/test/file', testData.string);
|
||||
const value = await storage.get('/test/file');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Verify file exists on disk
|
||||
const filePath = path.join(testDir, 'test', 'file');
|
||||
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toEqual(true);
|
||||
|
||||
// Test atomic writes (temp file should not exist)
|
||||
const tempPath = filePath + '.tmp';
|
||||
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
||||
expect(tempExists).toEqual(false);
|
||||
|
||||
// Test nested paths
|
||||
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
||||
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
||||
expect(nestedValue).toEqual(testData.largeString);
|
||||
|
||||
// Test list with filesystem
|
||||
await storage.set('/fs/items/a', 'alpha');
|
||||
await storage.set('/fs/items/b', 'beta');
|
||||
await storage.set('/fs/other/c', 'gamma');
|
||||
|
||||
// Filesystem backend now properly supports list
|
||||
const fsItems = await storage.list('/fs/items');
|
||||
expect(fsItems.length).toEqual(2); // Should find both items
|
||||
|
||||
// Clean up
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Custom Function Backend', async () => {
|
||||
// Create in-memory storage for custom functions
|
||||
const customStore = new Map<string, string>();
|
||||
|
||||
const storage = new StorageManager({
|
||||
readFunction: async (key: string) => {
|
||||
return customStore.get(key) || null;
|
||||
},
|
||||
writeFunction: async (key: string, value: string) => {
|
||||
customStore.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Test basic operations
|
||||
await storage.set('/custom/key', testData.string);
|
||||
expect(customStore.has('/custom/key')).toEqual(true);
|
||||
|
||||
const value = await storage.get('/custom/key');
|
||||
expect(value).toEqual(testData.string);
|
||||
|
||||
// Test that delete sets empty value (as per implementation)
|
||||
await storage.delete('/custom/key');
|
||||
expect(customStore.get('/custom/key')).toEqual('');
|
||||
|
||||
// Verify custom backend (filesystem is implemented as custom backend internally)
|
||||
expect(storage.getBackend()).toEqual('custom');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Key Validation', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Test key normalization
|
||||
await storage.set('test/key', 'value1'); // Missing leading slash
|
||||
const value1 = await storage.get('/test/key');
|
||||
expect(value1).toEqual('value1');
|
||||
|
||||
// Test dangerous path elements are removed
|
||||
await storage.set('/test/../danger/key', 'value2');
|
||||
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
||||
expect(value2).toEqual('value2');
|
||||
|
||||
// Test multiple slashes are normalized
|
||||
await storage.set('/test///multiple////slashes', 'value3');
|
||||
const value3 = await storage.get('/test/multiple/slashes');
|
||||
expect(value3).toEqual('value3');
|
||||
|
||||
// Test invalid keys throw errors
|
||||
let emptyKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set('', 'value');
|
||||
} catch (error) {
|
||||
emptyKeyError = error as Error;
|
||||
}
|
||||
expect(emptyKeyError).toBeTruthy();
|
||||
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
|
||||
let nullKeyError: Error | null = null;
|
||||
try {
|
||||
await storage.set(null as any, 'value');
|
||||
} catch (error) {
|
||||
nullKeyError = error as Error;
|
||||
}
|
||||
expect(nullKeyError).toBeTruthy();
|
||||
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Concurrent Access', async () => {
|
||||
const storage = new StorageManager();
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
// Simulate concurrent writes
|
||||
for (let i = 0; i < 100; i++) {
|
||||
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Verify all writes succeeded
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const value = await storage.get(`/concurrent/key${i}`);
|
||||
expect(value).toEqual(`value${i}`);
|
||||
}
|
||||
|
||||
// Test concurrent reads
|
||||
const readPromises: Promise<string | null>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
readPromises.push(storage.get(`/concurrent/key${i}`));
|
||||
}
|
||||
|
||||
const results = await Promise.all(readPromises);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
expect(results[i]).toEqual(`value${i}`);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Backend Priority', async () => {
|
||||
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
||||
|
||||
// Test that custom functions take priority over fsPath
|
||||
let warningLogged = false;
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (message: string) => {
|
||||
if (message.includes('Using custom read/write functions')) {
|
||||
warningLogged = true;
|
||||
}
|
||||
};
|
||||
|
||||
const storage = new StorageManager({
|
||||
fsPath: testDir,
|
||||
readFunction: async () => 'custom-value',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
console.warn = originalWarn;
|
||||
|
||||
expect(warningLogged).toEqual(true);
|
||||
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - Error Handling', async () => {
|
||||
// Test filesystem errors
|
||||
const storage = new StorageManager({
|
||||
readFunction: async () => {
|
||||
throw new Error('Read error');
|
||||
},
|
||||
writeFunction: async () => {
|
||||
throw new Error('Write error');
|
||||
}
|
||||
});
|
||||
|
||||
// Read errors should return null
|
||||
const value = await storage.get('/error/key');
|
||||
expect(value).toEqual(null);
|
||||
|
||||
// Write errors should propagate
|
||||
let writeError: Error | null = null;
|
||||
try {
|
||||
await storage.set('/error/key', 'value');
|
||||
} catch (error) {
|
||||
writeError = error as Error;
|
||||
}
|
||||
expect(writeError).toBeTruthy();
|
||||
expect(writeError?.message).toEqual('Write error');
|
||||
|
||||
// Test JSON parse errors
|
||||
const jsonStorage = new StorageManager({
|
||||
readFunction: async () => 'invalid json',
|
||||
writeFunction: async () => {}
|
||||
});
|
||||
|
||||
// Test JSON parse errors
|
||||
let jsonError: Error | null = null;
|
||||
try {
|
||||
await jsonStorage.getJSON('/invalid/json');
|
||||
} catch (error) {
|
||||
jsonError = error as Error;
|
||||
}
|
||||
expect(jsonError).toBeTruthy();
|
||||
expect(jsonError?.message).toContain('JSON');
|
||||
});
|
||||
|
||||
tap.test('Storage Manager - List Operations', async () => {
|
||||
const storage = new StorageManager();
|
||||
|
||||
// Populate storage with hierarchical data
|
||||
await storage.set('/app/config/database', 'db-config');
|
||||
await storage.set('/app/config/cache', 'cache-config');
|
||||
await storage.set('/app/data/users/1', 'user1');
|
||||
await storage.set('/app/data/users/2', 'user2');
|
||||
await storage.set('/app/logs/error.log', 'errors');
|
||||
|
||||
// List root
|
||||
const rootItems = await storage.list('/');
|
||||
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// List specific paths
|
||||
const configItems = await storage.list('/app/config');
|
||||
expect(configItems.length).toEqual(2);
|
||||
expect(configItems).toContain('/app/config/database');
|
||||
expect(configItems).toContain('/app/config/cache');
|
||||
|
||||
const userItems = await storage.list('/app/data/users');
|
||||
expect(userItems.length).toEqual(2);
|
||||
|
||||
// List non-existent path
|
||||
const emptyList = await storage.list('/nonexistent/path');
|
||||
expect(emptyList.length).toEqual(0);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
35
test_watch/devserver.ts
Normal file
35
test_watch/devserver.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Configure services as needed for development
|
||||
// OpsServer always starts on port 3000
|
||||
|
||||
// Example: Add SmartProxy routes
|
||||
// smartProxyConfig: {
|
||||
// routes: [...]
|
||||
// },
|
||||
|
||||
// Example: Add email configuration
|
||||
// emailConfig: {
|
||||
// ports: [2525],
|
||||
// hostname: 'localhost',
|
||||
// domains: [],
|
||||
// routes: []
|
||||
// },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
await devRouter.start();
|
||||
|
||||
// Graceful shutdown handlers
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down...');
|
||||
await devRouter.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
console.log('DcRouter dev server running. Press Ctrl+C to stop.');
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @pushrocks/commitinfo
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/platformservice',
|
||||
version: '1.0.5',
|
||||
description: 'contains the platformservice container with mail, sms, letter, ai services.'
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '6.4.4',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
166
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
166
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { CacheDb } from './classes.cachedb.js';
|
||||
|
||||
// Import document classes for cleanup
|
||||
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
|
||||
|
||||
/**
|
||||
* Configuration for the cache cleaner
|
||||
*/
|
||||
export interface ICacheCleanerOptions {
|
||||
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||
intervalMs?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheCleaner - Periodically removes expired documents from the cache
|
||||
*
|
||||
* Runs on a configurable interval (default: hourly) and queries each
|
||||
* collection for documents where expiresAt < now(), then deletes them.
|
||||
*/
|
||||
export class CacheCleaner {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
private options: Required<ICacheCleanerOptions>;
|
||||
private cacheDb: CacheDb;
|
||||
|
||||
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||
this.cacheDb = cacheDb;
|
||||
this.options = {
|
||||
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||
verbose: options.verbose || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic cleanup process
|
||||
*/
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.log('warn', 'CacheCleaner already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Run cleanup immediately on start
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
|
||||
// Schedule periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.runCleanup().catch((error) => {
|
||||
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||
});
|
||||
}, this.options.intervalMs);
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the periodic cleanup process
|
||||
*/
|
||||
public stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
logger.log('info', 'CacheCleaner stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single cleanup cycle
|
||||
*/
|
||||
public async runCleanup(): Promise<void> {
|
||||
if (!this.cacheDb.isReady()) {
|
||||
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results: { collection: string; deleted: number }[] = [];
|
||||
|
||||
try {
|
||||
const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
|
||||
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
|
||||
|
||||
const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
|
||||
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
|
||||
|
||||
// Log results
|
||||
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||
if (totalDeleted > 0 || this.options.verbose) {
|
||||
const summary = results
|
||||
.filter((r) => r.deleted > 0)
|
||||
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||
.join(', ');
|
||||
logger.log(
|
||||
'info',
|
||||
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired documents from a specific collection using smartdata API
|
||||
*/
|
||||
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
|
||||
documentClass: { getInstances: (filter: any) => Promise<T[]> },
|
||||
now: Date
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Find all expired documents
|
||||
const expiredDocs = await documentClass.getInstances({
|
||||
expiresAt: { $lt: now },
|
||||
});
|
||||
|
||||
// Delete each expired document
|
||||
let deletedCount = 0;
|
||||
for (const doc of expiredDocs) {
|
||||
try {
|
||||
await doc.delete();
|
||||
deletedCount++;
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cleaner is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cleanup interval in milliseconds
|
||||
*/
|
||||
public getIntervalMs(): number {
|
||||
return this.options.intervalMs;
|
||||
}
|
||||
}
|
||||
111
ts/cache/classes.cached.document.ts
vendored
Normal file
111
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Base class for all cached documents with TTL support
|
||||
*
|
||||
* Extends smartdata's SmartDataDbDoc to add:
|
||||
* - Automatic timestamps (createdAt, lastAccessedAt)
|
||||
* - TTL/expiration support (expiresAt)
|
||||
* - Helper methods for TTL management
|
||||
*
|
||||
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
|
||||
* since decorators on abstract classes don't propagate correctly.
|
||||
*/
|
||||
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
|
||||
/**
|
||||
* Timestamp when the document was created
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Timestamp when the document expires and should be cleaned up
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public expiresAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last access (for LRU-style eviction if needed)
|
||||
* NOTE: Subclasses must add @svDb() decorator
|
||||
*/
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Set the TTL (time to live) for this document
|
||||
* @param ttlMs Time to live in milliseconds
|
||||
*/
|
||||
public setTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using days
|
||||
* @param days Number of days until expiration
|
||||
*/
|
||||
public setTTLDays(days: number): void {
|
||||
this.setTTL(days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL using hours
|
||||
* @param hours Number of hours until expiration
|
||||
*/
|
||||
public setTTLHours(hours: number): void {
|
||||
this.setTTL(hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this document has expired
|
||||
*/
|
||||
public isExpired(): boolean {
|
||||
if (!this.expiresAt) {
|
||||
return false; // No expiration set
|
||||
}
|
||||
return new Date() > this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the lastAccessedAt timestamp
|
||||
*/
|
||||
public touch(): void {
|
||||
this.lastAccessedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining TTL in milliseconds
|
||||
* Returns 0 if expired, -1 if no expiration set
|
||||
*/
|
||||
public getRemainingTTL(): number {
|
||||
if (!this.expiresAt) {
|
||||
return -1;
|
||||
}
|
||||
const remaining = this.expiresAt.getTime() - Date.now();
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the TTL by the specified milliseconds from now
|
||||
* @param ttlMs Additional time to live in milliseconds
|
||||
*/
|
||||
public extendTTL(ttlMs: number): void {
|
||||
this.expiresAt = new Date(Date.now() + ttlMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the document to never expire (100 years in the future)
|
||||
*/
|
||||
public setNeverExpires(): void {
|
||||
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL constants in milliseconds
|
||||
*/
|
||||
export const TTL = {
|
||||
HOURS_1: 1 * 60 * 60 * 1000,
|
||||
HOURS_24: 24 * 60 * 60 * 1000,
|
||||
DAYS_7: 7 * 24 * 60 * 60 * 1000,
|
||||
DAYS_30: 30 * 24 * 60 * 60 * 1000,
|
||||
DAYS_90: 90 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
155
ts/cache/classes.cachedb.ts
vendored
Normal file
155
ts/cache/classes.cachedb.ts
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { defaultTsmDbPath } from '../paths.js';
|
||||
|
||||
/**
|
||||
* Configuration options for CacheDb
|
||||
*/
|
||||
export interface ICacheDbOptions {
|
||||
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||
storagePath?: string;
|
||||
/** Database name (default: dcrouter) */
|
||||
dbName?: string;
|
||||
/** Enable debug logging */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||
*
|
||||
* Provides persistent caching using smartdata as the ORM layer
|
||||
* and LocalTsmDb as the embedded database engine.
|
||||
*/
|
||||
export class CacheDb {
|
||||
private static instance: CacheDb | null = null;
|
||||
|
||||
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
private options: Required<ICacheDbOptions>;
|
||||
private isStarted: boolean = false;
|
||||
|
||||
constructor(options: ICacheDbOptions = {}) {
|
||||
this.options = {
|
||||
storagePath: options.storagePath || defaultTsmDbPath,
|
||||
dbName: options.dbName || 'dcrouter',
|
||||
debug: options.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the singleton instance
|
||||
*/
|
||||
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||
if (!CacheDb.instance) {
|
||||
CacheDb.instance = new CacheDb(options);
|
||||
}
|
||||
return CacheDb.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing)
|
||||
*/
|
||||
public static resetInstance(): void {
|
||||
CacheDb.instance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the cache database
|
||||
* - Initializes LocalTsmDb with file persistence
|
||||
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.log('warn', 'CacheDb already started');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||
|
||||
// Create LocalTsmDb instance
|
||||
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||
folderPath: this.options.storagePath,
|
||||
});
|
||||
|
||||
// Start LocalTsmDb and get connection info
|
||||
const connectionInfo = await this.localTsmDb.start();
|
||||
|
||||
if (this.options.debug) {
|
||||
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
||||
}
|
||||
|
||||
// Initialize smartdata with the connection URI
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUrl: connectionInfo.connectionUri,
|
||||
mongoDbName: this.options.dbName,
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
|
||||
this.isStarted = true;
|
||||
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the cache database
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.isStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close smartdata connection
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
|
||||
// Stop LocalTsmDb
|
||||
if (this.localTsmDb) {
|
||||
await this.localTsmDb.stop();
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
logger.log('info', 'CacheDb stopped');
|
||||
} catch (error) {
|
||||
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the smartdata database instance
|
||||
*/
|
||||
public getDb(): plugins.smartdata.SmartdataDb {
|
||||
if (!this.isStarted) {
|
||||
throw new Error('CacheDb not started. Call start() first.');
|
||||
}
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database is ready
|
||||
*/
|
||||
public isReady(): boolean {
|
||||
return this.isStarted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path
|
||||
*/
|
||||
public getStoragePath(): string {
|
||||
return this.options.storagePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database name
|
||||
*/
|
||||
public getDbName(): string {
|
||||
return this.options.dbName;
|
||||
}
|
||||
}
|
||||
240
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
240
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Email status in the cache
|
||||
*/
|
||||
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* CachedEmail - Stores email queue items in the cache
|
||||
*
|
||||
* Used for persistent email queue storage, tracking delivery status,
|
||||
* and maintaining email history for the configured TTL period.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* Unique identifier for this email
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id: string;
|
||||
|
||||
/**
|
||||
* Email message ID (RFC 822 Message-ID header)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public messageId: string;
|
||||
|
||||
/**
|
||||
* Sender email address (envelope from)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public from: string;
|
||||
|
||||
/**
|
||||
* Recipient email addresses
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public to: string[];
|
||||
|
||||
/**
|
||||
* CC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public cc: string[];
|
||||
|
||||
/**
|
||||
* BCC recipients
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public bcc: string[];
|
||||
|
||||
/**
|
||||
* Email subject
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public subject: string;
|
||||
|
||||
/**
|
||||
* Raw RFC822 email content
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public rawContent: string;
|
||||
|
||||
/**
|
||||
* Current status of the email
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TCachedEmailStatus;
|
||||
|
||||
/**
|
||||
* Number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public attempts: number = 0;
|
||||
|
||||
/**
|
||||
* Maximum number of delivery attempts
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public maxAttempts: number = 3;
|
||||
|
||||
/**
|
||||
* Timestamp for next delivery attempt
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public nextAttempt: Date;
|
||||
|
||||
/**
|
||||
* Last error message if delivery failed
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError: string;
|
||||
|
||||
/**
|
||||
* Timestamp when the email was successfully delivered
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public deliveredAt: Date;
|
||||
|
||||
/**
|
||||
* Sender domain (for querying/filtering)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public senderDomain: string;
|
||||
|
||||
/**
|
||||
* Priority level (higher = more important)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public priority: number = 0;
|
||||
|
||||
/**
|
||||
* JSON-serialized route data
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public routeData: string;
|
||||
|
||||
/**
|
||||
* DKIM signature status
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public dkimSigned: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||
this.status = 'pending';
|
||||
this.to = [];
|
||||
this.cc = [];
|
||||
this.bcc = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new CachedEmail with a unique ID
|
||||
*/
|
||||
public static createNew(): CachedEmail {
|
||||
const email = new CachedEmail();
|
||||
email.id = plugins.uuid.v4();
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an email by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||
return await CachedEmail.getInstance({
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails with a specific status
|
||||
*/
|
||||
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||
*/
|
||||
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||
const now = new Date();
|
||||
return await CachedEmail.getInstances({
|
||||
status: 'pending',
|
||||
nextAttempt: { $lte: now },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find emails by sender domain
|
||||
*/
|
||||
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||
return await CachedEmail.getInstances({
|
||||
senderDomain: domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as delivered
|
||||
*/
|
||||
public markDelivered(): void {
|
||||
this.status = 'delivered';
|
||||
this.deliveredAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as failed with error
|
||||
*/
|
||||
public markFailed(error: string): void {
|
||||
this.status = 'failed';
|
||||
this.lastError = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment attempt counter and schedule next attempt
|
||||
*/
|
||||
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||
this.attempts++;
|
||||
this.status = 'deferred';
|
||||
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||
|
||||
// If max attempts reached, mark as failed
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
this.status = 'failed';
|
||||
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sender domain from email address
|
||||
*/
|
||||
public updateSenderDomain(): void {
|
||||
if (this.from) {
|
||||
const match = this.from.match(/@([^>]+)>?$/);
|
||||
if (match) {
|
||||
this.senderDomain = match[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
247
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
247
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||
import { CacheDb } from '../classes.cachedb.js';
|
||||
|
||||
/**
|
||||
* Helper to get the smartdata database instance
|
||||
*/
|
||||
const getDb = () => CacheDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* IP reputation result data
|
||||
*/
|
||||
export interface IIPReputationData {
|
||||
score: number;
|
||||
isSpam: boolean;
|
||||
isProxy: boolean;
|
||||
isTor: boolean;
|
||||
isVPN: boolean;
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
blacklists?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CachedIPReputation - Stores IP reputation lookup results
|
||||
*
|
||||
* Caches the results of IP reputation checks to avoid repeated
|
||||
* external API calls. Default TTL is 24 hours.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
|
||||
// TTL fields from base class (decorators required on concrete class)
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt: Date = new Date();
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastAccessedAt: Date = new Date();
|
||||
|
||||
/**
|
||||
* IP address (unique identifier)
|
||||
*/
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public ipAddress: string;
|
||||
|
||||
/**
|
||||
* Reputation score (0-100, higher = better)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public score: number;
|
||||
|
||||
/**
|
||||
* Whether the IP is flagged as spam source
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isSpam: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a known proxy
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isProxy: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a Tor exit node
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isTor: boolean;
|
||||
|
||||
/**
|
||||
* Whether the IP is a VPN endpoint
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public isVPN: boolean;
|
||||
|
||||
/**
|
||||
* Country code (ISO 3166-1 alpha-2)
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public country: string;
|
||||
|
||||
/**
|
||||
* Autonomous System Number
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public asn: string;
|
||||
|
||||
/**
|
||||
* Organization name
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public org: string;
|
||||
|
||||
/**
|
||||
* List of blacklists the IP appears on
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public blacklists: string[];
|
||||
|
||||
/**
|
||||
* Number of times this IP has been checked
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public checkCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of connections from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public connectionCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of emails received from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public emailCount: number = 0;
|
||||
|
||||
/**
|
||||
* Number of spam emails from this IP
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public spamCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setTTL(TTL.HOURS_24); // Default 24-hour TTL
|
||||
this.blacklists = [];
|
||||
this.score = 50; // Default neutral score
|
||||
this.isSpam = false;
|
||||
this.isProxy = false;
|
||||
this.isTor = false;
|
||||
this.isVPN = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from reputation data
|
||||
*/
|
||||
public static fromReputationData(ipAddress: string, data: IIPReputationData): CachedIPReputation {
|
||||
const cached = new CachedIPReputation();
|
||||
cached.ipAddress = ipAddress;
|
||||
cached.score = data.score;
|
||||
cached.isSpam = data.isSpam;
|
||||
cached.isProxy = data.isProxy;
|
||||
cached.isTor = data.isTor;
|
||||
cached.isVPN = data.isVPN;
|
||||
cached.country = data.country || '';
|
||||
cached.asn = data.asn || '';
|
||||
cached.org = data.org || '';
|
||||
cached.blacklists = data.blacklists || [];
|
||||
cached.checkCount = 1;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to reputation data object
|
||||
*/
|
||||
public toReputationData(): IIPReputationData {
|
||||
this.touch();
|
||||
return {
|
||||
score: this.score,
|
||||
isSpam: this.isSpam,
|
||||
isProxy: this.isProxy,
|
||||
isTor: this.isTor,
|
||||
isVPN: this.isVPN,
|
||||
country: this.country,
|
||||
asn: this.asn,
|
||||
org: this.org,
|
||||
blacklists: this.blacklists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by IP address
|
||||
*/
|
||||
public static async findByIP(ipAddress: string): Promise<CachedIPReputation | null> {
|
||||
return await CachedIPReputation.getInstance({
|
||||
ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all IPs flagged as spam
|
||||
*/
|
||||
public static async findSpamIPs(): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
isSpam: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find IPs with score below threshold
|
||||
*/
|
||||
public static async findLowScoreIPs(threshold: number): Promise<CachedIPReputation[]> {
|
||||
return await CachedIPReputation.getInstances({
|
||||
score: { $lt: threshold },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a connection from this IP
|
||||
*/
|
||||
public recordConnection(): void {
|
||||
this.connectionCount++;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an email from this IP
|
||||
*/
|
||||
public recordEmail(isSpam: boolean = false): void {
|
||||
this.emailCount++;
|
||||
if (isSpam) {
|
||||
this.spamCount++;
|
||||
}
|
||||
this.touch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the reputation data
|
||||
*/
|
||||
public updateReputation(data: IIPReputationData): void {
|
||||
this.score = data.score;
|
||||
this.isSpam = data.isSpam;
|
||||
this.isProxy = data.isProxy;
|
||||
this.isTor = data.isTor;
|
||||
this.isVPN = data.isVPN;
|
||||
this.country = data.country || this.country;
|
||||
this.asn = data.asn || this.asn;
|
||||
this.org = data.org || this.org;
|
||||
this.blacklists = data.blacklists || this.blacklists;
|
||||
this.checkCount++;
|
||||
this.touch();
|
||||
// Refresh TTL on update
|
||||
this.setTTL(TTL.HOURS_24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this IP should be blocked
|
||||
*/
|
||||
public shouldBlock(): boolean {
|
||||
return this.isSpam || this.score < 20 || this.blacklists.length > 2;
|
||||
}
|
||||
}
|
||||
2
ts/cache/documents/index.ts
vendored
Normal file
2
ts/cache/documents/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
7
ts/cache/index.ts
vendored
Normal file
7
ts/cache/index.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Core cache infrastructure
|
||||
export * from './classes.cachedb.js';
|
||||
export * from './classes.cached.document.js';
|
||||
export * from './classes.cache.cleaner.js';
|
||||
|
||||
// Document classes
|
||||
export * from './documents/index.js';
|
||||
130
ts/classes.cert-provision-scheduler.ts
Normal file
130
ts/classes.cert-provision-scheduler.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { logger } from './logger.js';
|
||||
import type { StorageManager } from './storage/index.js';
|
||||
|
||||
interface IBackoffEntry {
|
||||
failures: number;
|
||||
lastFailure: string; // ISO string
|
||||
retryAfter: string; // ISO string
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages certificate provisioning scheduling with:
|
||||
* - Per-domain exponential backoff persisted in StorageManager
|
||||
*
|
||||
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||
* concurrency, per-domain dedup, and rate limiting internally.
|
||||
*/
|
||||
export class CertProvisionScheduler {
|
||||
private storageManager: StorageManager;
|
||||
private maxBackoffHours: number;
|
||||
|
||||
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||
private backoffCache = new Map<string, IBackoffEntry>();
|
||||
|
||||
constructor(
|
||||
storageManager: StorageManager,
|
||||
options?: { maxBackoffHours?: number }
|
||||
) {
|
||||
this.storageManager = storageManager;
|
||||
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage key for a domain's backoff entry
|
||||
*/
|
||||
private backoffKey(domain: string): string {
|
||||
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return `/cert-backoff/${clean}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load backoff entry from storage (with in-memory cache)
|
||||
*/
|
||||
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||
const cached = this.backoffCache.get(domain);
|
||||
if (cached) return cached;
|
||||
|
||||
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
||||
if (entry) {
|
||||
this.backoffCache.set(domain, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save backoff entry to both cache and storage
|
||||
*/
|
||||
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||
this.backoffCache.set(domain, entry);
|
||||
await this.storageManager.setJSON(this.backoffKey(domain), entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is currently in backoff
|
||||
*/
|
||||
async isInBackoff(domain: string): Promise<boolean> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return false;
|
||||
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
return retryAfter.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a provisioning failure for a domain.
|
||||
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
|
||||
*/
|
||||
async recordFailure(domain: string, error?: string): Promise<void> {
|
||||
const existing = await this.loadBackoff(domain);
|
||||
const failures = (existing?.failures ?? 0) + 1;
|
||||
|
||||
// Exponential backoff: failures^2 hours, capped
|
||||
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
|
||||
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
|
||||
|
||||
const entry: IBackoffEntry = {
|
||||
failures,
|
||||
lastFailure: new Date().toISOString(),
|
||||
retryAfter: retryAfter.toISOString(),
|
||||
lastError: error,
|
||||
};
|
||||
|
||||
await this.saveBackoff(domain, entry);
|
||||
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear backoff for a domain (on success or manual override)
|
||||
*/
|
||||
async clearBackoff(domain: string): Promise<void> {
|
||||
this.backoffCache.delete(domain);
|
||||
try {
|
||||
await this.storageManager.delete(this.backoffKey(domain));
|
||||
} catch {
|
||||
// Ignore delete errors (key may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backoff info for UI display
|
||||
*/
|
||||
async getBackoffInfo(domain: string): Promise<{
|
||||
failures: number;
|
||||
retryAfter?: string;
|
||||
lastError?: string;
|
||||
} | null> {
|
||||
const entry = await this.loadBackoff(domain);
|
||||
if (!entry) return null;
|
||||
|
||||
// Only return if still in backoff
|
||||
const retryAfter = new Date(entry.retryAfter);
|
||||
if (retryAfter.getTime() <= Date.now()) return null;
|
||||
|
||||
return {
|
||||
failures: entry.failures,
|
||||
retryAfter: entry.retryAfter,
|
||||
lastError: entry.lastError,
|
||||
};
|
||||
}
|
||||
}
|
||||
1643
ts/classes.dcrouter.ts
Normal file
1643
ts/classes.dcrouter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { PlatformServiceDb } from './classes.platformservicedb.js'
|
||||
import { EmailService } from './email/email.classes.emailservice.js';
|
||||
import { SmsService } from './sms/smsservice.js';
|
||||
import { LetterService } from './letter/classes.letterservice.js';
|
||||
import { MtaService } from './mta/mta.classes.mta.js';
|
||||
|
||||
export class SzPlatformService {
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
||||
public platformserviceDb: PlatformServiceDb;
|
||||
|
||||
public typedserver: plugins.typedserver.TypedServer;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// SubServices
|
||||
public emailService: EmailService;
|
||||
public letterService: LetterService;
|
||||
public mtaService: MtaService;
|
||||
public smsService: SmsService;
|
||||
|
||||
public async start() {
|
||||
this.platformserviceDb = new PlatformServiceDb(this);
|
||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
||||
|
||||
// lets start the sub services
|
||||
this.emailService = new EmailService(this);
|
||||
this.letterService = new LetterService(this, {
|
||||
letterxpressUser: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_USER'),
|
||||
letterxpressToken: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_TOKEN')
|
||||
});
|
||||
this.mtaService = new MtaService(this);
|
||||
this.smsService = new SmsService(this, {
|
||||
apiGatewayApiToken: await this.serviceQenv.getEnvVarOnDemand('SMS_API_TOKEN'),
|
||||
});
|
||||
|
||||
// lets start the server finally
|
||||
this.typedserver = new plugins.typedserver.TypedServer({
|
||||
cors: true,
|
||||
});
|
||||
await this.typedserver.start();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SzPlatformService } from './classes.platformservice.js';
|
||||
|
||||
|
||||
|
||||
export class PlatformServiceDb {
|
||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
public platformserviceRef: SzPlatformService;
|
||||
|
||||
constructor(platformserviceRefArg: SzPlatformService) {
|
||||
this.platformserviceRef = platformserviceRefArg;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
||||
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
||||
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
||||
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
||||
});
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
}
|
||||
46
ts/classes.storage-cert-manager.ts
Normal file
46
ts/classes.storage-cert-manager.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { StorageManager } from './storage/index.js';
|
||||
|
||||
/**
|
||||
* ICertManager implementation backed by StorageManager.
|
||||
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
||||
* survive process restarts without re-hitting ACME.
|
||||
*/
|
||||
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||
private keyPrefix = '/certs/';
|
||||
|
||||
constructor(private storageManager: StorageManager) {}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
||||
if (!data) return null;
|
||||
return new plugins.smartacme.Cert(data);
|
||||
}
|
||||
|
||||
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
||||
id: cert.id,
|
||||
domainName: cert.domainName,
|
||||
created: cert.created,
|
||||
privateKey: cert.privateKey,
|
||||
publicKey: cert.publicKey,
|
||||
csr: cert.csr,
|
||||
validUntil: cert.validUntil,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCertificate(domainName: string): Promise<void> {
|
||||
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async wipe(): Promise<void> {
|
||||
const keys = await this.storageManager.list(this.keyPrefix);
|
||||
for (const key of keys) {
|
||||
await this.storageManager.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
ts/config/index.ts
Normal file
2
ts/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.js';
|
||||
266
ts/config/validator.ts
Normal file
266
ts/config/validator.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { ValidationError } from '../errors/base.errors.js';
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface IValidationResult {
|
||||
/**
|
||||
* Whether the validation passed
|
||||
*/
|
||||
valid: boolean;
|
||||
|
||||
/**
|
||||
* Validation errors if any
|
||||
*/
|
||||
errors?: string[];
|
||||
|
||||
/**
|
||||
* Validated configuration (may include defaults)
|
||||
*/
|
||||
config?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation schema types
|
||||
*/
|
||||
export type ValidationSchema = Record<string, {
|
||||
/**
|
||||
* Type of the value
|
||||
*/
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
|
||||
/**
|
||||
* Whether the field is required
|
||||
*/
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* Default value if not specified
|
||||
*/
|
||||
default?: any;
|
||||
|
||||
/**
|
||||
* Minimum value (for numbers)
|
||||
*/
|
||||
min?: number;
|
||||
|
||||
/**
|
||||
* Maximum value (for numbers)
|
||||
*/
|
||||
max?: number;
|
||||
|
||||
/**
|
||||
* Minimum length (for strings or arrays)
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/**
|
||||
* Maximum length (for strings or arrays)
|
||||
*/
|
||||
maxLength?: number;
|
||||
|
||||
/**
|
||||
* Pattern to match (for strings)
|
||||
*/
|
||||
pattern?: RegExp;
|
||||
|
||||
/**
|
||||
* Allowed values (for strings, numbers)
|
||||
*/
|
||||
enum?: any[];
|
||||
|
||||
/**
|
||||
* Nested schema (for objects)
|
||||
*/
|
||||
schema?: ValidationSchema;
|
||||
|
||||
/**
|
||||
* Item schema (for arrays)
|
||||
*/
|
||||
items?: {
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
schema?: ValidationSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom validation function
|
||||
*/
|
||||
validate?: (value: any) => boolean | string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Configuration validator
|
||||
* Validates configuration objects against schemas and provides default values
|
||||
*/
|
||||
export class ConfigValidator {
|
||||
|
||||
/**
|
||||
* Validate a configuration object against a schema
|
||||
*
|
||||
* @param config Configuration object to validate
|
||||
* @param schema Validation schema
|
||||
* @returns Validation result
|
||||
*/
|
||||
public static validate<T>(config: T, schema: ValidationSchema): IValidationResult {
|
||||
const errors: string[] = [];
|
||||
const validatedConfig = { ...config };
|
||||
|
||||
// Validate each field against the schema
|
||||
for (const [key, rules] of Object.entries(schema)) {
|
||||
const value = config[key];
|
||||
|
||||
// Check if required
|
||||
if (rules.required && (value === undefined || value === null)) {
|
||||
errors.push(`${key} is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If not present and not required, apply default if available
|
||||
if ((value === undefined || value === null)) {
|
||||
if (rules.default !== undefined) {
|
||||
validatedConfig[key] = rules.default;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
if (value !== undefined && value !== null) {
|
||||
const valueType = Array.isArray(value) ? 'array' : typeof value;
|
||||
if (valueType !== rules.type) {
|
||||
errors.push(`${key} must be of type ${rules.type}, got ${valueType}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type-specific validations
|
||||
switch (rules.type) {
|
||||
case 'number':
|
||||
if (rules.min !== undefined && value < rules.min) {
|
||||
errors.push(`${key} must be at least ${rules.min}`);
|
||||
}
|
||||
if (rules.max !== undefined && value > rules.max) {
|
||||
errors.push(`${key} must be at most ${rules.max}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||||
}
|
||||
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must be at most ${rules.maxLength} characters`);
|
||||
}
|
||||
if (rules.pattern && !rules.pattern.test(value)) {
|
||||
errors.push(`${key} must match pattern ${rules.pattern}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||
errors.push(`${key} must have at least ${rules.minLength} items`);
|
||||
}
|
||||
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||
errors.push(`${key} must have at most ${rules.maxLength} items`);
|
||||
}
|
||||
if (rules.items && value.length > 0) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const itemType = Array.isArray(value[i]) ? 'array' : typeof value[i];
|
||||
if (itemType !== rules.items.type) {
|
||||
errors.push(`${key}[${i}] must be of type ${rules.items.type}, got ${itemType}`);
|
||||
} else if (rules.items.schema && itemType === 'object') {
|
||||
const itemResult = this.validate(value[i], rules.items.schema);
|
||||
if (!itemResult.valid) {
|
||||
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (rules.schema) {
|
||||
const nestedResult = this.validate(value, rules.schema);
|
||||
if (!nestedResult.valid) {
|
||||
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||
}
|
||||
validatedConfig[key] = nestedResult.config;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Enum validation
|
||||
if (rules.enum && !rules.enum.includes(value)) {
|
||||
errors.push(`${key} must be one of [${rules.enum.join(', ')}]`);
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.validate) {
|
||||
const result = rules.validate(value);
|
||||
if (result !== true) {
|
||||
errors.push(typeof result === 'string' ? result : `${key} failed custom validation`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
config: validatedConfig
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Apply defaults to a configuration object based on a schema
|
||||
*
|
||||
* @param config Configuration object to apply defaults to
|
||||
* @param schema Validation schema with defaults
|
||||
* @returns Configuration with defaults applied
|
||||
*/
|
||||
public static applyDefaults<T>(config: T, schema: ValidationSchema): T {
|
||||
const result = { ...config };
|
||||
|
||||
for (const [key, rules] of Object.entries(schema)) {
|
||||
if (result[key] === undefined && rules.default !== undefined) {
|
||||
result[key] = rules.default;
|
||||
}
|
||||
|
||||
// Apply defaults to nested objects
|
||||
if (result[key] && rules.type === 'object' && rules.schema) {
|
||||
result[key] = this.applyDefaults(result[key], rules.schema);
|
||||
}
|
||||
|
||||
// Apply defaults to array items
|
||||
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||
result[key] = result[key].map(item =>
|
||||
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a validation error if the configuration is invalid
|
||||
*
|
||||
* @param config Configuration to validate
|
||||
* @param schema Validation schema
|
||||
* @returns Validated configuration with defaults
|
||||
* @throws ValidationError if validation fails
|
||||
*/
|
||||
public static validateOrThrow<T>(config: T, schema: ValidationSchema): T {
|
||||
const result = this.validate(config, schema);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ValidationError(
|
||||
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||
'CONFIG_VALIDATION_ERROR',
|
||||
{ data: { errors: result.errors } }
|
||||
);
|
||||
}
|
||||
|
||||
return result.config;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class ApiManager {
|
||||
public emailRef: EmailService;
|
||||
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IRequest_SendEmail>(
|
||||
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
|
||||
const mailToSend = new plugins.smartmail.Smartmail({
|
||||
body: requestData.body,
|
||||
from: requestData.from,
|
||||
subject: requestData.title,
|
||||
});
|
||||
|
||||
if (requestData.attachments) {
|
||||
for (const attachment of requestData.attachments) {
|
||||
mailToSend.addAttachment(
|
||||
await plugins.smartfile.SmartFile.fromString(
|
||||
attachment.name,
|
||||
attachment.binaryAttachmentString,
|
||||
'binary'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.emailRef.mailgunConnector.sendEmail(mailToSend, requestData.to, {});
|
||||
logger.log(
|
||||
'info',
|
||||
`send an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
||||
{
|
||||
eventType: 'sentEmail',
|
||||
email: {
|
||||
to: requestData.to,
|
||||
subject: mailToSend.getSubject(),
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
responseId: 'abc', // TODO: generate proper response id
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
|
||||
export class MailgunConnector {
|
||||
public emailRef: EmailService;
|
||||
public mailgunAccount: plugins.mailgun.MailgunAccount;
|
||||
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
|
||||
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
|
||||
region: 'eu',
|
||||
});
|
||||
this.mailgunAccount.addSmtpCredentials(
|
||||
this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_SMTP_CREDENTIALS')
|
||||
);
|
||||
}
|
||||
|
||||
public async sendEmail(
|
||||
smartMailArg: plugins.smartmail.Smartmail<any>,
|
||||
toArg: string,
|
||||
dataArg: any = {}
|
||||
) {
|
||||
this.mailgunAccount.sendSmartMail(smartMailArg, toArg, dataArg);
|
||||
}
|
||||
|
||||
public async receiveEmail(messageUrl: string) {
|
||||
return await this.mailgunAccount.retrieveSmartMailFromMessageUrl(messageUrl);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { MailgunConnector } from './email.classes.connector.mailgun.js';
|
||||
import { RuleManager } from './email.classes.rulemanager.js';
|
||||
import { ApiManager } from './email.classes.apimanager.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
||||
|
||||
|
||||
export interface IEmailConstructorOptions {
|
||||
mailgunApiKey: string;
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
|
||||
// typedrouter
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// connectors
|
||||
public mailgunConnector: MailgunConnector;
|
||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
|
||||
// server
|
||||
public apiManager = new ApiManager(this);
|
||||
public ruleManager: RuleManager;
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.mailgunConnector = new MailgunConnector(this);
|
||||
this.ruleManager = new RuleManager(this);
|
||||
this.platformServiceRef.typedserver.server.addRoute(
|
||||
'/mailgun-notify',
|
||||
new plugins.typedserver.servertools.Handler('POST', async (req, res) => {
|
||||
console.log('Got a mailgun email notification');
|
||||
res.status(200);
|
||||
res.end();
|
||||
this.ruleManager.handleNotification(req.body);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
await this.ruleManager.init();
|
||||
logger.log('success', `Started email service`);
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
import { logger } from './email.logging.js';
|
||||
|
||||
export class RuleManager {
|
||||
public emailRef: EmailService;
|
||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
||||
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
|
||||
>();
|
||||
|
||||
constructor(emailRefArg: EmailService) {
|
||||
this.emailRef = emailRefArg;
|
||||
}
|
||||
|
||||
public async handleNotification(notification: plugins.mailgun.IMailgunNotification) {
|
||||
console.log(notification['message-url']);
|
||||
|
||||
// basic checks here
|
||||
// none for now
|
||||
|
||||
const fetchedSmartmail = await this.emailRef.mailgunConnector.receiveEmail(
|
||||
notification['message-url']
|
||||
);
|
||||
console.log('=======================');
|
||||
console.log('Received a mail:');
|
||||
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
|
||||
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
|
||||
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
|
||||
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
|
||||
|
||||
logger.log(
|
||||
'info',
|
||||
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
|
||||
{
|
||||
eventType: 'receivedEmail',
|
||||
email: {
|
||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
||||
}
|
||||
|
||||
public async init() {
|
||||
// lets forward stuff
|
||||
await this.createForwards();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates the default forwards
|
||||
*/
|
||||
public async createForwards() {
|
||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [
|
||||
{
|
||||
originalToAddress: ['bot@mail.nevermind.group'],
|
||||
forwardedToAddress: ['phil@metadata.company', 'dominik@metadata.company'],
|
||||
},
|
||||
{
|
||||
originalToAddress: ['legal@mail.lossless.com'],
|
||||
forwardedToAddress: ['phil@lossless.com'],
|
||||
},
|
||||
{
|
||||
originalToAddress: ['christine.nyamwaro@mail.lossless.com', 'christine@nyamwaro.com'],
|
||||
forwardedToAddress: ['phil@lossless.com'],
|
||||
},
|
||||
];
|
||||
console.log(`${forwards.length} forward rules configured:`);
|
||||
for (const forward of forwards) {
|
||||
console.log(forward);
|
||||
}
|
||||
|
||||
for (const forward of forwards) {
|
||||
this.smartruleInstance.createRule(
|
||||
10,
|
||||
async (smartmailArg) => {
|
||||
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
|
||||
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
|
||||
}, false);
|
||||
if (matched) {
|
||||
console.log('Forward rule matched');
|
||||
console.log(forward);
|
||||
return 'apply-continue';
|
||||
} else {
|
||||
return 'continue';
|
||||
}
|
||||
},
|
||||
async (smartmailArg: plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>) => {
|
||||
forward.forwardedToAddress.map(async (toArg) => {
|
||||
const forwardedSmartMail = new plugins.smartmail.Smartmail({
|
||||
body:
|
||||
`
|
||||
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
|
||||
<div><b>Original Sender:</b></div>
|
||||
<div>${smartmailArg.options.creationObjectRef.From}</div>
|
||||
<div><b>Original Recipient:</b></div>
|
||||
<div>${smartmailArg.options.creationObjectRef.To}</div>
|
||||
<div><b>Forwarded to:</b></div>
|
||||
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
|
||||
return `${pVal ? pVal + ', ' : ''}${cVal}`;
|
||||
}, null)}</div>
|
||||
<div><b>Subject:</b></div>
|
||||
<div>${smartmailArg.getSubject()}</div>
|
||||
<div><b>The original body can be found below.</b></div>
|
||||
</div>
|
||||
` + smartmailArg.getBody(),
|
||||
from: 'forwarder@mail.lossless.one',
|
||||
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
|
||||
});
|
||||
for (const attachment of smartmailArg.attachments) {
|
||||
forwardedSmartMail.addAttachment(attachment);
|
||||
}
|
||||
await this.emailRef.mailgunConnector.sendEmail(forwardedSmartMail, toArg);
|
||||
console.log(`forwarded mail to ${toArg}`);
|
||||
logger.log(
|
||||
'info',
|
||||
`email from ${
|
||||
smartmailArg.options.creationObjectRef.From
|
||||
} to phil@lossless.com with subject '${smartmailArg.getSubject()}'`,
|
||||
{
|
||||
eventType: 'forwardedEmail',
|
||||
email: {
|
||||
from: smartmailArg.options.creationObjectRef.From,
|
||||
to: smartmailArg.options.creationObjectRef.To,
|
||||
forwardedTo: toArg,
|
||||
subject: smartmailArg.options.creationObjectRef.Subject,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import * as plugins from './email.plugins.js';
|
||||
|
||||
export class TemplateManager {
|
||||
public smartmailDefault = new plugins.smartmail.Smartmail({
|
||||
body: `
|
||||
|
||||
`,
|
||||
from: `noreply@mail.lossless.com`,
|
||||
subject: `{{subject}}`,
|
||||
});
|
||||
|
||||
public createSmartmailFromData(tempalteTypeArg: plugins.lointEmail.TTemplates) {}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { EmailService } from './email.classes.emailservice.js';
|
||||
|
||||
export { EmailService as Email };
|
||||
525
ts/errors/base.errors.ts
Normal file
525
ts/errors/base.errors.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// Import TLogLevel from plugins
|
||||
import type { TLogLevel } from '../plugins.js';
|
||||
|
||||
/**
|
||||
* Context information added to structured errors
|
||||
*/
|
||||
export interface IErrorContext {
|
||||
/** Component or service where the error occurred */
|
||||
component?: string;
|
||||
|
||||
/** Operation that was being performed */
|
||||
operation?: string;
|
||||
|
||||
/** Unique request ID if available */
|
||||
requestId?: string;
|
||||
|
||||
/** Error occurred at timestamp */
|
||||
timestamp?: number;
|
||||
|
||||
/** User-visible message (safe to display to end-users) */
|
||||
userMessage?: string;
|
||||
|
||||
/** Additional structured data for debugging */
|
||||
data?: Record<string, any>;
|
||||
|
||||
/** Related entity IDs if applicable */
|
||||
entity?: {
|
||||
type: string;
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
/** Stack trace (if enabled in configuration) */
|
||||
stack?: string;
|
||||
|
||||
/** Retry information if applicable */
|
||||
retry?: {
|
||||
/** Maximum number of retries allowed */
|
||||
maxRetries?: number;
|
||||
|
||||
/** Current retry count */
|
||||
currentRetry?: number;
|
||||
|
||||
/** Next retry timestamp */
|
||||
nextRetryAt?: number;
|
||||
|
||||
/** Delay between retries (in ms) */
|
||||
retryDelay?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all errors in the Platform Service
|
||||
* Adds structured error information, logging, and error tracking
|
||||
*/
|
||||
export class PlatformError extends Error {
|
||||
/** Error code identifying the specific error type */
|
||||
public readonly code: string;
|
||||
|
||||
/** Error severity level */
|
||||
public readonly severity: ErrorSeverity;
|
||||
|
||||
/** Error category for grouping related errors */
|
||||
public readonly category: ErrorCategory;
|
||||
|
||||
/** Whether the error can be recovered from automatically */
|
||||
public readonly recoverability: ErrorRecoverability;
|
||||
|
||||
/** Additional context information */
|
||||
public readonly context: IErrorContext;
|
||||
|
||||
/**
|
||||
* Creates a new PlatformError
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code from error.codes.ts
|
||||
* @param severity Error severity level
|
||||
* @param category Error category
|
||||
* @param recoverability Error recoverability indication
|
||||
* @param context Additional context information
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
|
||||
category: ErrorCategory = ErrorCategory.OTHER,
|
||||
recoverability: ErrorRecoverability = ErrorRecoverability.NON_RECOVERABLE,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message);
|
||||
|
||||
// Set error metadata
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.severity = severity;
|
||||
this.category = category;
|
||||
this.recoverability = recoverability;
|
||||
|
||||
// Add timestamp if not provided
|
||||
this.context = {
|
||||
...context,
|
||||
timestamp: context.timestamp || Date.now(),
|
||||
};
|
||||
|
||||
// Capture stack trace
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
|
||||
// Log the error automatically unless explicitly disabled
|
||||
if (!context.data?.skipLogging) {
|
||||
this.logError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the error using the platform logger
|
||||
*/
|
||||
private logError(): void {
|
||||
const logLevel = this.getLogLevelFromSeverity() as TLogLevel;
|
||||
|
||||
// Construct structured log entry
|
||||
const logData = {
|
||||
error_code: this.code,
|
||||
error_name: this.name,
|
||||
severity: this.severity,
|
||||
category: this.category,
|
||||
recoverability: this.recoverability,
|
||||
...this.context
|
||||
};
|
||||
|
||||
// Log with appropriate level
|
||||
logger.log(logLevel, this.message, logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps severity levels to log levels
|
||||
*/
|
||||
private getLogLevelFromSeverity(): string {
|
||||
switch (this.severity) {
|
||||
case ErrorSeverity.CRITICAL:
|
||||
case ErrorSeverity.HIGH:
|
||||
return 'error';
|
||||
case ErrorSeverity.MEDIUM:
|
||||
return 'warn';
|
||||
case ErrorSeverity.LOW:
|
||||
return 'info';
|
||||
case ErrorSeverity.INFO:
|
||||
return 'debug';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JSON representation of the error
|
||||
*/
|
||||
public toJSON(): Record<string, any> {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
severity: this.severity,
|
||||
category: this.category,
|
||||
recoverability: this.recoverability,
|
||||
context: this.context,
|
||||
stack: process.env.NODE_ENV !== 'production' ? this.stack : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance with retry information
|
||||
*
|
||||
* @param maxRetries Maximum number of retries
|
||||
* @param currentRetry Current retry count
|
||||
* @param retryDelay Delay between retries in ms
|
||||
*/
|
||||
public withRetry(
|
||||
maxRetries: number,
|
||||
currentRetry: number = 0,
|
||||
retryDelay: number = 1000
|
||||
): PlatformError {
|
||||
const nextRetryAt = Date.now() + retryDelay;
|
||||
|
||||
// Clone the error with updated context
|
||||
const newContext = {
|
||||
...this.context,
|
||||
retry: {
|
||||
maxRetries,
|
||||
currentRetry,
|
||||
nextRetryAt,
|
||||
retryDelay
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new instance using the protected method that subclasses can override
|
||||
const newError = this.createWithContext(newContext);
|
||||
|
||||
// Update recoverability if we can retry
|
||||
if (currentRetry < maxRetries && newError.recoverability === ErrorRecoverability.NON_RECOVERABLE) {
|
||||
(newError as any).recoverability = ErrorRecoverability.MAYBE_RECOVERABLE;
|
||||
}
|
||||
|
||||
return newError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method to create a new instance with updated context
|
||||
* Subclasses can override this to handle their own constructor signatures
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
// Default implementation for PlatformError
|
||||
return new (this.constructor as typeof PlatformError)(
|
||||
this.message,
|
||||
this.code,
|
||||
this.severity,
|
||||
this.category,
|
||||
this.recoverability,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the error should be retried based on retry information
|
||||
*/
|
||||
public shouldRetry(): boolean {
|
||||
const { retry } = this.context;
|
||||
if (!retry) return false;
|
||||
|
||||
return retry.currentRetry < retry.maxRetries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-friendly message that is safe to display to end users
|
||||
*/
|
||||
public getUserMessage(): string {
|
||||
return this.context.userMessage || 'An unexpected error occurred.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for validation errors
|
||||
*/
|
||||
export class ValidationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new validation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.LOW,
|
||||
ErrorCategory.VALIDATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle ValidationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ValidationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for configuration errors
|
||||
*/
|
||||
export class ConfigurationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new configuration error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.CONFIGURATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle ConfigurationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ConfigurationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for network-related errors
|
||||
*/
|
||||
export class NetworkError extends PlatformError {
|
||||
/**
|
||||
* Creates a new network error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.CONNECTIVITY,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle NetworkError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof NetworkError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for resource availability errors (rate limits, quotas)
|
||||
*/
|
||||
export class ResourceError extends PlatformError {
|
||||
/**
|
||||
* Creates a new resource error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.RESOURCE,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle ResourceError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ResourceError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for authentication/authorization errors
|
||||
*/
|
||||
export class AuthenticationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new authentication error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.HIGH,
|
||||
ErrorCategory.AUTHENTICATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle AuthenticationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof AuthenticationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for operation errors (API calls, processing)
|
||||
*/
|
||||
export class OperationError extends PlatformError {
|
||||
/**
|
||||
* Creates a new operation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
* Overrides the base implementation to handle OperationError's constructor signature
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof OperationError)(
|
||||
this.message,
|
||||
this.code,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for critical system errors
|
||||
*/
|
||||
export class SystemError extends PlatformError {
|
||||
/**
|
||||
* Creates a new system error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorSeverity.CRITICAL,
|
||||
ErrorCategory.OTHER,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the appropriate error class based on error category
|
||||
*
|
||||
* @param category Error category
|
||||
* @returns The appropriate error class
|
||||
*/
|
||||
export function getErrorClassForCategory(category: ErrorCategory): any {
|
||||
switch (category) {
|
||||
case ErrorCategory.VALIDATION:
|
||||
return ValidationError;
|
||||
case ErrorCategory.CONFIGURATION:
|
||||
return ConfigurationError;
|
||||
case ErrorCategory.CONNECTIVITY:
|
||||
return NetworkError;
|
||||
case ErrorCategory.RESOURCE:
|
||||
return ResourceError;
|
||||
case ErrorCategory.AUTHENTICATION:
|
||||
return AuthenticationError;
|
||||
case ErrorCategory.OPERATION:
|
||||
return OperationError;
|
||||
default:
|
||||
return PlatformError;
|
||||
}
|
||||
}
|
||||
412
ts/errors/error-handler.ts
Normal file
412
ts/errors/error-handler.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { PlatformError } from './base.errors.js';
|
||||
import type { IErrorContext } from './base.errors.js';
|
||||
import { ErrorCategory, ErrorRecoverability, ErrorSeverity } from './error.codes.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Error handler configuration
|
||||
*/
|
||||
export interface IErrorHandlerConfig {
|
||||
/** Whether to log errors automatically */
|
||||
logErrors: boolean;
|
||||
|
||||
/** Whether to include stack traces in prod environment */
|
||||
includeStacksInProd: boolean;
|
||||
|
||||
/** Default retry options */
|
||||
retry: {
|
||||
/** Maximum retry attempts */
|
||||
maxAttempts: number;
|
||||
|
||||
/** Base delay between retries in ms */
|
||||
baseDelay: number;
|
||||
|
||||
/** Maximum delay between retries in ms */
|
||||
maxDelay: number;
|
||||
|
||||
/** Backoff factor for exponential backoff */
|
||||
backoffFactor: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Global error handler configuration
|
||||
*/
|
||||
const config: IErrorHandlerConfig = {
|
||||
logErrors: true,
|
||||
includeStacksInProd: false,
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Error handler utility
|
||||
* Provides methods for consistent error handling across the platform
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Current configuration
|
||||
*/
|
||||
public static config = config;
|
||||
|
||||
/**
|
||||
* Update error handler configuration
|
||||
*
|
||||
* @param newConfig New configuration (partial)
|
||||
*/
|
||||
public static configure(newConfig: Partial<IErrorHandlerConfig>): void {
|
||||
ErrorHandler.config = {
|
||||
...ErrorHandler.config,
|
||||
...newConfig,
|
||||
retry: {
|
||||
...ErrorHandler.config.retry,
|
||||
...(newConfig.retry || {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any error to a PlatformError
|
||||
*
|
||||
* @param error Error to convert
|
||||
* @param defaultCode Default error code if not a PlatformError
|
||||
* @param context Additional context
|
||||
* @returns PlatformError instance
|
||||
*/
|
||||
public static toPlatformError(
|
||||
error: any,
|
||||
defaultCode: string,
|
||||
context: IErrorContext = {}
|
||||
): PlatformError {
|
||||
// If already a PlatformError, just add context
|
||||
if (error instanceof PlatformError) {
|
||||
// Add context if provided
|
||||
if (Object.keys(context).length > 0) {
|
||||
return new (error.constructor as typeof PlatformError)(
|
||||
error.message,
|
||||
error.code,
|
||||
error.severity,
|
||||
error.category,
|
||||
error.recoverability,
|
||||
{
|
||||
...error.context,
|
||||
...context,
|
||||
data: {
|
||||
...(error.context.data || {}),
|
||||
...(context.data || {})
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
// Convert standard Error to PlatformError
|
||||
if (error instanceof Error) {
|
||||
return new PlatformError(
|
||||
error.message,
|
||||
defaultCode,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...(context.data || {}),
|
||||
originalError: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Not an Error instance
|
||||
return new PlatformError(
|
||||
typeof error === 'string' ? error : 'Unknown error',
|
||||
defaultCode,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an error for API responses
|
||||
* Sanitizes errors for safe external exposure
|
||||
*
|
||||
* @param error Error to format
|
||||
* @param includeDetails Whether to include detailed information
|
||||
* @returns Formatted error object
|
||||
*/
|
||||
public static formatErrorForResponse(
|
||||
error: any,
|
||||
includeDetails: boolean = false
|
||||
): Record<string, any> {
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
error,
|
||||
'PLATFORM_OPERATION_ERROR'
|
||||
);
|
||||
|
||||
// Basic error information
|
||||
const responseError: Record<string, any> = {
|
||||
code: platformError.code,
|
||||
message: platformError.getUserMessage(),
|
||||
requestId: platformError.context.requestId
|
||||
};
|
||||
|
||||
// Include more details if requested
|
||||
if (includeDetails) {
|
||||
responseError.details = {
|
||||
severity: platformError.severity,
|
||||
category: platformError.category,
|
||||
rawMessage: platformError.message,
|
||||
data: platformError.context.data
|
||||
};
|
||||
|
||||
// Only include stack trace in non-production or if explicitly enabled
|
||||
if (process.env.NODE_ENV !== 'production' || ErrorHandler.config.includeStacksInProd) {
|
||||
responseError.details.stack = platformError.stack;
|
||||
}
|
||||
}
|
||||
|
||||
return responseError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error with consistent logging and formatting
|
||||
*
|
||||
* @param error Error to handle
|
||||
* @param defaultCode Default error code if not a PlatformError
|
||||
* @param context Additional context
|
||||
* @returns Formatted error for response
|
||||
*/
|
||||
public static handleError(
|
||||
error: any,
|
||||
defaultCode: string,
|
||||
context: IErrorContext = {}
|
||||
): Record<string, any> {
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
error,
|
||||
defaultCode,
|
||||
context
|
||||
);
|
||||
|
||||
// Log the error if enabled
|
||||
if (ErrorHandler.config.logErrors) {
|
||||
logger.error(platformError.message, {
|
||||
error_code: platformError.code,
|
||||
error_name: platformError.name,
|
||||
error_severity: platformError.severity,
|
||||
error_category: platformError.category,
|
||||
error_recoverability: platformError.recoverability,
|
||||
...platformError.context,
|
||||
stack: platformError.stack
|
||||
});
|
||||
}
|
||||
|
||||
// Return formatted error for response
|
||||
const isDetailedMode = process.env.NODE_ENV !== 'production';
|
||||
return ErrorHandler.formatErrorForResponse(platformError, isDetailedMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with error handling
|
||||
*
|
||||
* @param fn Function to execute
|
||||
* @param defaultCode Default error code if the function throws
|
||||
* @param context Additional context
|
||||
* @returns Function result or error
|
||||
*/
|
||||
public static async execute<T>(
|
||||
fn: () => Promise<T>,
|
||||
defaultCode: string,
|
||||
context: IErrorContext = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
throw ErrorHandler.toPlatformError(error, defaultCode, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retries and exponential backoff
|
||||
*
|
||||
* @param fn Function to execute
|
||||
* @param defaultCode Default error code if the function throws
|
||||
* @param options Retry options
|
||||
* @param context Additional context
|
||||
* @returns Function result or error after max retries
|
||||
*/
|
||||
public static async executeWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
defaultCode: string,
|
||||
options: {
|
||||
maxAttempts?: number;
|
||||
baseDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
retryableErrorCodes?: string[];
|
||||
retryableErrorPatterns?: RegExp[];
|
||||
onRetry?: (error: PlatformError, attempt: number, delay: number) => void;
|
||||
} = {},
|
||||
context: IErrorContext = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = ErrorHandler.config.retry.maxAttempts,
|
||||
baseDelay = ErrorHandler.config.retry.baseDelay,
|
||||
maxDelay = ErrorHandler.config.retry.maxDelay,
|
||||
backoffFactor = ErrorHandler.config.retry.backoffFactor,
|
||||
retryableErrorCodes = [],
|
||||
retryableErrorPatterns = [],
|
||||
onRetry = () => {}
|
||||
} = options;
|
||||
|
||||
let lastError: PlatformError;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
// Convert to PlatformError
|
||||
const platformError = ErrorHandler.toPlatformError(
|
||||
error,
|
||||
defaultCode,
|
||||
{
|
||||
...context,
|
||||
retry: {
|
||||
currentRetry: attempt,
|
||||
maxRetries: maxAttempts,
|
||||
nextRetryAt: 0 // Will be set below if retrying
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
lastError = platformError;
|
||||
|
||||
// Check if we should retry
|
||||
const isLastAttempt = attempt >= maxAttempts - 1;
|
||||
|
||||
if (isLastAttempt) {
|
||||
// No more retries
|
||||
throw platformError;
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
const isRetryable =
|
||||
// Built-in recoverability
|
||||
platformError.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||
platformError.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||
platformError.recoverability === ErrorRecoverability.TRANSIENT ||
|
||||
// Specifically included error codes
|
||||
retryableErrorCodes.includes(platformError.code) ||
|
||||
// Matches error message patterns
|
||||
retryableErrorPatterns.some(pattern => pattern.test(platformError.message));
|
||||
|
||||
if (!isRetryable) {
|
||||
throw platformError;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
const delay = Math.min(baseDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||
|
||||
// Add jitter to prevent thundering herd problem (±20%)
|
||||
const jitter = 0.8 + Math.random() * 0.4;
|
||||
const actualDelay = Math.floor(delay * jitter);
|
||||
|
||||
// Update nextRetryAt in error context
|
||||
const nextRetryAt = Date.now() + actualDelay;
|
||||
platformError.context.retry!.nextRetryAt = nextRetryAt;
|
||||
|
||||
// Log retry attempt
|
||||
logger.warn(`Retrying operation after error (attempt ${attempt + 1}/${maxAttempts}): ${platformError.message}`, {
|
||||
error_code: platformError.code,
|
||||
retry_attempt: attempt + 1,
|
||||
retry_max_attempts: maxAttempts,
|
||||
retry_delay_ms: actualDelay,
|
||||
retry_next_at: new Date(nextRetryAt).toISOString()
|
||||
});
|
||||
|
||||
// Call onRetry callback
|
||||
onRetry(platformError, attempt + 1, actualDelay);
|
||||
|
||||
// Wait before next retry
|
||||
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen, but TypeScript needs it
|
||||
throw lastError!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a middleware for handling errors in HTTP requests
|
||||
*
|
||||
* @returns Middleware function
|
||||
*/
|
||||
export function createErrorHandlerMiddleware() {
|
||||
return (error: any, req: any, res: any, next: any) => {
|
||||
// Add request context
|
||||
const context: IErrorContext = {
|
||||
requestId: req.headers['x-request-id'] || req.headers['x-correlation-id'],
|
||||
component: 'HttpServer',
|
||||
operation: `${req.method} ${req.url}`,
|
||||
data: {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
ip: req.ip || req.connection.remoteAddress,
|
||||
userAgent: req.headers['user-agent']
|
||||
}
|
||||
};
|
||||
|
||||
// Handle the error
|
||||
const formattedError = ErrorHandler.handleError(
|
||||
error,
|
||||
'PLATFORM_OPERATION_ERROR',
|
||||
context
|
||||
);
|
||||
|
||||
// Set status code based on error type
|
||||
let statusCode = 500;
|
||||
|
||||
if (error instanceof PlatformError) {
|
||||
// Map error categories to HTTP status codes
|
||||
switch (error.category) {
|
||||
case ErrorCategory.VALIDATION:
|
||||
statusCode = 400;
|
||||
break;
|
||||
case ErrorCategory.AUTHENTICATION:
|
||||
statusCode = 401;
|
||||
break;
|
||||
case ErrorCategory.RESOURCE:
|
||||
statusCode = 429;
|
||||
break;
|
||||
case ErrorCategory.OPERATION:
|
||||
statusCode = 400;
|
||||
break;
|
||||
default:
|
||||
statusCode = 500;
|
||||
}
|
||||
} else if (error.statusCode) {
|
||||
// Use provided status code if available
|
||||
statusCode = error.statusCode;
|
||||
}
|
||||
|
||||
// Send error response
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: formattedError
|
||||
});
|
||||
};
|
||||
}
|
||||
165
ts/errors/error.codes.ts
Normal file
165
ts/errors/error.codes.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Platform Service Error Codes
|
||||
*
|
||||
* This file contains all error codes used across the platform service.
|
||||
*
|
||||
* Format: PREFIX_ERROR_TYPE
|
||||
* - PREFIX: Component/domain prefix (e.g., EMAIL, MTA, SMS)
|
||||
* - ERROR_TYPE: Specific error type within the domain
|
||||
*/
|
||||
|
||||
// General platform errors (PLATFORM_*)
|
||||
export const PLATFORM_INITIALIZATION_ERROR = 'PLATFORM_INITIALIZATION_ERROR';
|
||||
export const PLATFORM_CONFIGURATION_ERROR = 'PLATFORM_CONFIGURATION_ERROR';
|
||||
export const PLATFORM_OPERATION_ERROR = 'PLATFORM_OPERATION_ERROR';
|
||||
export const PLATFORM_NOT_IMPLEMENTED = 'PLATFORM_NOT_IMPLEMENTED';
|
||||
export const PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED';
|
||||
export const PLATFORM_SERVICE_UNAVAILABLE = 'PLATFORM_SERVICE_UNAVAILABLE';
|
||||
|
||||
// Email service errors (EMAIL_*)
|
||||
export const EMAIL_SERVICE_ERROR = 'EMAIL_SERVICE_ERROR';
|
||||
export const EMAIL_TEMPLATE_ERROR = 'EMAIL_TEMPLATE_ERROR';
|
||||
export const EMAIL_VALIDATION_ERROR = 'EMAIL_VALIDATION_ERROR';
|
||||
export const EMAIL_SEND_ERROR = 'EMAIL_SEND_ERROR';
|
||||
export const EMAIL_RECEIVE_ERROR = 'EMAIL_RECEIVE_ERROR';
|
||||
export const EMAIL_ATTACHMENT_ERROR = 'EMAIL_ATTACHMENT_ERROR';
|
||||
export const EMAIL_PARSE_ERROR = 'EMAIL_PARSE_ERROR';
|
||||
export const EMAIL_RATE_LIMIT_EXCEEDED = 'EMAIL_RATE_LIMIT_EXCEEDED';
|
||||
|
||||
// MTA-specific errors (MTA_*)
|
||||
export const MTA_CONNECTION_ERROR = 'MTA_CONNECTION_ERROR';
|
||||
export const MTA_AUTHENTICATION_ERROR = 'MTA_AUTHENTICATION_ERROR';
|
||||
export const MTA_DELIVERY_ERROR = 'MTA_DELIVERY_ERROR';
|
||||
export const MTA_CONFIGURATION_ERROR = 'MTA_CONFIGURATION_ERROR';
|
||||
export const MTA_DNS_ERROR = 'MTA_DNS_ERROR';
|
||||
export const MTA_TIMEOUT_ERROR = 'MTA_TIMEOUT_ERROR';
|
||||
export const MTA_PROTOCOL_ERROR = 'MTA_PROTOCOL_ERROR';
|
||||
|
||||
// Bounce management errors (BOUNCE_*)
|
||||
export const BOUNCE_PROCESSING_ERROR = 'BOUNCE_PROCESSING_ERROR';
|
||||
export const BOUNCE_STORAGE_ERROR = 'BOUNCE_STORAGE_ERROR';
|
||||
export const BOUNCE_CLASSIFICATION_ERROR = 'BOUNCE_CLASSIFICATION_ERROR';
|
||||
|
||||
// Email authentication errors (AUTH_*)
|
||||
export const AUTH_SPF_ERROR = 'AUTH_SPF_ERROR';
|
||||
export const AUTH_DKIM_ERROR = 'AUTH_DKIM_ERROR';
|
||||
export const AUTH_DMARC_ERROR = 'AUTH_DMARC_ERROR';
|
||||
export const AUTH_KEY_ERROR = 'AUTH_KEY_ERROR';
|
||||
|
||||
// Content scanning errors (SCAN_*)
|
||||
export const SCAN_ANALYSIS_ERROR = 'SCAN_ANALYSIS_ERROR';
|
||||
export const SCAN_MALWARE_DETECTED = 'SCAN_MALWARE_DETECTED';
|
||||
export const SCAN_PHISHING_DETECTED = 'SCAN_PHISHING_DETECTED';
|
||||
export const SCAN_CONTENT_REJECTED = 'SCAN_CONTENT_REJECTED';
|
||||
|
||||
// IP and reputation errors (REPUTATION_*)
|
||||
export const REPUTATION_CHECK_ERROR = 'REPUTATION_CHECK_ERROR';
|
||||
export const REPUTATION_DATA_ERROR = 'REPUTATION_DATA_ERROR';
|
||||
export const REPUTATION_BLOCKLIST_ERROR = 'REPUTATION_BLOCKLIST_ERROR';
|
||||
export const REPUTATION_UPDATE_ERROR = 'REPUTATION_UPDATE_ERROR';
|
||||
|
||||
// IP warmup errors (WARMUP_*)
|
||||
export const WARMUP_ALLOCATION_ERROR = 'WARMUP_ALLOCATION_ERROR';
|
||||
export const WARMUP_LIMIT_EXCEEDED = 'WARMUP_LIMIT_EXCEEDED';
|
||||
export const WARMUP_SCHEDULE_ERROR = 'WARMUP_SCHEDULE_ERROR';
|
||||
|
||||
// Network and connectivity errors (NETWORK_*)
|
||||
export const NETWORK_CONNECTION_ERROR = 'NETWORK_CONNECTION_ERROR';
|
||||
export const NETWORK_TIMEOUT = 'NETWORK_TIMEOUT';
|
||||
export const NETWORK_DNS_ERROR = 'NETWORK_DNS_ERROR';
|
||||
export const NETWORK_TLS_ERROR = 'NETWORK_TLS_ERROR';
|
||||
|
||||
// Queue and processing errors (QUEUE_*)
|
||||
export const QUEUE_FULL_ERROR = 'QUEUE_FULL_ERROR';
|
||||
export const QUEUE_PROCESSING_ERROR = 'QUEUE_PROCESSING_ERROR';
|
||||
export const QUEUE_PERSISTENCE_ERROR = 'QUEUE_PERSISTENCE_ERROR';
|
||||
export const QUEUE_ITEM_NOT_FOUND = 'QUEUE_ITEM_NOT_FOUND';
|
||||
|
||||
// DcRouter errors (DCR_*)
|
||||
export const DCR_ROUTING_ERROR = 'DCR_ROUTING_ERROR';
|
||||
export const DCR_CONFIGURATION_ERROR = 'DCR_CONFIGURATION_ERROR';
|
||||
export const DCR_PROXY_ERROR = 'DCR_PROXY_ERROR';
|
||||
export const DCR_DOMAIN_ERROR = 'DCR_DOMAIN_ERROR';
|
||||
|
||||
// SMS service errors (SMS_*)
|
||||
export const SMS_SERVICE_ERROR = 'SMS_SERVICE_ERROR';
|
||||
export const SMS_SEND_ERROR = 'SMS_SEND_ERROR';
|
||||
export const SMS_VALIDATION_ERROR = 'SMS_VALIDATION_ERROR';
|
||||
export const SMS_RATE_LIMIT_EXCEEDED = 'SMS_RATE_LIMIT_EXCEEDED';
|
||||
|
||||
// Storage errors (STORAGE_*)
|
||||
export const STORAGE_WRITE_ERROR = 'STORAGE_WRITE_ERROR';
|
||||
export const STORAGE_READ_ERROR = 'STORAGE_READ_ERROR';
|
||||
export const STORAGE_DELETE_ERROR = 'STORAGE_DELETE_ERROR';
|
||||
export const STORAGE_QUOTA_EXCEEDED = 'STORAGE_QUOTA_EXCEEDED';
|
||||
|
||||
// Rule management errors (RULE_*)
|
||||
export const RULE_VALIDATION_ERROR = 'RULE_VALIDATION_ERROR';
|
||||
export const RULE_EXECUTION_ERROR = 'RULE_EXECUTION_ERROR';
|
||||
export const RULE_NOT_FOUND = 'RULE_NOT_FOUND';
|
||||
|
||||
// Type definitions for error severity
|
||||
export enum ErrorSeverity {
|
||||
/** Critical errors that require immediate attention */
|
||||
CRITICAL = 'CRITICAL',
|
||||
|
||||
/** High-impact errors that may affect service functioning */
|
||||
HIGH = 'HIGH',
|
||||
|
||||
/** Medium-impact errors that cause partial degradation */
|
||||
MEDIUM = 'MEDIUM',
|
||||
|
||||
/** Low-impact errors that have minimal or local impact */
|
||||
LOW = 'LOW',
|
||||
|
||||
/** Informational errors that are not problematic */
|
||||
INFO = 'INFO'
|
||||
}
|
||||
|
||||
// Type definitions for error categories
|
||||
export enum ErrorCategory {
|
||||
/** Errors related to configuration */
|
||||
CONFIGURATION = 'CONFIGURATION',
|
||||
|
||||
/** Errors related to network connectivity */
|
||||
CONNECTIVITY = 'CONNECTIVITY',
|
||||
|
||||
/** Errors related to authentication/authorization */
|
||||
AUTHENTICATION = 'AUTHENTICATION',
|
||||
|
||||
/** Errors related to data validation */
|
||||
VALIDATION = 'VALIDATION',
|
||||
|
||||
/** Errors related to resource availability */
|
||||
RESOURCE = 'RESOURCE',
|
||||
|
||||
/** Errors related to service operations */
|
||||
OPERATION = 'OPERATION',
|
||||
|
||||
/** Errors related to third-party integrations */
|
||||
INTEGRATION = 'INTEGRATION',
|
||||
|
||||
/** Errors related to security */
|
||||
SECURITY = 'SECURITY',
|
||||
|
||||
/** Errors related to data storage */
|
||||
STORAGE = 'STORAGE',
|
||||
|
||||
/** Errors that don't fit into other categories */
|
||||
OTHER = 'OTHER'
|
||||
}
|
||||
|
||||
// Type definitions for error recoverability
|
||||
export enum ErrorRecoverability {
|
||||
/** Error cannot be automatically recovered from */
|
||||
NON_RECOVERABLE = 'NON_RECOVERABLE',
|
||||
|
||||
/** Error might be recoverable with retry */
|
||||
MAYBE_RECOVERABLE = 'MAYBE_RECOVERABLE',
|
||||
|
||||
/** Error is definitely recoverable with retries */
|
||||
RECOVERABLE = 'RECOVERABLE',
|
||||
|
||||
/** Error is transient and should resolve without action */
|
||||
TRANSIENT = 'TRANSIENT'
|
||||
}
|
||||
193
ts/errors/index.ts
Normal file
193
ts/errors/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Platform Service Error System
|
||||
*
|
||||
* This module provides a comprehensive error handling system for the Platform Service,
|
||||
* with structured error types, error codes, and consistent patterns for logging and recovery.
|
||||
*/
|
||||
|
||||
// Export error codes and types
|
||||
export * from './error.codes.js';
|
||||
|
||||
// Export base error classes
|
||||
export * from './base.errors.js';
|
||||
|
||||
// Export domain-specific error classes
|
||||
export * from './reputation.errors.js';
|
||||
|
||||
// Export error handler
|
||||
export * from './error-handler.js';
|
||||
|
||||
// Export utility function to create specific error types based on the error category
|
||||
import { getErrorClassForCategory } from './base.errors.js';
|
||||
export { getErrorClassForCategory };
|
||||
|
||||
// Import needed classes for utility functions
|
||||
import { PlatformError } from './base.errors.js';
|
||||
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||
|
||||
/**
|
||||
* Create a typed error from a standard Error
|
||||
* Useful for converting errors from external libraries or APIs
|
||||
*
|
||||
* @param error Standard error to convert
|
||||
* @param code Error code to assign
|
||||
* @param contextData Additional context data
|
||||
* @returns Typed PlatformError
|
||||
*/
|
||||
export function fromError(
|
||||
error: Error,
|
||||
code: string,
|
||||
contextData: Record<string, any> = {}
|
||||
): PlatformError {
|
||||
return new PlatformError(
|
||||
error.message,
|
||||
code,
|
||||
ErrorSeverity.MEDIUM,
|
||||
ErrorCategory.OPERATION,
|
||||
ErrorRecoverability.NON_RECOVERABLE,
|
||||
{
|
||||
data: {
|
||||
...contextData,
|
||||
originalError: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is retryable
|
||||
*
|
||||
* @param error Error to check
|
||||
* @returns Boolean indicating if the error should be retried
|
||||
*/
|
||||
export function isRetryable(error: any): boolean {
|
||||
// If it's our platform error, use its recoverability property
|
||||
if (error && typeof error === 'object' && 'recoverability' in error) {
|
||||
return error.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||
error.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||
error.recoverability === ErrorRecoverability.TRANSIENT;
|
||||
}
|
||||
|
||||
// Check if it's a network error (these are often transient)
|
||||
if (error && typeof error === 'object' && error.code) {
|
||||
const networkErrors = [
|
||||
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH',
|
||||
'ENETUNREACH', 'ENOTFOUND', 'EPROTO', 'ECONNABORTED'
|
||||
];
|
||||
|
||||
return networkErrors.includes(error.code);
|
||||
}
|
||||
|
||||
// By default, we can't determine if the error is retryable
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a wrapped version of a function that catches errors
|
||||
* and converts them to typed PlatformErrors
|
||||
*
|
||||
* @param fn Function to wrap
|
||||
* @param errorCode Default error code to use
|
||||
* @param contextData Additional context data
|
||||
* @returns Wrapped function
|
||||
*/
|
||||
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
errorCode: string,
|
||||
contextData: Record<string, any> = {}
|
||||
): T {
|
||||
return (async function(...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
try {
|
||||
return await fn(...args);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
// Already a typed error, rethrow
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw fromError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
errorCode,
|
||||
{
|
||||
...contextData,
|
||||
fnName: fn.name,
|
||||
args: args.map(arg =>
|
||||
typeof arg === 'object'
|
||||
? '[Object]'
|
||||
: String(arg).substring(0, 100)
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
*
|
||||
* @param fn Function to retry
|
||||
* @param options Retry options
|
||||
* @returns Function result or throws after max retries
|
||||
*/
|
||||
export async function retry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
backoffFactor?: number;
|
||||
retryableErrors?: Array<string | RegExp>;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
initialDelay = 1000,
|
||||
maxDelay = 30000,
|
||||
backoffFactor = 2,
|
||||
retryableErrors = []
|
||||
} = options;
|
||||
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
|
||||
// Check if we should retry
|
||||
const shouldRetry = attempt < maxRetries && (
|
||||
isRetryable(error) ||
|
||||
retryableErrors.some(pattern => {
|
||||
if (typeof pattern === 'string') {
|
||||
return lastError.message.includes(pattern);
|
||||
}
|
||||
return pattern.test(lastError.message);
|
||||
})
|
||||
);
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
const delay = Math.min(initialDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||
|
||||
// Add jitter to prevent thundering herd problem (±20%)
|
||||
const jitter = 0.8 + Math.random() * 0.4;
|
||||
const actualDelay = Math.floor(delay * jitter);
|
||||
|
||||
// Wait before next retry
|
||||
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen, but TypeScript needs it
|
||||
throw lastError!;
|
||||
}
|
||||
422
ts/errors/reputation.errors.ts
Normal file
422
ts/errors/reputation.errors.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import {
|
||||
PlatformError,
|
||||
OperationError,
|
||||
ResourceError
|
||||
} from './base.errors.js';
|
||||
import type { IErrorContext } from './base.errors.js';
|
||||
|
||||
import {
|
||||
REPUTATION_CHECK_ERROR,
|
||||
REPUTATION_DATA_ERROR,
|
||||
REPUTATION_BLOCKLIST_ERROR,
|
||||
REPUTATION_UPDATE_ERROR,
|
||||
WARMUP_ALLOCATION_ERROR,
|
||||
WARMUP_LIMIT_EXCEEDED,
|
||||
WARMUP_SCHEDULE_ERROR
|
||||
} from './error.codes.js';
|
||||
|
||||
/**
|
||||
* Base class for reputation-related errors
|
||||
*/
|
||||
export class ReputationError extends OperationError {
|
||||
/**
|
||||
* Creates a new reputation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param code Error code
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, code, context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for reputation check errors
|
||||
*/
|
||||
export class ReputationCheckError extends ReputationError {
|
||||
/**
|
||||
* Creates a new reputation check error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_CHECK_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ReputationCheckError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for an IP reputation check error
|
||||
*
|
||||
* @param ip IP address
|
||||
* @param provider Reputation provider
|
||||
* @param originalError Original error
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static ipCheckFailed(
|
||||
ip: string,
|
||||
provider: string,
|
||||
originalError?: Error,
|
||||
context: IErrorContext = {}
|
||||
): ReputationCheckError {
|
||||
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||
return new ReputationCheckError(
|
||||
`Failed to check reputation for IP ${ip} with provider ${provider}${errorMsg}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
ip,
|
||||
provider,
|
||||
originalError: originalError ? {
|
||||
message: originalError.message,
|
||||
stack: originalError.stack
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for a domain reputation check error
|
||||
*
|
||||
* @param domain Domain
|
||||
* @param provider Reputation provider
|
||||
* @param originalError Original error
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static domainCheckFailed(
|
||||
domain: string,
|
||||
provider: string,
|
||||
originalError?: Error,
|
||||
context: IErrorContext = {}
|
||||
): ReputationCheckError {
|
||||
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||
return new ReputationCheckError(
|
||||
`Failed to check reputation for domain ${domain} with provider ${provider}${errorMsg}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
domain,
|
||||
provider,
|
||||
originalError: originalError ? {
|
||||
message: originalError.message,
|
||||
stack: originalError.stack
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for reputation data errors
|
||||
*/
|
||||
export class ReputationDataError extends ReputationError {
|
||||
/**
|
||||
* Creates a new reputation data error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_DATA_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ReputationDataError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for a data access error
|
||||
*
|
||||
* @param entity Entity type (domain, ip)
|
||||
* @param entityId Entity identifier
|
||||
* @param operation Operation that failed (read, write, update)
|
||||
* @param originalError Original error
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static dataAccessFailed(
|
||||
entity: string,
|
||||
entityId: string,
|
||||
operation: string,
|
||||
originalError?: Error,
|
||||
context: IErrorContext = {}
|
||||
): ReputationDataError {
|
||||
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||
return new ReputationDataError(
|
||||
`Failed to ${operation} reputation data for ${entity} ${entityId}${errorMsg}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
entity,
|
||||
entityId,
|
||||
operation,
|
||||
originalError: originalError ? {
|
||||
message: originalError.message,
|
||||
stack: originalError.stack
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for blocklist-related errors
|
||||
*/
|
||||
export class BlocklistError extends ReputationError {
|
||||
/**
|
||||
* Creates a new blocklist error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_BLOCKLIST_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof BlocklistError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for an entity found on a blocklist
|
||||
*
|
||||
* @param entity Entity type (domain, ip)
|
||||
* @param entityId Entity identifier
|
||||
* @param blocklist Blocklist name
|
||||
* @param reason Reason for listing (if available)
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static entityBlocked(
|
||||
entity: string,
|
||||
entityId: string,
|
||||
blocklist: string,
|
||||
reason?: string,
|
||||
context: IErrorContext = {}
|
||||
): BlocklistError {
|
||||
const reasonText = reason ? ` (${reason})` : '';
|
||||
return new BlocklistError(
|
||||
`${entity.charAt(0).toUpperCase() + entity.slice(1)} ${entityId} is listed on blocklist ${blocklist}${reasonText}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
entity,
|
||||
entityId,
|
||||
blocklist,
|
||||
reason
|
||||
},
|
||||
userMessage: `The ${entity} ${entityId} is on a blocklist. This may affect email deliverability.`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for reputation update errors
|
||||
*/
|
||||
export class ReputationUpdateError extends ReputationError {
|
||||
/**
|
||||
* Creates a new reputation update error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, REPUTATION_UPDATE_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof ReputationUpdateError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for IP warmup allocation errors
|
||||
*/
|
||||
export class WarmupAllocationError extends ReputationError {
|
||||
/**
|
||||
* Creates a new warmup allocation error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, WARMUP_ALLOCATION_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof WarmupAllocationError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for no available IPs
|
||||
*
|
||||
* @param domain Domain requesting an IP
|
||||
* @param policy Allocation policy that was used
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static noAvailableIps(
|
||||
domain: string,
|
||||
policy: string,
|
||||
context: IErrorContext = {}
|
||||
): WarmupAllocationError {
|
||||
return new WarmupAllocationError(
|
||||
`No available IPs for domain ${domain} using ${policy} allocation policy`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
domain,
|
||||
policy
|
||||
},
|
||||
userMessage: `No available sending IPs for ${domain}.`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for IP warmup limit exceeded errors
|
||||
*/
|
||||
export class WarmupLimitError extends ResourceError {
|
||||
/**
|
||||
* Creates a new warmup limit error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, WARMUP_LIMIT_EXCEEDED, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof WarmupLimitError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance for daily sending limit exceeded
|
||||
*
|
||||
* @param ip IP address
|
||||
* @param domain Domain
|
||||
* @param limit Daily limit
|
||||
* @param sent Number of emails sent
|
||||
* @param context Additional context
|
||||
*/
|
||||
public static dailyLimitExceeded(
|
||||
ip: string,
|
||||
domain: string,
|
||||
limit: number,
|
||||
sent: number,
|
||||
context: IErrorContext = {}
|
||||
): WarmupLimitError {
|
||||
return new WarmupLimitError(
|
||||
`Daily sending limit exceeded for IP ${ip} and domain ${domain}: ${sent}/${limit}`,
|
||||
{
|
||||
...context,
|
||||
data: {
|
||||
...context.data,
|
||||
ip,
|
||||
domain,
|
||||
limit,
|
||||
sent
|
||||
},
|
||||
userMessage: `Daily sending limit reached for ${domain}.`
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for IP warmup schedule errors
|
||||
*/
|
||||
export class WarmupScheduleError extends ReputationError {
|
||||
/**
|
||||
* Creates a new warmup schedule error
|
||||
*
|
||||
* @param message Error message
|
||||
* @param context Additional context
|
||||
*/
|
||||
constructor(
|
||||
message: string,
|
||||
context: IErrorContext = {}
|
||||
) {
|
||||
super(message, WARMUP_SCHEDULE_ERROR, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance with updated context
|
||||
*/
|
||||
protected createWithContext(context: IErrorContext): PlatformError {
|
||||
return new (this.constructor as typeof WarmupScheduleError)(
|
||||
this.message,
|
||||
context
|
||||
);
|
||||
}
|
||||
}
|
||||
16
ts/index.ts
16
ts/index.ts
@@ -1,4 +1,16 @@
|
||||
export * from './00_commitinfo_data.js';
|
||||
import { SzPlatformService } from './classes.platformservice.js';
|
||||
|
||||
export const runCli = async () => {}
|
||||
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
|
||||
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
|
||||
// DcRouter
|
||||
export * from './classes.dcrouter.js';
|
||||
|
||||
// RADIUS module
|
||||
export * from './radius/index.js';
|
||||
|
||||
// Remote Ingress module
|
||||
export * from './remoteingress/index.js';
|
||||
|
||||
export const runCli = async () => {};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export interface ILetterConstructorOptions {
|
||||
letterxpressUser: string;
|
||||
letterxpressToken: string;
|
||||
}
|
||||
|
||||
export class LetterService {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
public options: ILetterConstructorOptions;
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ILetterConstructorOptions) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.options = optionsArg;
|
||||
}
|
||||
}
|
||||
86
ts/logger.ts
86
ts/logger.ts
@@ -1,9 +1,91 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const logger = new plugins.smartlog.Smartlog({
|
||||
// Map NODE_ENV to valid TEnvironment
|
||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
||||
'development': 'local',
|
||||
'test': 'test',
|
||||
'staging': 'staging',
|
||||
'production': 'production'
|
||||
};
|
||||
|
||||
// Default Smartlog instance
|
||||
const baseLogger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
environment: envMap[nodeEnv] || 'production',
|
||||
runtime: 'node',
|
||||
zone: 'serve.zone',
|
||||
}
|
||||
});
|
||||
|
||||
// Extended logger compatible with the original enhanced logger API
|
||||
class StandardLogger {
|
||||
private defaultContext: Record<string, any> = {};
|
||||
private correlationId: string | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
// Log methods
|
||||
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
|
||||
const combinedContext = {
|
||||
...this.defaultContext,
|
||||
...context
|
||||
};
|
||||
|
||||
if (this.correlationId) {
|
||||
combinedContext.correlation_id = this.correlationId;
|
||||
}
|
||||
|
||||
baseLogger.log(level, message, combinedContext);
|
||||
}
|
||||
|
||||
public error(message: string, context: Record<string, any> = {}) {
|
||||
this.log('error', message, context);
|
||||
}
|
||||
|
||||
public warn(message: string, context: Record<string, any> = {}) {
|
||||
this.log('warn', message, context);
|
||||
}
|
||||
|
||||
public info(message: string, context: Record<string, any> = {}) {
|
||||
this.log('info', message, context);
|
||||
}
|
||||
|
||||
public success(message: string, context: Record<string, any> = {}) {
|
||||
this.log('success', message, context);
|
||||
}
|
||||
|
||||
public debug(message: string, context: Record<string, any> = {}) {
|
||||
this.log('debug', message, context);
|
||||
}
|
||||
|
||||
// Context management
|
||||
public setContext(context: Record<string, any>, overwrite: boolean = false) {
|
||||
if (overwrite) {
|
||||
this.defaultContext = context;
|
||||
} else {
|
||||
this.defaultContext = {
|
||||
...this.defaultContext,
|
||||
...context
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Correlation ID management
|
||||
public setCorrelationId(id: string | null = null): string {
|
||||
this.correlationId = id || randomUUID();
|
||||
return this.correlationId;
|
||||
}
|
||||
|
||||
public getCorrelationId(): string | null {
|
||||
return this.correlationId;
|
||||
}
|
||||
|
||||
public clearCorrelationId(): void {
|
||||
this.correlationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const logger = new StandardLogger();
|
||||
|
||||
75
ts/monitoring/classes.metricscache.ts
Normal file
75
ts/monitoring/classes.metricscache.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface ICacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class MetricsCache {
|
||||
private cache = new Map<string, ICacheEntry<any>>();
|
||||
private readonly defaultTTL: number;
|
||||
|
||||
constructor(defaultTTL: number = 500) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data or compute and cache it
|
||||
*/
|
||||
public get<T>(key: string, computeFn: () => T | Promise<T>, ttl?: number): T | Promise<T> {
|
||||
const cached = this.cache.get(key);
|
||||
const now = Date.now();
|
||||
const actualTTL = ttl ?? this.defaultTTL;
|
||||
|
||||
if (cached && (now - cached.timestamp) < actualTTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const result = computeFn();
|
||||
|
||||
// Handle both sync and async compute functions
|
||||
if (result instanceof Promise) {
|
||||
return result.then(data => {
|
||||
this.cache.set(key, { data, timestamp: now });
|
||||
return data;
|
||||
});
|
||||
} else {
|
||||
this.cache.set(key, { data: result, timestamp: now });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a specific cache entry
|
||||
*/
|
||||
public invalidate(key: string): void {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache entries
|
||||
*/
|
||||
public clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
public getStats(): { size: number; keys: string[] } {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
*/
|
||||
public cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now - entry.timestamp > this.defaultTTL) {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
542
ts/monitoring/classes.metricsmanager.ts
Normal file
542
ts/monitoring/classes.metricsmanager.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { DcRouter } from '../classes.dcrouter.js';
|
||||
import { MetricsCache } from './classes.metricscache.js';
|
||||
|
||||
export class MetricsManager {
|
||||
private logger: plugins.smartlog.Smartlog;
|
||||
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
||||
private dcRouter: DcRouter;
|
||||
private resetInterval?: NodeJS.Timeout;
|
||||
private metricsCache: MetricsCache;
|
||||
|
||||
// Constants
|
||||
private readonly MAX_TOP_DOMAINS = 1000; // Limit topDomains Map size
|
||||
|
||||
// Track email-specific metrics
|
||||
private emailMetrics = {
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
failedToday: 0,
|
||||
bouncedToday: 0,
|
||||
queueSize: 0,
|
||||
lastResetDate: new Date().toDateString(),
|
||||
deliveryTimes: [] as number[], // Track delivery times in ms
|
||||
recipients: new Map<string, number>(), // Track email count by recipient
|
||||
recentActivity: [] as Array<{ timestamp: number; type: string; details: string }>,
|
||||
};
|
||||
|
||||
// Track DNS-specific metrics
|
||||
private dnsMetrics = {
|
||||
totalQueries: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
queryTypes: {} as Record<string, number>,
|
||||
topDomains: new Map<string, number>(),
|
||||
lastResetDate: new Date().toDateString(),
|
||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
||||
responseTimes: [] as number[], // Track response times in ms
|
||||
};
|
||||
|
||||
// Track security-specific metrics
|
||||
private securityMetrics = {
|
||||
blockedIPs: 0,
|
||||
authFailures: 0,
|
||||
spamDetected: 0,
|
||||
malwareDetected: 0,
|
||||
phishingDetected: 0,
|
||||
lastResetDate: new Date().toDateString(),
|
||||
incidents: [] as Array<{ timestamp: number; type: string; severity: string; details: string }>,
|
||||
};
|
||||
|
||||
constructor(dcRouter: DcRouter) {
|
||||
this.dcRouter = dcRouter;
|
||||
// Create a new Smartlog instance for metrics
|
||||
this.logger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
runtime: 'node',
|
||||
zone: 'dcrouter-metrics',
|
||||
}
|
||||
});
|
||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
|
||||
// Initialize metrics cache with 500ms TTL
|
||||
this.metricsCache = new MetricsCache(500);
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
// Start SmartMetrics collection
|
||||
this.smartMetrics.start();
|
||||
|
||||
// Reset daily counters at midnight
|
||||
this.resetInterval = setInterval(() => {
|
||||
const currentDate = new Date().toDateString();
|
||||
|
||||
if (currentDate !== this.emailMetrics.lastResetDate) {
|
||||
this.emailMetrics.sentToday = 0;
|
||||
this.emailMetrics.receivedToday = 0;
|
||||
this.emailMetrics.failedToday = 0;
|
||||
this.emailMetrics.bouncedToday = 0;
|
||||
this.emailMetrics.deliveryTimes = [];
|
||||
this.emailMetrics.recipients.clear();
|
||||
this.emailMetrics.recentActivity = [];
|
||||
this.emailMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
if (currentDate !== this.dnsMetrics.lastResetDate) {
|
||||
this.dnsMetrics.totalQueries = 0;
|
||||
this.dnsMetrics.cacheHits = 0;
|
||||
this.dnsMetrics.cacheMisses = 0;
|
||||
this.dnsMetrics.queryTypes = {};
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
this.dnsMetrics.queryTimestamps = [];
|
||||
this.dnsMetrics.responseTimes = [];
|
||||
this.dnsMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
if (currentDate !== this.securityMetrics.lastResetDate) {
|
||||
this.securityMetrics.blockedIPs = 0;
|
||||
this.securityMetrics.authFailures = 0;
|
||||
this.securityMetrics.spamDetected = 0;
|
||||
this.securityMetrics.malwareDetected = 0;
|
||||
this.securityMetrics.phishingDetected = 0;
|
||||
this.securityMetrics.incidents = [];
|
||||
this.securityMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
}, 60000); // Check every minute
|
||||
|
||||
this.logger.log('info', 'MetricsManager started');
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
// Clear the reset interval
|
||||
if (this.resetInterval) {
|
||||
clearInterval(this.resetInterval);
|
||||
this.resetInterval = undefined;
|
||||
}
|
||||
|
||||
this.smartMetrics.stop();
|
||||
this.logger.log('info', 'MetricsManager stopped');
|
||||
}
|
||||
|
||||
// Get server metrics from SmartMetrics and SmartProxy
|
||||
public async getServerStats() {
|
||||
return this.metricsCache.get('serverStats', async () => {
|
||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||
|
||||
return {
|
||||
uptime: process.uptime(),
|
||||
startTime: Date.now() - (process.uptime() * 1000),
|
||||
memoryUsage: {
|
||||
heapUsed: process.memoryUsage().heapUsed,
|
||||
heapTotal: process.memoryUsage().heapTotal,
|
||||
external: process.memoryUsage().external,
|
||||
rss: process.memoryUsage().rss,
|
||||
// Add SmartMetrics memory data
|
||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||
},
|
||||
cpuUsage: {
|
||||
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
|
||||
system: 0, // SmartMetrics doesn't separate user/system
|
||||
},
|
||||
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
||||
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
||||
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
|
||||
throughput: proxyMetrics ? {
|
||||
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||
bytesOut: proxyMetrics.totals.bytesOut(),
|
||||
bytesInPerSecond: proxyMetrics.throughput.instant().in,
|
||||
bytesOutPerSecond: proxyMetrics.throughput.instant().out,
|
||||
} : { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get email metrics
|
||||
public async getEmailStats() {
|
||||
return this.metricsCache.get('emailStats', () => {
|
||||
// Calculate average delivery time
|
||||
const avgDeliveryTime = this.emailMetrics.deliveryTimes.length > 0
|
||||
? this.emailMetrics.deliveryTimes.reduce((a, b) => a + b, 0) / this.emailMetrics.deliveryTimes.length
|
||||
: 0;
|
||||
|
||||
// Get top recipients
|
||||
const topRecipients = Array.from(this.emailMetrics.recipients.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([email, count]) => ({ email, count }));
|
||||
|
||||
// Get recent activity (last 50 entries)
|
||||
const recentActivity = this.emailMetrics.recentActivity.slice(-50);
|
||||
|
||||
return {
|
||||
sentToday: this.emailMetrics.sentToday,
|
||||
receivedToday: this.emailMetrics.receivedToday,
|
||||
failedToday: this.emailMetrics.failedToday,
|
||||
bounceRate: this.emailMetrics.bouncedToday > 0
|
||||
? (this.emailMetrics.bouncedToday / this.emailMetrics.sentToday) * 100
|
||||
: 0,
|
||||
deliveryRate: this.emailMetrics.sentToday > 0
|
||||
? ((this.emailMetrics.sentToday - this.emailMetrics.failedToday) / this.emailMetrics.sentToday) * 100
|
||||
: 100,
|
||||
queueSize: this.emailMetrics.queueSize,
|
||||
averageDeliveryTime: Math.round(avgDeliveryTime),
|
||||
topRecipients,
|
||||
recentActivity,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get DNS metrics
|
||||
public async getDnsStats() {
|
||||
return this.metricsCache.get('dnsStats', () => {
|
||||
const cacheHitRate = this.dnsMetrics.totalQueries > 0
|
||||
? (this.dnsMetrics.cacheHits / this.dnsMetrics.totalQueries) * 100
|
||||
: 0;
|
||||
|
||||
const topDomains = Array.from(this.dnsMetrics.topDomains.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([domain, count]) => ({ domain, count }));
|
||||
|
||||
// Calculate queries per second from recent timestamps
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = now - 60000;
|
||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
||||
const queriesPerSecond = recentQueries.length / 60;
|
||||
|
||||
// Calculate average response time
|
||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||
? this.dnsMetrics.responseTimes.reduce((a, b) => a + b, 0) / this.dnsMetrics.responseTimes.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
queriesPerSecond: Math.round(queriesPerSecond * 10) / 10,
|
||||
totalQueries: this.dnsMetrics.totalQueries,
|
||||
cacheHits: this.dnsMetrics.cacheHits,
|
||||
cacheMisses: this.dnsMetrics.cacheMisses,
|
||||
cacheHitRate: cacheHitRate,
|
||||
topDomains: topDomains,
|
||||
queryTypes: this.dnsMetrics.queryTypes,
|
||||
averageResponseTime: Math.round(avgResponseTime),
|
||||
activeDomains: this.dnsMetrics.topDomains.size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get security metrics
|
||||
public async getSecurityStats() {
|
||||
return this.metricsCache.get('securityStats', () => {
|
||||
// Get recent incidents (last 20)
|
||||
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
||||
|
||||
return {
|
||||
blockedIPs: this.securityMetrics.blockedIPs,
|
||||
authFailures: this.securityMetrics.authFailures,
|
||||
spamDetected: this.securityMetrics.spamDetected,
|
||||
malwareDetected: this.securityMetrics.malwareDetected,
|
||||
phishingDetected: this.securityMetrics.phishingDetected,
|
||||
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
||||
this.securityMetrics.malwareDetected +
|
||||
this.securityMetrics.phishingDetected,
|
||||
recentIncidents,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Get connection info from SmartProxy
|
||||
public async getConnectionInfo() {
|
||||
return this.metricsCache.get('connectionInfo', () => {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||
const connectionInfo = [];
|
||||
|
||||
for (const [routeName, count] of connectionsByRoute) {
|
||||
connectionInfo.push({
|
||||
type: 'https',
|
||||
count,
|
||||
source: routeName,
|
||||
lastActivity: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return connectionInfo;
|
||||
});
|
||||
}
|
||||
|
||||
// Email event tracking methods
|
||||
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
||||
this.emailMetrics.sentToday++;
|
||||
|
||||
if (recipient) {
|
||||
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||
this.emailMetrics.recipients.set(recipient, count + 1);
|
||||
}
|
||||
|
||||
if (deliveryTimeMs) {
|
||||
this.emailMetrics.deliveryTimes.push(deliveryTimeMs);
|
||||
// Keep only last 1000 delivery times
|
||||
if (this.emailMetrics.deliveryTimes.length > 1000) {
|
||||
this.emailMetrics.deliveryTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'sent',
|
||||
details: recipient || 'unknown',
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackEmailReceived(sender?: string): void {
|
||||
this.emailMetrics.receivedToday++;
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'received',
|
||||
details: sender || 'unknown',
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackEmailFailed(recipient?: string, reason?: string): void {
|
||||
this.emailMetrics.failedToday++;
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'failed',
|
||||
details: `${recipient || 'unknown'}: ${reason || 'unknown error'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackEmailBounced(recipient?: string): void {
|
||||
this.emailMetrics.bouncedToday++;
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'bounced',
|
||||
details: recipient || 'unknown',
|
||||
});
|
||||
|
||||
// Keep only last 1000 activities
|
||||
if (this.emailMetrics.recentActivity.length > 1000) {
|
||||
this.emailMetrics.recentActivity.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public updateQueueSize(size: number): void {
|
||||
this.emailMetrics.queueSize = size;
|
||||
}
|
||||
|
||||
// DNS event tracking methods
|
||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
|
||||
this.dnsMetrics.totalQueries++;
|
||||
|
||||
if (cacheHit) {
|
||||
this.dnsMetrics.cacheHits++;
|
||||
} else {
|
||||
this.dnsMetrics.cacheMisses++;
|
||||
}
|
||||
|
||||
// Track query timestamp
|
||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
||||
|
||||
// Keep only timestamps from last 5 minutes
|
||||
const fiveMinutesAgo = Date.now() - 300000;
|
||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
||||
|
||||
// Track response time if provided
|
||||
if (responseTimeMs) {
|
||||
this.dnsMetrics.responseTimes.push(responseTimeMs);
|
||||
// Keep only last 1000 response times
|
||||
if (this.dnsMetrics.responseTimes.length > 1000) {
|
||||
this.dnsMetrics.responseTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Track query types
|
||||
this.dnsMetrics.queryTypes[queryType] = (this.dnsMetrics.queryTypes[queryType] || 0) + 1;
|
||||
|
||||
// Track top domains with size limit
|
||||
const currentCount = this.dnsMetrics.topDomains.get(domain) || 0;
|
||||
this.dnsMetrics.topDomains.set(domain, currentCount + 1);
|
||||
|
||||
// If we've exceeded the limit, remove the least accessed domains
|
||||
if (this.dnsMetrics.topDomains.size > this.MAX_TOP_DOMAINS) {
|
||||
// Convert to array, sort by count, and keep only top domains
|
||||
const sortedDomains = Array.from(this.dnsMetrics.topDomains.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); // Keep 80% to avoid frequent cleanup
|
||||
|
||||
// Clear and repopulate with top domains
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
sortedDomains.forEach(([domain, count]) => {
|
||||
this.dnsMetrics.topDomains.set(domain, count);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Security event tracking methods
|
||||
public trackBlockedIP(ip?: string, reason?: string): void {
|
||||
this.securityMetrics.blockedIPs++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'ip_blocked',
|
||||
severity: 'medium',
|
||||
details: `IP ${ip || 'unknown'} blocked: ${reason || 'security policy'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackAuthFailure(username?: string, ip?: string): void {
|
||||
this.securityMetrics.authFailures++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'auth_failure',
|
||||
severity: 'low',
|
||||
details: `Authentication failed for ${username || 'unknown'} from ${ip || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackSpamDetected(sender?: string): void {
|
||||
this.securityMetrics.spamDetected++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'spam_detected',
|
||||
severity: 'low',
|
||||
details: `Spam detected from ${sender || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackMalwareDetected(source?: string): void {
|
||||
this.securityMetrics.malwareDetected++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'malware_detected',
|
||||
severity: 'high',
|
||||
details: `Malware detected from ${source || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
public trackPhishingDetected(source?: string): void {
|
||||
this.securityMetrics.phishingDetected++;
|
||||
|
||||
this.securityMetrics.incidents.push({
|
||||
timestamp: Date.now(),
|
||||
type: 'phishing_detected',
|
||||
severity: 'high',
|
||||
details: `Phishing attempt from ${source || 'unknown'}`,
|
||||
});
|
||||
|
||||
// Keep only last 1000 incidents
|
||||
if (this.securityMetrics.incidents.length > 1000) {
|
||||
this.securityMetrics.incidents.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Get network metrics from SmartProxy
|
||||
public async getNetworkStats() {
|
||||
// Use shorter cache TTL for network stats to ensure real-time updates
|
||||
return this.metricsCache.get('networkStats', () => {
|
||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||
|
||||
if (!proxyMetrics) {
|
||||
return {
|
||||
connectionsByIP: new Map<string, number>(),
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get metrics using the new API
|
||||
const connectionsByIP = proxyMetrics.connections.byIP();
|
||||
const instantThroughput = proxyMetrics.throughput.instant();
|
||||
|
||||
// Get throughput rate
|
||||
const throughputRate = {
|
||||
bytesInPerSecond: instantThroughput.in,
|
||||
bytesOutPerSecond: instantThroughput.out
|
||||
};
|
||||
|
||||
// Get top IPs
|
||||
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||
|
||||
// Get total data transferred
|
||||
const totalDataTransferred = {
|
||||
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||
bytesOut: proxyMetrics.totals.bytesOut()
|
||||
};
|
||||
|
||||
// Get throughput history from Rust engine (up to 300 seconds)
|
||||
const throughputHistory = proxyMetrics.throughput.history(300);
|
||||
|
||||
// Get per-IP throughput
|
||||
const throughputByIP = proxyMetrics.throughput.byIP();
|
||||
|
||||
// Get HTTP request rates
|
||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||
const requestsTotal = proxyMetrics.requests.total();
|
||||
|
||||
return {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
topIPs,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
requestsPerSecond,
|
||||
requestsTotal,
|
||||
};
|
||||
}, 200); // Use 200ms cache for more frequent updates
|
||||
}
|
||||
}
|
||||
1
ts/monitoring/index.ts
Normal file
1
ts/monitoring/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.metricsmanager.js';
|
||||
@@ -1,8 +0,0 @@
|
||||
export * from './mta.classes.dkimcreator.js';
|
||||
export * from './mta.classes.emailsignjob.js';
|
||||
export * from './mta.classes.dkimverifier.js';
|
||||
export * from './mta.classes.mta.js';
|
||||
export * from './mta.classes.smtpserver.js';
|
||||
export * from './mta.classes.emailsendjob.js';
|
||||
export * from './mta.classes.mta.js';
|
||||
export * from './mta.classes.email.js';
|
||||
@@ -1,7 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export class ApiManager {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
|
||||
import { Email } from './mta.classes.email.js';
|
||||
import type { MtaService } from './mta.classes.mta.js';
|
||||
|
||||
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
||||
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
||||
const generateKeyPair = plugins.util.promisify(plugins.crypto.generateKeyPair);
|
||||
|
||||
export interface IKeyPaths {
|
||||
privateKeyPath: string;
|
||||
publicKeyPath: string;
|
||||
}
|
||||
|
||||
export class DKIMCreator {
|
||||
private keysDir: string;
|
||||
|
||||
constructor(metaRef: MtaService, keysDir = paths.keysDir) {
|
||||
this.keysDir = keysDir;
|
||||
}
|
||||
|
||||
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
|
||||
return {
|
||||
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
|
||||
publicKeyPath: plugins.path.join(this.keysDir, `${domainArg}-public.pem`),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if a DKIM key is present and creates one and stores it to disk otherwise
|
||||
public async handleDKIMKeysForDomain(domainArg: string): Promise<void> {
|
||||
try {
|
||||
await this.readDKIMKeys(domainArg);
|
||||
} catch (error) {
|
||||
console.log(`No DKIM keys found for ${domainArg}. Generating...`);
|
||||
await this.createAndStoreDKIMKeys(domainArg);
|
||||
const dnsValue = await this.getDNSRecordForDomain(domainArg);
|
||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
||||
plugins.smartfile.memory.toFsSync(JSON.stringify(dnsValue, null, 2), plugins.path.join(paths.dnsRecordsDir, `${domainArg}.dkimrecord.json`));
|
||||
}
|
||||
}
|
||||
|
||||
public async handleDKIMKeysForEmail(email: Email): Promise<void> {
|
||||
const domain = email.from.split('@')[1];
|
||||
await this.handleDKIMKeysForDomain(domain);
|
||||
}
|
||||
|
||||
// Read DKIM keys from disk
|
||||
public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const keyPaths = await this.getKeyPathsForDomain(domainArg);
|
||||
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
||||
readFile(keyPaths.privateKeyPath),
|
||||
readFile(keyPaths.publicKeyPath),
|
||||
]);
|
||||
|
||||
// Convert the buffers to strings
|
||||
const privateKey = privateKeyBuffer.toString();
|
||||
const publicKey = publicKeyBuffer.toString();
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Create a DKIM key pair
|
||||
private async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
||||
});
|
||||
|
||||
return { privateKey, publicKey };
|
||||
}
|
||||
|
||||
// Store a DKIM key pair to disk
|
||||
private async storeDKIMKeys(
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
privateKeyPath: string,
|
||||
publicKeyPath: string
|
||||
): Promise<void> {
|
||||
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
|
||||
}
|
||||
|
||||
// Create a DKIM key pair and store it to disk
|
||||
private async createAndStoreDKIMKeys(domain: string): Promise<void> {
|
||||
const { privateKey, publicKey } = await this.createDKIMKeys();
|
||||
const keyPaths = await this.getKeyPathsForDomain(domain);
|
||||
await this.storeDKIMKeys(
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPaths.privateKeyPath,
|
||||
keyPaths.publicKeyPath
|
||||
);
|
||||
console.log(`DKIM keys for ${domain} created and stored.`);
|
||||
}
|
||||
|
||||
private async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
|
||||
await this.handleDKIMKeysForDomain(domainArg);
|
||||
const keys = await this.readDKIMKeys(domainArg);
|
||||
|
||||
// Remove the PEM header and footer and newlines
|
||||
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||||
const pemFooter = '-----END PUBLIC KEY-----';
|
||||
const keyContents = keys.publicKey
|
||||
.replace(pemHeader, '')
|
||||
.replace(pemFooter, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
// Now generate the DKIM DNS TXT record
|
||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
||||
|
||||
return {
|
||||
name: `mta._domainkey.${domainArg}`,
|
||||
type: 'TXT',
|
||||
dnsSecEnabled: null,
|
||||
value: dnsRecordValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { MtaService } from './mta.classes.mta.js';
|
||||
|
||||
class DKIMVerifier {
|
||||
public mtaRef: MtaService;
|
||||
|
||||
constructor(mtaRefArg: MtaService) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
}
|
||||
|
||||
async verify(email: string): Promise<boolean> {
|
||||
console.log('Trying to verify DKIM now...');
|
||||
|
||||
try {
|
||||
const verification = await plugins.mailauth.authenticate(email, {
|
||||
/* resolver: (...args) => {
|
||||
console.log(args);
|
||||
} */
|
||||
});
|
||||
console.log(verification);
|
||||
if (verification && verification.dkim.results[0].status.result === 'pass') {
|
||||
console.log('DKIM Verification result: pass');
|
||||
return true;
|
||||
} else {
|
||||
console.error('DKIM Verification failed:', verification?.error || 'Unknown error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DKIM Verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DKIMVerifier };
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { MtaService } from './mta.classes.mta.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export class DNSManager {
|
||||
public mtaRef: MtaService;
|
||||
|
||||
constructor(mtaRefArg: MtaService) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
export interface IAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export interface IEmailOptions {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
attachments: IAttachment[];
|
||||
mightBeSpam?: boolean;
|
||||
}
|
||||
|
||||
export class Email {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
attachments: IAttachment[];
|
||||
mightBeSpam: boolean;
|
||||
|
||||
constructor(options: IEmailOptions) {
|
||||
this.from = options.from;
|
||||
this.to = options.to;
|
||||
this.subject = options.subject;
|
||||
this.text = options.text;
|
||||
this.attachments = options.attachments;
|
||||
this.mightBeSpam = options.mightBeSpam || false;
|
||||
}
|
||||
|
||||
public getFromDomain() {
|
||||
return this.from.split('@')[1]
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { Email } from './mta.classes.email.js';
|
||||
import { EmailSignJob } from './mta.classes.emailsignjob.js';
|
||||
import type { MtaService } from './mta.classes.mta.js';
|
||||
|
||||
export class EmailSendJob {
|
||||
mtaRef: MtaService;
|
||||
private email: Email;
|
||||
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
|
||||
private mxRecord: string = null;
|
||||
|
||||
constructor(mtaRef: MtaService, emailArg: Email) {
|
||||
this.email = emailArg;
|
||||
this.mtaRef = mtaRef;
|
||||
}
|
||||
|
||||
async send(): Promise<void> {
|
||||
const domain = this.email.to.split('@')[1];
|
||||
const addresses = await this.resolveMx(domain);
|
||||
addresses.sort((a, b) => a.priority - b.priority);
|
||||
this.mxRecord = addresses[0].exchange;
|
||||
|
||||
console.log(`Using ${this.mxRecord} as mail server for domain ${domain}`);
|
||||
|
||||
this.socket = plugins.net.connect(25, this.mxRecord);
|
||||
await this.processInitialResponse();
|
||||
await this.sendCommand(`EHLO ${this.email.from.split('@')[1]}\r\n`, '250');
|
||||
try {
|
||||
await this.sendCommand('STARTTLS\r\n', '220');
|
||||
this.socket = plugins.tls.connect({ socket: this.socket, rejectUnauthorized: false });
|
||||
await this.processTLSUpgrade(this.email.from.split('@')[1]);
|
||||
} catch (error) {
|
||||
console.log('Error sending STARTTLS command:', error);
|
||||
console.log('Continuing with unencrypted connection...');
|
||||
}
|
||||
|
||||
await this.sendMessage();
|
||||
}
|
||||
|
||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
||||
if (err) {
|
||||
console.error('Error resolving MX:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(addresses);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private processInitialResponse(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket.once('data', (data) => {
|
||||
const response = data.toString();
|
||||
if (!response.startsWith('220')) {
|
||||
console.error('Unexpected initial server response:', response);
|
||||
reject(new Error(`Unexpected initial server response: ${response}`));
|
||||
} else {
|
||||
console.log('Received initial server response:', response);
|
||||
console.log('Connected to server, sending EHLO...');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private processTLSUpgrade(domain: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket.once('secureConnect', async () => {
|
||||
console.log('TLS started successfully');
|
||||
try {
|
||||
await this.sendCommand(`EHLO ${domain}\r\n`, '250');
|
||||
resolve();
|
||||
} catch (err) {
|
||||
console.error('Error sending EHLO after TLS upgrade:', err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sendCommand(command: string, expectedResponseCode?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket.write(command, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expectedResponseCode) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.once('data', (data) => {
|
||||
const response = data.toString();
|
||||
if (response.startsWith('221')) {
|
||||
this.socket.destroy();
|
||||
resolve();
|
||||
}
|
||||
if (!response.startsWith(expectedResponseCode)) {
|
||||
reject(new Error(`Unexpected server response: ${response}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMessage(): Promise<void> {
|
||||
console.log('Preparing email message...');
|
||||
const messageId = `<${plugins.uuid.v4()}@${this.email.from.split('@')[1]}>`;
|
||||
|
||||
// Create a boundary for the email parts
|
||||
const boundary = '----=_NextPart_' + plugins.uuid.v4();
|
||||
|
||||
const headers = {
|
||||
From: this.email.from,
|
||||
To: this.email.to,
|
||||
Subject: this.email.subject,
|
||||
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
||||
};
|
||||
|
||||
// Construct the body of the message
|
||||
let body = `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
|
||||
|
||||
// Then, the attachments
|
||||
for (let attachment of this.email.attachments) {
|
||||
body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`;
|
||||
body += 'Content-Transfer-Encoding: base64\r\n';
|
||||
body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n\r\n`;
|
||||
body += attachment.content.toString('base64') + '\r\n';
|
||||
}
|
||||
|
||||
// End of email
|
||||
body += `--${boundary}--\r\n`;
|
||||
|
||||
// Create an instance of DKIMSigner
|
||||
const dkimSigner = new EmailSignJob(this.mtaRef, {
|
||||
domain: this.email.getFromDomain(), // Replace with your domain
|
||||
selector: `mta`, // Replace with your DKIM selector
|
||||
headers: headers,
|
||||
body: body,
|
||||
});
|
||||
|
||||
// Construct the message with DKIM-Signature header
|
||||
let message = `Message-ID: ${messageId}\r\nFrom: ${this.email.from}\r\nTo: ${this.email.to}\r\nSubject: ${this.email.subject}\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
|
||||
message += body;
|
||||
|
||||
let signatureHeader = await dkimSigner.getSignatureHeader(message);
|
||||
message = `${signatureHeader}${message}`;
|
||||
|
||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
||||
plugins.smartfile.memory.toFsSync(message, plugins.path.join(paths.sentEmailsDir, `${Date.now()}.eml`));
|
||||
|
||||
|
||||
// Adding necessary commands before sending the actual email message
|
||||
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
|
||||
await this.sendCommand(`RCPT TO:<${this.email.to}>\r\n`, '250');
|
||||
await this.sendCommand(`DATA\r\n`, '354');
|
||||
|
||||
// Now send the message content
|
||||
await this.sendCommand(message);
|
||||
await this.sendCommand('\r\n.\r\n', '250');
|
||||
|
||||
await this.sendCommand('QUIT\r\n', '221');
|
||||
console.log('Email message sent successfully!');
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { MtaService } from './mta.classes.mta.js';
|
||||
|
||||
interface Headers {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface IEmailSignJobOptions {
|
||||
domain: string;
|
||||
selector: string;
|
||||
headers: Headers;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export class EmailSignJob {
|
||||
mtaRef: MtaService;
|
||||
jobOptions: IEmailSignJobOptions;
|
||||
|
||||
constructor(mtaRefArg: MtaService, options: IEmailSignJobOptions) {
|
||||
this.mtaRef = mtaRefArg;
|
||||
this.jobOptions = options;
|
||||
}
|
||||
|
||||
async loadPrivateKey(): Promise<string> {
|
||||
return plugins.fs.promises.readFile(
|
||||
(await this.mtaRef.dkimCreator.getKeyPathsForDomain(this.jobOptions.domain)).privateKeyPath,
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||
const signResult = await plugins.dkimSign(emailMessage, {
|
||||
// Optional, default canonicalization, default is "relaxed/relaxed"
|
||||
canonicalization: 'relaxed/relaxed', // c=
|
||||
|
||||
// Optional, default signing and hashing algorithm
|
||||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set
|
||||
algorithm: 'rsa-sha256',
|
||||
|
||||
// Optional, default is current time
|
||||
signTime: new Date(), // t=
|
||||
|
||||
// Keys for one or more signatures
|
||||
// Different signatures can use different algorithms (mostly useful when
|
||||
// you want to sign a message both with RSA and Ed25519)
|
||||
signatureData: [
|
||||
{
|
||||
signingDomain: this.jobOptions.domain, // d=
|
||||
selector: this.jobOptions.selector, // s=
|
||||
// supported key types: RSA, Ed25519
|
||||
privateKey: await this.loadPrivateKey(), // k=
|
||||
|
||||
// Optional algorithm, default is derived from the key.
|
||||
// Overrides whatever was set in parent object
|
||||
algorithm: 'rsa-sha256',
|
||||
|
||||
// Optional signature specifc canonicalization, overrides whatever was set in parent object
|
||||
canonicalization: 'relaxed/relaxed', // c=
|
||||
|
||||
// Maximum number of canonicalized body bytes to sign (eg. the "l=" tag).
|
||||
// Do not use though. This is available only for compatibility testing.
|
||||
// maxBodyLength: 12345
|
||||
},
|
||||
],
|
||||
});
|
||||
const signature = signResult.signatures;
|
||||
return signature;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
import { Email } from './mta.classes.email.js';
|
||||
import { EmailSendJob } from './mta.classes.emailsendjob.js';
|
||||
import { DKIMCreator } from './mta.classes.dkimcreator.js';
|
||||
import { DKIMVerifier } from './mta.classes.dkimverifier.js';
|
||||
import { SMTPServer } from './mta.classes.smtpserver.js';
|
||||
import { DNSManager } from './mta.classes.dnsmanager.js';
|
||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
||||
|
||||
export class MtaService {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
public server: SMTPServer;
|
||||
public dkimCreator: DKIMCreator;
|
||||
public dkimVerifier: DKIMVerifier;
|
||||
public dnsManager: DNSManager;
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.dkimCreator = new DKIMCreator(this);
|
||||
this.dkimVerifier = new DKIMVerifier(this);
|
||||
this.dnsManager = new DNSManager(this);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
// lets get the certificate
|
||||
/**
|
||||
* gets a certificate for a domain used by a service
|
||||
* @param serviceNameArg
|
||||
* @param domainNameArg
|
||||
*/
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||
typedrouter,
|
||||
'https://cloudly.lossless.one:443'
|
||||
);
|
||||
const getCertificateForDomainOverHttps = async (domainNameArg: string) => {
|
||||
const typedCertificateRequest =
|
||||
typedsocketClient.createTypedRequest<any>('getSslCertificate');
|
||||
const typedResponse = await typedCertificateRequest.fire({
|
||||
authToken: '', // do proper auth here
|
||||
requiredCertName: domainNameArg,
|
||||
});
|
||||
return typedResponse.certificate;
|
||||
};
|
||||
const certificate = await getCertificateForDomainOverHttps('mta.lossless.one');
|
||||
await typedsocketClient.stop();
|
||||
this.server = new SMTPServer(this, {
|
||||
port: 25,
|
||||
key: certificate.privateKey,
|
||||
cert: certificate.publicKey,
|
||||
});
|
||||
await this.server.start();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (!this.server) {
|
||||
console.error('Server is not running');
|
||||
return;
|
||||
}
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
public async send(email: Email): Promise<void> {
|
||||
await this.dkimCreator.handleDKIMKeysForEmail(email);
|
||||
const sendJob = new EmailSendJob(this, email);
|
||||
await sendJob.send();
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { Email } from './mta.classes.email.js';
|
||||
import type { MtaService } from './mta.classes.mta.js';
|
||||
|
||||
export interface ISmtpServerOptions {
|
||||
port: number;
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
export class SMTPServer {
|
||||
public mtaRef: MtaService;
|
||||
private smtpServerOptions: ISmtpServerOptions;
|
||||
private server: plugins.net.Server;
|
||||
private emailBufferStringMap: Map<plugins.net.Socket, string>;
|
||||
|
||||
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
||||
console.log('SMTPServer instance is being created...');
|
||||
|
||||
this.mtaRef = mtaRefArg;
|
||||
this.smtpServerOptions = optionsArg;
|
||||
this.emailBufferStringMap = new Map();
|
||||
|
||||
this.server = plugins.net.createServer((socket) => {
|
||||
console.log('New connection established...');
|
||||
|
||||
socket.write('220 mta.lossless.one ESMTP Postfix\r\n');
|
||||
|
||||
socket.on('data', (data) => {
|
||||
this.processData(socket, data);
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
console.log('Socket closed. Deleting related emailBuffer...');
|
||||
socket.destroy();
|
||||
this.emailBufferStringMap.delete(socket);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
console.error('Socket error occurred. Deleting related emailBuffer...');
|
||||
socket.destroy();
|
||||
this.emailBufferStringMap.delete(socket);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('Connection was closed by the client');
|
||||
socket.destroy();
|
||||
this.emailBufferStringMap.delete(socket);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private startTLS(socket: plugins.net.Socket) {
|
||||
const secureContext = plugins.tls.createSecureContext({
|
||||
key: this.smtpServerOptions.key,
|
||||
cert: this.smtpServerOptions.cert,
|
||||
});
|
||||
|
||||
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
||||
secureContext: secureContext,
|
||||
isServer: true,
|
||||
});
|
||||
|
||||
tlsSocket.on('secure', () => {
|
||||
console.log('Connection secured.');
|
||||
this.emailBufferStringMap.set(tlsSocket, this.emailBufferStringMap.get(socket) || '');
|
||||
this.emailBufferStringMap.delete(socket);
|
||||
});
|
||||
|
||||
// Use the same handler for the 'data' event as for the unsecured socket.
|
||||
tlsSocket.on('data', (data: Buffer) => {
|
||||
this.processData(tlsSocket, Buffer.from(data));
|
||||
});
|
||||
|
||||
tlsSocket.on('end', () => {
|
||||
console.log('TLS socket closed. Deleting related emailBuffer...');
|
||||
this.emailBufferStringMap.delete(tlsSocket);
|
||||
});
|
||||
|
||||
tlsSocket.on('error', (err) => {
|
||||
console.error('TLS socket error occurred. Deleting related emailBuffer...');
|
||||
this.emailBufferStringMap.delete(tlsSocket);
|
||||
});
|
||||
}
|
||||
|
||||
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer) {
|
||||
const dataString = data.toString();
|
||||
console.log(`Received data:`);
|
||||
console.log(`${dataString}`)
|
||||
|
||||
if (dataString.startsWith('EHLO')) {
|
||||
socket.write('250-mta.lossless.one Hello\r\n250 STARTTLS\r\n');
|
||||
} else if (dataString.startsWith('MAIL FROM')) {
|
||||
socket.write('250 Ok\r\n');
|
||||
} else if (dataString.startsWith('RCPT TO')) {
|
||||
socket.write('250 Ok\r\n');
|
||||
} else if (dataString.startsWith('STARTTLS')) {
|
||||
socket.write('220 Ready to start TLS\r\n');
|
||||
this.startTLS(socket);
|
||||
} else if (dataString.startsWith('DATA')) {
|
||||
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||
let emailBuffer = this.emailBufferStringMap.get(socket);
|
||||
if (!emailBuffer) {
|
||||
this.emailBufferStringMap.set(socket, '');
|
||||
}
|
||||
} else if (dataString.startsWith('QUIT')) {
|
||||
socket.write('221 Bye\r\n');
|
||||
console.log('Received QUIT command, closing the socket...');
|
||||
socket.destroy();
|
||||
this.parseEmail(socket);
|
||||
} else {
|
||||
let emailBuffer = this.emailBufferStringMap.get(socket);
|
||||
if (typeof emailBuffer === 'string') {
|
||||
emailBuffer += dataString;
|
||||
this.emailBufferStringMap.set(socket, emailBuffer);
|
||||
}
|
||||
socket.write('250 Ok\r\n');
|
||||
}
|
||||
|
||||
if (dataString.endsWith('\r\n.\r\n') ) { // End of data
|
||||
console.log('Received end of data.');
|
||||
}
|
||||
}
|
||||
|
||||
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket) {
|
||||
let emailData = this.emailBufferStringMap.get(socket);
|
||||
// lets strip the end sequence
|
||||
emailData = emailData?.replace(/\r\n\.\r\n$/, '');
|
||||
|
||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
||||
plugins.smartfile.memory.toFsSync(emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`));
|
||||
|
||||
|
||||
if (!emailData) {
|
||||
console.error('No email data found for socket.');
|
||||
return;
|
||||
}
|
||||
|
||||
let mightBeSpam = false;
|
||||
|
||||
// Verifying the email with DKIM
|
||||
try {
|
||||
const isVerified = await this.mtaRef.dkimVerifier.verify(emailData);
|
||||
mightBeSpam = !isVerified;
|
||||
} catch (error) {
|
||||
console.error('Failed to verify DKIM signature:', error);
|
||||
mightBeSpam = true;
|
||||
}
|
||||
|
||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
||||
console.log(parsedEmail)
|
||||
const email = new Email({
|
||||
from: parsedEmail.from?.value[0].address || '',
|
||||
to:
|
||||
parsedEmail.to instanceof Array
|
||||
? parsedEmail.to[0].value[0].address
|
||||
: parsedEmail.to?.value[0].address,
|
||||
subject: parsedEmail.subject || '',
|
||||
text: parsedEmail.html || parsedEmail.text,
|
||||
attachments:
|
||||
parsedEmail.attachments?.map((attachment) => ({
|
||||
filename: attachment.filename || '',
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})) || [],
|
||||
mightBeSpam: mightBeSpam,
|
||||
});
|
||||
|
||||
console.log('mail received!');
|
||||
console.log(email);
|
||||
|
||||
this.emailBufferStringMap.delete(socket);
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.server.listen(this.smtpServerOptions.port, () => {
|
||||
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.server.getConnections((err, count) => {
|
||||
if (err) throw err;
|
||||
console.log('Number of active connections: ', count);
|
||||
});
|
||||
this.server.close(() => {
|
||||
console.log('SMTP Server is now stopped');
|
||||
});
|
||||
}
|
||||
}
|
||||
73
ts/opsserver/classes.opsserver.ts
Normal file
73
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type DcRouter from '../classes.dcrouter.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import * as handlers from './handlers/index.js';
|
||||
|
||||
export class OpsServer {
|
||||
public dcRouterRef: DcRouter;
|
||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||
|
||||
// TypedRouter for OpsServer-specific handlers
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Handler instances
|
||||
public adminHandler: handlers.AdminHandler;
|
||||
private configHandler: handlers.ConfigHandler;
|
||||
private logsHandler: handlers.LogsHandler;
|
||||
private securityHandler: handlers.SecurityHandler;
|
||||
private statsHandler: handlers.StatsHandler;
|
||||
private radiusHandler: handlers.RadiusHandler;
|
||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||
private certificateHandler: handlers.CertificateHandler;
|
||||
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
|
||||
// Add our typedrouter to the dcRouter's main typedrouter
|
||||
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.server = new plugins.typedserver.utilityservers.UtilityWebsiteServer({
|
||||
domain: 'localhost',
|
||||
feedMetadata: null,
|
||||
serveDir: paths.distServe,
|
||||
});
|
||||
|
||||
// The server has a built-in typedrouter at /typedrequest
|
||||
// Add the main dcRouter typedrouter to the server's typedrouter
|
||||
this.server.typedrouter.addTypedRouter(this.dcRouterRef.typedrouter);
|
||||
|
||||
// Set up handlers
|
||||
await this.setupHandlers();
|
||||
|
||||
await this.server.start(3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up all TypedRequest handlers
|
||||
*/
|
||||
private async setupHandlers(): Promise<void> {
|
||||
// Instantiate all handlers - they self-register with the typedrouter
|
||||
this.adminHandler = new handlers.AdminHandler(this);
|
||||
await this.adminHandler.initialize(); // JWT needs async initialization
|
||||
|
||||
this.configHandler = new handlers.ConfigHandler(this);
|
||||
this.logsHandler = new handlers.LogsHandler(this);
|
||||
this.securityHandler = new handlers.SecurityHandler(this);
|
||||
this.statsHandler = new handlers.StatsHandler(this);
|
||||
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');
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.server) {
|
||||
await this.server.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
240
ts/opsserver/handlers/admin.handler.ts
Normal file
240
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export interface IJwtData {
|
||||
userId: string;
|
||||
status: 'loggedIn' | 'loggedOut';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export class AdminHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// JWT instance
|
||||
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
private users = new Map<string, {
|
||||
id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
}>();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.initializeJwt();
|
||||
this.initializeDefaultUsers();
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private async initializeJwt(): Promise<void> {
|
||||
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||
await this.smartjwtInstance.init();
|
||||
|
||||
// For development, create new keypair each time
|
||||
// In production, load from storage like cloudly does
|
||||
await this.smartjwtInstance.createNewKeyPair();
|
||||
}
|
||||
|
||||
private initializeDefaultUsers(): void {
|
||||
// Add default admin user
|
||||
const adminId = plugins.uuid.v4();
|
||||
this.users.set(adminId, {
|
||||
id: adminId,
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Admin Login Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
async (dataArg) => {
|
||||
try {
|
||||
// Find user by username and password
|
||||
let user: { id: string; username: string; password: string; role: string } | null = null;
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
||||
user = userData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: user.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
throw error;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Admin Logout Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
|
||||
'adminLogout',
|
||||
async (dataArg) => {
|
||||
// In a real implementation, you might want to blacklist the JWT
|
||||
// For now, just return success
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Verify Identity Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'verifyIdentity',
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
|
||||
// Check if expired
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if logged in
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = this.users.get(jwtData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a guard for valid identity (matching cloudly pattern)
|
||||
*/
|
||||
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
if (!dataArg.identity?.jwt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||
|
||||
// Check expiration
|
||||
if (jwtData.expiresAt < Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check status
|
||||
if (jwtData.status !== 'loggedIn') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify data hasn't been tampered with
|
||||
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataArg.identity.userId !== jwtData.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
failedHint: 'identity is not valid',
|
||||
name: 'validIdentityGuard',
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Create a guard for admin identity (matching cloudly pattern)
|
||||
*/
|
||||
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
||||
identity: interfaces.data.IIdentity;
|
||||
}>(
|
||||
async (dataArg) => {
|
||||
// First check if identity is valid
|
||||
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
return dataArg.identity.role === 'admin';
|
||||
},
|
||||
{
|
||||
failedHint: 'user is not admin',
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
}
|
||||
327
ts/opsserver/handlers/certificate.handler.ts
Normal file
327
ts/opsserver/handlers/certificate.handler.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
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 = await this.buildCertificateOverview();
|
||||
const summary = this.buildSummary(certificates);
|
||||
return { certificates, summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Legacy route-based reprovision (backward compat)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||
'reprovisionCertificate',
|
||||
async (dataArg) => {
|
||||
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Domain-based reprovision (preferred)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||
'reprovisionCertificateDomain',
|
||||
async (dataArg) => {
|
||||
return this.reprovisionCertificateDomain(dataArg.domain);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build domain-centric certificate overview.
|
||||
* Instead of one row per route, we produce one row per unique domain.
|
||||
*/
|
||||
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const smartProxy = dcRouter.smartProxy;
|
||||
if (!smartProxy) return [];
|
||||
|
||||
const routes = smartProxy.routeManager.getRoutes();
|
||||
|
||||
// Phase 1: Collect unique domains with their associated route info
|
||||
const domainMap = new Map<string, {
|
||||
routeNames: string[];
|
||||
source: interfaces.requests.TCertificateSource;
|
||||
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
canReprovision: boolean;
|
||||
}>();
|
||||
|
||||
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') {
|
||||
if ((smartProxy.settings as any).certProvisionFunction) {
|
||||
source = 'provision-function';
|
||||
} else {
|
||||
source = 'acme';
|
||||
}
|
||||
} else if (tls.certificate && typeof tls.certificate === 'object') {
|
||||
source = 'static';
|
||||
}
|
||||
|
||||
const canReprovision = source === 'acme' || source === 'provision-function';
|
||||
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||
|
||||
for (const domain of routeDomains) {
|
||||
const existing = domainMap.get(domain);
|
||||
if (existing) {
|
||||
// Add this route name to the existing domain entry
|
||||
if (!existing.routeNames.includes(route.name)) {
|
||||
existing.routeNames.push(route.name);
|
||||
}
|
||||
// Upgrade source if more specific
|
||||
if (existing.source === 'none' && source !== 'none') {
|
||||
existing.source = source;
|
||||
existing.canReprovision = canReprovision;
|
||||
}
|
||||
} else {
|
||||
domainMap.set(domain, {
|
||||
routeNames: [route.name],
|
||||
source,
|
||||
tlsMode,
|
||||
canReprovision,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Resolve status for each unique domain
|
||||
const certificates: interfaces.requests.ICertificateInfo[] = [];
|
||||
|
||||
for (const [domain, info] of domainMap) {
|
||||
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 certificateStatusMap (now keyed by domain)
|
||||
const eventStatus = dcRouter.certificateStatusMap.get(domain);
|
||||
if (eventStatus) {
|
||||
status = eventStatus.status;
|
||||
expiryDate = eventStatus.expiryDate;
|
||||
issuedAt = eventStatus.issuedAt;
|
||||
error = eventStatus.error;
|
||||
if (eventStatus.source) {
|
||||
issuer = eventStatus.source;
|
||||
}
|
||||
}
|
||||
|
||||
// Try SmartProxy certificate status if no event data
|
||||
if (status === 'unknown' && info.routeNames.length > 0) {
|
||||
try {
|
||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||
if (rustStatus) {
|
||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||
status = rustStatus.status;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Rust bridge may not support this command yet — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Check persisted cert data from StorageManager
|
||||
if (status === 'unknown') {
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// Compute status from expiry date
|
||||
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 (info.source === 'static' && status === 'unknown') {
|
||||
status = 'valid';
|
||||
}
|
||||
|
||||
// ACME/provision-function routes with no cert data are still provisioning
|
||||
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
|
||||
status = 'provisioning';
|
||||
}
|
||||
|
||||
// Phase 3: Attach backoff info
|
||||
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
|
||||
if (dcRouter.certProvisionScheduler) {
|
||||
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
|
||||
if (bi) {
|
||||
backoffInfo = bi;
|
||||
}
|
||||
}
|
||||
|
||||
certificates.push({
|
||||
domain,
|
||||
routeNames: info.routeNames,
|
||||
status,
|
||||
source: info.source,
|
||||
tlsMode: info.tlsMode,
|
||||
expiryDate,
|
||||
issuer,
|
||||
issuedAt,
|
||||
error,
|
||||
canReprovision: info.canReprovision,
|
||||
backoffInfo,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route-based reprovisioning
|
||||
*/
|
||||
private async reprovisionCertificateByRoute(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 for domains in this route
|
||||
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||
if (entry.routeNames.includes(routeName)) {
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
}
|
||||
}
|
||||
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
||||
*/
|
||||
private async reprovisionCertificateDomain(domain: 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' };
|
||||
}
|
||||
|
||||
// Clear backoff for this domain (user override)
|
||||
if (dcRouter.certProvisionScheduler) {
|
||||
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||
}
|
||||
|
||||
// Clear status map entry so it gets refreshed
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
|
||||
// Try to provision via SmartAcme directly
|
||||
if (dcRouter.smartAcme) {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try provisioning via the first matching route
|
||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||
if (routeNames.length > 0) {
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||
}
|
||||
}
|
||||
108
ts/opsserver/handlers/config.handler.ts
Normal file
108
ts/opsserver/handlers/config.handler.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class ConfigHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Configuration Handler (read-only)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||
'getConfiguration',
|
||||
async (dataArg, toolsArg) => {
|
||||
const config = await this.getConfiguration(dataArg.section);
|
||||
return {
|
||||
config,
|
||||
section: dataArg.section,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(section?: string): Promise<{
|
||||
email: {
|
||||
enabled: boolean;
|
||||
ports: number[];
|
||||
maxMessageSize: number;
|
||||
rateLimits: {
|
||||
perMinute: number;
|
||||
perHour: number;
|
||||
perDay: number;
|
||||
};
|
||||
domains?: string[];
|
||||
};
|
||||
dns: {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
nameservers: string[];
|
||||
caching: boolean;
|
||||
ttl: number;
|
||||
};
|
||||
proxy: {
|
||||
enabled: boolean;
|
||||
httpPort: number;
|
||||
httpsPort: number;
|
||||
maxConnections: number;
|
||||
};
|
||||
security: {
|
||||
blockList: string[];
|
||||
rateLimit: boolean;
|
||||
spamDetection: boolean;
|
||||
tlsRequired: boolean;
|
||||
};
|
||||
}> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
|
||||
// Get email domains if email server is configured
|
||||
let emailDomains: string[] = [];
|
||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
||||
} else if (dcRouter.options.emailConfig?.domains) {
|
||||
// Fallback: get domains from email config options
|
||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
||||
typeof d === 'string' ? d : d.domain
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
email: {
|
||||
enabled: !!dcRouter.emailServer,
|
||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
||||
rateLimits: {
|
||||
perMinute: 10,
|
||||
perHour: 100,
|
||||
perDay: 1000,
|
||||
},
|
||||
domains: emailDomains,
|
||||
},
|
||||
dns: {
|
||||
enabled: !!dcRouter.dnsServer,
|
||||
port: 53,
|
||||
nameservers: dcRouter.options.dnsNsDomains || [],
|
||||
caching: true,
|
||||
ttl: 300,
|
||||
},
|
||||
proxy: {
|
||||
enabled: !!dcRouter.smartProxy,
|
||||
httpPort: 80,
|
||||
httpsPort: 443,
|
||||
maxConnections: 1000,
|
||||
},
|
||||
security: {
|
||||
blockList: [],
|
||||
rateLimit: true,
|
||||
spamDetection: true,
|
||||
tlsRequired: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
318
ts/opsserver/handlers/email-ops.handler.ts
Normal file
318
ts/opsserver/handlers/email-ops.handler.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { SecurityLogger } from '../../security/index.js';
|
||||
|
||||
export class EmailOpsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Queued Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
||||
'getQueuedEmails',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const stats = queue.getStats();
|
||||
|
||||
// Get all queue items and filter by status if provided
|
||||
const items = this.getQueueItems(
|
||||
dataArg.status,
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: stats.queueSize,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Sent Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
||||
'getSentEmails',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'delivered',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length, // Note: total would ideally come from a counter
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Failed Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
|
||||
'getFailedEmails',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'failed',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Resend Failed Email Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||
'resendEmail',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(dataArg.emailId);
|
||||
|
||||
if (!item) {
|
||||
return { success: false, error: 'Email not found in queue' };
|
||||
}
|
||||
|
||||
if (item.status !== 'failed') {
|
||||
return { success: false, error: `Email is not in failed state (current: ${item.status})` };
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-enqueue the failed email by creating a new queue entry
|
||||
// with the same data but reset attempt count
|
||||
const newQueueId = await queue.enqueue(
|
||||
item.processingResult,
|
||||
item.processingMode,
|
||||
item.route
|
||||
);
|
||||
|
||||
// Optionally remove the old failed entry
|
||||
await queue.removeItem(dataArg.emailId);
|
||||
|
||||
return { success: true, newQueueId };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to resend email'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Security Incidents Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
|
||||
'getSecurityIncidents',
|
||||
async (dataArg) => {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
const filter: {
|
||||
level?: any;
|
||||
type?: any;
|
||||
} = {};
|
||||
|
||||
if (dataArg.level) {
|
||||
filter.level = dataArg.level;
|
||||
}
|
||||
|
||||
if (dataArg.type) {
|
||||
filter.type = dataArg.type;
|
||||
}
|
||||
|
||||
const incidents = securityLogger.getRecentEvents(
|
||||
dataArg.limit || 100,
|
||||
Object.keys(filter).length > 0 ? filter : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
incidents: incidents.map(event => ({
|
||||
timestamp: event.timestamp,
|
||||
level: event.level as interfaces.requests.TSecurityLogLevel,
|
||||
type: event.type as interfaces.requests.TSecurityEventType,
|
||||
message: event.message,
|
||||
details: event.details,
|
||||
ipAddress: event.ipAddress,
|
||||
userId: event.userId,
|
||||
sessionId: event.sessionId,
|
||||
emailId: event.emailId,
|
||||
domain: event.domain,
|
||||
action: event.action,
|
||||
result: event.result,
|
||||
success: event.success,
|
||||
})),
|
||||
total: incidents.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Bounce Records Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
|
||||
'getBounceRecords',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { records: [], suppressionList: [], total: 0 };
|
||||
}
|
||||
|
||||
// Use smartmta's public API for bounce/suppression data
|
||||
const suppressionList = emailServer.getSuppressionList();
|
||||
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
|
||||
|
||||
// Create bounce records from the available data
|
||||
const records: interfaces.requests.IBounceRecord[] = [];
|
||||
|
||||
for (const email of hardBouncedAddresses) {
|
||||
const bounceInfo = emailServer.getBounceHistory(email);
|
||||
if (bounceInfo) {
|
||||
records.push({
|
||||
id: `bounce-${email}`,
|
||||
recipient: email,
|
||||
sender: '',
|
||||
domain: email.split('@')[1] || '',
|
||||
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
|
||||
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
|
||||
timestamp: (bounceInfo as any).lastBounce,
|
||||
processed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit and offset
|
||||
const limit = dataArg.limit || 50;
|
||||
const offset = dataArg.offset || 0;
|
||||
const paginatedRecords = records.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
records: paginatedRecords,
|
||||
suppressionList,
|
||||
total: records.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove from Suppression List Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
|
||||
'removeFromSuppressionList',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
emailServer.removeFromSuppressionList(dataArg.email);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get queue items with filtering and pagination
|
||||
*/
|
||||
private getQueueItems(
|
||||
status?: interfaces.requests.TEmailQueueStatus,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): interfaces.requests.IEmailQueueItem[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const items: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
// Access the internal queue map via reflection
|
||||
// This is necessary because the queue doesn't expose iteration methods
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter and convert items
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
// Apply status filter if provided
|
||||
if (status && item.status !== status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email details from processingResult if available
|
||||
const processingResult = item.processingResult;
|
||||
let from = '';
|
||||
let to: string[] = [];
|
||||
let subject = '';
|
||||
|
||||
if (processingResult) {
|
||||
// Check if it's an Email object or raw email data
|
||||
if (processingResult.email) {
|
||||
from = processingResult.email.from || '';
|
||||
to = processingResult.email.to || [];
|
||||
subject = processingResult.email.subject || '';
|
||||
} else if (processingResult.from) {
|
||||
from = processingResult.from;
|
||||
to = processingResult.to || [];
|
||||
subject = processingResult.subject || '';
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: item.id,
|
||||
processingMode: item.processingMode,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
|
||||
lastError: item.lastError,
|
||||
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
|
||||
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
|
||||
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
// Apply pagination
|
||||
return items.slice(offset, offset + limit);
|
||||
}
|
||||
}
|
||||
9
ts/opsserver/handlers/index.ts
Normal file
9
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './admin.handler.js';
|
||||
export * from './config.handler.js';
|
||||
export * from './logs.handler.js';
|
||||
export * from './security.handler.js';
|
||||
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';
|
||||
195
ts/opsserver/handlers/logs.handler.ts
Normal file
195
ts/opsserver/handlers/logs.handler.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class LogsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Recent Logs Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'getRecentLogs',
|
||||
async (dataArg, toolsArg) => {
|
||||
const logs = await this.getRecentLogs(
|
||||
dataArg.level,
|
||||
dataArg.category,
|
||||
dataArg.limit || 100,
|
||||
dataArg.offset || 0,
|
||||
dataArg.search,
|
||||
dataArg.timeRange
|
||||
);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: logs.length, // TODO: Implement proper total count
|
||||
hasMore: false, // TODO: Implement proper pagination
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Log Stream Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||
'getLogStream',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Create a virtual stream for log streaming
|
||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||
|
||||
// Set up log streaming
|
||||
const streamLogs = this.setupLogStream(
|
||||
virtualStream,
|
||||
dataArg.filters?.level,
|
||||
dataArg.filters?.category,
|
||||
dataArg.follow
|
||||
);
|
||||
|
||||
// Start streaming
|
||||
streamLogs.start();
|
||||
|
||||
// VirtualStream handles cleanup automatically
|
||||
|
||||
return {
|
||||
logStream: virtualStream as any, // Cast to IVirtualStream interface
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getRecentLogs(
|
||||
level?: 'error' | 'warn' | 'info' | 'debug',
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
search?: string,
|
||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d'
|
||||
): Promise<Array<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}>> {
|
||||
// TODO: Implement actual log retrieval from storage or logger
|
||||
// For now, return mock data
|
||||
const mockLogs: Array<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}> = [];
|
||||
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
const now = Date.now();
|
||||
|
||||
// Generate some mock log entries
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
|
||||
// Filter by requested criteria
|
||||
if (level && mockLevel !== level) continue;
|
||||
if (category && mockCategory !== category) continue;
|
||||
|
||||
mockLogs.push({
|
||||
timestamp: now - (i * 60000), // 1 minute apart
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Sample log message ${i} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
return mockLogs.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
private setupLogStream(
|
||||
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
||||
levelFilter?: string[],
|
||||
categoryFilter?: string[],
|
||||
follow: boolean = true
|
||||
): {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
} {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
let logIndex = 0;
|
||||
|
||||
const start = () => {
|
||||
if (!follow) {
|
||||
// Send existing logs and close
|
||||
this.getRecentLogs(
|
||||
levelFilter?.[0] as any,
|
||||
categoryFilter?.[0] as any,
|
||||
100,
|
||||
0
|
||||
).then(logs => {
|
||||
logs.forEach(log => {
|
||||
const logData = JSON.stringify(log);
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
});
|
||||
// VirtualStream doesn't have end() method - it closes automatically
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For follow mode, simulate real-time log streaming
|
||||
intervalId = setInterval(() => {
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
|
||||
// Filter by requested criteria
|
||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
||||
|
||||
const logEntry = {
|
||||
timestamp: Date.now(),
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Real-time log ${logIndex++} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
};
|
||||
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
}, 2000); // Send a log every 2 seconds
|
||||
|
||||
// TODO: Hook into actual logger events
|
||||
// logger.on('log', (logEntry) => {
|
||||
// if (matchesCriteria(logEntry, level, service)) {
|
||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
// TODO: Unhook from logger events
|
||||
};
|
||||
|
||||
return { start, stop };
|
||||
}
|
||||
}
|
||||
405
ts/opsserver/handlers/radius.handler.ts
Normal file
405
ts/opsserver/handlers/radius.handler.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RadiusHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// ========================================================================
|
||||
// RADIUS Client Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all RADIUS clients
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||
'getRadiusClients',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { clients: [] };
|
||||
}
|
||||
|
||||
const clients = radiusServer.getClients();
|
||||
return {
|
||||
clients: clients.map(c => ({
|
||||
name: c.name,
|
||||
ipRange: c.ipRange,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||
'setRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await radiusServer.addClient(dataArg.client);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a RADIUS client
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||
'removeRadiusClient',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const removed = radiusServer.removeClient(dataArg.name);
|
||||
return {
|
||||
success: removed,
|
||||
message: removed ? undefined : 'Client not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// VLAN Mapping Management
|
||||
// ========================================================================
|
||||
|
||||
// Get all VLAN mappings
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||
'getVlanMappings',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
mappings: [],
|
||||
config: { defaultVlan: 1, allowUnknownMacs: true },
|
||||
};
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const mappings = vlanManager.getAllMappings();
|
||||
const config = vlanManager.getConfig();
|
||||
|
||||
return {
|
||||
mappings: mappings.map(m => ({
|
||||
mac: m.mac,
|
||||
vlan: m.vlan,
|
||||
description: m.description,
|
||||
enabled: m.enabled,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
})),
|
||||
config: {
|
||||
defaultVlan: config.defaultVlan,
|
||||
allowUnknownMacs: config.allowUnknownMacs,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Add or update a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||
'setVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const mapping = await vlanManager.addMapping(dataArg.mapping);
|
||||
return {
|
||||
success: true,
|
||||
mapping: {
|
||||
mac: mapping.mac,
|
||||
vlan: mapping.vlan,
|
||||
description: mapping.description,
|
||||
enabled: mapping.enabled,
|
||||
createdAt: mapping.createdAt,
|
||||
updatedAt: mapping.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove a VLAN mapping
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||
'removeVlanMapping',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const removed = await vlanManager.removeMapping(dataArg.mac);
|
||||
return {
|
||||
success: removed,
|
||||
message: removed ? undefined : 'Mapping not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Update VLAN configuration
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||
'updateVlanConfig',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
success: false,
|
||||
config: { defaultVlan: 1, allowUnknownMacs: true },
|
||||
};
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
vlanManager.updateConfig({
|
||||
defaultVlan: dataArg.defaultVlan,
|
||||
allowUnknownMacs: dataArg.allowUnknownMacs,
|
||||
});
|
||||
|
||||
const config = vlanManager.getConfig();
|
||||
return {
|
||||
success: true,
|
||||
config: {
|
||||
defaultVlan: config.defaultVlan,
|
||||
allowUnknownMacs: config.allowUnknownMacs,
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Test VLAN assignment
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||
'testVlanAssignment',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { assigned: false, vlan: 0, isDefault: false };
|
||||
}
|
||||
|
||||
const vlanManager = radiusServer.getVlanManager();
|
||||
const result = vlanManager.assignVlan(dataArg.mac);
|
||||
|
||||
return {
|
||||
assigned: result.assigned,
|
||||
vlan: result.vlan,
|
||||
isDefault: result.isDefault,
|
||||
matchedRule: result.matchedRule
|
||||
? {
|
||||
mac: result.matchedRule.mac,
|
||||
vlan: result.matchedRule.vlan,
|
||||
description: result.matchedRule.description,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Accounting / Session Management
|
||||
// ========================================================================
|
||||
|
||||
// Get active sessions
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||
'getRadiusSessions',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { sessions: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
let sessions = accountingManager.getActiveSessions();
|
||||
|
||||
// Apply filters
|
||||
if (dataArg.filter) {
|
||||
if (dataArg.filter.username) {
|
||||
sessions = sessions.filter(s => s.username === dataArg.filter!.username);
|
||||
}
|
||||
if (dataArg.filter.nasIpAddress) {
|
||||
sessions = sessions.filter(s => s.nasIpAddress === dataArg.filter!.nasIpAddress);
|
||||
}
|
||||
if (dataArg.filter.vlanId !== undefined) {
|
||||
sessions = sessions.filter(s => s.vlanId === dataArg.filter!.vlanId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
username: s.username,
|
||||
macAddress: s.macAddress,
|
||||
nasIpAddress: s.nasIpAddress,
|
||||
nasIdentifier: s.nasIdentifier,
|
||||
vlanId: s.vlanId,
|
||||
framedIpAddress: s.framedIpAddress,
|
||||
startTime: s.startTime,
|
||||
lastUpdateTime: s.lastUpdateTime,
|
||||
status: s.status,
|
||||
inputOctets: s.inputOctets,
|
||||
outputOctets: s.outputOctets,
|
||||
sessionTime: s.sessionTime,
|
||||
})),
|
||||
totalCount: sessions.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Disconnect a session
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||
'disconnectRadiusSession',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return { success: false, message: 'RADIUS server not configured' };
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
const disconnected = await accountingManager.disconnectSession(
|
||||
dataArg.sessionId,
|
||||
dataArg.reason || 'AdminReset'
|
||||
);
|
||||
|
||||
return {
|
||||
success: disconnected,
|
||||
message: disconnected ? undefined : 'Session not found',
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get accounting summary
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||
'getRadiusAccountingSummary',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
summary: {
|
||||
periodStart: dataArg.startTime,
|
||||
periodEnd: dataArg.endTime,
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
totalSessionTime: 0,
|
||||
averageSessionDuration: 0,
|
||||
uniqueUsers: 0,
|
||||
sessionsByVlan: {},
|
||||
topUsersByTraffic: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountingManager = radiusServer.getAccountingManager();
|
||||
const summary = await accountingManager.getSummary(dataArg.startTime, dataArg.endTime);
|
||||
|
||||
return { summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// Statistics
|
||||
// ========================================================================
|
||||
|
||||
// Get RADIUS statistics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||
'getRadiusStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||
|
||||
if (!radiusServer) {
|
||||
return {
|
||||
stats: {
|
||||
running: false,
|
||||
uptime: 0,
|
||||
authRequests: 0,
|
||||
authAccepts: 0,
|
||||
authRejects: 0,
|
||||
accountingRequests: 0,
|
||||
activeSessions: 0,
|
||||
vlanMappings: 0,
|
||||
clients: 0,
|
||||
},
|
||||
vlanStats: {
|
||||
totalMappings: 0,
|
||||
enabledMappings: 0,
|
||||
exactMatches: 0,
|
||||
ouiPatterns: 0,
|
||||
wildcardPatterns: 0,
|
||||
},
|
||||
accountingStats: {
|
||||
activeSessions: 0,
|
||||
totalSessionsStarted: 0,
|
||||
totalSessionsStopped: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
interimUpdatesReceived: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = radiusServer.getStats();
|
||||
const vlanStats = radiusServer.getVlanManager().getStats();
|
||||
const accountingStats = radiusServer.getAccountingManager().getStats();
|
||||
|
||||
return {
|
||||
stats,
|
||||
vlanStats,
|
||||
accountingStats,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
163
ts/opsserver/handlers/remoteingress.handler.ts
Normal file
163
ts/opsserver/handlers/remoteingress.handler.ts
Normal 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() };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
333
ts/opsserver/handlers/security.handler.ts
Normal file
333
ts/opsserver/handlers/security.handler.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
|
||||
export class SecurityHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Security Metrics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||
'getSecurityMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const metrics = await this.collectSecurityMetrics();
|
||||
return {
|
||||
metrics: {
|
||||
blockedIPs: metrics.blockedIPs,
|
||||
reputationScores: metrics.reputationScores,
|
||||
spamDetected: metrics.spamDetection.detected,
|
||||
malwareDetected: metrics.malwareDetected,
|
||||
phishingDetected: metrics.phishingDetected,
|
||||
authenticationFailures: metrics.authFailures,
|
||||
suspiciousActivities: metrics.suspiciousActivities,
|
||||
},
|
||||
trends: dataArg.includeDetails ? {
|
||||
spam: metrics.trends.spam,
|
||||
malware: metrics.trends.malware,
|
||||
phishing: metrics.trends.phishing,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Active Connections Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||
'getActiveConnections',
|
||||
async (dataArg, toolsArg) => {
|
||||
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
|
||||
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
|
||||
id: conn.id,
|
||||
remoteAddress: conn.source.ip,
|
||||
localAddress: conn.destination.ip,
|
||||
startTime: conn.startTime,
|
||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||
state: conn.status as any,
|
||||
bytesReceived: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesSent: Math.floor(conn.bytesTransferred / 2),
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
total: connectionInfos.length,
|
||||
byProtocol: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.protocol] = (acc[conn.protocol] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as { [protocol: string]: number }),
|
||||
byState: connectionInfos.reduce((acc, conn) => {
|
||||
acc[conn.state] = (acc[conn.state] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as { [state: string]: number }),
|
||||
};
|
||||
|
||||
return {
|
||||
connections: connectionInfos,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Network Stats Handler - provides comprehensive network metrics
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler(
|
||||
'getNetworkStats',
|
||||
async (dataArg, toolsArg) => {
|
||||
// Get network stats from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Convert per-IP throughput Map to serializable array
|
||||
const throughputByIP: Array<{ ip: string; in: number; out: number }> = [];
|
||||
if (networkStats.throughputByIP) {
|
||||
for (const [ip, tp] of networkStats.throughputByIP) {
|
||||
throughputByIP.push({ ip, in: tp.in, out: tp.out });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
connectionsByIP: [],
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Rate Limit Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||
'getRateLimitStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const status = await this.getRateLimitStatus(dataArg.domain, dataArg.ip);
|
||||
const limits: interfaces.data.IRateLimitInfo[] = status.limits.map(limit => ({
|
||||
domain: limit.identifier,
|
||||
currentRate: limit.current,
|
||||
limit: limit.limit,
|
||||
remaining: limit.limit - limit.current,
|
||||
resetTime: limit.resetAt,
|
||||
blocked: limit.status === 'limited',
|
||||
}));
|
||||
|
||||
return {
|
||||
limits,
|
||||
globalLimit: dataArg.includeBlocked ? {
|
||||
current: limits.reduce((sum, l) => sum + l.currentRate, 0),
|
||||
limit: 1000, // Global limit
|
||||
remaining: 1000 - limits.reduce((sum, l) => sum + l.currentRate, 0),
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async collectSecurityMetrics(): Promise<{
|
||||
blockedIPs: string[];
|
||||
reputationScores: { [domain: string]: number };
|
||||
spamDetection: {
|
||||
detected: number;
|
||||
falsePositives: number;
|
||||
};
|
||||
malwareDetected: number;
|
||||
phishingDetected: number;
|
||||
authFailures: number;
|
||||
suspiciousActivities: number;
|
||||
trends: {
|
||||
spam: Array<{ timestamp: number; value: number }>;
|
||||
malware: Array<{ timestamp: number; value: number }>;
|
||||
phishing: Array<{ timestamp: number; value: number }>;
|
||||
};
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const securityStats = await this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats();
|
||||
return {
|
||||
blockedIPs: [], // TODO: Track actual blocked IPs
|
||||
reputationScores: {},
|
||||
spamDetection: {
|
||||
detected: securityStats.spamDetected,
|
||||
falsePositives: 0,
|
||||
},
|
||||
malwareDetected: securityStats.malwareDetected,
|
||||
phishingDetected: securityStats.phishingDetected,
|
||||
authFailures: securityStats.authFailures,
|
||||
suspiciousActivities: 0,
|
||||
trends: {
|
||||
spam: [],
|
||||
malware: [],
|
||||
phishing: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
blockedIPs: [],
|
||||
reputationScores: {},
|
||||
spamDetection: {
|
||||
detected: 0,
|
||||
falsePositives: 0,
|
||||
},
|
||||
malwareDetected: 0,
|
||||
phishingDetected: 0,
|
||||
authFailures: 0,
|
||||
suspiciousActivities: 0,
|
||||
trends: {
|
||||
spam: [],
|
||||
malware: [],
|
||||
phishing: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getActiveConnections(
|
||||
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
|
||||
state?: string
|
||||
): Promise<Array<{
|
||||
id: string;
|
||||
type: 'http' | 'smtp' | 'dns';
|
||||
source: {
|
||||
ip: string;
|
||||
port: number;
|
||||
country?: string;
|
||||
};
|
||||
destination: {
|
||||
ip: string;
|
||||
port: number;
|
||||
service?: string;
|
||||
};
|
||||
startTime: number;
|
||||
bytesTransferred: number;
|
||||
status: 'active' | 'idle' | 'closing';
|
||||
}>> {
|
||||
const connections: Array<{
|
||||
id: string;
|
||||
type: 'http' | 'smtp' | 'dns';
|
||||
source: {
|
||||
ip: string;
|
||||
port: number;
|
||||
country?: string;
|
||||
};
|
||||
destination: {
|
||||
ip: string;
|
||||
port: number;
|
||||
service?: string;
|
||||
};
|
||||
startTime: number;
|
||||
bytesTransferred: number;
|
||||
status: 'active' | 'idle' | 'closing';
|
||||
}> = [];
|
||||
|
||||
// Get connection info and network stats from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Use IP-based connection data from the new metrics API
|
||||
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
||||
let connIndex = 0;
|
||||
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
||||
|
||||
for (const [ip, count] of networkStats.connectionsByIP) {
|
||||
// Create a connection entry for each active IP connection
|
||||
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
|
||||
connections.push({
|
||||
id: `conn-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: Math.floor(Math.random() * 50000) + 10000, // High port range
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
|
||||
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (connectionInfo.length > 0) {
|
||||
// Fallback to route-based connection info if no IP data available
|
||||
connectionInfo.forEach((info, index) => {
|
||||
connections.push({
|
||||
id: `conn-${index}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: 'unknown',
|
||||
port: 0,
|
||||
},
|
||||
destination: {
|
||||
ip: this.opsServerRef.dcRouterRef.options.publicIp || 'server',
|
||||
port: 443,
|
||||
service: info.source,
|
||||
},
|
||||
startTime: info.lastActivity.getTime(),
|
||||
bytesTransferred: 0,
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by protocol if specified
|
||||
if (protocol) {
|
||||
return connections.filter(conn => {
|
||||
if (protocol === 'https' || protocol === 'http') {
|
||||
return conn.type === 'http';
|
||||
}
|
||||
return conn.type === protocol.replace('s', ''); // smtp/smtps -> smtp
|
||||
});
|
||||
}
|
||||
|
||||
return connections;
|
||||
}
|
||||
|
||||
private async getRateLimitStatus(
|
||||
domain?: string,
|
||||
ip?: string
|
||||
): Promise<{
|
||||
limits: Array<{
|
||||
identifier: string;
|
||||
type: 'ip' | 'domain' | 'email';
|
||||
limit: number;
|
||||
current: number;
|
||||
resetAt: number;
|
||||
status: 'ok' | 'warning' | 'limited';
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual rate limit status collection
|
||||
return {
|
||||
limits: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
513
ts/opsserver/handlers/stats.handler.ts
Normal file
513
ts/opsserver/handlers/stats.handler.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
|
||||
export class StatsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Server Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'getServerStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const stats = await this.collectServerStats();
|
||||
return {
|
||||
stats: {
|
||||
uptime: stats.uptime,
|
||||
startTime: Date.now() - (stats.uptime * 1000),
|
||||
memoryUsage: stats.memoryUsage,
|
||||
cpuUsage: stats.cpuUsage,
|
||||
activeConnections: stats.activeConnections,
|
||||
totalConnections: stats.totalConnections,
|
||||
requestsPerSecond: stats.requestsPerSecond,
|
||||
throughput: stats.throughput,
|
||||
},
|
||||
history: dataArg.includeHistory ? stats.history : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Email Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||
'getEmailStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer) {
|
||||
return {
|
||||
stats: {
|
||||
sent: 0,
|
||||
received: 0,
|
||||
bounced: 0,
|
||||
queued: 0,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: 0,
|
||||
bounceRate: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await this.collectEmailStats();
|
||||
return {
|
||||
stats: {
|
||||
sent: stats.sentToday,
|
||||
received: stats.receivedToday,
|
||||
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
|
||||
queued: stats.queueSize,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: stats.deliveryRate,
|
||||
bounceRate: stats.bounceRate,
|
||||
},
|
||||
domainBreakdown: dataArg.includeDetails ? stats.domainBreakdown : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// DNS Statistics Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||
'getDnsStatistics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const dnsServer = this.opsServerRef.dcRouterRef.dnsServer;
|
||||
if (!dnsServer) {
|
||||
return {
|
||||
stats: {
|
||||
totalQueries: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
cacheHitRate: 0,
|
||||
activeDomains: 0,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stats = await this.collectDnsStats();
|
||||
return {
|
||||
stats: {
|
||||
totalQueries: stats.totalQueries,
|
||||
cacheHits: stats.cacheHits,
|
||||
cacheMisses: stats.cacheMisses,
|
||||
cacheHitRate: stats.cacheHitRate,
|
||||
activeDomains: stats.topDomains.length,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: stats.queryTypes,
|
||||
},
|
||||
domainBreakdown: dataArg.includeQueryTypes ? stats.domainBreakdown : undefined,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Queue Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||
'getQueueStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
const queues: interfaces.data.IQueueStatus[] = [];
|
||||
|
||||
if (emailServer) {
|
||||
const status = await this.getQueueStatus();
|
||||
queues.push({
|
||||
name: dataArg.queueName || 'default',
|
||||
size: status.pending,
|
||||
processing: status.active,
|
||||
failed: status.failed,
|
||||
retrying: status.retrying,
|
||||
averageProcessingTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
queues,
|
||||
totalItems: queues.reduce((sum, q) => sum + q.size + q.processing + q.failed + q.retrying, 0),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Health Status Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'getHealthStatus',
|
||||
async (dataArg, toolsArg) => {
|
||||
const health = await this.checkHealthStatus();
|
||||
return {
|
||||
health: {
|
||||
healthy: health.healthy,
|
||||
uptime: process.uptime(),
|
||||
services: health.services.reduce((acc, service) => {
|
||||
acc[service.name] = {
|
||||
status: service.status,
|
||||
message: service.message,
|
||||
lastCheck: Date.now(),
|
||||
};
|
||||
return acc;
|
||||
}, {} as any),
|
||||
version: '2.12.0', // TODO: Get from package.json
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Combined Metrics Handler - More efficient for frontend polling
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||
'getCombinedMetrics',
|
||||
async (dataArg, toolsArg) => {
|
||||
const sections = dataArg.sections || {
|
||||
server: true,
|
||||
email: true,
|
||||
dns: true,
|
||||
security: true,
|
||||
network: true,
|
||||
};
|
||||
|
||||
const metrics: any = {};
|
||||
|
||||
// Run all metrics collection in parallel
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (sections.server) {
|
||||
promises.push(
|
||||
this.collectServerStats().then(stats => {
|
||||
metrics.server = {
|
||||
uptime: stats.uptime,
|
||||
startTime: Date.now() - (stats.uptime * 1000),
|
||||
memoryUsage: stats.memoryUsage,
|
||||
cpuUsage: stats.cpuUsage,
|
||||
activeConnections: stats.activeConnections,
|
||||
totalConnections: stats.totalConnections,
|
||||
requestsPerSecond: stats.requestsPerSecond,
|
||||
throughput: stats.throughput,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.email) {
|
||||
promises.push(
|
||||
this.collectEmailStats().then(stats => {
|
||||
metrics.email = {
|
||||
sent: stats.sentToday,
|
||||
received: stats.receivedToday,
|
||||
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
|
||||
queued: stats.queueSize,
|
||||
failed: 0,
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: stats.deliveryRate,
|
||||
bounceRate: stats.bounceRate,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.dns) {
|
||||
promises.push(
|
||||
this.collectDnsStats().then(stats => {
|
||||
metrics.dns = {
|
||||
totalQueries: stats.totalQueries,
|
||||
cacheHits: stats.cacheHits,
|
||||
cacheMisses: stats.cacheMisses,
|
||||
cacheHitRate: stats.cacheHitRate,
|
||||
activeDomains: stats.topDomains.length,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: stats.queryTypes,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
||||
metrics.security = {
|
||||
blockedIPs: stats.blockedIPs,
|
||||
reputationScores: {},
|
||||
spamDetected: stats.spamDetected,
|
||||
malwareDetected: stats.malwareDetected,
|
||||
phishingDetected: stats.phishingDetected,
|
||||
authenticationFailures: stats.authFailures,
|
||||
suspiciousActivities: stats.totalThreatsBlocked,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const stats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
const serverStats = await this.collectServerStats();
|
||||
|
||||
// Build per-IP bandwidth lookup from throughputByIP
|
||||
const ipBandwidth = new Map<string, { in: number; out: number }>();
|
||||
if (stats.throughputByIP) {
|
||||
for (const [ip, tp] of stats.throughputByIP) {
|
||||
ipBandwidth.set(ip, { in: tp.in, out: tp.out });
|
||||
}
|
||||
}
|
||||
|
||||
metrics.network = {
|
||||
totalBandwidth: {
|
||||
in: stats.throughputRate.bytesInPerSecond,
|
||||
out: stats.throughputRate.bytesOutPerSecond,
|
||||
},
|
||||
totalBytes: {
|
||||
in: stats.totalDataTransferred.bytesIn,
|
||||
out: stats.totalDataTransferred.bytesOut,
|
||||
},
|
||||
activeConnections: serverStats.activeConnections,
|
||||
connectionDetails: [],
|
||||
topEndpoints: stats.topIPs.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
requests: ip.count,
|
||||
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
|
||||
})),
|
||||
throughputHistory: stats.throughputHistory || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
requestsTotal: stats.requestsTotal || 0,
|
||||
};
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async collectServerStats(): Promise<{
|
||||
uptime: number;
|
||||
cpuUsage: {
|
||||
user: number;
|
||||
system: number;
|
||||
};
|
||||
memoryUsage: interfaces.data.IServerStats['memoryUsage'];
|
||||
requestsPerSecond: number;
|
||||
activeConnections: number;
|
||||
totalConnections: number;
|
||||
throughput: interfaces.data.IServerStats['throughput'];
|
||||
history: Array<{
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}>;
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const serverStats = await this.opsServerRef.dcRouterRef.metricsManager.getServerStats();
|
||||
return {
|
||||
uptime: serverStats.uptime,
|
||||
cpuUsage: serverStats.cpuUsage,
|
||||
memoryUsage: serverStats.memoryUsage,
|
||||
requestsPerSecond: serverStats.requestsPerSecond,
|
||||
activeConnections: serverStats.activeConnections,
|
||||
totalConnections: serverStats.totalConnections,
|
||||
throughput: serverStats.throughput,
|
||||
history: [], // TODO: Implement history tracking
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to basic stats if MetricsManager not available
|
||||
const uptime = process.uptime();
|
||||
const memUsage = process.memoryUsage();
|
||||
const cpuUsage = plugins.os.loadavg()[0] * 100 / plugins.os.cpus().length;
|
||||
|
||||
return {
|
||||
uptime,
|
||||
cpuUsage: {
|
||||
user: cpuUsage * 0.7,
|
||||
system: cpuUsage * 0.3,
|
||||
},
|
||||
memoryUsage: {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
heapTotal: memUsage.heapTotal,
|
||||
external: memUsage.external,
|
||||
rss: memUsage.rss,
|
||||
},
|
||||
requestsPerSecond: 0,
|
||||
activeConnections: 0,
|
||||
totalConnections: 0,
|
||||
throughput: { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async collectEmailStats(): Promise<{
|
||||
sentToday: number;
|
||||
receivedToday: number;
|
||||
bounceRate: number;
|
||||
deliveryRate: number;
|
||||
queueSize: number;
|
||||
domainBreakdown?: { [domain: string]: interfaces.data.IEmailStats };
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const emailStats = await this.opsServerRef.dcRouterRef.metricsManager.getEmailStats();
|
||||
return {
|
||||
sentToday: emailStats.sentToday,
|
||||
receivedToday: emailStats.receivedToday,
|
||||
bounceRate: emailStats.bounceRate,
|
||||
deliveryRate: emailStats.deliveryRate,
|
||||
queueSize: emailStats.queueSize,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
bounceRate: 0,
|
||||
deliveryRate: 100,
|
||||
queueSize: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async collectDnsStats(): Promise<{
|
||||
queriesPerSecond: number;
|
||||
totalQueries: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
cacheHitRate: number;
|
||||
topDomains: Array<{
|
||||
domain: string;
|
||||
count: number;
|
||||
}>;
|
||||
queryTypes: { [key: string]: number };
|
||||
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
if (this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
const dnsStats = await this.opsServerRef.dcRouterRef.metricsManager.getDnsStats();
|
||||
return {
|
||||
queriesPerSecond: dnsStats.queriesPerSecond,
|
||||
totalQueries: dnsStats.totalQueries,
|
||||
cacheHits: dnsStats.cacheHits,
|
||||
cacheMisses: dnsStats.cacheMisses,
|
||||
cacheHitRate: dnsStats.cacheHitRate,
|
||||
topDomains: dnsStats.topDomains,
|
||||
queryTypes: dnsStats.queryTypes,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
queriesPerSecond: 0,
|
||||
totalQueries: 0,
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0,
|
||||
cacheHitRate: 0,
|
||||
topDomains: [],
|
||||
queryTypes: {},
|
||||
};
|
||||
}
|
||||
|
||||
private async getQueueStatus(): Promise<{
|
||||
pending: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
retrying: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
nextRetry?: number;
|
||||
}>;
|
||||
}> {
|
||||
// TODO: Implement actual queue status collection
|
||||
return {
|
||||
pending: 0,
|
||||
active: 0,
|
||||
failed: 0,
|
||||
retrying: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async checkHealthStatus(): Promise<{
|
||||
healthy: boolean;
|
||||
services: Array<{
|
||||
name: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
message?: string;
|
||||
}>;
|
||||
checks: Array<{
|
||||
name: string;
|
||||
passed: boolean;
|
||||
message?: string;
|
||||
}>;
|
||||
}> {
|
||||
const services: Array<{
|
||||
name: string;
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
message?: string;
|
||||
}> = [];
|
||||
|
||||
// Check HTTP Proxy
|
||||
if (this.opsServerRef.dcRouterRef.smartProxy) {
|
||||
services.push({
|
||||
name: 'HTTP/HTTPS Proxy',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check Email Server
|
||||
if (this.opsServerRef.dcRouterRef.emailServer) {
|
||||
services.push({
|
||||
name: 'Email Server',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check DNS Server
|
||||
if (this.opsServerRef.dcRouterRef.dnsServer) {
|
||||
services.push({
|
||||
name: 'DNS Server',
|
||||
status: 'healthy',
|
||||
});
|
||||
}
|
||||
|
||||
// Check OpsServer
|
||||
services.push({
|
||||
name: 'OpsServer',
|
||||
status: 'healthy',
|
||||
});
|
||||
|
||||
const healthy = services.every(s => s.status === 'healthy');
|
||||
|
||||
return {
|
||||
healthy,
|
||||
services,
|
||||
checks: [
|
||||
{
|
||||
name: 'Memory Usage',
|
||||
passed: process.memoryUsage().heapUsed < (plugins.os.totalmem() * 0.9),
|
||||
message: 'Memory usage within limits',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
56
ts/opsserver/helpers/guards.ts
Normal file
56
ts/opsserver/helpers/guards.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { AdminHandler } from '../handlers/admin.handler.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Helper function to use identity guards in handlers
|
||||
*
|
||||
* @example
|
||||
* // In a handler:
|
||||
* await passGuards(toolsArg, this.opsServerRef.adminHandler.validIdentityGuard, dataArg);
|
||||
*/
|
||||
export async function passGuards<T extends { identity?: any }>(
|
||||
toolsArg: any,
|
||||
guard: plugins.smartguard.Guard<T>,
|
||||
dataArg: T
|
||||
): Promise<void> {
|
||||
const result = await guard.exec(dataArg);
|
||||
if (!result) {
|
||||
const failedHint = await guard.getFailedHint(dataArg);
|
||||
throw new plugins.typedrequest.TypedResponseError(failedHint || 'Guard check failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check admin identity in handlers
|
||||
*/
|
||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check valid identity in handlers
|
||||
*/
|
||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
||||
adminHandler: AdminHandler,
|
||||
dataArg: T
|
||||
): Promise<void> {
|
||||
if (!dataArg.identity) {
|
||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||
}
|
||||
|
||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||
if (!passed) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||
}
|
||||
}
|
||||
1
ts/opsserver/index.ts
Normal file
1
ts/opsserver/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.opsserver.js';
|
||||
55
ts/paths.ts
55
ts/paths.ts
@@ -1,12 +1,55 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
// Code/asset paths (not affected by baseDir)
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
export const assetsDir = plugins.path.join(packageDir, './assets');
|
||||
export const keysDir = plugins.path.join(assetsDir, './keys');
|
||||
export const dnsRecordsDir = plugins.path.join(assetsDir, './dns-records');
|
||||
export const sentEmailsDir = plugins.path.join(assetsDir, './sent-emails');
|
||||
export const receivedEmailsDir = plugins.path.join(assetsDir, './received-emails');
|
||||
plugins.smartfile.fs.ensureDirSync(keysDir);
|
||||
export const distServe = plugins.path.join(packageDir, './dist_serve');
|
||||
|
||||
// Default base for all dcrouter data (always user-writable)
|
||||
export const dcrouterHomeDir = plugins.path.join(plugins.os.homedir(), '.serve.zone', 'dcrouter');
|
||||
|
||||
// Configure data directory with environment variable or default to ~/.serve.zone/dcrouter/data
|
||||
const DEFAULT_DATA_PATH = plugins.path.join(dcrouterHomeDir, 'data');
|
||||
export const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: DEFAULT_DATA_PATH;
|
||||
|
||||
// Default TsmDB path for CacheDb
|
||||
export const defaultTsmDbPath = plugins.path.join(dcrouterHomeDir, 'tsmdb');
|
||||
|
||||
// DNS records directory (only surviving MTA directory reference)
|
||||
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
|
||||
103
ts/plugins.ts
103
ts/plugins.ts
@@ -2,7 +2,9 @@
|
||||
import * as dns from 'dns';
|
||||
import * as fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
import * as http from 'http';
|
||||
import * as net from 'net';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as tls from 'tls';
|
||||
import * as util from 'util';
|
||||
@@ -11,7 +13,9 @@ export {
|
||||
dns,
|
||||
fs,
|
||||
crypto,
|
||||
http,
|
||||
net,
|
||||
os,
|
||||
path,
|
||||
tls,
|
||||
util,
|
||||
@@ -19,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
|
||||
@@ -38,22 +44,35 @@ export {
|
||||
// @push.rocks scope
|
||||
import * as projectinfo from '@push.rocks/projectinfo';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartacme from '@push.rocks/smartacme';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartdns from '@push.rocks/smartdns';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartguard from '@push.rocks/smartguard';
|
||||
import * as smartjwt from '@push.rocks/smartjwt';
|
||||
import * as smartlog from '@push.rocks/smartlog';
|
||||
import * as smartmail from '@push.rocks/smartmail';
|
||||
import * as smartmetrics from '@push.rocks/smartmetrics';
|
||||
import * as smartmta from '@push.rocks/smartmta';
|
||||
import * as smartmongo from '@push.rocks/smartmongo';
|
||||
import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as smartproxy from '@push.rocks/smartproxy';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartradius from '@push.rocks/smartradius';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartrx from '@push.rocks/smartrx';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartpromise, smartrequest, smartrx };
|
||||
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
|
||||
|
||||
// Define SmartLog types for use in error handling
|
||||
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
|
||||
|
||||
// apiclient.xyz scope
|
||||
import * as letterxpress from '@apiclient.xyz/letterxpress';
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
|
||||
export {
|
||||
letterxpress,
|
||||
cloudflare,
|
||||
}
|
||||
|
||||
// tsclass scope
|
||||
@@ -64,14 +83,76 @@ export {
|
||||
}
|
||||
|
||||
// third party
|
||||
import * as mailauth from 'mailauth';
|
||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
||||
import mailparser from 'mailparser';
|
||||
import * as uuid from 'uuid';
|
||||
|
||||
export {
|
||||
mailauth,
|
||||
dkimSign,
|
||||
mailparser,
|
||||
uuid,
|
||||
}
|
||||
|
||||
// Filesystem utilities (compatibility helpers for smartfile v13+)
|
||||
export const fsUtils = {
|
||||
/**
|
||||
* Ensure a directory exists, creating it recursively if needed (sync)
|
||||
*/
|
||||
ensureDirSync: (dirPath: string): void => {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure a directory exists, creating it recursively if needed (async)
|
||||
*/
|
||||
ensureDir: async (dirPath: string): Promise<void> => {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Write JSON content to a file synchronously
|
||||
*/
|
||||
toFsSync: (content: any, filePath: string): void => {
|
||||
const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
fs.writeFileSync(filePath, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Write JSON content to a file asynchronously
|
||||
*/
|
||||
toFs: async (content: any, filePath: string): Promise<void> => {
|
||||
const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
await fs.promises.writeFile(filePath, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a file or directory exists
|
||||
*/
|
||||
fileExistsSync: (filePath: string): boolean => {
|
||||
return fs.existsSync(filePath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a file or directory exists (async)
|
||||
*/
|
||||
fileExists: async (filePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Read a JSON file synchronously
|
||||
*/
|
||||
toObjectSync: <T = any>(filePath: string): T => {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content) as T;
|
||||
},
|
||||
|
||||
/**
|
||||
* Read a JSON file asynchronously
|
||||
*/
|
||||
toObject: async <T = any>(filePath: string): Promise<T> => {
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as T;
|
||||
},
|
||||
};
|
||||
|
||||
607
ts/radius/classes.accounting.manager.ts
Normal file
607
ts/radius/classes.accounting.manager.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
|
||||
/**
|
||||
* RADIUS accounting session
|
||||
*/
|
||||
export interface IAccountingSession {
|
||||
/** Unique session ID from RADIUS */
|
||||
sessionId: string;
|
||||
/** Username (often MAC address for MAB) */
|
||||
username: string;
|
||||
/** MAC address of the device */
|
||||
macAddress?: string;
|
||||
/** NAS IP address (switch/AP) */
|
||||
nasIpAddress: string;
|
||||
/** NAS port (physical or virtual) */
|
||||
nasPort?: number;
|
||||
/** NAS port type */
|
||||
nasPortType?: string;
|
||||
/** NAS identifier (name) */
|
||||
nasIdentifier?: string;
|
||||
/** Assigned VLAN */
|
||||
vlanId?: number;
|
||||
/** Assigned IP address (if any) */
|
||||
framedIpAddress?: string;
|
||||
/** Called station ID (usually BSSID for wireless) */
|
||||
calledStationId?: string;
|
||||
/** Calling station ID (usually client MAC) */
|
||||
callingStationId?: string;
|
||||
/** Session start time */
|
||||
startTime: number;
|
||||
/** Session end time (0 if active) */
|
||||
endTime: number;
|
||||
/** Last update time (interim accounting) */
|
||||
lastUpdateTime: number;
|
||||
/** Session status */
|
||||
status: 'active' | 'stopped' | 'terminated';
|
||||
/** Termination cause (if stopped) */
|
||||
terminateCause?: string;
|
||||
/** Input octets (bytes received by NAS from client) */
|
||||
inputOctets: number;
|
||||
/** Output octets (bytes sent by NAS to client) */
|
||||
outputOctets: number;
|
||||
/** Input packets */
|
||||
inputPackets: number;
|
||||
/** Output packets */
|
||||
outputPackets: number;
|
||||
/** Session duration in seconds */
|
||||
sessionTime: number;
|
||||
/** Service type */
|
||||
serviceType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounting summary for a time period
|
||||
*/
|
||||
export interface IAccountingSummary {
|
||||
/** Time period start */
|
||||
periodStart: number;
|
||||
/** Time period end */
|
||||
periodEnd: number;
|
||||
/** Total sessions */
|
||||
totalSessions: number;
|
||||
/** Active sessions */
|
||||
activeSessions: number;
|
||||
/** Total input bytes */
|
||||
totalInputBytes: number;
|
||||
/** Total output bytes */
|
||||
totalOutputBytes: number;
|
||||
/** Total session time (seconds) */
|
||||
totalSessionTime: number;
|
||||
/** Average session duration (seconds) */
|
||||
averageSessionDuration: number;
|
||||
/** Unique users/devices */
|
||||
uniqueUsers: number;
|
||||
/** Sessions by VLAN */
|
||||
sessionsByVlan: Record<number, number>;
|
||||
/** Top users by traffic */
|
||||
topUsersByTraffic: Array<{ username: string; totalBytes: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounting manager configuration
|
||||
*/
|
||||
export interface IAccountingManagerConfig {
|
||||
/** Storage key prefix */
|
||||
storagePrefix?: string;
|
||||
/** Session retention period in days (default: 30) */
|
||||
retentionDays?: number;
|
||||
/** Enable detailed session logging */
|
||||
detailedLogging?: boolean;
|
||||
/** Maximum active sessions to track in memory */
|
||||
maxActiveSessions?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages RADIUS accounting data including:
|
||||
* - Session tracking (start/stop/interim)
|
||||
* - Data usage tracking (bytes in/out)
|
||||
* - Session history and retention
|
||||
* - Billing reports and summaries
|
||||
*/
|
||||
export class AccountingManager {
|
||||
private activeSessions: Map<string, IAccountingSession> = new Map();
|
||||
private config: Required<IAccountingManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
|
||||
// Counters for statistics
|
||||
private stats = {
|
||||
totalSessionsStarted: 0,
|
||||
totalSessionsStopped: 0,
|
||||
totalInputBytes: 0,
|
||||
totalOutputBytes: 0,
|
||||
interimUpdatesReceived: 0,
|
||||
};
|
||||
|
||||
constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/accounting',
|
||||
retentionDays: config?.retentionDays ?? 30,
|
||||
detailedLogging: config?.detailedLogging ?? false,
|
||||
maxActiveSessions: config?.maxActiveSessions ?? 10000,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the accounting manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.storageManager) {
|
||||
await this.loadActiveSessions();
|
||||
}
|
||||
logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting start request
|
||||
*/
|
||||
async handleAccountingStart(data: {
|
||||
sessionId: string;
|
||||
username: string;
|
||||
macAddress?: string;
|
||||
nasIpAddress: string;
|
||||
nasPort?: number;
|
||||
nasPortType?: string;
|
||||
nasIdentifier?: string;
|
||||
vlanId?: number;
|
||||
framedIpAddress?: string;
|
||||
calledStationId?: string;
|
||||
callingStationId?: string;
|
||||
serviceType?: string;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
const session: IAccountingSession = {
|
||||
sessionId: data.sessionId,
|
||||
username: data.username,
|
||||
macAddress: data.macAddress,
|
||||
nasIpAddress: data.nasIpAddress,
|
||||
nasPort: data.nasPort,
|
||||
nasPortType: data.nasPortType,
|
||||
nasIdentifier: data.nasIdentifier,
|
||||
vlanId: data.vlanId,
|
||||
framedIpAddress: data.framedIpAddress,
|
||||
calledStationId: data.calledStationId,
|
||||
callingStationId: data.callingStationId,
|
||||
serviceType: data.serviceType,
|
||||
startTime: now,
|
||||
endTime: 0,
|
||||
lastUpdateTime: now,
|
||||
status: 'active',
|
||||
inputOctets: 0,
|
||||
outputOctets: 0,
|
||||
inputPackets: 0,
|
||||
outputPackets: 0,
|
||||
sessionTime: 0,
|
||||
};
|
||||
|
||||
// Check if we're at capacity
|
||||
if (this.activeSessions.size >= this.config.maxActiveSessions) {
|
||||
// Remove oldest session
|
||||
const oldest = this.findOldestSession();
|
||||
if (oldest) {
|
||||
await this.evictSession(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeSessions.set(data.sessionId, session);
|
||||
this.stats.totalSessionsStarted++;
|
||||
|
||||
if (this.config.detailedLogging) {
|
||||
logger.log('info', `Accounting Start: session=${data.sessionId}, user=${data.username}, NAS=${data.nasIpAddress}`);
|
||||
}
|
||||
|
||||
// Persist session
|
||||
if (this.storageManager) {
|
||||
await this.persistSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting interim update request
|
||||
*/
|
||||
async handleAccountingUpdate(data: {
|
||||
sessionId: string;
|
||||
inputOctets?: number;
|
||||
outputOctets?: number;
|
||||
inputPackets?: number;
|
||||
outputPackets?: number;
|
||||
sessionTime?: number;
|
||||
}): Promise<void> {
|
||||
const session = this.activeSessions.get(data.sessionId);
|
||||
|
||||
if (!session) {
|
||||
logger.log('warn', `Interim update for unknown session: ${data.sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update session metrics
|
||||
if (data.inputOctets !== undefined) {
|
||||
session.inputOctets = data.inputOctets;
|
||||
}
|
||||
if (data.outputOctets !== undefined) {
|
||||
session.outputOctets = data.outputOctets;
|
||||
}
|
||||
if (data.inputPackets !== undefined) {
|
||||
session.inputPackets = data.inputPackets;
|
||||
}
|
||||
if (data.outputPackets !== undefined) {
|
||||
session.outputPackets = data.outputPackets;
|
||||
}
|
||||
if (data.sessionTime !== undefined) {
|
||||
session.sessionTime = data.sessionTime;
|
||||
}
|
||||
|
||||
session.lastUpdateTime = Date.now();
|
||||
this.stats.interimUpdatesReceived++;
|
||||
|
||||
if (this.config.detailedLogging) {
|
||||
logger.log('debug', `Accounting Interim: session=${data.sessionId}, in=${data.inputOctets}, out=${data.outputOctets}`);
|
||||
}
|
||||
|
||||
// Update persisted session
|
||||
if (this.storageManager) {
|
||||
await this.persistSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting stop request
|
||||
*/
|
||||
async handleAccountingStop(data: {
|
||||
sessionId: string;
|
||||
terminateCause?: string;
|
||||
inputOctets?: number;
|
||||
outputOctets?: number;
|
||||
inputPackets?: number;
|
||||
outputPackets?: number;
|
||||
sessionTime?: number;
|
||||
}): Promise<void> {
|
||||
const session = this.activeSessions.get(data.sessionId);
|
||||
|
||||
if (!session) {
|
||||
logger.log('warn', `Stop for unknown session: ${data.sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update final metrics
|
||||
if (data.inputOctets !== undefined) {
|
||||
session.inputOctets = data.inputOctets;
|
||||
}
|
||||
if (data.outputOctets !== undefined) {
|
||||
session.outputOctets = data.outputOctets;
|
||||
}
|
||||
if (data.inputPackets !== undefined) {
|
||||
session.inputPackets = data.inputPackets;
|
||||
}
|
||||
if (data.outputPackets !== undefined) {
|
||||
session.outputPackets = data.outputPackets;
|
||||
}
|
||||
if (data.sessionTime !== undefined) {
|
||||
session.sessionTime = data.sessionTime;
|
||||
}
|
||||
|
||||
session.endTime = Date.now();
|
||||
session.lastUpdateTime = session.endTime;
|
||||
session.status = 'stopped';
|
||||
session.terminateCause = data.terminateCause;
|
||||
|
||||
// Update global stats
|
||||
this.stats.totalSessionsStopped++;
|
||||
this.stats.totalInputBytes += session.inputOctets;
|
||||
this.stats.totalOutputBytes += session.outputOctets;
|
||||
|
||||
if (this.config.detailedLogging) {
|
||||
logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`);
|
||||
}
|
||||
|
||||
// Archive the session
|
||||
if (this.storageManager) {
|
||||
await this.archiveSession(session);
|
||||
}
|
||||
|
||||
// Remove from active sessions
|
||||
this.activeSessions.delete(data.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active session by ID
|
||||
*/
|
||||
getSession(sessionId: string): IAccountingSession | undefined {
|
||||
return this.activeSessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getActiveSessions(): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions by username
|
||||
*/
|
||||
getSessionsByUsername(username: string): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values()).filter(s => s.username === username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions by NAS IP
|
||||
*/
|
||||
getSessionsByNas(nasIpAddress: string): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values()).filter(s => s.nasIpAddress === nasIpAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active sessions by VLAN
|
||||
*/
|
||||
getSessionsByVlan(vlanId: number): IAccountingSession[] {
|
||||
return Array.from(this.activeSessions.values()).filter(s => s.vlanId === vlanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting summary for a time period
|
||||
*/
|
||||
async getSummary(startTime: number, endTime: number): Promise<IAccountingSummary> {
|
||||
// Get archived sessions for the time period
|
||||
const archivedSessions = await this.getArchivedSessions(startTime, endTime);
|
||||
|
||||
// Combine with active sessions that started within the period
|
||||
const activeSessions = Array.from(this.activeSessions.values()).filter(
|
||||
s => s.startTime >= startTime && s.startTime <= endTime
|
||||
);
|
||||
|
||||
const allSessions = [...archivedSessions, ...activeSessions];
|
||||
|
||||
// Calculate summary
|
||||
let totalInputBytes = 0;
|
||||
let totalOutputBytes = 0;
|
||||
let totalSessionTime = 0;
|
||||
const uniqueUsers = new Set<string>();
|
||||
const sessionsByVlan: Record<number, number> = {};
|
||||
const userTraffic: Record<string, number> = {};
|
||||
|
||||
for (const session of allSessions) {
|
||||
totalInputBytes += session.inputOctets;
|
||||
totalOutputBytes += session.outputOctets;
|
||||
totalSessionTime += session.sessionTime;
|
||||
uniqueUsers.add(session.username);
|
||||
|
||||
if (session.vlanId !== undefined) {
|
||||
sessionsByVlan[session.vlanId] = (sessionsByVlan[session.vlanId] || 0) + 1;
|
||||
}
|
||||
|
||||
const userBytes = session.inputOctets + session.outputOctets;
|
||||
userTraffic[session.username] = (userTraffic[session.username] || 0) + userBytes;
|
||||
}
|
||||
|
||||
// Top users by traffic
|
||||
const topUsersByTraffic = Object.entries(userTraffic)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([username, totalBytes]) => ({ username, totalBytes }));
|
||||
|
||||
return {
|
||||
periodStart: startTime,
|
||||
periodEnd: endTime,
|
||||
totalSessions: allSessions.length,
|
||||
activeSessions: activeSessions.length,
|
||||
totalInputBytes,
|
||||
totalOutputBytes,
|
||||
totalSessionTime,
|
||||
averageSessionDuration: allSessions.length > 0 ? totalSessionTime / allSessions.length : 0,
|
||||
uniqueUsers: uniqueUsers.size,
|
||||
sessionsByVlan,
|
||||
topUsersByTraffic,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
getStats(): {
|
||||
activeSessions: number;
|
||||
totalSessionsStarted: number;
|
||||
totalSessionsStopped: number;
|
||||
totalInputBytes: number;
|
||||
totalOutputBytes: number;
|
||||
interimUpdatesReceived: number;
|
||||
} {
|
||||
return {
|
||||
activeSessions: this.activeSessions.size,
|
||||
...this.stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a session (admin action)
|
||||
*/
|
||||
async disconnectSession(sessionId: string, reason: string = 'AdminReset'): Promise<boolean> {
|
||||
const session = this.activeSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.handleAccountingStop({
|
||||
sessionId,
|
||||
terminateCause: reason,
|
||||
sessionTime: Math.floor((Date.now() - session.startTime) / 1000),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old archived sessions based on retention policy
|
||||
*/
|
||||
async cleanupOldSessions(): Promise<number> {
|
||||
if (!this.storageManager) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||
let deletedCount = 0;
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (session && session.endTime > 0 && session.endTime < cutoffTime) {
|
||||
await this.storageManager.delete(key);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to cleanup old sessions: ${error.message}`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the oldest active session
|
||||
*/
|
||||
private findOldestSession(): string | null {
|
||||
let oldestTime = Infinity;
|
||||
let oldestSessionId: string | null = null;
|
||||
|
||||
for (const [sessionId, session] of this.activeSessions) {
|
||||
if (session.lastUpdateTime < oldestTime) {
|
||||
oldestTime = session.lastUpdateTime;
|
||||
oldestSessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
return oldestSessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict a session from memory
|
||||
*/
|
||||
private async evictSession(sessionId: string): Promise<void> {
|
||||
const session = this.activeSessions.get(sessionId);
|
||||
if (session) {
|
||||
session.status = 'terminated';
|
||||
session.terminateCause = 'SessionEvicted';
|
||||
session.endTime = Date.now();
|
||||
|
||||
if (this.storageManager) {
|
||||
await this.archiveSession(session);
|
||||
}
|
||||
|
||||
this.activeSessions.delete(sessionId);
|
||||
logger.log('warn', `Evicted session ${sessionId} due to capacity limit`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load active sessions from storage
|
||||
*/
|
||||
private async loadActiveSessions(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (session && session.status === 'active') {
|
||||
this.activeSessions.set(session.sessionId, session);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load active sessions: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a session to storage
|
||||
*/
|
||||
private async persistSession(session: IAccountingSession): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
||||
try {
|
||||
await this.storageManager.setJSON(key, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a completed session
|
||||
*/
|
||||
private async archiveSession(session: IAccountingSession): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove from active
|
||||
const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`;
|
||||
await this.storageManager.delete(activeKey);
|
||||
|
||||
// Add to archive with date-based path
|
||||
const date = new Date(session.endTime);
|
||||
const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`;
|
||||
await this.storageManager.setJSON(archiveKey, session);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get archived sessions for a time period
|
||||
*/
|
||||
private async getArchivedSessions(startTime: number, endTime: number): Promise<IAccountingSession[]> {
|
||||
if (!this.storageManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions: IAccountingSession[] = [];
|
||||
|
||||
try {
|
||||
const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`);
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await this.storageManager.getJSON<IAccountingSession>(key);
|
||||
if (
|
||||
session &&
|
||||
session.endTime > 0 &&
|
||||
session.startTime <= endTime &&
|
||||
session.endTime >= startTime
|
||||
) {
|
||||
sessions.push(session);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore individual errors
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to get archived sessions: ${error.message}`);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
}
|
||||
532
ts/radius/classes.radius.server.ts
Normal file
532
ts/radius/classes.radius.server.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
||||
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
||||
|
||||
/**
|
||||
* RADIUS client (NAS) configuration
|
||||
*/
|
||||
export interface IRadiusClient {
|
||||
/** Client name for identification */
|
||||
name: string;
|
||||
/** IP address or CIDR range */
|
||||
ipRange: string;
|
||||
/** Shared secret for this client */
|
||||
secret: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether this client is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS server configuration
|
||||
*/
|
||||
export interface IRadiusServerConfig {
|
||||
/** Authentication port (default: 1812) */
|
||||
authPort?: number;
|
||||
/** Accounting port (default: 1813) */
|
||||
acctPort?: number;
|
||||
/** Bind address (default: 0.0.0.0) */
|
||||
bindAddress?: string;
|
||||
/** NAS clients configuration */
|
||||
clients: IRadiusClient[];
|
||||
/** VLAN assignment configuration */
|
||||
vlanAssignment?: IVlanManagerConfig & {
|
||||
/** Static MAC to VLAN mappings */
|
||||
mappings?: Array<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>;
|
||||
};
|
||||
/** Accounting configuration */
|
||||
accounting?: IAccountingManagerConfig & {
|
||||
/** Whether accounting is enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS authentication result
|
||||
*/
|
||||
export interface IRadiusAuthResult {
|
||||
/** Whether authentication was successful */
|
||||
success: boolean;
|
||||
/** Reject reason (if not successful) */
|
||||
rejectReason?: string;
|
||||
/** Reply message to send to client */
|
||||
replyMessage?: string;
|
||||
/** Session timeout in seconds */
|
||||
sessionTimeout?: number;
|
||||
/** Idle timeout in seconds */
|
||||
idleTimeout?: number;
|
||||
/** VLAN to assign */
|
||||
vlanId?: number;
|
||||
/** Framed IP address to assign */
|
||||
framedIpAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication request data from RADIUS
|
||||
*/
|
||||
export interface IAuthRequestData {
|
||||
username: string;
|
||||
password?: string;
|
||||
nasIpAddress: string;
|
||||
nasPort?: number;
|
||||
nasPortType?: string;
|
||||
nasIdentifier?: string;
|
||||
calledStationId?: string;
|
||||
callingStationId?: string;
|
||||
serviceType?: string;
|
||||
framedMtu?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS Server wrapper that provides:
|
||||
* - MAC Authentication Bypass (MAB) for network devices
|
||||
* - VLAN assignment based on MAC address
|
||||
* - Accounting for session tracking and billing
|
||||
* - Integration with SmartProxy routing
|
||||
*/
|
||||
export class RadiusServer {
|
||||
private radiusServer?: plugins.smartradius.RadiusServer;
|
||||
private vlanManager: VlanManager;
|
||||
private accountingManager: AccountingManager;
|
||||
private config: IRadiusServerConfig;
|
||||
private storageManager?: StorageManager;
|
||||
private clientSecrets: Map<string, string> = new Map();
|
||||
private running: boolean = false;
|
||||
|
||||
// Statistics
|
||||
private stats = {
|
||||
authRequests: 0,
|
||||
authAccepts: 0,
|
||||
authRejects: 0,
|
||||
accountingRequests: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
authPort: config.authPort ?? 1812,
|
||||
acctPort: config.acctPort ?? 1813,
|
||||
bindAddress: config.bindAddress ?? '0.0.0.0',
|
||||
...config,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// Initialize VLAN manager
|
||||
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
|
||||
|
||||
// Initialize accounting manager
|
||||
this.accountingManager = new AccountingManager(config.accounting, storageManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RADIUS server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.log('warn', 'RADIUS server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', `Starting RADIUS server on ${this.config.bindAddress}:${this.config.authPort} (auth) and ${this.config.acctPort} (acct)`);
|
||||
|
||||
// Initialize managers
|
||||
await this.vlanManager.initialize();
|
||||
await this.accountingManager.initialize();
|
||||
|
||||
// Import static VLAN mappings if provided
|
||||
if (this.config.vlanAssignment?.mappings) {
|
||||
await this.vlanManager.importMappings(this.config.vlanAssignment.mappings);
|
||||
}
|
||||
|
||||
// Build client secrets map
|
||||
this.buildClientSecretsMap();
|
||||
|
||||
// Create the RADIUS server
|
||||
this.radiusServer = new plugins.smartradius.RadiusServer({
|
||||
authPort: this.config.authPort,
|
||||
acctPort: this.config.acctPort,
|
||||
bindAddress: this.config.bindAddress,
|
||||
defaultSecret: this.getDefaultSecret(),
|
||||
authenticationHandler: this.handleAuthentication.bind(this),
|
||||
accountingHandler: this.handleAccounting.bind(this),
|
||||
});
|
||||
|
||||
// Configure per-client secrets
|
||||
for (const [ip, secret] of this.clientSecrets) {
|
||||
this.radiusServer.setClientSecret(ip, secret);
|
||||
}
|
||||
|
||||
// Start the server
|
||||
await this.radiusServer.start();
|
||||
|
||||
this.running = true;
|
||||
this.stats.startTime = Date.now();
|
||||
|
||||
logger.log('info', `RADIUS server started with ${this.config.clients.length} configured clients`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RADIUS server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Stopping RADIUS server...');
|
||||
|
||||
if (this.radiusServer) {
|
||||
await this.radiusServer.stop();
|
||||
this.radiusServer = undefined;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
logger.log('info', 'RADIUS server stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication request
|
||||
*/
|
||||
private async handleAuthentication(request: any): Promise<any> {
|
||||
this.stats.authRequests++;
|
||||
|
||||
const authData: IAuthRequestData = {
|
||||
username: request.attributes?.UserName || '',
|
||||
password: request.attributes?.UserPassword,
|
||||
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
|
||||
nasPort: request.attributes?.NasPort,
|
||||
nasPortType: request.attributes?.NasPortType,
|
||||
nasIdentifier: request.attributes?.NasIdentifier,
|
||||
calledStationId: request.attributes?.CalledStationId,
|
||||
callingStationId: request.attributes?.CallingStationId,
|
||||
serviceType: request.attributes?.ServiceType,
|
||||
};
|
||||
|
||||
logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
|
||||
|
||||
// Perform MAC Authentication Bypass (MAB)
|
||||
// In MAB, the username is typically the MAC address
|
||||
const result = await this.performMabAuthentication(authData);
|
||||
|
||||
if (result.success) {
|
||||
this.stats.authAccepts++;
|
||||
logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
|
||||
|
||||
// Build response with VLAN attributes
|
||||
const response: any = {
|
||||
code: plugins.smartradius.ERadiusCode.AccessAccept,
|
||||
replyMessage: result.replyMessage,
|
||||
};
|
||||
|
||||
// Add VLAN attributes if assigned
|
||||
if (result.vlanId !== undefined) {
|
||||
response.tunnelType = 13; // VLAN
|
||||
response.tunnelMediumType = 6; // IEEE 802
|
||||
response.tunnelPrivateGroupId = String(result.vlanId);
|
||||
}
|
||||
|
||||
// Add session timeout if specified
|
||||
if (result.sessionTimeout) {
|
||||
response.sessionTimeout = result.sessionTimeout;
|
||||
}
|
||||
|
||||
// Add idle timeout if specified
|
||||
if (result.idleTimeout) {
|
||||
response.idleTimeout = result.idleTimeout;
|
||||
}
|
||||
|
||||
// Add framed IP if specified
|
||||
if (result.framedIpAddress) {
|
||||
response.framedIpAddress = result.framedIpAddress;
|
||||
}
|
||||
|
||||
return response;
|
||||
} else {
|
||||
this.stats.authRejects++;
|
||||
logger.log('warn', `RADIUS Auth Reject: user=${authData.username}, reason=${result.rejectReason}`);
|
||||
|
||||
return {
|
||||
code: plugins.smartradius.ERadiusCode.AccessReject,
|
||||
replyMessage: result.rejectReason || 'Access Denied',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting request
|
||||
*/
|
||||
private async handleAccounting(request: any): Promise<any> {
|
||||
this.stats.accountingRequests++;
|
||||
|
||||
if (!this.config.accounting?.enabled) {
|
||||
// Still respond even if not tracking
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
}
|
||||
|
||||
const statusType = request.attributes?.AcctStatusType;
|
||||
const sessionId = request.attributes?.AcctSessionId || '';
|
||||
|
||||
const accountingData = {
|
||||
sessionId,
|
||||
username: request.attributes?.UserName || '',
|
||||
macAddress: request.attributes?.CallingStationId,
|
||||
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
|
||||
nasPort: request.attributes?.NasPort,
|
||||
nasPortType: request.attributes?.NasPortType,
|
||||
nasIdentifier: request.attributes?.NasIdentifier,
|
||||
calledStationId: request.attributes?.CalledStationId,
|
||||
callingStationId: request.attributes?.CallingStationId,
|
||||
inputOctets: request.attributes?.AcctInputOctets,
|
||||
outputOctets: request.attributes?.AcctOutputOctets,
|
||||
inputPackets: request.attributes?.AcctInputPackets,
|
||||
outputPackets: request.attributes?.AcctOutputPackets,
|
||||
sessionTime: request.attributes?.AcctSessionTime,
|
||||
terminateCause: request.attributes?.AcctTerminateCause,
|
||||
serviceType: request.attributes?.ServiceType,
|
||||
};
|
||||
|
||||
try {
|
||||
switch (statusType) {
|
||||
case plugins.smartradius.EAcctStatusType.Start:
|
||||
logger.log('debug', `RADIUS Acct Start: session=${sessionId}, user=${accountingData.username}`);
|
||||
await this.accountingManager.handleAccountingStart(accountingData);
|
||||
break;
|
||||
|
||||
case plugins.smartradius.EAcctStatusType.Stop:
|
||||
logger.log('debug', `RADIUS Acct Stop: session=${sessionId}`);
|
||||
await this.accountingManager.handleAccountingStop(accountingData);
|
||||
break;
|
||||
|
||||
case plugins.smartradius.EAcctStatusType.InterimUpdate:
|
||||
logger.log('debug', `RADIUS Acct Interim: session=${sessionId}`);
|
||||
await this.accountingManager.handleAccountingUpdate(accountingData);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
||||
}
|
||||
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform MAC Authentication Bypass
|
||||
*/
|
||||
private async performMabAuthentication(data: IAuthRequestData): Promise<IRadiusAuthResult> {
|
||||
// Extract MAC address from username or CallingStationId
|
||||
const macAddress = this.extractMacAddress(data);
|
||||
|
||||
if (!macAddress) {
|
||||
return {
|
||||
success: false,
|
||||
rejectReason: 'No MAC address found',
|
||||
};
|
||||
}
|
||||
|
||||
// Look up VLAN assignment
|
||||
const vlanResult = this.vlanManager.assignVlan(macAddress);
|
||||
|
||||
if (!vlanResult.assigned) {
|
||||
return {
|
||||
success: false,
|
||||
rejectReason: 'Unknown MAC address',
|
||||
};
|
||||
}
|
||||
|
||||
// Build successful result
|
||||
const result: IRadiusAuthResult = {
|
||||
success: true,
|
||||
vlanId: vlanResult.vlan,
|
||||
replyMessage: vlanResult.isDefault
|
||||
? `Assigned to default VLAN ${vlanResult.vlan}`
|
||||
: `Assigned to VLAN ${vlanResult.vlan}`,
|
||||
};
|
||||
|
||||
// Apply any additional settings from the matched rule
|
||||
if (vlanResult.matchedRule) {
|
||||
// Future: Add session timeout, idle timeout, etc. from rule
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MAC address from authentication data
|
||||
*/
|
||||
private extractMacAddress(data: IAuthRequestData): string | null {
|
||||
// Try CallingStationId first (most common for MAB)
|
||||
if (data.callingStationId) {
|
||||
return this.normalizeMac(data.callingStationId);
|
||||
}
|
||||
|
||||
// Try username (often MAC address in MAB)
|
||||
if (data.username && this.looksLikeMac(data.username)) {
|
||||
return this.normalizeMac(data.username);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a MAC address
|
||||
*/
|
||||
private looksLikeMac(value: string): boolean {
|
||||
// Remove common separators and check length
|
||||
const cleaned = value.replace(/[-:. ]/g, '');
|
||||
return /^[0-9a-fA-F]{12}$/.test(cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize MAC address format
|
||||
*/
|
||||
private normalizeMac(mac: string): string {
|
||||
return this.vlanManager.normalizeMac(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build client secrets map from configuration
|
||||
*/
|
||||
private buildClientSecretsMap(): void {
|
||||
this.clientSecrets.clear();
|
||||
|
||||
for (const client of this.config.clients) {
|
||||
if (!client.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle CIDR ranges
|
||||
if (client.ipRange.includes('/')) {
|
||||
// For CIDR ranges, we'll use the network address as key
|
||||
// In practice, smartradius may handle this differently
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.clientSecrets.set(network, client.secret);
|
||||
} else {
|
||||
this.clientSecrets.set(client.ipRange, client.secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default secret for unknown clients
|
||||
*/
|
||||
private getDefaultSecret(): string {
|
||||
// Use first enabled client's secret as default, or a random one
|
||||
for (const client of this.config.clients) {
|
||||
if (client.enabled) {
|
||||
return client.secret;
|
||||
}
|
||||
}
|
||||
return plugins.crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a RADIUS client
|
||||
*/
|
||||
async addClient(client: IRadiusClient): Promise<void> {
|
||||
// Check if client already exists
|
||||
const existingIndex = this.config.clients.findIndex(c => c.name === client.name);
|
||||
if (existingIndex >= 0) {
|
||||
this.config.clients[existingIndex] = client;
|
||||
} else {
|
||||
this.config.clients.push(client);
|
||||
}
|
||||
|
||||
// Update client secrets if running
|
||||
if (this.running && this.radiusServer && client.enabled) {
|
||||
if (client.ipRange.includes('/')) {
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.radiusServer.setClientSecret(network, client.secret);
|
||||
this.clientSecrets.set(network, client.secret);
|
||||
} else {
|
||||
this.radiusServer.setClientSecret(client.ipRange, client.secret);
|
||||
this.clientSecrets.set(client.ipRange, client.secret);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `RADIUS client ${client.enabled ? 'added' : 'disabled'}: ${client.name} (${client.ipRange})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a RADIUS client
|
||||
*/
|
||||
removeClient(name: string): boolean {
|
||||
const index = this.config.clients.findIndex(c => c.name === name);
|
||||
if (index >= 0) {
|
||||
const client = this.config.clients[index];
|
||||
this.config.clients.splice(index, 1);
|
||||
|
||||
// Remove from secrets map
|
||||
if (client.ipRange.includes('/')) {
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.clientSecrets.delete(network);
|
||||
} else {
|
||||
this.clientSecrets.delete(client.ipRange);
|
||||
}
|
||||
|
||||
logger.log('info', `RADIUS client removed: ${name}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured clients
|
||||
*/
|
||||
getClients(): IRadiusClient[] {
|
||||
return [...this.config.clients];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VLAN manager for direct access to VLAN operations
|
||||
*/
|
||||
getVlanManager(): VlanManager {
|
||||
return this.vlanManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting manager for direct access to accounting operations
|
||||
*/
|
||||
getAccountingManager(): AccountingManager {
|
||||
return this.accountingManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
*/
|
||||
getStats(): {
|
||||
running: boolean;
|
||||
uptime: number;
|
||||
authRequests: number;
|
||||
authAccepts: number;
|
||||
authRejects: number;
|
||||
accountingRequests: number;
|
||||
activeSessions: number;
|
||||
vlanMappings: number;
|
||||
clients: number;
|
||||
} {
|
||||
return {
|
||||
running: this.running,
|
||||
uptime: this.running ? Date.now() - this.stats.startTime : 0,
|
||||
authRequests: this.stats.authRequests,
|
||||
authAccepts: this.stats.authAccepts,
|
||||
authRejects: this.stats.authRejects,
|
||||
accountingRequests: this.stats.accountingRequests,
|
||||
activeSessions: this.accountingManager.getStats().activeSessions,
|
||||
vlanMappings: this.vlanManager.getStats().totalMappings,
|
||||
clients: this.config.clients.filter(c => c.enabled).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
363
ts/radius/classes.vlan.manager.ts
Normal file
363
ts/radius/classes.vlan.manager.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
|
||||
/**
|
||||
* MAC address to VLAN mapping
|
||||
*/
|
||||
export interface IMacVlanMapping {
|
||||
/** MAC address (full) or OUI pattern (e.g., "00:11:22" for vendor prefix) */
|
||||
mac: string;
|
||||
/** VLAN ID to assign */
|
||||
vlan: number;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether this mapping is enabled */
|
||||
enabled: boolean;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last update timestamp */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* VLAN assignment result
|
||||
*/
|
||||
export interface IVlanAssignmentResult {
|
||||
/** Whether a VLAN was successfully assigned */
|
||||
assigned: boolean;
|
||||
/** The assigned VLAN ID (or default if not matched) */
|
||||
vlan: number;
|
||||
/** The matching rule (if any) */
|
||||
matchedRule?: IMacVlanMapping;
|
||||
/** Whether default VLAN was used */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* VlanManager configuration
|
||||
*/
|
||||
export interface IVlanManagerConfig {
|
||||
/** Default VLAN for unknown MACs */
|
||||
defaultVlan?: number;
|
||||
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
||||
allowUnknownMacs?: boolean;
|
||||
/** Storage key prefix for persistence */
|
||||
storagePrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages MAC address to VLAN mappings with support for:
|
||||
* - Exact MAC address matching
|
||||
* - OUI (vendor prefix) pattern matching
|
||||
* - Wildcard patterns
|
||||
* - Default VLAN for unknown devices
|
||||
*/
|
||||
export class VlanManager {
|
||||
private mappings: Map<string, IMacVlanMapping> = new Map();
|
||||
private config: Required<IVlanManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
|
||||
// Cache for normalized MAC lookups
|
||||
private normalizedMacCache: Map<string, string> = new Map();
|
||||
|
||||
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
defaultVlan: config?.defaultVlan ?? 1,
|
||||
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VLAN manager and load persisted mappings
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.storageManager) {
|
||||
await this.loadMappings();
|
||||
}
|
||||
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a MAC address to lowercase with colons
|
||||
* Accepts formats: 00:11:22:33:44:55, 00-11-22-33-44-55, 001122334455
|
||||
*/
|
||||
normalizeMac(mac: string): string {
|
||||
// Check cache first
|
||||
const cached = this.normalizedMacCache.get(mac);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Remove all separators and convert to lowercase
|
||||
const cleaned = mac.toLowerCase().replace(/[-:]/g, '');
|
||||
|
||||
// Format with colons
|
||||
const normalized = cleaned.match(/.{1,2}/g)?.join(':') || mac.toLowerCase();
|
||||
|
||||
// Cache the result
|
||||
this.normalizedMacCache.set(mac, normalized);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MAC address matches a pattern
|
||||
* Supports:
|
||||
* - Exact match: "00:11:22:33:44:55"
|
||||
* - OUI match: "00:11:22" (matches any device with this vendor prefix)
|
||||
* - Wildcard: "*" (matches all)
|
||||
*/
|
||||
macMatchesPattern(mac: string, pattern: string): boolean {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const normalizedPattern = this.normalizeMac(pattern);
|
||||
|
||||
// Wildcard matches all
|
||||
if (pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (normalizedMac === normalizedPattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OUI/prefix match (pattern is shorter than full MAC)
|
||||
if (normalizedPattern.length < 17 && normalizedMac.startsWith(normalizedPattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a MAC to VLAN mapping
|
||||
*/
|
||||
async addMapping(mapping: Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>): Promise<IMacVlanMapping> {
|
||||
const normalizedMac = this.normalizeMac(mapping.mac);
|
||||
const now = Date.now();
|
||||
|
||||
const existingMapping = this.mappings.get(normalizedMac);
|
||||
const fullMapping: IMacVlanMapping = {
|
||||
...mapping,
|
||||
mac: normalizedMac,
|
||||
createdAt: existingMapping?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.mappings.set(normalizedMac, fullMapping);
|
||||
|
||||
// Persist to storage
|
||||
if (this.storageManager) {
|
||||
await this.saveMappings();
|
||||
}
|
||||
|
||||
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
||||
return fullMapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a MAC to VLAN mapping
|
||||
*/
|
||||
async removeMapping(mac: string): Promise<boolean> {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const removed = this.mappings.delete(normalizedMac);
|
||||
|
||||
if (removed && this.storageManager) {
|
||||
await this.saveMappings();
|
||||
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific mapping by MAC
|
||||
*/
|
||||
getMapping(mac: string): IMacVlanMapping | undefined {
|
||||
return this.mappings.get(this.normalizeMac(mac));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings
|
||||
*/
|
||||
getAllMappings(): IMacVlanMapping[] {
|
||||
return Array.from(this.mappings.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine VLAN assignment for a MAC address
|
||||
* Returns the most specific matching rule (exact > OUI > wildcard > default)
|
||||
*/
|
||||
assignVlan(mac: string): IVlanAssignmentResult {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
|
||||
// First, try exact match
|
||||
const exactMatch = this.mappings.get(normalizedMac);
|
||||
if (exactMatch && exactMatch.enabled) {
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: exactMatch.vlan,
|
||||
matchedRule: exactMatch,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Try OUI/prefix matches (sorted by specificity - longer patterns first)
|
||||
const patternMatches: IMacVlanMapping[] = [];
|
||||
for (const mapping of this.mappings.values()) {
|
||||
if (mapping.enabled && mapping.mac !== normalizedMac && this.macMatchesPattern(normalizedMac, mapping.mac)) {
|
||||
patternMatches.push(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by pattern length (most specific first)
|
||||
patternMatches.sort((a, b) => b.mac.length - a.mac.length);
|
||||
|
||||
if (patternMatches.length > 0) {
|
||||
const bestMatch = patternMatches[0];
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: bestMatch.vlan,
|
||||
matchedRule: bestMatch,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No match - use default VLAN if allowed
|
||||
if (this.config.allowUnknownMacs) {
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: this.config.defaultVlan,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown MAC and not allowed
|
||||
return {
|
||||
assigned: false,
|
||||
vlan: 0,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import mappings
|
||||
*/
|
||||
async importMappings(mappings: Array<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>): Promise<number> {
|
||||
let imported = 0;
|
||||
|
||||
for (const mapping of mappings) {
|
||||
await this.addMapping(mapping);
|
||||
imported++;
|
||||
}
|
||||
|
||||
logger.log('info', `Imported ${imported} VLAN mappings`);
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all mappings
|
||||
*/
|
||||
exportMappings(): IMacVlanMapping[] {
|
||||
return this.getAllMappings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<IVlanManagerConfig>): void {
|
||||
if (config.defaultVlan !== undefined) {
|
||||
this.config.defaultVlan = config.defaultVlan;
|
||||
}
|
||||
if (config.allowUnknownMacs !== undefined) {
|
||||
this.config.allowUnknownMacs = config.allowUnknownMacs;
|
||||
}
|
||||
logger.log('info', `VlanManager config updated: defaultVlan=${this.config.defaultVlan}, allowUnknown=${this.config.allowUnknownMacs}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): Required<IVlanManagerConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
getStats(): {
|
||||
totalMappings: number;
|
||||
enabledMappings: number;
|
||||
exactMatches: number;
|
||||
ouiPatterns: number;
|
||||
wildcardPatterns: number;
|
||||
} {
|
||||
let exactMatches = 0;
|
||||
let ouiPatterns = 0;
|
||||
let wildcardPatterns = 0;
|
||||
let enabledMappings = 0;
|
||||
|
||||
for (const mapping of this.mappings.values()) {
|
||||
if (mapping.enabled) {
|
||||
enabledMappings++;
|
||||
}
|
||||
|
||||
if (mapping.mac === '*') {
|
||||
wildcardPatterns++;
|
||||
} else if (mapping.mac.length < 17) {
|
||||
// OUI patterns are shorter than full MAC (17 chars with colons)
|
||||
ouiPatterns++;
|
||||
} else {
|
||||
exactMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMappings: this.mappings.size,
|
||||
enabledMappings,
|
||||
exactMatches,
|
||||
ouiPatterns,
|
||||
wildcardPatterns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings from storage
|
||||
*/
|
||||
private async loadMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
|
||||
if (data && Array.isArray(data)) {
|
||||
for (const mapping of data) {
|
||||
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
||||
}
|
||||
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save mappings to storage
|
||||
*/
|
||||
private async saveMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mappings = Array.from(this.mappings.values());
|
||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
ts/radius/index.ts
Normal file
14
ts/radius/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* RADIUS module for DcRouter
|
||||
*
|
||||
* Provides:
|
||||
* - MAC Authentication Bypass (MAB) for network device authentication
|
||||
* - VLAN assignment based on MAC addresses
|
||||
* - OUI (vendor prefix) pattern matching for device categorization
|
||||
* - RADIUS accounting for session tracking and billing
|
||||
* - Integration with StorageManager for persistence
|
||||
*/
|
||||
|
||||
export * from './classes.radius.server.js';
|
||||
export * from './classes.vlan.manager.js';
|
||||
export * from './classes.accounting.manager.js';
|
||||
160
ts/remoteingress/classes.remoteingress-manager.ts
Normal file
160
ts/remoteingress/classes.remoteingress-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
ts/remoteingress/classes.tunnel-manager.ts
Normal file
126
ts/remoteingress/classes.tunnel-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
ts/remoteingress/index.ts
Normal file
2
ts/remoteingress/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './classes.remoteingress-manager.js';
|
||||
export * from './classes.tunnel-manager.js';
|
||||
738
ts/security/classes.contentscanner.ts
Normal file
738
ts/security/classes.contentscanner.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Email, type Core } from '@push.rocks/smartmta';
|
||||
type IAttachment = Core.IAttachment;
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Scan result information
|
||||
*/
|
||||
export interface IScanResult {
|
||||
isClean: boolean; // Whether the content is clean (no threats detected)
|
||||
threatType?: string; // Type of threat if detected
|
||||
threatDetails?: string; // Details about the detected threat
|
||||
threatScore: number; // 0 (clean) to 100 (definitely malicious)
|
||||
scannedElements: string[]; // What was scanned (subject, body, attachments, etc.)
|
||||
timestamp: number; // When this scan was performed
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for content scanner configuration
|
||||
*/
|
||||
export interface IContentScannerOptions {
|
||||
maxCacheSize?: number; // Maximum number of entries to cache
|
||||
cacheTTL?: number; // TTL for cache entries in ms
|
||||
scanSubject?: boolean; // Whether to scan email subjects
|
||||
scanBody?: boolean; // Whether to scan email bodies
|
||||
scanAttachments?: boolean; // Whether to scan attachments
|
||||
maxAttachmentSizeToScan?: number; // Max size of attachments to scan in bytes
|
||||
scanAttachmentNames?: boolean; // Whether to scan attachment filenames
|
||||
blockExecutables?: boolean; // Whether to block executable attachments
|
||||
blockMacros?: boolean; // Whether to block documents with macros
|
||||
customRules?: Array<{ // Custom scanning rules
|
||||
pattern: string | RegExp; // Pattern to match
|
||||
type: string; // Type of threat
|
||||
score: number; // Threat score
|
||||
description: string; // Description of the threat
|
||||
}>;
|
||||
minThreatScore?: number; // Minimum score to consider content as a threat
|
||||
highThreatScore?: number; // Score above which content is considered high threat
|
||||
}
|
||||
|
||||
/**
|
||||
* Threat categories
|
||||
*/
|
||||
export enum ThreatCategory {
|
||||
SPAM = 'spam',
|
||||
PHISHING = 'phishing',
|
||||
MALWARE = 'malware',
|
||||
EXECUTABLE = 'executable',
|
||||
SUSPICIOUS_LINK = 'suspicious_link',
|
||||
MALICIOUS_MACRO = 'malicious_macro',
|
||||
XSS = 'xss',
|
||||
SENSITIVE_DATA = 'sensitive_data',
|
||||
BLACKLISTED_CONTENT = 'blacklisted_content',
|
||||
CUSTOM_RULE = 'custom_rule'
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Scanner for detecting malicious email content
|
||||
*/
|
||||
export class ContentScanner {
|
||||
private static instance: ContentScanner;
|
||||
private scanCache: LRUCache<string, IScanResult>;
|
||||
private options: Required<IContentScannerOptions>;
|
||||
|
||||
// Predefined patterns for common threats
|
||||
private static readonly MALICIOUS_PATTERNS = {
|
||||
// Phishing patterns
|
||||
phishing: [
|
||||
/(?:verify|confirm|update|login).*(?:account|password|details)/i,
|
||||
/urgent.*(?:action|attention|required)/i,
|
||||
/(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)/i,
|
||||
/your.*(?:account).*(?:suspended|compromised|locked)/i,
|
||||
/\b(?:password reset|security alert|security notice)\b/i
|
||||
],
|
||||
|
||||
// Spam indicators
|
||||
spam: [
|
||||
/\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b/i,
|
||||
/\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b/i,
|
||||
/\b(?:earn from home|make money fast|earn \$\d{3,}\/day)\b/i,
|
||||
/\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b/i,
|
||||
/\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b/i
|
||||
],
|
||||
|
||||
// Malware indicators in text
|
||||
malware: [
|
||||
/(?:attached file|see attachment).*(?:invoice|receipt|statement|document)/i,
|
||||
/open.*(?:the attached|this attachment)/i,
|
||||
/(?:enable|allow).*(?:macros|content|editing)/i,
|
||||
/download.*(?:attachment|file|document)/i,
|
||||
/\b(?:ransomware protection|virus alert|malware detected)\b/i
|
||||
],
|
||||
|
||||
// Suspicious links
|
||||
suspiciousLinks: [
|
||||
/https?:\/\/bit\.ly\//i,
|
||||
/https?:\/\/goo\.gl\//i,
|
||||
/https?:\/\/t\.co\//i,
|
||||
/https?:\/\/tinyurl\.com\//i,
|
||||
/https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/i, // IP address URLs
|
||||
/https?:\/\/.*\.(?:xyz|top|club|gq|cf)\//i, // Suspicious TLDs
|
||||
/(?:login|account|signin|auth).*\.(?!gov|edu|com|org|net)\w+\.\w+/i, // Login pages on unusual domains
|
||||
],
|
||||
|
||||
// XSS and script injection
|
||||
scriptInjection: [
|
||||
/<script.*>.*<\/script>/is,
|
||||
/javascript:/i,
|
||||
/on(?:click|load|mouse|error|focus|blur)=".*"/i,
|
||||
/document\.(?:cookie|write|location)/i,
|
||||
/eval\s*\(/i
|
||||
],
|
||||
|
||||
// Sensitive data patterns
|
||||
sensitiveData: [
|
||||
/\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b/, // SSN
|
||||
/\b\d{13,16}\b/, // Credit card numbers
|
||||
/\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b/ // Possible Base64
|
||||
]
|
||||
};
|
||||
|
||||
// Common executable extensions
|
||||
private static readonly EXECUTABLE_EXTENSIONS = [
|
||||
'.exe', '.dll', '.bat', '.cmd', '.msi', '.js', '.vbs', '.ps1',
|
||||
'.sh', '.jar', '.py', '.com', '.scr', '.pif', '.hta', '.cpl',
|
||||
'.reg', '.vba', '.lnk', '.wsf', '.msi', '.msp', '.mst'
|
||||
];
|
||||
|
||||
// Document formats that may contain macros
|
||||
private static readonly MACRO_DOCUMENT_EXTENSIONS = [
|
||||
'.doc', '.docm', '.xls', '.xlsm', '.ppt', '.pptm', '.dotm', '.xlsb', '.ppam', '.potm'
|
||||
];
|
||||
|
||||
/**
|
||||
* Default options for the content scanner
|
||||
*/
|
||||
private static readonly DEFAULT_OPTIONS: Required<IContentScannerOptions> = {
|
||||
maxCacheSize: 10000,
|
||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
scanSubject: true,
|
||||
scanBody: true,
|
||||
scanAttachments: true,
|
||||
maxAttachmentSizeToScan: 10 * 1024 * 1024, // 10MB
|
||||
scanAttachmentNames: true,
|
||||
blockExecutables: true,
|
||||
blockMacros: true,
|
||||
customRules: [],
|
||||
minThreatScore: 30, // Minimum score to consider content as a threat
|
||||
highThreatScore: 70 // Score above which content is considered high threat
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor for the ContentScanner
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: IContentScannerOptions = {}) {
|
||||
// Merge with default options
|
||||
this.options = {
|
||||
...ContentScanner.DEFAULT_OPTIONS,
|
||||
...options
|
||||
};
|
||||
|
||||
// Initialize cache
|
||||
this.scanCache = new LRUCache<string, IScanResult>({
|
||||
max: this.options.maxCacheSize,
|
||||
ttl: this.options.cacheTTL,
|
||||
});
|
||||
|
||||
logger.log('info', 'ContentScanner initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the scanner
|
||||
* @param options Configuration options
|
||||
* @returns Singleton scanner instance
|
||||
*/
|
||||
public static getInstance(options: IContentScannerOptions = {}): ContentScanner {
|
||||
if (!ContentScanner.instance) {
|
||||
ContentScanner.instance = new ContentScanner(options);
|
||||
}
|
||||
return ContentScanner.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an email for malicious content
|
||||
* @param email The email to scan
|
||||
* @returns Scan result
|
||||
*/
|
||||
public async scanEmail(email: Email): Promise<IScanResult> {
|
||||
try {
|
||||
// Generate a cache key from the email
|
||||
const cacheKey = this.generateCacheKey(email);
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = this.scanCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
logger.log('info', `Using cached scan result for email ${email.getMessageId()}`);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// Initialize scan result
|
||||
const result: IScanResult = {
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: [],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// List of scan promises
|
||||
const scanPromises: Array<Promise<void>> = [];
|
||||
|
||||
// Scan subject
|
||||
if (this.options.scanSubject && email.subject) {
|
||||
scanPromises.push(this.scanSubject(email.subject, result));
|
||||
}
|
||||
|
||||
// Scan body content
|
||||
if (this.options.scanBody) {
|
||||
if (email.text) {
|
||||
scanPromises.push(this.scanTextContent(email.text, result));
|
||||
}
|
||||
|
||||
if (email.html) {
|
||||
scanPromises.push(this.scanHtmlContent(email.html, result));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan attachments
|
||||
if (this.options.scanAttachments && email.attachments && email.attachments.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
scanPromises.push(this.scanAttachment(attachment, result));
|
||||
}
|
||||
}
|
||||
|
||||
// Run all scans in parallel
|
||||
await Promise.all(scanPromises);
|
||||
|
||||
// Determine if the email is clean based on threat score
|
||||
result.isClean = result.threatScore < this.options.minThreatScore;
|
||||
|
||||
// Save to cache
|
||||
this.scanCache.set(cacheKey, result);
|
||||
|
||||
// Log high threat findings
|
||||
if (result.threatScore >= this.options.highThreatScore) {
|
||||
this.logHighThreatFound(email, result);
|
||||
} else if (!result.isClean) {
|
||||
this.logThreatFound(email, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
||||
messageId: email.getMessageId(),
|
||||
error: error.stack
|
||||
});
|
||||
|
||||
// Return a safe default with error indication
|
||||
return {
|
||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||
threatScore: 0,
|
||||
scannedElements: ['error'],
|
||||
timestamp: Date.now(),
|
||||
threatType: 'scan_error',
|
||||
threatDetails: `Scan error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from an email
|
||||
* @param email The email to generate a key for
|
||||
* @returns Cache key
|
||||
*/
|
||||
private generateCacheKey(email: Email): string {
|
||||
// Use message ID if available
|
||||
if (email.getMessageId()) {
|
||||
return `email:${email.getMessageId()}`;
|
||||
}
|
||||
|
||||
// Fallback to a hash of key content
|
||||
const contentToHash = [
|
||||
email.from,
|
||||
email.subject || '',
|
||||
email.text?.substring(0, 1000) || '',
|
||||
email.html?.substring(0, 1000) || '',
|
||||
email.attachments?.length || 0
|
||||
].join(':');
|
||||
|
||||
return `email:${plugins.crypto.createHash('sha256').update(contentToHash).digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan email subject for threats
|
||||
* @param subject The subject to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanSubject(subject: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('subject');
|
||||
|
||||
// Check against phishing patterns
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.phishing) {
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += 25;
|
||||
result.threatType = ThreatCategory.PHISHING;
|
||||
result.threatDetails = `Subject contains potential phishing indicators: ${subject}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check against spam patterns
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.spam) {
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += 15;
|
||||
result.threatType = ThreatCategory.SPAM;
|
||||
result.threatDetails = `Subject contains potential spam indicators: ${subject}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom rules
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += rule.score;
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan plain text content for threats
|
||||
* @param text The text content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanTextContent(text: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('text');
|
||||
|
||||
// Check suspicious links
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.suspiciousLinks) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 20;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SUSPICIOUS_LINK ? 0 : 20)) {
|
||||
result.threatType = ThreatCategory.SUSPICIOUS_LINK;
|
||||
result.threatDetails = `Text contains suspicious links`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check phishing
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.phishing) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 25;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.PHISHING ? 0 : 25)) {
|
||||
result.threatType = ThreatCategory.PHISHING;
|
||||
result.threatDetails = `Text contains potential phishing indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check spam
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.spam) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 15;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SPAM ? 0 : 15)) {
|
||||
result.threatType = ThreatCategory.SPAM;
|
||||
result.threatDetails = `Text contains potential spam indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check malware indicators
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.malware) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 30;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.MALWARE ? 0 : 30)) {
|
||||
result.threatType = ThreatCategory.MALWARE;
|
||||
result.threatDetails = `Text contains potential malware indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check sensitive data
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.sensitiveData) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 25;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SENSITIVE_DATA ? 0 : 25)) {
|
||||
result.threatType = ThreatCategory.SENSITIVE_DATA;
|
||||
result.threatDetails = `Text contains potentially sensitive data patterns`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom rules
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += rule.score;
|
||||
if (!result.threatType || result.threatScore > 20) {
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan HTML content for threats
|
||||
* @param html The HTML content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanHtmlContent(html: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('html');
|
||||
|
||||
// Check for script injection
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.scriptInjection) {
|
||||
if (pattern.test(html)) {
|
||||
result.threatScore += 40;
|
||||
if (!result.threatType || result.threatType !== ThreatCategory.XSS) {
|
||||
result.threatType = ThreatCategory.XSS;
|
||||
result.threatDetails = `HTML contains potentially malicious script content`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text content from HTML for further scanning
|
||||
const textContent = this.extractTextFromHtml(html);
|
||||
if (textContent) {
|
||||
// We'll leverage the text scanning but not double-count threat score
|
||||
const tempResult: IScanResult = {
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: [],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await this.scanTextContent(textContent, tempResult);
|
||||
|
||||
// Only add additional threat types if they're more severe
|
||||
if (tempResult.threatType && tempResult.threatScore > 0) {
|
||||
// Add half of the text content score to avoid double counting
|
||||
result.threatScore += Math.floor(tempResult.threatScore / 2);
|
||||
|
||||
// Adopt the threat type if more severe or no existing type
|
||||
if (!result.threatType || tempResult.threatScore > result.threatScore) {
|
||||
result.threatType = tempResult.threatType;
|
||||
result.threatDetails = tempResult.threatDetails;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and check links from HTML
|
||||
const links = this.extractLinksFromHtml(html);
|
||||
if (links.length > 0) {
|
||||
// Check for suspicious links
|
||||
let suspiciousLinks = 0;
|
||||
for (const link of links) {
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.suspiciousLinks) {
|
||||
if (pattern.test(link)) {
|
||||
suspiciousLinks++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suspiciousLinks > 0) {
|
||||
// Add score based on percentage of suspicious links
|
||||
const suspiciousPercentage = (suspiciousLinks / links.length) * 100;
|
||||
const additionalScore = Math.min(40, Math.floor(suspiciousPercentage / 2.5));
|
||||
result.threatScore += additionalScore;
|
||||
|
||||
if (!result.threatType || additionalScore > 20) {
|
||||
result.threatType = ThreatCategory.SUSPICIOUS_LINK;
|
||||
result.threatDetails = `HTML contains ${suspiciousLinks} suspicious links out of ${links.length} total links`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an attachment for threats
|
||||
* @param attachment The attachment to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanAttachment(attachment: IAttachment, result: IScanResult): Promise<void> {
|
||||
const filename = attachment.filename.toLowerCase();
|
||||
result.scannedElements.push(`attachment:${filename}`);
|
||||
|
||||
// Skip large attachments if configured
|
||||
if (attachment.content && attachment.content.length > this.options.maxAttachmentSizeToScan) {
|
||||
logger.log('info', `Skipping scan of large attachment: ${filename} (${attachment.content.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check filename for executable extensions
|
||||
if (this.options.blockExecutables) {
|
||||
for (const ext of ContentScanner.EXECUTABLE_EXTENSIONS) {
|
||||
if (filename.endsWith(ext)) {
|
||||
result.threatScore += 70; // High score for executable attachments
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment has a potentially dangerous extension: ${filename}`;
|
||||
return; // No need to scan contents if filename already flagged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Office documents with macros
|
||||
if (this.options.blockMacros) {
|
||||
for (const ext of ContentScanner.MACRO_DOCUMENT_EXTENSIONS) {
|
||||
if (filename.endsWith(ext)) {
|
||||
// For Office documents, check if they contain macros
|
||||
// This is a simplified check - a real implementation would use specialized libraries
|
||||
// to detect macros in Office documents
|
||||
if (attachment.content && this.likelyContainsMacros(attachment)) {
|
||||
result.threatScore += 60;
|
||||
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
||||
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform basic content analysis if we have content buffer
|
||||
if (attachment.content) {
|
||||
// Convert to string for scanning, with a limit to prevent memory issues
|
||||
const textContent = this.extractTextFromBuffer(attachment.content);
|
||||
|
||||
if (textContent) {
|
||||
// Scan for malicious patterns in attachment content
|
||||
for (const category in ContentScanner.MALICIOUS_PATTERNS) {
|
||||
const patterns = ContentScanner.MALICIOUS_PATTERNS[category];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(textContent)) {
|
||||
result.threatScore += 30;
|
||||
|
||||
if (!result.threatType) {
|
||||
result.threatType = this.mapCategoryToThreatType(category);
|
||||
result.threatDetails = `Attachment content contains suspicious patterns: ${filename}`;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for PE headers (Windows executables)
|
||||
if (attachment.content.length > 64 &&
|
||||
attachment.content[0] === 0x4D &&
|
||||
attachment.content[1] === 0x5A) { // 'MZ' header
|
||||
result.threatScore += 80;
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment contains executable code: ${filename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract links from HTML content
|
||||
* @param html HTML content
|
||||
* @returns Array of extracted links
|
||||
*/
|
||||
private extractLinksFromHtml(html: string): string[] {
|
||||
const links: string[] = [];
|
||||
|
||||
// Simple regex-based extraction - a real implementation might use a proper HTML parser
|
||||
const matches = html.match(/href=["'](https?:\/\/[^"']+)["']/gi);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
const linkMatch = match.match(/href=["'](https?:\/\/[^"']+)["']/i);
|
||||
if (linkMatch && linkMatch[1]) {
|
||||
links.push(linkMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from HTML
|
||||
* @param html HTML content
|
||||
* @returns Extracted text
|
||||
*/
|
||||
private extractTextFromHtml(html: string): string {
|
||||
// Remove HTML tags and decode entities - simplified version
|
||||
return html
|
||||
.replace(/<style[^>]*>.*?<\/style>/gs, '')
|
||||
.replace(/<script[^>]*>.*?<\/script>/gs, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a binary buffer for scanning
|
||||
* @param buffer Binary content
|
||||
* @returns Extracted text (may be partial)
|
||||
*/
|
||||
private extractTextFromBuffer(buffer: Buffer): string {
|
||||
try {
|
||||
// Limit the amount we convert to avoid memory issues
|
||||
const sampleSize = Math.min(buffer.length, 100 * 1024); // 100KB max sample
|
||||
const sample = buffer.slice(0, sampleSize);
|
||||
|
||||
// Try to convert to string, filtering out non-printable chars
|
||||
return sample.toString('utf8')
|
||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||
.replace(/\uFFFD/g, ''); // Remove replacement char
|
||||
} catch (error) {
|
||||
logger.log('warn', `Error extracting text from buffer: ${error.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an Office document likely contains macros
|
||||
* This is a simplified check - real implementation would use specialized libraries
|
||||
* @param attachment The attachment to check
|
||||
* @returns Whether the file likely contains macros
|
||||
*/
|
||||
private likelyContainsMacros(attachment: IAttachment): boolean {
|
||||
// Simple heuristic: look for VBA/macro related strings
|
||||
// This is a simplified approach and not comprehensive
|
||||
const content = this.extractTextFromBuffer(attachment.content);
|
||||
const macroIndicators = [
|
||||
/vbaProject\.bin/i,
|
||||
/Microsoft VBA/i,
|
||||
/\bVBA\b/,
|
||||
/Auto_Open/i,
|
||||
/AutoExec/i,
|
||||
/DocumentOpen/i,
|
||||
/AutoOpen/i,
|
||||
/\bExecute\(/i,
|
||||
/\bShell\(/i,
|
||||
/\bCreateObject\(/i
|
||||
];
|
||||
|
||||
for (const indicator of macroIndicators) {
|
||||
if (indicator.test(content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a pattern category to a threat type
|
||||
* @param category The pattern category
|
||||
* @returns The corresponding threat type
|
||||
*/
|
||||
private mapCategoryToThreatType(category: string): string {
|
||||
switch (category) {
|
||||
case 'phishing': return ThreatCategory.PHISHING;
|
||||
case 'spam': return ThreatCategory.SPAM;
|
||||
case 'malware': return ThreatCategory.MALWARE;
|
||||
case 'suspiciousLinks': return ThreatCategory.SUSPICIOUS_LINK;
|
||||
case 'scriptInjection': return ThreatCategory.XSS;
|
||||
case 'sensitiveData': return ThreatCategory.SENSITIVE_DATA;
|
||||
default: return ThreatCategory.BLACKLISTED_CONTENT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a high threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
* @param result The scan result
|
||||
*/
|
||||
private logHighThreatFound(email: Email, result: IScanResult): void {
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.MALWARE,
|
||||
message: `High threat content detected in email from ${email.from} to ${email.to.join(', ')}`,
|
||||
details: {
|
||||
messageId: email.getMessageId(),
|
||||
threatType: result.threatType,
|
||||
threatDetails: result.threatDetails,
|
||||
threatScore: result.threatScore,
|
||||
scannedElements: result.scannedElements,
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
* @param result The scan result
|
||||
*/
|
||||
private logThreatFound(email: Email, result: IScanResult): void {
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.SPAM,
|
||||
message: `Suspicious content detected in email from ${email.from} to ${email.to.join(', ')}`,
|
||||
details: {
|
||||
messageId: email.getMessageId(),
|
||||
threatType: result.threatType,
|
||||
threatDetails: result.threatDetails,
|
||||
threatScore: result.threatScore,
|
||||
scannedElements: result.scannedElements,
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat level description based on score
|
||||
* @param score Threat score
|
||||
* @returns Threat level description
|
||||
*/
|
||||
public static getThreatLevel(score: number): 'none' | 'low' | 'medium' | 'high' {
|
||||
if (score < 20) {
|
||||
return 'none';
|
||||
} else if (score < 40) {
|
||||
return 'low';
|
||||
} else if (score < 70) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
}
|
||||
592
ts/security/classes.ipreputationchecker.ts
Normal file
592
ts/security/classes.ipreputationchecker.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Reputation check result information
|
||||
*/
|
||||
export interface IReputationResult {
|
||||
score: number; // 0 (worst) to 100 (best)
|
||||
isSpam: boolean; // true if the IP is known for spam
|
||||
isProxy: boolean; // true if the IP is a known proxy
|
||||
isTor: boolean; // true if the IP is a known Tor exit node
|
||||
isVPN: boolean; // true if the IP is a known VPN
|
||||
country?: string; // Country code (if available)
|
||||
asn?: string; // Autonomous System Number (if available)
|
||||
org?: string; // Organization name (if available)
|
||||
blacklists?: string[]; // Names of blacklists that include this IP
|
||||
timestamp: number; // When this result was created/retrieved
|
||||
error?: string; // Error message if check failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Reputation threshold scores
|
||||
*/
|
||||
export enum ReputationThreshold {
|
||||
HIGH_RISK = 20, // Score below this is considered high risk
|
||||
MEDIUM_RISK = 50, // Score below this is considered medium risk
|
||||
LOW_RISK = 80 // Score below this is considered low risk (but not trusted)
|
||||
}
|
||||
|
||||
/**
|
||||
* IP type classifications
|
||||
*/
|
||||
export enum IPType {
|
||||
RESIDENTIAL = 'residential',
|
||||
DATACENTER = 'datacenter',
|
||||
PROXY = 'proxy',
|
||||
TOR = 'tor',
|
||||
VPN = 'vpn',
|
||||
UNKNOWN = 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the IP Reputation Checker
|
||||
*/
|
||||
export interface IIPReputationOptions {
|
||||
maxCacheSize?: number; // Maximum number of IPs to cache (default: 10000)
|
||||
cacheTTL?: number; // TTL for cache entries in ms (default: 24 hours)
|
||||
dnsblServers?: string[]; // List of DNSBL servers to check
|
||||
highRiskThreshold?: number; // Score below this is high risk
|
||||
mediumRiskThreshold?: number; // Score below this is medium risk
|
||||
lowRiskThreshold?: number; // Score below this is low risk
|
||||
enableLocalCache?: boolean; // Whether to persist cache to disk (default: true)
|
||||
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
|
||||
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for checking IP reputation of inbound email senders
|
||||
*/
|
||||
export class IPReputationChecker {
|
||||
private static instance: IPReputationChecker;
|
||||
private reputationCache: LRUCache<string, IReputationResult>;
|
||||
private options: Required<IIPReputationOptions>;
|
||||
private storageManager?: any; // StorageManager instance
|
||||
|
||||
// Default DNSBL servers
|
||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||
'zen.spamhaus.org', // Spamhaus
|
||||
'bl.spamcop.net', // SpamCop
|
||||
'b.barracudacentral.org', // Barracuda
|
||||
'spam.dnsbl.sorbs.net', // SORBS
|
||||
'dnsbl.sorbs.net', // SORBS (expanded)
|
||||
'cbl.abuseat.org', // Composite Blocking List
|
||||
'xbl.spamhaus.org', // Spamhaus XBL
|
||||
'pbl.spamhaus.org', // Spamhaus PBL
|
||||
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
||||
'psbl.surriel.com' // PSBL
|
||||
];
|
||||
|
||||
// Default options
|
||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||
maxCacheSize: 10000,
|
||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
dnsblServers: IPReputationChecker.DEFAULT_DNSBL_SERVERS,
|
||||
highRiskThreshold: ReputationThreshold.HIGH_RISK,
|
||||
mediumRiskThreshold: ReputationThreshold.MEDIUM_RISK,
|
||||
lowRiskThreshold: ReputationThreshold.LOW_RISK,
|
||||
enableLocalCache: true,
|
||||
enableDNSBL: true,
|
||||
enableIPInfo: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor for IPReputationChecker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
*/
|
||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
||||
// Merge with default options
|
||||
this.options = {
|
||||
...IPReputationChecker.DEFAULT_OPTIONS,
|
||||
...options
|
||||
};
|
||||
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// If no storage manager provided, log warning
|
||||
if (!storageManager && this.options.enableLocalCache) {
|
||||
logger.log('warn',
|
||||
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
|
||||
' IP reputation cache will only be stored to filesystem.\n' +
|
||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize reputation cache
|
||||
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||||
max: this.options.maxCacheSize,
|
||||
ttl: this.options.cacheTTL, // Cache TTL
|
||||
});
|
||||
|
||||
// Load cache from disk if enabled
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the load operation
|
||||
this.loadCache().catch(error => {
|
||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the checker
|
||||
* @param options Configuration options
|
||||
* @param storageManager Optional StorageManager instance for persistence
|
||||
* @returns Singleton instance
|
||||
*/
|
||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
||||
if (!IPReputationChecker.instance) {
|
||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
||||
}
|
||||
return IPReputationChecker.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an IP address's reputation
|
||||
* @param ip IP address to check
|
||||
* @returns Reputation check result
|
||||
*/
|
||||
public async checkReputation(ip: string): Promise<IReputationResult> {
|
||||
try {
|
||||
// Validate IP address format
|
||||
if (!this.isValidIPAddress(ip)) {
|
||||
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||||
return this.createErrorResult(ip, 'Invalid IP address format');
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = this.reputationCache.get(ip);
|
||||
if (cachedResult) {
|
||||
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
||||
score: cachedResult.score,
|
||||
isSpam: cachedResult.isSpam
|
||||
});
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// Initialize empty result
|
||||
const result: IReputationResult = {
|
||||
score: 100, // Start with perfect score
|
||||
isSpam: false,
|
||||
isProxy: false,
|
||||
isTor: false,
|
||||
isVPN: false,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Check IP against DNS blacklists if enabled
|
||||
if (this.options.enableDNSBL) {
|
||||
const dnsblResult = await this.checkDNSBL(ip);
|
||||
|
||||
// Update result with DNSBL information
|
||||
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
||||
result.isSpam = dnsblResult.listCount > 0;
|
||||
result.blacklists = dnsblResult.lists;
|
||||
}
|
||||
|
||||
// Get additional IP information if enabled
|
||||
if (this.options.enableIPInfo) {
|
||||
const ipInfo = await this.getIPInfo(ip);
|
||||
|
||||
// Update result with IP info
|
||||
result.country = ipInfo.country;
|
||||
result.asn = ipInfo.asn;
|
||||
result.org = ipInfo.org;
|
||||
|
||||
// Adjust score based on IP type
|
||||
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
||||
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
||||
|
||||
// Set proxy flags
|
||||
result.isProxy = ipInfo.type === IPType.PROXY;
|
||||
result.isTor = ipInfo.type === IPType.TOR;
|
||||
result.isVPN = ipInfo.type === IPType.VPN;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score is between 0 and 100
|
||||
result.score = Math.max(0, Math.min(100, result.score));
|
||||
|
||||
// Update cache with result
|
||||
this.reputationCache.set(ip, result);
|
||||
|
||||
// Save cache if enabled
|
||||
if (this.options.enableLocalCache) {
|
||||
// Fire and forget the save operation
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Log the reputation check
|
||||
this.logReputationCheck(ip, result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
||||
ip,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
return this.createErrorResult(ip, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an IP against DNS blacklists
|
||||
* @param ip IP address to check
|
||||
* @returns DNSBL check results
|
||||
*/
|
||||
private async checkDNSBL(ip: string): Promise<{
|
||||
listCount: number;
|
||||
lists: string[];
|
||||
}> {
|
||||
try {
|
||||
// Reverse the IP for DNSBL queries
|
||||
const reversedIP = this.reverseIP(ip);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
this.options.dnsblServers.map(async (server) => {
|
||||
try {
|
||||
const lookupDomain = `${reversedIP}.${server}`;
|
||||
await plugins.dns.promises.resolve(lookupDomain);
|
||||
return server; // IP is listed in this DNSBL
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return null; // IP is not listed in this DNSBL
|
||||
}
|
||||
throw error; // Other error
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Extract successful lookups (listed in DNSBL)
|
||||
const lists = results
|
||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
||||
result.status === 'fulfilled' && result.value !== null
|
||||
)
|
||||
.map(result => result.value);
|
||||
|
||||
return {
|
||||
listCount: lists.length,
|
||||
lists
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
||||
return {
|
||||
listCount: 0,
|
||||
lists: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about an IP address
|
||||
* @param ip IP address to check
|
||||
* @returns IP information
|
||||
*/
|
||||
private async getIPInfo(ip: string): Promise<{
|
||||
country?: string;
|
||||
asn?: string;
|
||||
org?: string;
|
||||
type: IPType;
|
||||
}> {
|
||||
try {
|
||||
// In a real implementation, this would use an IP data service API
|
||||
// For this implementation, we'll use a simplified approach
|
||||
|
||||
// Check if it's a known Tor exit node (simplified)
|
||||
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
||||
|
||||
// Check if it's a known VPN (simplified)
|
||||
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
||||
|
||||
// Check if it's a known proxy (simplified)
|
||||
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
||||
|
||||
// Determine IP type
|
||||
let type = IPType.UNKNOWN;
|
||||
if (isTor) {
|
||||
type = IPType.TOR;
|
||||
} else if (isVPN) {
|
||||
type = IPType.VPN;
|
||||
} else if (isProxy) {
|
||||
type = IPType.PROXY;
|
||||
} else {
|
||||
// Simple datacenters detection (major cloud providers)
|
||||
if (
|
||||
ip.startsWith('13.') || // AWS
|
||||
ip.startsWith('35.') || // Google Cloud
|
||||
ip.startsWith('52.') || // AWS
|
||||
ip.startsWith('34.') || // Google Cloud
|
||||
ip.startsWith('104.') // Various providers
|
||||
) {
|
||||
type = IPType.DATACENTER;
|
||||
} else {
|
||||
type = IPType.RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the information
|
||||
return {
|
||||
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
||||
asn: 'AS12345', // Simplified, would look up real ASN
|
||||
org: this.determineOrg(ip), // Simplified, would use real org data
|
||||
type
|
||||
};
|
||||
} catch (error) {
|
||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
||||
return {
|
||||
type: IPType.UNKNOWN
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified method to determine country from IP
|
||||
* In a real implementation, this would use a geolocation database or service
|
||||
* @param ip IP address
|
||||
* @returns Country code
|
||||
*/
|
||||
private determineCountry(ip: string): string {
|
||||
// Simplified mapping for demo purposes
|
||||
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'US';
|
||||
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'US';
|
||||
if (ip.startsWith('185.')) return 'NL';
|
||||
if (ip.startsWith('171.')) return 'DE';
|
||||
return 'XX'; // Unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified method to determine organization from IP
|
||||
* In a real implementation, this would use an IP-to-org database or service
|
||||
* @param ip IP address
|
||||
* @returns Organization name
|
||||
*/
|
||||
private determineOrg(ip: string): string {
|
||||
// Simplified mapping for demo purposes
|
||||
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'Amazon AWS';
|
||||
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'Google Cloud';
|
||||
if (ip.startsWith('185.156.')) return 'NordVPN';
|
||||
if (ip.startsWith('37.120.')) return 'ExpressVPN';
|
||||
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
||||
* @param ip IP address to reverse
|
||||
* @returns Reversed IP for DNSBL queries
|
||||
*/
|
||||
private reverseIP(ip: string): string {
|
||||
return ip.split('.').reverse().join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error result for when reputation check fails
|
||||
* @param ip IP address
|
||||
* @param errorMessage Error message
|
||||
* @returns Error result
|
||||
*/
|
||||
private createErrorResult(ip: string, errorMessage: string): IReputationResult {
|
||||
return {
|
||||
score: 50, // Neutral score for errors
|
||||
isSpam: false,
|
||||
isProxy: false,
|
||||
isTor: false,
|
||||
isVPN: false,
|
||||
timestamp: Date.now(),
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IP address format
|
||||
* @param ip IP address to validate
|
||||
* @returns Whether the IP is valid
|
||||
*/
|
||||
private isValidIPAddress(ip: string): boolean {
|
||||
// IPv4 regex pattern
|
||||
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipv4Pattern.test(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log reputation check to security logger
|
||||
* @param ip IP address
|
||||
* @param result Reputation result
|
||||
*/
|
||||
private logReputationCheck(ip: string, result: IReputationResult): void {
|
||||
// Determine log level based on reputation score
|
||||
let logLevel = SecurityLogLevel.INFO;
|
||||
if (result.score < this.options.highRiskThreshold) {
|
||||
logLevel = SecurityLogLevel.WARN;
|
||||
} else if (result.score < this.options.mediumRiskThreshold) {
|
||||
logLevel = SecurityLogLevel.INFO;
|
||||
}
|
||||
|
||||
// Log the check
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: logLevel,
|
||||
type: SecurityEventType.IP_REPUTATION,
|
||||
message: `IP reputation check ${result.isSpam ? 'flagged spam' : 'completed'} for ${ip}`,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
score: result.score,
|
||||
isSpam: result.isSpam,
|
||||
isProxy: result.isProxy,
|
||||
isTor: result.isTor,
|
||||
isVPN: result.isVPN,
|
||||
country: result.country,
|
||||
blacklists: result.blacklists
|
||||
},
|
||||
success: !result.isSpam
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cache to disk or storage manager
|
||||
*/
|
||||
private async saveCache(): Promise<void> {
|
||||
try {
|
||||
// Convert cache entries to serializable array
|
||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
||||
ip,
|
||||
data
|
||||
}));
|
||||
|
||||
// Only save if we have entries
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheData = JSON.stringify(entries);
|
||||
|
||||
// Save to storage manager if available
|
||||
if (this.storageManager) {
|
||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
||||
} else {
|
||||
// Fall back to filesystem
|
||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
||||
plugins.fsUtils.ensureDirSync(cacheDir);
|
||||
|
||||
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
||||
plugins.fsUtils.toFsSync(cacheData, cacheFile);
|
||||
|
||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cache from disk or storage manager
|
||||
*/
|
||||
private async loadCache(): Promise<void> {
|
||||
try {
|
||||
let cacheData: string | null = null;
|
||||
let fromFilesystem = false;
|
||||
|
||||
// Try to load from storage manager first
|
||||
if (this.storageManager) {
|
||||
try {
|
||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
||||
|
||||
if (!cacheData) {
|
||||
// Check if data exists in filesystem and migrate it
|
||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||||
|
||||
if (plugins.fs.existsSync(cacheFile)) {
|
||||
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
|
||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||||
fromFilesystem = true;
|
||||
|
||||
// Migrate to storage manager
|
||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
|
||||
|
||||
// Optionally delete the old file after successful migration
|
||||
try {
|
||||
plugins.fs.unlinkSync(cacheFile);
|
||||
logger.log('info', 'Old cache file removed after migration');
|
||||
} catch (deleteError) {
|
||||
logger.log('warn', `Could not delete old cache file: ${deleteError.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Error loading from StorageManager: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
// No storage manager, load from filesystem
|
||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||||
|
||||
if (plugins.fs.existsSync(cacheFile)) {
|
||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||||
fromFilesystem = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and restore cache if data was found
|
||||
if (cacheData) {
|
||||
const entries = JSON.parse(cacheData);
|
||||
|
||||
// Validate and filter entries
|
||||
const now = Date.now();
|
||||
const validEntries = entries.filter(entry => {
|
||||
const age = now - entry.data.timestamp;
|
||||
return age < this.options.cacheTTL; // Only load entries that haven't expired
|
||||
});
|
||||
|
||||
// Restore cache
|
||||
for (const entry of validEntries) {
|
||||
this.reputationCache.set(entry.ip, entry.data);
|
||||
}
|
||||
|
||||
const source = fromFilesystem ? 'disk' : 'StorageManager';
|
||||
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from ${source}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the risk level for a reputation score
|
||||
* @param score Reputation score (0-100)
|
||||
* @returns Risk level description
|
||||
*/
|
||||
public static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted' {
|
||||
if (score < ReputationThreshold.HIGH_RISK) {
|
||||
return 'high';
|
||||
} else if (score < ReputationThreshold.MEDIUM_RISK) {
|
||||
return 'medium';
|
||||
} else if (score < ReputationThreshold.LOW_RISK) {
|
||||
return 'low';
|
||||
} else {
|
||||
return 'trusted';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the storage manager after instantiation
|
||||
* This is useful when the storage manager is not available at construction time
|
||||
* @param storageManager The StorageManager instance to use
|
||||
*/
|
||||
public updateStorageManager(storageManager: any): void {
|
||||
this.storageManager = storageManager;
|
||||
logger.log('info', 'IPReputationChecker storage manager updated');
|
||||
|
||||
// If cache is enabled and we have entries, save them to the new storage manager
|
||||
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
||||
this.saveCache().catch(error => {
|
||||
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
299
ts/security/classes.securitylogger.ts
Normal file
299
ts/security/classes.securitylogger.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Log level for security events
|
||||
*/
|
||||
export enum SecurityLogLevel {
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event types for categorization
|
||||
*/
|
||||
export enum SecurityEventType {
|
||||
AUTHENTICATION = 'authentication',
|
||||
ACCESS_CONTROL = 'access_control',
|
||||
EMAIL_VALIDATION = 'email_validation',
|
||||
EMAIL_PROCESSING = 'email_processing',
|
||||
EMAIL_FORWARDING = 'email_forwarding',
|
||||
EMAIL_DELIVERY = 'email_delivery',
|
||||
DKIM = 'dkim',
|
||||
SPF = 'spf',
|
||||
DMARC = 'dmarc',
|
||||
RATE_LIMIT = 'rate_limit',
|
||||
RATE_LIMITING = 'rate_limiting',
|
||||
SPAM = 'spam',
|
||||
MALWARE = 'malware',
|
||||
CONNECTION = 'connection',
|
||||
DATA_EXPOSURE = 'data_exposure',
|
||||
CONFIGURATION = 'configuration',
|
||||
IP_REPUTATION = 'ip_reputation',
|
||||
REJECTED_CONNECTION = 'rejected_connection'
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event interface
|
||||
*/
|
||||
export interface ISecurityEvent {
|
||||
timestamp: number;
|
||||
level: SecurityLogLevel;
|
||||
type: SecurityEventType;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
emailId?: string;
|
||||
domain?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security logger for enhanced security monitoring
|
||||
*/
|
||||
export class SecurityLogger {
|
||||
private static instance: SecurityLogger;
|
||||
private securityEvents: ISecurityEvent[] = [];
|
||||
private maxEventHistory: number;
|
||||
private enableNotifications: boolean;
|
||||
|
||||
private constructor(options?: {
|
||||
maxEventHistory?: number;
|
||||
enableNotifications?: boolean;
|
||||
}) {
|
||||
this.maxEventHistory = options?.maxEventHistory || 1000;
|
||||
this.enableNotifications = options?.enableNotifications || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(options?: {
|
||||
maxEventHistory?: number;
|
||||
enableNotifications?: boolean;
|
||||
}): SecurityLogger {
|
||||
if (!SecurityLogger.instance) {
|
||||
SecurityLogger.instance = new SecurityLogger(options);
|
||||
}
|
||||
return SecurityLogger.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
* @param event The security event to log
|
||||
*/
|
||||
public logEvent(event: Omit<ISecurityEvent, 'timestamp'>): void {
|
||||
const fullEvent: ISecurityEvent = {
|
||||
...event,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Store in memory buffer
|
||||
this.securityEvents.push(fullEvent);
|
||||
|
||||
// Trim history if needed
|
||||
if (this.securityEvents.length > this.maxEventHistory) {
|
||||
this.securityEvents.shift();
|
||||
}
|
||||
|
||||
// Log to regular logger with appropriate level
|
||||
switch (event.level) {
|
||||
case SecurityLogLevel.INFO:
|
||||
logger.log('info', `[SECURITY:${event.type}] ${event.message}`, event.details);
|
||||
break;
|
||||
case SecurityLogLevel.WARN:
|
||||
logger.log('warn', `[SECURITY:${event.type}] ${event.message}`, event.details);
|
||||
break;
|
||||
case SecurityLogLevel.ERROR:
|
||||
case SecurityLogLevel.CRITICAL:
|
||||
logger.log('error', `[SECURITY:${event.type}] ${event.message}`, event.details);
|
||||
|
||||
// Send notification for critical events if enabled
|
||||
if (event.level === SecurityLogLevel.CRITICAL && this.enableNotifications) {
|
||||
this.sendNotification(fullEvent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent security events
|
||||
* @param limit Maximum number of events to return
|
||||
* @param filter Filter for specific event types
|
||||
* @returns Recent security events
|
||||
*/
|
||||
public getRecentEvents(limit: number = 100, filter?: {
|
||||
level?: SecurityLogLevel;
|
||||
type?: SecurityEventType;
|
||||
fromTimestamp?: number;
|
||||
toTimestamp?: number;
|
||||
}): ISecurityEvent[] {
|
||||
let filteredEvents = this.securityEvents;
|
||||
|
||||
// Apply filters
|
||||
if (filter) {
|
||||
if (filter.level) {
|
||||
filteredEvents = filteredEvents.filter(event => event.level === filter.level);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
filteredEvents = filteredEvents.filter(event => event.type === filter.type);
|
||||
}
|
||||
|
||||
if (filter.fromTimestamp) {
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp);
|
||||
}
|
||||
|
||||
if (filter.toTimestamp) {
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Return most recent events up to limit
|
||||
return filteredEvents
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by security level
|
||||
* @param level The security level to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events matching the level
|
||||
*/
|
||||
public getEventsByLevel(level: SecurityLogLevel, limit: number = 100): ISecurityEvent[] {
|
||||
return this.getRecentEvents(limit, { level });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by security type
|
||||
* @param type The event type to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events matching the type
|
||||
*/
|
||||
public getEventsByType(type: SecurityEventType, limit: number = 100): ISecurityEvent[] {
|
||||
return this.getRecentEvents(limit, { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events for a specific IP address
|
||||
* @param ipAddress The IP address to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events for the IP address
|
||||
*/
|
||||
public getEventsByIP(ipAddress: string, limit: number = 100): ISecurityEvent[] {
|
||||
return this.securityEvents
|
||||
.filter(event => event.ipAddress === ipAddress)
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events for a specific domain
|
||||
* @param domain The domain to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events for the domain
|
||||
*/
|
||||
public getEventsByDomain(domain: string, limit: number = 100): ISecurityEvent[] {
|
||||
return this.securityEvents
|
||||
.filter(event => event.domain === domain)
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification for critical security events
|
||||
* @param event The security event to notify about
|
||||
* @private
|
||||
*/
|
||||
private sendNotification(event: ISecurityEvent): void {
|
||||
// In a production environment, this would integrate with a notification service
|
||||
// For now, we'll just log that we would send a notification
|
||||
logger.log('error', `[SECURITY NOTIFICATION] ${event.message}`, {
|
||||
...event,
|
||||
notificationSent: true
|
||||
});
|
||||
|
||||
// Future integration with alerting systems would go here
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event history
|
||||
*/
|
||||
public clearEvents(): void {
|
||||
this.securityEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistical summary of security events
|
||||
* @param timeWindow Optional time window in milliseconds
|
||||
* @returns Summary of security events
|
||||
*/
|
||||
public getEventsSummary(timeWindow?: number): {
|
||||
total: number;
|
||||
byLevel: Record<SecurityLogLevel, number>;
|
||||
byType: Record<SecurityEventType, number>;
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topDomains: Array<{ domain: string; count: number }>;
|
||||
} {
|
||||
// Filter by time window if provided
|
||||
let events = this.securityEvents;
|
||||
if (timeWindow) {
|
||||
const cutoff = Date.now() - timeWindow;
|
||||
events = events.filter(e => e.timestamp >= cutoff);
|
||||
}
|
||||
|
||||
// Count by level
|
||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
||||
acc[level] = events.filter(e => e.level === level).length;
|
||||
return acc;
|
||||
}, {} as Record<SecurityLogLevel, number>);
|
||||
|
||||
// Count by type
|
||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
||||
acc[type] = events.filter(e => e.type === type).length;
|
||||
return acc;
|
||||
}, {} as Record<SecurityEventType, number>);
|
||||
|
||||
// Count by IP
|
||||
const ipCounts = new Map<string, number>();
|
||||
events.forEach(e => {
|
||||
if (e.ipAddress) {
|
||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Count by domain
|
||||
const domainCounts = new Map<string, number>();
|
||||
events.forEach(e => {
|
||||
if (e.domain) {
|
||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort and limit top entries
|
||||
const topIPs = Array.from(ipCounts.entries())
|
||||
.map(([ip, count]) => ({ ip, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
const topDomains = Array.from(domainCounts.entries())
|
||||
.map(([domain, count]) => ({ domain, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
total: events.length,
|
||||
byLevel,
|
||||
byType,
|
||||
topIPs,
|
||||
topDomains
|
||||
};
|
||||
}
|
||||
}
|
||||
21
ts/security/index.ts
Normal file
21
ts/security/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType,
|
||||
type ISecurityEvent
|
||||
} from './classes.securitylogger.js';
|
||||
|
||||
export {
|
||||
IPReputationChecker,
|
||||
ReputationThreshold,
|
||||
IPType,
|
||||
type IReputationResult,
|
||||
type IIPReputationOptions
|
||||
} from './classes.ipreputationchecker.js';
|
||||
|
||||
export {
|
||||
ContentScanner,
|
||||
ThreatCategory,
|
||||
type IScanResult,
|
||||
type IContentScannerOptions
|
||||
} from './classes.contentscanner.js';
|
||||
@@ -1,22 +1,28 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
||||
|
||||
export interface ISmsConstructorOptions {
|
||||
apiGatewayApiToken: string;
|
||||
}
|
||||
import type { ISmsConfig } from './config/sms.config.js';
|
||||
import { smsConfigSchema } from './config/sms.schema.js';
|
||||
import { ConfigValidator } from '../config/validator.js';
|
||||
|
||||
export class SmsService {
|
||||
public platformServiceRef: SzPlatformService;
|
||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
public options: ISmsConstructorOptions;
|
||||
public config: ISmsConfig;
|
||||
|
||||
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ISmsConstructorOptions) {
|
||||
this.platformServiceRef = platformServiceRefArg;
|
||||
this.options = optionsArg;
|
||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
constructor(options: ISmsConfig) {
|
||||
// Validate and apply defaults to configuration
|
||||
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Set configuration with defaults
|
||||
this.config = validationResult.config;
|
||||
|
||||
// Add router to platform service
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,21 +59,22 @@ export class SmsService {
|
||||
}
|
||||
|
||||
public async sendSms(toNumber: number, fromName: string, messageText: string) {
|
||||
// Use default sender if not specified
|
||||
const sender = fromName || this.config.defaultSender || 'PlatformService';
|
||||
|
||||
const payload = {
|
||||
sender: fromName,
|
||||
sender,
|
||||
message: messageText,
|
||||
recipients: [{ msisdn: toNumber }],
|
||||
};
|
||||
|
||||
const resp = await plugins.smartrequest.request('https://gatewayapi.com/rest/mtsms', {
|
||||
method: 'POST',
|
||||
requestBody: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${this.options.apiGatewayApiToken}:`).toString('base64')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const json = await resp.body;
|
||||
const resp = await plugins.smartrequest.SmartRequest.create()
|
||||
.url('https://gatewayapi.com/rest/mtsms')
|
||||
.header('Authorization', `Basic ${Buffer.from(`${this.config.apiGatewayApiToken}:`).toString('base64')}`)
|
||||
.header('Content-Type', 'application/json')
|
||||
.json(payload)
|
||||
.post();
|
||||
const json = await resp.json();
|
||||
logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, {
|
||||
eventType: 'sentSms',
|
||||
sms: {
|
||||
109
ts/sms/config/sms.config.ts
Normal file
109
ts/sms/config/sms.config.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* SMS service configuration interface
|
||||
*/
|
||||
export interface ISmsConfig {
|
||||
/**
|
||||
* API token for the gateway service
|
||||
*/
|
||||
apiGatewayApiToken: string;
|
||||
|
||||
/**
|
||||
* Default sender ID or phone number
|
||||
*/
|
||||
defaultSender?: string;
|
||||
|
||||
/**
|
||||
* SMS rate limiting
|
||||
*/
|
||||
rateLimit?: {
|
||||
/**
|
||||
* Whether rate limiting is enabled
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum requests per period
|
||||
*/
|
||||
maxPerPeriod?: number;
|
||||
|
||||
/**
|
||||
* Period duration in milliseconds
|
||||
*/
|
||||
periodMs?: number;
|
||||
|
||||
/**
|
||||
* Whether to apply rate limit per key
|
||||
*/
|
||||
perKey?: boolean;
|
||||
|
||||
/**
|
||||
* Number of burst tokens
|
||||
*/
|
||||
burstTokens?: number;
|
||||
|
||||
/**
|
||||
* Maximum messages per recipient per day
|
||||
*/
|
||||
maxPerRecipientPerDay?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* SMS provider configuration
|
||||
*/
|
||||
provider?: {
|
||||
/**
|
||||
* Provider type
|
||||
*/
|
||||
type?: 'gateway' | 'twilio' | 'other';
|
||||
|
||||
/**
|
||||
* Provider-specific configuration
|
||||
*/
|
||||
config?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* Fallback provider configuration
|
||||
*/
|
||||
fallback?: {
|
||||
/**
|
||||
* Whether to use fallback provider
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* Provider type
|
||||
*/
|
||||
type?: 'gateway' | 'twilio' | 'other';
|
||||
|
||||
/**
|
||||
* Provider-specific configuration
|
||||
*/
|
||||
config?: Record<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Verification code settings
|
||||
*/
|
||||
verification?: {
|
||||
/**
|
||||
* Code length
|
||||
*/
|
||||
codeLength?: number;
|
||||
|
||||
/**
|
||||
* Code expiration time in seconds
|
||||
*/
|
||||
expirationSeconds?: number;
|
||||
|
||||
/**
|
||||
* Maximum number of attempts
|
||||
*/
|
||||
maxAttempts?: number;
|
||||
|
||||
/**
|
||||
* Cooldown period in seconds
|
||||
*/
|
||||
cooldownSeconds?: number;
|
||||
};
|
||||
}
|
||||
122
ts/sms/config/sms.schema.ts
Normal file
122
ts/sms/config/sms.schema.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { ValidationSchema } from '../../config/validator.js';
|
||||
|
||||
/**
|
||||
* SMS service configuration schema
|
||||
*/
|
||||
export const smsConfigSchema: ValidationSchema = {
|
||||
apiGatewayApiToken: {
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
defaultSender: {
|
||||
type: 'string',
|
||||
required: false
|
||||
},
|
||||
rateLimit: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
schema: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
maxPerPeriod: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 100,
|
||||
min: 1
|
||||
},
|
||||
periodMs: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 60000, // 1 minute
|
||||
min: 1000
|
||||
},
|
||||
perKey: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
burstTokens: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 5,
|
||||
min: 0
|
||||
},
|
||||
maxPerRecipientPerDay: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 10,
|
||||
min: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
schema: {
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
enum: ['gateway', 'twilio', 'other'],
|
||||
default: 'gateway'
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
required: false
|
||||
},
|
||||
fallback: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
schema: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
enum: ['gateway', 'twilio', 'other']
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
verification: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
schema: {
|
||||
codeLength: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 6,
|
||||
min: 4,
|
||||
max: 10
|
||||
},
|
||||
expirationSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 300, // 5 minutes
|
||||
min: 60
|
||||
},
|
||||
maxAttempts: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 3,
|
||||
min: 1
|
||||
},
|
||||
cooldownSeconds: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
default: 60, // 1 minute
|
||||
min: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
export * from './smsservice.js';
|
||||
export * from './classes.smsservice.js';
|
||||
400
ts/storage/classes.storagemanager.ts
Normal file
400
ts/storage/classes.storagemanager.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// Promisify filesystem operations
|
||||
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
||||
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
||||
const unlink = plugins.util.promisify(plugins.fs.unlink);
|
||||
const rename = plugins.util.promisify(plugins.fs.rename);
|
||||
const readdir = plugins.util.promisify(plugins.fs.readdir);
|
||||
|
||||
/**
|
||||
* Storage configuration interface
|
||||
*/
|
||||
export interface IStorageConfig {
|
||||
/** Filesystem path for storage */
|
||||
fsPath?: string;
|
||||
/** Custom read function */
|
||||
readFunction?: (key: string) => Promise<string>;
|
||||
/** Custom write function */
|
||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage backend type
|
||||
*/
|
||||
export type StorageBackend = 'filesystem' | 'custom' | 'memory';
|
||||
|
||||
/**
|
||||
* Central storage manager for DcRouter
|
||||
* Provides unified key-value storage with multiple backend support
|
||||
*/
|
||||
export class StorageManager {
|
||||
private backend: StorageBackend;
|
||||
private memoryStore: Map<string, string> = new Map();
|
||||
private config: IStorageConfig;
|
||||
private fsBasePath?: string;
|
||||
|
||||
constructor(config?: IStorageConfig) {
|
||||
this.config = config || {};
|
||||
|
||||
// Check if both fsPath and custom functions are provided
|
||||
if (config?.fsPath && (config?.readFunction || config?.writeFunction)) {
|
||||
console.warn(
|
||||
'⚠️ WARNING: Both fsPath and custom read/write functions are configured.\n' +
|
||||
' Using custom read/write functions. fsPath will be ignored.'
|
||||
);
|
||||
}
|
||||
|
||||
// Determine backend based on configuration
|
||||
if (config?.readFunction && config?.writeFunction) {
|
||||
this.backend = 'custom';
|
||||
} else if (config?.fsPath) {
|
||||
// Set up internal read/write functions for filesystem
|
||||
this.backend = 'custom'; // Use custom backend with internal functions
|
||||
this.fsBasePath = plugins.path.resolve(config.fsPath);
|
||||
this.ensureDirectory(this.fsBasePath);
|
||||
|
||||
// Set up internal filesystem read/write functions
|
||||
this.config.readFunction = async (key: string) => {
|
||||
return this.fsRead(key);
|
||||
};
|
||||
this.config.writeFunction = async (key: string, value: string) => {
|
||||
await this.fsWrite(key, value);
|
||||
};
|
||||
} else {
|
||||
this.backend = 'memory';
|
||||
this.showMemoryWarning();
|
||||
}
|
||||
|
||||
logger.log('info', `StorageManager initialized with ${this.backend} backend`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning when using memory backend
|
||||
*/
|
||||
private showMemoryWarning(): void {
|
||||
console.warn(
|
||||
'⚠️ WARNING: StorageManager is using in-memory storage.\n' +
|
||||
' Data will be lost when the process restarts.\n' +
|
||||
' Configure storage.fsPath or storage functions for persistence.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists for filesystem backend
|
||||
*/
|
||||
private async ensureDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await plugins.fsUtils.ensureDir(dirPath);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to create storage directory: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize storage key
|
||||
*/
|
||||
private validateKey(key: string): string {
|
||||
if (!key || typeof key !== 'string') {
|
||||
throw new Error('Storage key must be a non-empty string');
|
||||
}
|
||||
|
||||
// Ensure key starts with /
|
||||
if (!key.startsWith('/')) {
|
||||
key = '/' + key;
|
||||
}
|
||||
|
||||
// Remove any dangerous path elements
|
||||
key = key.replace(/\.\./g, '').replace(/\/+/g, '/');
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert key to filesystem path
|
||||
*/
|
||||
private keyToPath(key: string): string {
|
||||
if (!this.fsBasePath) {
|
||||
throw new Error('Filesystem base path not configured');
|
||||
}
|
||||
|
||||
// Remove leading slash and convert to path
|
||||
const relativePath = key.substring(1);
|
||||
return plugins.path.join(this.fsBasePath, relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal filesystem read function
|
||||
*/
|
||||
private async fsRead(key: string): Promise<string> {
|
||||
const filePath = this.keyToPath(key);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
return content;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal filesystem write function
|
||||
*/
|
||||
private async fsWrite(key: string, value: string): Promise<void> {
|
||||
const filePath = this.keyToPath(key);
|
||||
const dir = plugins.path.dirname(filePath);
|
||||
|
||||
// Ensure directory exists
|
||||
await plugins.fsUtils.ensureDir(dir);
|
||||
|
||||
// Write atomically with temp file
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
await writeFile(tempPath, value, 'utf8');
|
||||
await rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value by key
|
||||
*/
|
||||
async get(key: string): Promise<string | null> {
|
||||
key = this.validateKey(key);
|
||||
|
||||
try {
|
||||
switch (this.backend) {
|
||||
|
||||
case 'custom': {
|
||||
if (!this.config.readFunction) {
|
||||
throw new Error('Read function not configured');
|
||||
}
|
||||
try {
|
||||
return await this.config.readFunction(key);
|
||||
} catch (error) {
|
||||
// Assume null if read fails (key doesn't exist)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
return this.memoryStore.get(key) || null;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage get error for key ${key}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value by key
|
||||
*/
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
key = this.validateKey(key);
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Storage value must be a string');
|
||||
}
|
||||
|
||||
try {
|
||||
switch (this.backend) {
|
||||
case 'filesystem': {
|
||||
const filePath = this.keyToPath(key);
|
||||
const dirPath = plugins.path.dirname(filePath);
|
||||
|
||||
// Ensure directory exists
|
||||
await plugins.fsUtils.ensureDir(dirPath);
|
||||
|
||||
// Write atomically
|
||||
const tempPath = filePath + '.tmp';
|
||||
await writeFile(tempPath, value, 'utf8');
|
||||
await rename(tempPath, filePath);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
if (!this.config.writeFunction) {
|
||||
throw new Error('Write function not configured');
|
||||
}
|
||||
await this.config.writeFunction(key, value);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
this.memoryStore.set(key, value);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage set error for key ${key}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete value by key
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
key = this.validateKey(key);
|
||||
|
||||
try {
|
||||
switch (this.backend) {
|
||||
case 'filesystem': {
|
||||
const filePath = this.keyToPath(key);
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
// Try to delete by setting empty value
|
||||
if (this.config.writeFunction) {
|
||||
await this.config.writeFunction(key, '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
this.memoryStore.delete(key);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage delete error for key ${key}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keys by prefix
|
||||
*/
|
||||
async list(prefix?: string): Promise<string[]> {
|
||||
prefix = prefix ? this.validateKey(prefix) : '/';
|
||||
|
||||
try {
|
||||
switch (this.backend) {
|
||||
case 'custom': {
|
||||
// If we have fsBasePath, this is actually filesystem backend
|
||||
if (this.fsBasePath) {
|
||||
const basePath = this.keyToPath(prefix);
|
||||
const keys: string[] = [];
|
||||
|
||||
const walkDir = async (dir: string, baseDir: string): Promise<void> => {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = plugins.path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walkDir(fullPath, baseDir);
|
||||
} else if (entry.isFile()) {
|
||||
// Convert path back to key
|
||||
const relativePath = plugins.path.relative(this.fsBasePath!, fullPath);
|
||||
const key = '/' + relativePath.replace(/\\/g, '/');
|
||||
if (key.startsWith(prefix)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walkDir(basePath, basePath);
|
||||
return keys.sort();
|
||||
} else {
|
||||
// True custom backends need to implement their own listing
|
||||
logger.log('warn', 'List operation not supported for custom backend');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
const keys: string[] = [];
|
||||
for (const key of this.memoryStore.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown backend: ${this.backend}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `Storage list error for prefix ${prefix}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
*/
|
||||
async exists(key: string): Promise<boolean> {
|
||||
key = this.validateKey(key);
|
||||
|
||||
try {
|
||||
const value = await this.get(key);
|
||||
return value !== null;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage backend type
|
||||
*/
|
||||
getBackend(): StorageBackend {
|
||||
// If we're using custom backend with fsBasePath, report it as filesystem
|
||||
if (this.backend === 'custom' && this.fsBasePath) {
|
||||
return 'filesystem' as StorageBackend;
|
||||
}
|
||||
return this.backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON helper: Get and parse JSON value
|
||||
*/
|
||||
async getJSON<T = any>(key: string): Promise<T | null> {
|
||||
const value = await this.get(key);
|
||||
if (value === null || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to parse JSON for key ${key}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON helper: Set value as JSON
|
||||
*/
|
||||
async setJSON(key: string, value: any): Promise<void> {
|
||||
const jsonString = JSON.stringify(value, null, 2);
|
||||
await this.set(key, jsonString);
|
||||
}
|
||||
}
|
||||
2
ts/storage/index.ts
Normal file
2
ts/storage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Storage module exports
|
||||
export * from './classes.storagemanager.js';
|
||||
3
ts/tspublish.json
Normal file
3
ts/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 2
|
||||
}
|
||||
8
ts_interfaces/data/auth.ts
Normal file
8
ts_interfaces/data/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface IIdentity {
|
||||
jwt: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
expiresAt: number;
|
||||
role?: string;
|
||||
type?: string;
|
||||
}
|
||||
3
ts_interfaces/data/index.ts
Normal file
3
ts_interfaces/data/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user