Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ dist/
|
|||||||
dist_*/
|
dist_*/
|
||||||
|
|
||||||
# custom
|
# custom
|
||||||
|
**/.claude/settings.local.json
|
||||||
|
.nogit/data/
|
||||||
|
readme.plan.md
|
||||||
|
|||||||
401
changelog.md
401
changelog.md
@@ -1,74 +1,371 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2025-03-15 - 2.3.0 - feat(platformservice)
|
## 2026-02-13 - 5.1.0 - feat(acme)
|
||||||
Add AIBridge module and refactor service file paths for improved module organization
|
Integrate SmartAcme DNS-01 handling and add certificate provisioning for SmartProxy
|
||||||
|
|
||||||
- Added new AIBridge class in ts/aibridge/classes.aibridge.ts.
|
- Add smartAcme property and lifecycle management (start/stop) in DcRouter
|
||||||
- Renamed letter service file from ts/letter/letterservice.ts to ts/letter/classes.letterservice.ts and updated its index.
|
- Create SmartAcme instance when DNS challenge handlers are present and wire certProvisionFunction to SmartProxy to return certificates for domains
|
||||||
- Updated platformservice.ts to import letter and SMS services from new paths.
|
- Fall back to http-01 provisioning on SmartAcme errors for a domain
|
||||||
- Renamed SMS service file from ts/sms/smsservice.ts to ts/sms/classes.smsservice.ts and updated its index accordingly.
|
- Stop SmartAcme during shutdown sequence to clean up resources
|
||||||
|
- Bump dependency @push.rocks/smartproxy to ^23.1.5
|
||||||
|
|
||||||
## 2025-03-15 - 2.2.1 - fix(platformservice)
|
## 2026-02-13 - 5.0.7 - fix(deps)
|
||||||
Refactor module structure to update import paths and file organization
|
bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2
|
||||||
|
|
||||||
- Removed obsolete file 'ts/classes.platformservice.ts' and updated references to use 'ts/platformservice.ts'.
|
- package.json: updated @push.rocks/smartdns from ^7.8.0 to ^7.8.1 (patch)
|
||||||
- Updated import paths in PlatformServiceDb, EmailService, and other modules to use new file structure.
|
- package.json: updated @push.rocks/smartmta from ^5.2.1 to ^5.2.2 (patch)
|
||||||
- Renamed and moved files in the email, mta, letter, and sms directories to align with new module layout.
|
|
||||||
- Fixed references to external modules (e.g. '@serve.zone/interfaces', '@push.rocks/*', etc.) to reflect the updated paths.
|
|
||||||
|
|
||||||
## 2025-03-15 - 2.2.0 - feat(plugins)
|
## 2026-02-12 - 5.0.6 - fix(deps)
|
||||||
Add smartproxy support by including the @push.rocks/smartproxy dependency and exporting it in the plugins module.
|
bump @push.rocks/smartproxy to ^23.1.4
|
||||||
|
|
||||||
- Added '@push.rocks/smartproxy' dependency version '^4.1.0' to package.json
|
- package.json: @push.rocks/smartproxy ^23.1.2 → ^23.1.4
|
||||||
- Updated ts/plugins.ts to export the smartproxy module alongside other push.rocks modules
|
- Dependency-only version bump, no source code changes
|
||||||
|
|
||||||
## 2025-03-15 - 2.1.0 - feat(MTA)
|
## 2026-02-12 - 5.0.5 - fix(dcrouter)
|
||||||
Update readme with detailed Mail Transfer Agent usage and examples
|
remove legacy handling of emailConfig.routes that added domain-based routes
|
||||||
|
|
||||||
- Added a comprehensive MTA section with usage examples including SMTP server setup, DKIM signing/verification, SPF/DMARC support, and API integration
|
- Removed loop that added domain-based email routes from emailConfig.routes into emailRoutes
|
||||||
- Expanded the conclusion to highlight MTA capabilities alongside email, SMS, letter, and AI services
|
- 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
|
||||||
|
|
||||||
## 2025-03-15 - 2.0.0 - BREAKING CHANGE(platformservice)
|
## 2026-02-12 - 5.0.4 - fix(cache)
|
||||||
Remove deprecated AIBridge module and update email service to use the MTA connector; update dependency versions and adjust build scripts in package.json.
|
use user-writable ~/.serve.zone/dcrouter for TsmDB and centralize data path logic
|
||||||
|
|
||||||
- Completely remove the aibridge module files (aibridge.classes.aibridge.ts, aibridge.classes.aibridgedb.ts, aibridge.classes.openaibridge.ts, aibridge.paths.ts, aibridge.plugins.ts, and index.ts) as they are no longer needed.
|
- Default TsmDB storage changed from /etc/dcrouter/tsmdb to ~/.serve.zone/dcrouter/tsmdb
|
||||||
- Switch the email service from using MailgunConnector to the new MTA connector for sending emails.
|
- Introduced dcrouterHomeDir, dataDir, and defaultTsmDbPath in ts/paths.ts
|
||||||
- Update dependency versions for @serve.zone/interfaces, @tsclass/tsclass, letterxpress, and uuid in package.json.
|
- CacheDb now defaults to defaultTsmDbPath when no storagePath is provided
|
||||||
- Enhance the build script in package.json and add pnpm configuration.
|
- 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
|
||||||
|
|
||||||
## 2025-03-15 - 1.1.2 - fix(mta)
|
## 2026-02-12 - 5.0.3 - fix(packaging)
|
||||||
Expose HttpResponse.statusCode and add explicit generic type annotations in DNSManager cache retrieval
|
add files whitelist to package.json and remove Playwright-generated screenshots
|
||||||
|
|
||||||
- Changed HttpResponse.statusCode from private to public to allow external access and inspection
|
- 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).
|
||||||
- Added explicit generic type parameters in getFromCache calls for lookupMx and lookupTxt to enhance type safety
|
- Remove multiple .playwright-mcp/*.png screenshot files (clean up Playwright test artifacts and reduce repository noise/size).
|
||||||
|
|
||||||
## 2025-03-15 - 1.1.1 - fix(paths)
|
## 2026-02-12 - 5.0.2 - fix(docs)
|
||||||
Update directory paths to use a dedicated 'data' directory and add ensureDirectories function for proper directory creation.
|
update documentation and packaging configuration: document smartmta/smartdns integrations, adjust API method names, and add release registry info
|
||||||
|
|
||||||
- Refactored ts/paths.ts to define a base data directory using process.cwd().
|
- README: document SmartDNS as Rust-powered DNS engine and smartmta as TypeScript+Rust MTA; add Rust-powered architecture section and component package table
|
||||||
- Reorganized MTA directories (keys, dns, emails sent/received/failed, logs) under the data directory.
|
- README: update Node.js requirement from 18+ to 20+; replace embedded cache DB TsmDb with LocalTsmDb and reduce listed cached document types
|
||||||
- Added ensureDirectories function to create missing directories at runtime.
|
- 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
|
||||||
|
|
||||||
## 2025-03-15 - 1.1.1 - fix(mta)
|
## 2026-02-11 - 5.0.1 - fix(deps/tests)
|
||||||
Refactor API Manager and DKIMCreator: remove Express dependency in favor of Node's native HTTP server, add an HttpResponse helper to improve request handling, update path and authentication logic, and expose previously private DKIMCreator methods for API access.
|
bump two dependencies and disable cache in tests
|
||||||
|
|
||||||
- Replaced Express-based middleware with native HTTP server handling, including request body parsing and CORS headers.
|
- Bumped @api.global/typedrequest from ^3.2.5 to ^3.2.6
|
||||||
- Introduced an HttpResponse helper class to standardize response writing.
|
- Bumped @push.rocks/smartradius from ^1.1.0 to ^1.1.1
|
||||||
- Updated route matching, parameter extraction, and error handling within the API Manager.
|
- 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
|
||||||
- Modified DKIMCreator methods (createDKIMKeys, storeDKIMKeys, createAndStoreDKIMKeys, and getDNSRecordForDomain) from private to public for better API accessibility.
|
|
||||||
- Updated plugin imports to include the native HTTP module.
|
|
||||||
|
|
||||||
## 2025-03-15 - 1.1.0 - feat(mta)
|
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mta)
|
||||||
Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes
|
migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation
|
||||||
|
|
||||||
- Introduce a state machine (SmtpState) and session management in the SMTP server to replace legacy buffering
|
- Replace ~27k LOC custom MTA (ts/mail/, ts/deliverability/) with @push.rocks/smartmta v5.2.1 (TypeScript+Rust hybrid)
|
||||||
- Refactor DNSManager with caching and improved SPF, DKIM, and DMARC verification methods
|
- Remove many SMTP client/server test suites and test helpers; testing approach and fixtures changed/removed
|
||||||
- Update Email class to support multiple recipients, CC, BCC with input sanitization and validation
|
- 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
|
||||||
- Add detailed logging, TLS upgrade handling, and error-based retry logic in EmailSendJob
|
- 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
|
||||||
- Implement a new API Manager with typed routes for sending emails, DKIM key generation, domain verification, and statistics
|
- SmartProxy route validation stricter: forward actions must use targets (array) instead of target (singular) — tests/configs updated accordingly
|
||||||
- Integrate certificate provisioning with auto-renewal and TLS options in the MTA service configuration
|
- DKIM generation/serving moved to smartmta (dcrouter no longer manages DKIM keys directly)
|
||||||
|
|
||||||
## 2024-05-11 - 1.0.10 to 1.0.8 - core
|
## 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.
|
Applied core fixes across several versions on this day.
|
||||||
|
|
||||||
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
|
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
|
||||||
@@ -94,4 +391,4 @@ Applied a core fix.
|
|||||||
- Fixed core functionality for version 1.0.1
|
- 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.
|
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,12 +1,39 @@
|
|||||||
{
|
{
|
||||||
"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",
|
"projectType": "service",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "gitlab.com",
|
"githost": "gitlab.com",
|
||||||
"gitscope": "serve.zone",
|
"gitscope": "serve.zone",
|
||||||
"gitrepo": "platformservice",
|
"gitrepo": "dcrouter",
|
||||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
"description": "A traffic router intended to be gating your datacenter.",
|
||||||
"npmPackagename": "@serve.zone/platformservice",
|
"npmPackagename": "@serve.zone/dcrouter",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "serve.zone",
|
"projectDomain": "serve.zone",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -17,8 +44,7 @@
|
|||||||
"SMTP server",
|
"SMTP server",
|
||||||
"mail parsing",
|
"mail parsing",
|
||||||
"DKIM",
|
"DKIM",
|
||||||
"platform service",
|
"traffic router",
|
||||||
"mailgun integration",
|
|
||||||
"letterXpress",
|
"letterXpress",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"Anthropic AI",
|
"Anthropic AI",
|
||||||
@@ -31,12 +57,19 @@
|
|||||||
"SMTP STARTTLS",
|
"SMTP STARTTLS",
|
||||||
"DNS management"
|
"DNS management"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": [],
|
||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
"dockerBuildargEnvMap": {
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||||
|
|||||||
113
package.json
113
package.json
@@ -1,52 +1,64 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/platformservice",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": true,
|
"private": false,
|
||||||
"version": "2.3.0",
|
"version": "5.1.0",
|
||||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"main": "dist_ts/index.js",
|
|
||||||
"typings": "dist_ts/index.d.ts",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist_ts/index.js",
|
||||||
|
"./interfaces": "./dist_ts_interfaces/index.js"
|
||||||
|
},
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/)",
|
"test": "(tstest test/ --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
"localPublish": ""
|
"bundle": "(tsbundle)",
|
||||||
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.1.17",
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
"@git.zone/tsrun": "^1.2.8",
|
"@git.zone/tsbundle": "^2.8.3",
|
||||||
"@git.zone/tstest": "^1.0.88",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tswatch": "^2.0.1",
|
"@git.zone/tstest": "^3.1.8",
|
||||||
"@push.rocks/tapbundle": "^5.0.22"
|
"@git.zone/tswatch": "^3.1.0",
|
||||||
|
"@types/node": "^25.2.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.0.19",
|
"@api.global/typedrequest": "^3.2.6",
|
||||||
"@api.global/typedserver": "^3.0.27",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedsocket": "^3.0.0",
|
"@api.global/typedserver": "^8.3.0",
|
||||||
"@apiclient.xyz/cloudflare": "^6.0.3",
|
"@api.global/typedsocket": "^4.1.0",
|
||||||
"@apiclient.xyz/letterxpress": "^1.0.20",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@design.estate/dees-catalog": "^3.42.0",
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@design.estate/dees-element": "^2.1.6",
|
||||||
"@push.rocks/smartdata": "^5.0.7",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/smartfile": "^11.0.4",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartlog": "^3.0.3",
|
"@push.rocks/smartacme": "^8.0.0",
|
||||||
"@push.rocks/smartmail": "^1.0.24",
|
"@push.rocks/smartdata": "^7.0.15",
|
||||||
"@push.rocks/smartpath": "^5.0.5",
|
"@push.rocks/smartdns": "^7.8.1",
|
||||||
"@push.rocks/smartpromise": "^4.0.3",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartproxy": "^4.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartrequest": "^2.0.21",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartrule": "^2.0.1",
|
"@push.rocks/smartlog": "^3.1.10",
|
||||||
"@push.rocks/smartrx": "^3.0.7",
|
"@push.rocks/smartmetrics": "^2.0.10",
|
||||||
"@push.rocks/smartstate": "^2.0.0",
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@serve.zone/interfaces": "^4.12.1",
|
"@push.rocks/smartmta": "^5.2.2",
|
||||||
"@tsclass/tsclass": "^5.0.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@types/mailparser": "^3.4.5",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"mailauth": "^4.6.5",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"mailparser": "^3.6.9",
|
"@push.rocks/smartproxy": "^23.1.5",
|
||||||
"uuid": "^11.1.0"
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
|
"@push.rocks/smartstate": "^2.0.30",
|
||||||
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
|
"lru-cache": "^11.2.6",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mail service",
|
"mail service",
|
||||||
@@ -56,8 +68,7 @@
|
|||||||
"SMTP server",
|
"SMTP server",
|
||||||
"mail parsing",
|
"mail parsing",
|
||||||
"DKIM",
|
"DKIM",
|
||||||
"platform service",
|
"mail router",
|
||||||
"mailgun integration",
|
|
||||||
"letterXpress",
|
"letterXpress",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"Anthropic AI",
|
"Anthropic AI",
|
||||||
@@ -68,7 +79,12 @@
|
|||||||
"email templating",
|
"email templating",
|
||||||
"rule management",
|
"rule management",
|
||||||
"SMTP STARTTLS",
|
"SMTP STARTTLS",
|
||||||
"DNS management"
|
"DNS management",
|
||||||
|
"RADIUS",
|
||||||
|
"AAA",
|
||||||
|
"network authentication",
|
||||||
|
"VLAN assignment",
|
||||||
|
"MAC authentication"
|
||||||
],
|
],
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
@@ -76,5 +92,18 @@
|
|||||||
"mongodb-memory-server",
|
"mongodb-memory-server",
|
||||||
"puppeteer"
|
"puppeteer"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.11.0",
|
||||||
|
"files": [
|
||||||
|
"ts/**/*",
|
||||||
|
"ts_web/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"dist_*/**/*",
|
||||||
|
"dist_ts/**/*",
|
||||||
|
"dist_ts_web/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"cli.js",
|
||||||
|
"npmextra.json",
|
||||||
|
"readme.md"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
9963
pnpm-lock.yaml
generated
9963
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
773
readme.hints.md
773
readme.hints.md
@@ -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();
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
|
|
||||||
tap.test('should create a platform service', async () => {});
|
|
||||||
|
|
||||||
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.');
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/platformservice',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '2.3.0',
|
version: '5.1.0',
|
||||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export class AIBridge {
|
|
||||||
|
|
||||||
}
|
|
||||||
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';
|
||||||
1380
ts/classes.dcrouter.ts
Normal file
1380
ts/classes.dcrouter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import { SzPlatformService } from './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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,87 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './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);
|
|
||||||
|
|
||||||
// Register API endpoints
|
|
||||||
this.registerApiEndpoints();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register API endpoints for email functionality
|
|
||||||
*/
|
|
||||||
private registerApiEndpoints() {
|
|
||||||
// Register the SendEmail endpoint
|
|
||||||
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'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email through the service which will route to the appropriate connector
|
|
||||||
const emailId = await this.emailRef.sendEmail(mailToSend, requestData.to, {});
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`sent an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
|
||||||
{
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
email: {
|
|
||||||
to: requestData.to,
|
|
||||||
subject: mailToSend.getSubject(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseId: emailId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add endpoint to check email status
|
|
||||||
this.typedRouter.addTypedHandler<{ emailId: string }>(
|
|
||||||
new plugins.typedrequest.TypedHandler('checkEmailStatus', async (requestData) => {
|
|
||||||
// If MTA is enabled, use it to check status
|
|
||||||
if (this.emailRef.mtaConnector) {
|
|
||||||
const status = await this.emailRef.mtaConnector.checkEmailStatus(requestData.emailId);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Mailgun, we don't have a status check implementation currently
|
|
||||||
return {
|
|
||||||
status: 'unknown',
|
|
||||||
details: { message: 'Status tracking not available for current provider' }
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add statistics endpoint
|
|
||||||
this.typedRouter.addTypedHandler<void>(
|
|
||||||
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
|
|
||||||
return this.emailRef.getStats();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
// Import MTA classes
|
|
||||||
import {
|
|
||||||
MtaService,
|
|
||||||
Email as MtaEmail,
|
|
||||||
type IEmailOptions,
|
|
||||||
DeliveryStatus,
|
|
||||||
type IAttachment
|
|
||||||
} from '../mta/index.js';
|
|
||||||
|
|
||||||
export class MtaConnector {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
private mtaService: MtaService;
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService, mtaService?: MtaService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
this.mtaService = mtaService || this.emailRef.mtaService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using the MTA service
|
|
||||||
* @param smartmail The email to send
|
|
||||||
* @param toAddresses Recipients (comma-separated or array)
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendEmail(
|
|
||||||
smartmail: plugins.smartmail.Smartmail<>,
|
|
||||||
toAddresses: string | string[],
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// Process recipients
|
|
||||||
const toArray = Array.isArray(toAddresses)
|
|
||||||
? toAddresses
|
|
||||||
: toAddresses.split(',').map(addr => addr.trim());
|
|
||||||
|
|
||||||
// Map SmartMail attachments to MTA attachments
|
|
||||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
|
||||||
return {
|
|
||||||
filename: attachment.parsedPath.base,
|
|
||||||
content: Buffer.from(attachment.contentBuffer),
|
|
||||||
contentType: (attachment as any)?.getContentType?.() || 'application/octet-stream' // TODO: revisit after smartfile has been updated
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create MTA Email
|
|
||||||
const mtaEmail = new MtaEmail({
|
|
||||||
from: smartmail.options.from,
|
|
||||||
to: toArray,
|
|
||||||
subject: smartmail.getSubject(),
|
|
||||||
text: smartmail.getBody(false), // Plain text version
|
|
||||||
html: smartmail.getBody(true), // HTML version
|
|
||||||
attachments
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send using MTA
|
|
||||||
const emailId = await this.mtaService.send(mtaEmail);
|
|
||||||
|
|
||||||
logger.log('info', `Email sent via MTA to ${toAddresses}`, {
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
to: toAddresses
|
|
||||||
});
|
|
||||||
|
|
||||||
return emailId;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve and process an incoming email
|
|
||||||
* For MTA, this would handle an email already received by the SMTP server
|
|
||||||
* @param emailData The raw email data or identifier
|
|
||||||
*/
|
|
||||||
public async receiveEmail(emailData: string): Promise<plugins.smartmail.Smartmail<>> {
|
|
||||||
try {
|
|
||||||
// In a real implementation, this would retrieve an email from the MTA storage
|
|
||||||
// For now, we can use a simplified approach:
|
|
||||||
|
|
||||||
// Parse the email (assuming emailData is a raw email or a file path)
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
|
||||||
|
|
||||||
// Create a Smartmail from the parsed email
|
|
||||||
const smartmail = new plugins.smartmail.Smartmail({
|
|
||||||
from: parsedEmail.from?.text || '',
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
body: parsedEmail.html || parsedEmail.text || '',
|
|
||||||
creationObjectRef: {
|
|
||||||
From: parsedEmail.from?.text || '',
|
|
||||||
To: parsedEmail.to?.text || '',
|
|
||||||
Subject: parsedEmail.subject || ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add attachments if present
|
|
||||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
|
||||||
for (const attachment of parsedEmail.attachments) {
|
|
||||||
smartmail.addAttachment(
|
|
||||||
await plugins.smartfile.SmartFile.fromBuffer(
|
|
||||||
attachment.filename || 'attachment',
|
|
||||||
attachment.content
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return smartmail;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to receive email via MTA: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the status of a sent email
|
|
||||||
* @param emailId The email ID to check
|
|
||||||
*/
|
|
||||||
public async checkEmailStatus(emailId: string): Promise<{
|
|
||||||
status: string;
|
|
||||||
details?: any;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const status = this.mtaService.getEmailStatus(emailId);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return {
|
|
||||||
status: 'unknown',
|
|
||||||
details: { message: 'Email not found' }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: status.status,
|
|
||||||
details: {
|
|
||||||
attempts: status.attempts,
|
|
||||||
lastAttempt: status.lastAttempt,
|
|
||||||
nextAttempt: status.nextAttempt,
|
|
||||||
error: status.error?.message
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to check email status: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
details: { message: error.message }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { MtaConnector } from './classes.connector.mta.js';
|
|
||||||
import { RuleManager } from './classes.rulemanager.js';
|
|
||||||
import { ApiManager } from './classes.apimanager.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
import type { SzPlatformService } from '../platformservice.js';
|
|
||||||
|
|
||||||
// Import MTA service
|
|
||||||
import { MtaService, type IMtaConfig } from '../mta/index.js';
|
|
||||||
|
|
||||||
export interface IEmailConstructorOptions {
|
|
||||||
useMta?: boolean;
|
|
||||||
mtaConfig?: IMtaConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email service with support for both Mailgun and local MTA
|
|
||||||
*/
|
|
||||||
export class EmailService {
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
|
|
||||||
// typedrouter
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
// connectors
|
|
||||||
public mtaConnector: MtaConnector;
|
|
||||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
||||||
|
|
||||||
// MTA service
|
|
||||||
public mtaService: MtaService;
|
|
||||||
|
|
||||||
// services
|
|
||||||
public apiManager: ApiManager;
|
|
||||||
public ruleManager: RuleManager;
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
private config: IEmailConstructorOptions;
|
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, options: IEmailConstructorOptions = {}) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
this.config = {
|
|
||||||
useMta: options.useMta ?? true,
|
|
||||||
mtaConfig: options.mtaConfig || {}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.useMta) {
|
|
||||||
// Initialize MTA service
|
|
||||||
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
|
|
||||||
// Initialize MTA connector
|
|
||||||
this.mtaConnector = new MtaConnector(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize API manager and rule manager
|
|
||||||
this.apiManager = new ApiManager(this);
|
|
||||||
this.ruleManager = new RuleManager(this);
|
|
||||||
|
|
||||||
// Set up MTA SMTP server webhook if using MTA
|
|
||||||
if (this.config.useMta) {
|
|
||||||
// The MTA SMTP server will handle incoming emails directly
|
|
||||||
// through its SMTP protocol. No additional webhook needed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the email service
|
|
||||||
*/
|
|
||||||
public async start() {
|
|
||||||
// Initialize rule manager
|
|
||||||
await this.ruleManager.init();
|
|
||||||
|
|
||||||
// Start MTA service if enabled
|
|
||||||
if (this.config.useMta && this.mtaService) {
|
|
||||||
await this.mtaService.start();
|
|
||||||
logger.log('success', 'Started MTA service');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('success', `Started email service`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the email service
|
|
||||||
*/
|
|
||||||
public async stop() {
|
|
||||||
// Stop MTA service if it's running
|
|
||||||
if (this.config.useMta && this.mtaService) {
|
|
||||||
await this.mtaService.stop();
|
|
||||||
logger.log('info', 'Stopped MTA service');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', 'Stopped email service');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using the configured provider (Mailgun or MTA)
|
|
||||||
* @param email The email to send
|
|
||||||
* @param to Recipient(s)
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendEmail(
|
|
||||||
email: plugins.smartmail.Smartmail<>,
|
|
||||||
to: string | string[],
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
// Determine which connector to use
|
|
||||||
if (this.config.useMta && this.mtaConnector) {
|
|
||||||
return this.mtaConnector.sendEmail(email, to, options);
|
|
||||||
} else {
|
|
||||||
throw new Error('No email provider configured');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get email service statistics
|
|
||||||
*/
|
|
||||||
public getStats() {
|
|
||||||
const stats: any = {
|
|
||||||
activeProviders: []
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.useMta) {
|
|
||||||
stats.activeProviders.push('mta');
|
|
||||||
stats.mta = this.mtaService.getStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
export class RuleManager {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
|
||||||
plugins.smartmail.Smartmail<any>
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
|
|
||||||
// Register MTA handler for incoming emails if MTA is enabled
|
|
||||||
if (this.emailRef.mtaService) {
|
|
||||||
this.setupMtaIncomingHandler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up handler for incoming emails via MTA's SMTP server
|
|
||||||
*/
|
|
||||||
private setupMtaIncomingHandler() {
|
|
||||||
// The original MtaService doesn't have a direct callback for incoming emails,
|
|
||||||
// but we can modify this approach based on how you prefer to integrate.
|
|
||||||
// One option would be to extend the MtaService to add an event emitter.
|
|
||||||
|
|
||||||
// For now, we'll use a directory watcher as an example
|
|
||||||
// This would watch the directory where MTA saves incoming emails
|
|
||||||
const incomingDir = this.emailRef.mtaService['receivedEmailsDir'] || './received';
|
|
||||||
|
|
||||||
// Simple file watcher (in real implementation, use proper file watching)
|
|
||||||
// This is just conceptual - would need modification to work with your specific setup
|
|
||||||
this.watchIncomingEmails(incomingDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch directory for incoming emails (conceptual implementation)
|
|
||||||
*/
|
|
||||||
private watchIncomingEmails(directory: string) {
|
|
||||||
console.log(`Watching for incoming emails in: ${directory}`);
|
|
||||||
|
|
||||||
// Conceptual - in a real implementation, set up proper file watching
|
|
||||||
// or modify the MTA to emit events when emails are received
|
|
||||||
|
|
||||||
/*
|
|
||||||
// Example using a file watcher:
|
|
||||||
const watcher = plugins.fs.watch(directory, async (eventType, filename) => {
|
|
||||||
if (eventType === 'rename' && filename.endsWith('.eml')) {
|
|
||||||
const filePath = plugins.path.join(directory, filename);
|
|
||||||
await this.handleMtaIncomingEmail(filePath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming email received via MTA
|
|
||||||
*/
|
|
||||||
public async handleMtaIncomingEmail(emailPath: string) {
|
|
||||||
try {
|
|
||||||
// Process the email file
|
|
||||||
const fetchedSmartmail = await this.emailRef.mtaConnector.receiveEmail(emailPath);
|
|
||||||
|
|
||||||
console.log('=======================');
|
|
||||||
console.log('Received a mail via MTA:');
|
|
||||||
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',
|
|
||||||
provider: 'mta',
|
|
||||||
email: {
|
|
||||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
|
||||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
|
||||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process with rules
|
|
||||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to process incoming MTA email: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
// Setup email rules
|
|
||||||
await this.createForwards();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* creates the default forwards
|
|
||||||
*/
|
|
||||||
public async createForwards() {
|
|
||||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [];
|
|
||||||
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<any>) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the EmailService's sendEmail method to send with the appropriate provider
|
|
||||||
await this.emailRef.sendEmail(forwardedSmartMail, toArg);
|
|
||||||
|
|
||||||
console.log(`forwarded mail to ${toArg}`);
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`email from ${
|
|
||||||
smartmailArg.options.creationObjectRef.From
|
|
||||||
} to ${toArg} 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 '../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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
ts/index.ts
13
ts/index.ts
@@ -1,4 +1,13 @@
|
|||||||
export * from './00_commitinfo_data.js';
|
export * from './00_commitinfo_data.js';
|
||||||
import { SzPlatformService } from './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';
|
||||||
|
|
||||||
|
export const runCli = async () => {};
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { SzPlatformService } from '../platformservice.js';
|
|
||||||
import * as plugins from '../plugins.js';
|
|
||||||
|
|
||||||
export interface ILetterConstructorOptions {
|
|
||||||
letterxpressUser: string;
|
|
||||||
letterxpressToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LetterService {
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
public options: ILetterConstructorOptions;
|
|
||||||
public letterxpressAccount: plugins.letterxpress.LetterXpressAccount;
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ILetterConstructorOptions) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
this.options = optionsArg;
|
|
||||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
|
|
||||||
this.typedrouter.addTypedHandler<
|
|
||||||
plugins.servezoneInterfaces.platformservice.letter.IRequest_SendLetter
|
|
||||||
>(new plugins.typedrequest.TypedHandler('sendLetter', async dataArg => {
|
|
||||||
if(dataArg.needsCover) {
|
|
||||||
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
processId: '',
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
this.letterxpressAccount = new plugins.letterxpress.LetterXpressAccount({
|
|
||||||
username: this.options.letterxpressUser,
|
|
||||||
apiKey: this.options.letterxpressToken,
|
|
||||||
});
|
|
||||||
await this.letterxpressAccount.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './classes.letterservice.js';
|
|
||||||
86
ts/logger.ts
86
ts/logger.ts
@@ -1,9 +1,91 @@
|
|||||||
import * as plugins from './plugins.js';
|
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: {
|
logContext: {
|
||||||
environment: 'production',
|
environment: envMap[nodeEnv] || 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
zone: 'serve.zone',
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
522
ts/monitoring/classes.metricsmanager.ts
Normal file
522
ts/monitoring/classes.metricsmanager.ts
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
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()
|
||||||
|
} : { bytesIn: 0, bytesOut: 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: [],
|
||||||
|
totalDataTransferred: { bytesIn: 0, bytesOut: 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()
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionsByIP,
|
||||||
|
throughputRate,
|
||||||
|
topIPs,
|
||||||
|
totalDataTransferred,
|
||||||
|
};
|
||||||
|
}, 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,956 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Email } from './classes.email.js';
|
|
||||||
import type { IEmailOptions } from './classes.email.js';
|
|
||||||
import { DeliveryStatus } from './classes.emailsendjob.js';
|
|
||||||
import type { MtaService } from './classes.mta.js';
|
|
||||||
import type { IDnsRecord } from './classes.dnsmanager.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication options for API requests
|
|
||||||
*/
|
|
||||||
interface AuthOptions {
|
|
||||||
/** Required API keys for different endpoints */
|
|
||||||
apiKeys: Map<string, string[]>;
|
|
||||||
/** JWT secret for token-based authentication */
|
|
||||||
jwtSecret?: string;
|
|
||||||
/** Whether to validate IP addresses */
|
|
||||||
validateIp?: boolean;
|
|
||||||
/** Allowed IP addresses */
|
|
||||||
allowedIps?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting options for API endpoints
|
|
||||||
*/
|
|
||||||
interface RateLimitOptions {
|
|
||||||
/** Maximum requests per window */
|
|
||||||
maxRequests: number;
|
|
||||||
/** Time window in milliseconds */
|
|
||||||
windowMs: number;
|
|
||||||
/** Whether to apply per endpoint */
|
|
||||||
perEndpoint?: boolean;
|
|
||||||
/** Whether to apply per IP */
|
|
||||||
perIp?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API route definition
|
|
||||||
*/
|
|
||||||
interface ApiRoute {
|
|
||||||
/** HTTP method */
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
||||||
/** Path pattern */
|
|
||||||
path: string;
|
|
||||||
/** Handler function */
|
|
||||||
handler: (req: any, res: any) => Promise<any>;
|
|
||||||
/** Required authentication level */
|
|
||||||
authLevel: 'none' | 'basic' | 'admin';
|
|
||||||
/** Rate limiting options */
|
|
||||||
rateLimit?: RateLimitOptions;
|
|
||||||
/** Route description */
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email send request
|
|
||||||
*/
|
|
||||||
interface SendEmailRequest {
|
|
||||||
/** Email details */
|
|
||||||
email: IEmailOptions;
|
|
||||||
/** Whether to validate domains before sending */
|
|
||||||
validateDomains?: boolean;
|
|
||||||
/** Priority level (1-5, 1 = highest) */
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email status response
|
|
||||||
*/
|
|
||||||
interface EmailStatusResponse {
|
|
||||||
/** Email ID */
|
|
||||||
id: string;
|
|
||||||
/** Current status */
|
|
||||||
status: DeliveryStatus;
|
|
||||||
/** Send time */
|
|
||||||
sentAt?: Date;
|
|
||||||
/** Delivery time */
|
|
||||||
deliveredAt?: Date;
|
|
||||||
/** Error message if failed */
|
|
||||||
error?: string;
|
|
||||||
/** Recipient address */
|
|
||||||
recipient: string;
|
|
||||||
/** Number of delivery attempts */
|
|
||||||
attempts: number;
|
|
||||||
/** Next retry time */
|
|
||||||
nextRetry?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain verification response
|
|
||||||
*/
|
|
||||||
interface DomainVerificationResponse {
|
|
||||||
/** Domain name */
|
|
||||||
domain: string;
|
|
||||||
/** Whether the domain is verified */
|
|
||||||
verified: boolean;
|
|
||||||
/** Verification details */
|
|
||||||
details: {
|
|
||||||
/** SPF record status */
|
|
||||||
spf: {
|
|
||||||
valid: boolean;
|
|
||||||
record?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
/** DKIM record status */
|
|
||||||
dkim: {
|
|
||||||
valid: boolean;
|
|
||||||
record?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
/** DMARC record status */
|
|
||||||
dmarc: {
|
|
||||||
valid: boolean;
|
|
||||||
record?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
/** MX record status */
|
|
||||||
mx: {
|
|
||||||
valid: boolean;
|
|
||||||
records?: string[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API error response
|
|
||||||
*/
|
|
||||||
interface ApiError {
|
|
||||||
/** Error code */
|
|
||||||
code: string;
|
|
||||||
/** Error message */
|
|
||||||
message: string;
|
|
||||||
/** Detailed error information */
|
|
||||||
details?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple HTTP Response helper
|
|
||||||
*/
|
|
||||||
class HttpResponse {
|
|
||||||
private headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
public statusCode: number = 200;
|
|
||||||
|
|
||||||
constructor(private res: any) {}
|
|
||||||
|
|
||||||
header(name: string, value: string): HttpResponse {
|
|
||||||
this.headers[name] = value;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
status(code: number): HttpResponse {
|
|
||||||
this.statusCode = code;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
json(data: any): void {
|
|
||||||
this.res.writeHead(this.statusCode, this.headers);
|
|
||||||
this.res.end(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
end(): void {
|
|
||||||
this.res.writeHead(this.statusCode, this.headers);
|
|
||||||
this.res.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Manager for MTA service
|
|
||||||
*/
|
|
||||||
export class ApiManager {
|
|
||||||
/** TypedRouter for API routing */
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
/** MTA service reference */
|
|
||||||
private mtaRef: MtaService;
|
|
||||||
/** HTTP server */
|
|
||||||
private server: any;
|
|
||||||
/** Authentication options */
|
|
||||||
private authOptions: AuthOptions;
|
|
||||||
/** API routes */
|
|
||||||
private routes: ApiRoute[] = [];
|
|
||||||
/** Rate limiters */
|
|
||||||
private rateLimiters: Map<string, {
|
|
||||||
count: number;
|
|
||||||
resetTime: number;
|
|
||||||
clients: Map<string, {
|
|
||||||
count: number;
|
|
||||||
resetTime: number;
|
|
||||||
}>;
|
|
||||||
}> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize API Manager
|
|
||||||
* @param mtaRef MTA service reference
|
|
||||||
*/
|
|
||||||
constructor(mtaRef?: MtaService) {
|
|
||||||
this.mtaRef = mtaRef;
|
|
||||||
|
|
||||||
// Default authentication options
|
|
||||||
this.authOptions = {
|
|
||||||
apiKeys: new Map(),
|
|
||||||
validateIp: false,
|
|
||||||
allowedIps: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register routes
|
|
||||||
this.registerRoutes();
|
|
||||||
|
|
||||||
// Create HTTP server with request handler
|
|
||||||
this.server = plugins.http.createServer(this.handleRequest.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set MTA service reference
|
|
||||||
* @param mtaRef MTA service reference
|
|
||||||
*/
|
|
||||||
public setMtaService(mtaRef: MtaService): void {
|
|
||||||
this.mtaRef = mtaRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure authentication options
|
|
||||||
* @param options Authentication options
|
|
||||||
*/
|
|
||||||
public configureAuth(options: Partial<AuthOptions>): void {
|
|
||||||
this.authOptions = {
|
|
||||||
...this.authOptions,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle HTTP request
|
|
||||||
*/
|
|
||||||
private async handleRequest(req: any, res: any): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
// Create a response helper
|
|
||||||
const response = new HttpResponse(res);
|
|
||||||
|
|
||||||
// Add CORS headers
|
|
||||||
response.header('Access-Control-Allow-Origin', '*');
|
|
||||||
response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
|
|
||||||
|
|
||||||
// Handle preflight OPTIONS request
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return response.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse URL to get path and query
|
|
||||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
||||||
const path = url.pathname;
|
|
||||||
|
|
||||||
// Collect request body if POST or PUT
|
|
||||||
let body = '';
|
|
||||||
if (req.method === 'POST' || req.method === 'PUT') {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
req.on('data', (chunk: Buffer) => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('end', () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (err: Error) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse body as JSON if Content-Type is application/json
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
|
||||||
if (contentType.includes('application/json')) {
|
|
||||||
try {
|
|
||||||
req.body = JSON.parse(body);
|
|
||||||
} catch (error) {
|
|
||||||
return response.status(400).json({
|
|
||||||
code: 'INVALID_JSON',
|
|
||||||
message: 'Invalid JSON in request body'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
req.body = body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authentication level to request
|
|
||||||
req.authLevel = 'none';
|
|
||||||
|
|
||||||
// Check API key
|
|
||||||
const apiKey = req.headers['x-api-key'];
|
|
||||||
if (apiKey) {
|
|
||||||
for (const [level, keys] of this.authOptions.apiKeys.entries()) {
|
|
||||||
if (keys.includes(apiKey)) {
|
|
||||||
req.authLevel = level;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check JWT token (if configured)
|
|
||||||
if (this.authOptions.jwtSecret && req.headers.authorization) {
|
|
||||||
try {
|
|
||||||
const token = req.headers.authorization.split(' ')[1];
|
|
||||||
// Note: We would need to add JWT verification
|
|
||||||
// Using a simple placeholder for now
|
|
||||||
const decoded = { level: 'none' }; // Simplified - would use actual JWT library
|
|
||||||
|
|
||||||
if (decoded && decoded.level) {
|
|
||||||
req.authLevel = decoded.level;
|
|
||||||
req.user = decoded;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Invalid token, but don't fail the request yet
|
|
||||||
console.error('Invalid JWT token:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check IP address (if configured)
|
|
||||||
if (this.authOptions.validateIp) {
|
|
||||||
const clientIp = req.socket.remoteAddress;
|
|
||||||
if (!this.authOptions.allowedIps.includes(clientIp)) {
|
|
||||||
return response.status(403).json({
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
message: 'IP address not allowed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching route
|
|
||||||
const route = this.findRoute(req.method, path);
|
|
||||||
|
|
||||||
if (!route) {
|
|
||||||
return response.status(404).json({
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
message: 'Endpoint not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
if (route.authLevel !== 'none' && req.authLevel !== route.authLevel && req.authLevel !== 'admin') {
|
|
||||||
return response.status(403).json({
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
message: `This endpoint requires ${route.authLevel} access`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check rate limit
|
|
||||||
if (route.rateLimit) {
|
|
||||||
const exceeded = this.checkRateLimit(route, req);
|
|
||||||
if (exceeded) {
|
|
||||||
return response.status(429).json({
|
|
||||||
code: 'RATE_LIMIT_EXCEEDED',
|
|
||||||
message: 'Rate limit exceeded, please try again later'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract path parameters
|
|
||||||
const pathParams = this.extractPathParams(route.path, path);
|
|
||||||
req.params = pathParams;
|
|
||||||
|
|
||||||
// Extract query parameters
|
|
||||||
req.query = {};
|
|
||||||
for (const [key, value] of url.searchParams.entries()) {
|
|
||||||
req.query[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the request
|
|
||||||
await route.handler(req, response);
|
|
||||||
|
|
||||||
// Log request
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
console.log(`[API] ${req.method} ${path} ${response.statusCode} ${duration}ms`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error handling request:`, error);
|
|
||||||
|
|
||||||
// Send appropriate error response
|
|
||||||
const status = error.status || 500;
|
|
||||||
const apiError: ApiError = {
|
|
||||||
code: error.code || 'INTERNAL_ERROR',
|
|
||||||
message: error.message || 'Internal server error'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
apiError.details = error.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.status(status).json(apiError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a route matching the method and path
|
|
||||||
*/
|
|
||||||
private findRoute(method: string, path: string): ApiRoute | null {
|
|
||||||
for (const route of this.routes) {
|
|
||||||
if (route.method === method && this.pathMatches(route.path, path)) {
|
|
||||||
return route;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a path matches a route pattern
|
|
||||||
*/
|
|
||||||
private pathMatches(pattern: string, path: string): boolean {
|
|
||||||
// Convert route pattern to regex
|
|
||||||
const patternParts = pattern.split('/');
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
if (patternParts.length !== pathParts.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
|
||||||
if (patternParts[i].startsWith(':')) {
|
|
||||||
// Parameter - always matches
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patternParts[i] !== pathParts[i]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract path parameters from URL
|
|
||||||
*/
|
|
||||||
private extractPathParams(pattern: string, path: string): Record<string, string> {
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
const patternParts = pattern.split('/');
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
|
||||||
if (patternParts[i].startsWith(':')) {
|
|
||||||
const paramName = patternParts[i].substring(1);
|
|
||||||
params[paramName] = pathParts[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register API routes
|
|
||||||
*/
|
|
||||||
private registerRoutes(): void {
|
|
||||||
// Email routes
|
|
||||||
this.addRoute({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/email/send',
|
|
||||||
handler: this.handleSendEmail.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Send an email'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/email/status/:id',
|
|
||||||
handler: this.handleGetEmailStatus.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Get email delivery status'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Domain routes
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/domain/verify/:domain',
|
|
||||||
handler: this.handleVerifyDomain.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Verify domain DNS records'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/domain/records/:domain',
|
|
||||||
handler: this.handleGetDomainRecords.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Get recommended DNS records for domain'
|
|
||||||
});
|
|
||||||
|
|
||||||
// DKIM routes
|
|
||||||
this.addRoute({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/dkim/generate/:domain',
|
|
||||||
handler: this.handleGenerateDkim.bind(this),
|
|
||||||
authLevel: 'admin',
|
|
||||||
description: 'Generate DKIM keys for domain'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/dkim/public/:domain',
|
|
||||||
handler: this.handleGetDkimPublicKey.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Get DKIM public key for domain'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stats route
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/stats',
|
|
||||||
handler: this.handleGetStats.bind(this),
|
|
||||||
authLevel: 'admin',
|
|
||||||
description: 'Get MTA statistics'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Documentation route
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api',
|
|
||||||
handler: this.handleGetApiDocs.bind(this),
|
|
||||||
authLevel: 'none',
|
|
||||||
description: 'API documentation'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an API route
|
|
||||||
* @param route Route definition
|
|
||||||
*/
|
|
||||||
private addRoute(route: ApiRoute): void {
|
|
||||||
this.routes.push(route);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check rate limit for a route
|
|
||||||
* @param route Route definition
|
|
||||||
* @param req Express request
|
|
||||||
* @returns Whether rate limit is exceeded
|
|
||||||
*/
|
|
||||||
private checkRateLimit(route: ApiRoute, req: any): boolean {
|
|
||||||
if (!route.rateLimit) return false;
|
|
||||||
|
|
||||||
const { maxRequests, windowMs, perEndpoint, perIp } = route.rateLimit;
|
|
||||||
|
|
||||||
// Determine rate limit key
|
|
||||||
let key = 'global';
|
|
||||||
if (perEndpoint) {
|
|
||||||
key = `${route.method}:${route.path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create limiter
|
|
||||||
if (!this.rateLimiters.has(key)) {
|
|
||||||
this.rateLimiters.set(key, {
|
|
||||||
count: 0,
|
|
||||||
resetTime: Date.now() + windowMs,
|
|
||||||
clients: new Map()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const limiter = this.rateLimiters.get(key);
|
|
||||||
|
|
||||||
// Reset if window has passed
|
|
||||||
if (Date.now() > limiter.resetTime) {
|
|
||||||
limiter.count = 0;
|
|
||||||
limiter.resetTime = Date.now() + windowMs;
|
|
||||||
limiter.clients.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-IP limit if enabled
|
|
||||||
if (perIp) {
|
|
||||||
const clientIp = req.socket.remoteAddress;
|
|
||||||
let clientLimiter = limiter.clients.get(clientIp);
|
|
||||||
|
|
||||||
if (!clientLimiter) {
|
|
||||||
clientLimiter = {
|
|
||||||
count: 0,
|
|
||||||
resetTime: Date.now() + windowMs
|
|
||||||
};
|
|
||||||
limiter.clients.set(clientIp, clientLimiter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset client limiter if needed
|
|
||||||
if (Date.now() > clientLimiter.resetTime) {
|
|
||||||
clientLimiter.count = 0;
|
|
||||||
clientLimiter.resetTime = Date.now() + windowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check client limit
|
|
||||||
if (clientLimiter.count >= maxRequests) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment client count
|
|
||||||
clientLimiter.count++;
|
|
||||||
} else {
|
|
||||||
// Check global limit
|
|
||||||
if (limiter.count >= maxRequests) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment global count
|
|
||||||
limiter.count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an API error
|
|
||||||
* @param code Error code
|
|
||||||
* @param message Error message
|
|
||||||
* @param status HTTP status code
|
|
||||||
* @param details Additional details
|
|
||||||
* @returns API error
|
|
||||||
*/
|
|
||||||
private createError(code: string, message: string, status = 400, details?: any): Error & { code: string; status: number; details?: any } {
|
|
||||||
const error = new Error(message) as Error & { code: string; status: number; details?: any };
|
|
||||||
error.code = code;
|
|
||||||
error.status = status;
|
|
||||||
if (details) {
|
|
||||||
error.details = details;
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that MTA service is available
|
|
||||||
*/
|
|
||||||
private validateMtaService(): void {
|
|
||||||
if (!this.mtaRef) {
|
|
||||||
throw this.createError('SERVICE_UNAVAILABLE', 'MTA service is not available', 503);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle email send request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleSendEmail(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const data = req.body as SendEmailRequest;
|
|
||||||
|
|
||||||
if (!data || !data.email) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing email data');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create Email instance
|
|
||||||
const email = new Email(data.email);
|
|
||||||
|
|
||||||
// Validate domains if requested
|
|
||||||
if (data.validateDomains) {
|
|
||||||
const fromDomain = email.getFromDomain();
|
|
||||||
if (fromDomain) {
|
|
||||||
const verification = await this.mtaRef.dnsManager.verifyEmailAuthRecords(fromDomain);
|
|
||||||
|
|
||||||
// Check if SPF and DKIM are valid
|
|
||||||
if (!verification.spf.valid || !verification.dkim.valid) {
|
|
||||||
throw this.createError('DOMAIN_VERIFICATION_FAILED', 'Domain DNS verification failed', 400, {
|
|
||||||
verification
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
const id = await this.mtaRef.send(email);
|
|
||||||
|
|
||||||
// Return success response
|
|
||||||
res.json({
|
|
||||||
id,
|
|
||||||
message: 'Email queued successfully',
|
|
||||||
status: 'pending'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Handle Email constructor errors
|
|
||||||
if (error.message.includes('Invalid') || error.message.includes('must have')) {
|
|
||||||
throw this.createError('INVALID_EMAIL', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle email status request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetEmailStatus(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const id = req.params.id;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing email ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get email status
|
|
||||||
const status = this.mtaRef.getEmailStatus(id);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
throw this.createError('NOT_FOUND', `Email with ID ${id} not found`, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create response
|
|
||||||
const response: EmailStatusResponse = {
|
|
||||||
id: status.id,
|
|
||||||
status: status.status,
|
|
||||||
sentAt: status.addedAt,
|
|
||||||
recipient: status.email.to[0],
|
|
||||||
attempts: status.attempts
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add additional fields if available
|
|
||||||
if (status.lastAttempt) {
|
|
||||||
response.sentAt = status.lastAttempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.status === DeliveryStatus.DELIVERED) {
|
|
||||||
response.deliveredAt = status.lastAttempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.error) {
|
|
||||||
response.error = status.error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.nextAttempt) {
|
|
||||||
response.nextRetry = status.nextAttempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle domain verification request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleVerifyDomain(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify domain DNS records
|
|
||||||
const records = await this.mtaRef.dnsManager.verifyEmailAuthRecords(domain);
|
|
||||||
|
|
||||||
// Get MX records
|
|
||||||
let mxValid = false;
|
|
||||||
let mxRecords: string[] = [];
|
|
||||||
let mxError: string = undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mxResult = await this.mtaRef.dnsManager.lookupMx(domain);
|
|
||||||
mxValid = mxResult.length > 0;
|
|
||||||
mxRecords = mxResult.map(mx => mx.exchange);
|
|
||||||
} catch (error) {
|
|
||||||
mxError = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create response
|
|
||||||
const response: DomainVerificationResponse = {
|
|
||||||
domain,
|
|
||||||
verified: records.spf.valid && records.dkim.valid && records.dmarc.valid && mxValid,
|
|
||||||
details: {
|
|
||||||
spf: {
|
|
||||||
valid: records.spf.valid,
|
|
||||||
record: records.spf.value,
|
|
||||||
error: records.spf.error
|
|
||||||
},
|
|
||||||
dkim: {
|
|
||||||
valid: records.dkim.valid,
|
|
||||||
record: records.dkim.value,
|
|
||||||
error: records.dkim.error
|
|
||||||
},
|
|
||||||
dmarc: {
|
|
||||||
valid: records.dmarc.valid,
|
|
||||||
record: records.dmarc.value,
|
|
||||||
error: records.dmarc.error
|
|
||||||
},
|
|
||||||
mx: {
|
|
||||||
valid: mxValid,
|
|
||||||
records: mxRecords,
|
|
||||||
error: mxError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('VERIFICATION_FAILED', `Domain verification failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get domain records request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetDomainRecords(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate recommended DNS records
|
|
||||||
const records = await this.mtaRef.dnsManager.generateAllRecommendedRecords(domain);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
domain,
|
|
||||||
records
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('GENERATION_FAILED', `DNS record generation failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle generate DKIM keys request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGenerateDkim(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate DKIM keys
|
|
||||||
await this.mtaRef.dkimCreator.handleDKIMKeysForDomain(domain);
|
|
||||||
|
|
||||||
// Get DNS record
|
|
||||||
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
domain,
|
|
||||||
dnsRecord,
|
|
||||||
message: 'DKIM keys generated successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('GENERATION_FAILED', `DKIM generation failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get DKIM public key request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetDkimPublicKey(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get DKIM keys
|
|
||||||
const keys = await this.mtaRef.dkimCreator.readDKIMKeys(domain);
|
|
||||||
|
|
||||||
// Get DNS record
|
|
||||||
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
domain,
|
|
||||||
publicKey: keys.publicKey,
|
|
||||||
dnsRecord
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('NOT_FOUND', `DKIM keys not found for domain: ${domain}`, 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get stats request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetStats(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
// Get MTA stats
|
|
||||||
const stats = this.mtaRef.getStats();
|
|
||||||
|
|
||||||
res.json(stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get API docs request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetApiDocs(req: any, res: any): Promise<void> {
|
|
||||||
// Generate API documentation
|
|
||||||
const docs = {
|
|
||||||
name: 'MTA API',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'API for interacting with the MTA service',
|
|
||||||
endpoints: this.routes.map(route => ({
|
|
||||||
method: route.method,
|
|
||||||
path: route.path,
|
|
||||||
description: route.description,
|
|
||||||
authLevel: route.authLevel
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(docs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the API server
|
|
||||||
* @param port Port to listen on
|
|
||||||
* @returns Promise that resolves when server is started
|
|
||||||
*/
|
|
||||||
public start(port: number = 3000): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
// Start HTTP server
|
|
||||||
this.server.listen(port, () => {
|
|
||||||
console.log(`API server listening on port ${port}`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start API server:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the API server
|
|
||||||
*/
|
|
||||||
public stop(): void {
|
|
||||||
if (this.server) {
|
|
||||||
this.server.close();
|
|
||||||
console.log('API server stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
|
|
||||||
import { Email } from './classes.email.js';
|
|
||||||
import type { MtaService } from './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(private 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 - changed to public for API access
|
|
||||||
public 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 - changed to public for API access
|
|
||||||
public 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 - changed to public for API access
|
|
||||||
public 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.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Changed to public for API access
|
|
||||||
public 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 './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,559 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for DNS record information
|
|
||||||
*/
|
|
||||||
export interface IDnsRecord {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
ttl?: number;
|
|
||||||
dnsSecEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for DNS lookup options
|
|
||||||
*/
|
|
||||||
export interface IDnsLookupOptions {
|
|
||||||
/** Cache time to live in milliseconds, 0 to disable caching */
|
|
||||||
cacheTtl?: number;
|
|
||||||
/** Timeout for DNS queries in milliseconds */
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for DNS verification result
|
|
||||||
*/
|
|
||||||
export interface IDnsVerificationResult {
|
|
||||||
record: string;
|
|
||||||
found: boolean;
|
|
||||||
valid: boolean;
|
|
||||||
value?: string;
|
|
||||||
expectedValue?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manager for DNS-related operations, including record lookups, verification, and generation
|
|
||||||
*/
|
|
||||||
export class DNSManager {
|
|
||||||
public mtaRef: MtaService;
|
|
||||||
private cache: Map<string, { data: any; expires: number }> = new Map();
|
|
||||||
private defaultOptions: IDnsLookupOptions = {
|
|
||||||
cacheTtl: 300000, // 5 minutes
|
|
||||||
timeout: 5000 // 5 seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) {
|
|
||||||
this.mtaRef = mtaRefArg;
|
|
||||||
|
|
||||||
if (options) {
|
|
||||||
this.defaultOptions = {
|
|
||||||
...this.defaultOptions,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the DNS records directory exists
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lookup MX records for a domain
|
|
||||||
* @param domain Domain to look up
|
|
||||||
* @param options Lookup options
|
|
||||||
* @returns Array of MX records sorted by priority
|
|
||||||
*/
|
|
||||||
public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
|
||||||
const cacheKey = `mx:${domain}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.getFromCache<plugins.dns.MxRecord[]>(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
|
|
||||||
|
|
||||||
// Sort by priority
|
|
||||||
records.sort((a, b) => a.priority - b.priority);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error looking up MX records for ${domain}:`, error);
|
|
||||||
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lookup TXT records for a domain
|
|
||||||
* @param domain Domain to look up
|
|
||||||
* @param options Lookup options
|
|
||||||
* @returns Array of TXT records
|
|
||||||
*/
|
|
||||||
public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> {
|
|
||||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
|
||||||
const cacheKey = `txt:${domain}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.getFromCache<string[][]>(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error looking up TXT records for ${domain}:`, error);
|
|
||||||
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find specific TXT record by subdomain and prefix
|
|
||||||
* @param domain Base domain
|
|
||||||
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
|
|
||||||
* @param prefix Record prefix to match (e.g., "v=DKIM1")
|
|
||||||
* @param options Lookup options
|
|
||||||
* @returns Matching TXT record or null if not found
|
|
||||||
*/
|
|
||||||
public async findTxtRecord(
|
|
||||||
domain: string,
|
|
||||||
subdomain: string = '',
|
|
||||||
prefix: string = '',
|
|
||||||
options?: IDnsLookupOptions
|
|
||||||
): Promise<string | null> {
|
|
||||||
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await this.lookupTxt(fullDomain, options);
|
|
||||||
|
|
||||||
for (const recordArray of records) {
|
|
||||||
// TXT records can be split into chunks, join them
|
|
||||||
const record = recordArray.join('');
|
|
||||||
|
|
||||||
if (!prefix || record.startsWith(prefix)) {
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
// Domain might not exist or no TXT records
|
|
||||||
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a domain has a valid SPF record
|
|
||||||
* @param domain Domain to verify
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
|
||||||
public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> {
|
|
||||||
const result: IDnsVerificationResult = {
|
|
||||||
record: 'SPF',
|
|
||||||
found: false,
|
|
||||||
valid: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
|
|
||||||
|
|
||||||
if (spfRecord) {
|
|
||||||
result.found = true;
|
|
||||||
result.value = spfRecord;
|
|
||||||
|
|
||||||
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
|
|
||||||
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
|
|
||||||
result.valid = isValid;
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
result.error = 'SPF record format is invalid';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = 'No SPF record found';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.error = `Error verifying SPF: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a domain has a valid DKIM record
|
|
||||||
* @param domain Domain to verify
|
|
||||||
* @param selector DKIM selector (usually "mta" in our case)
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
|
||||||
public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> {
|
|
||||||
const result: IDnsVerificationResult = {
|
|
||||||
record: 'DKIM',
|
|
||||||
found: false,
|
|
||||||
valid: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dkimSelector = `${selector}._domainkey`;
|
|
||||||
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
|
|
||||||
|
|
||||||
if (dkimRecord) {
|
|
||||||
result.found = true;
|
|
||||||
result.value = dkimRecord;
|
|
||||||
|
|
||||||
// Basic validation - check for required fields
|
|
||||||
const hasP = dkimRecord.includes('p=');
|
|
||||||
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
result.error = 'DKIM record is missing required fields';
|
|
||||||
} else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
|
|
||||||
result.valid = false;
|
|
||||||
result.error = 'DKIM record has invalid public key format';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = `No DKIM record found for selector ${selector}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.error = `Error verifying DKIM: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a domain has a valid DMARC record
|
|
||||||
* @param domain Domain to verify
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
|
||||||
public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> {
|
|
||||||
const result: IDnsVerificationResult = {
|
|
||||||
record: 'DMARC',
|
|
||||||
found: false,
|
|
||||||
valid: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dmarcDomain = `_dmarc.${domain}`;
|
|
||||||
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
|
|
||||||
|
|
||||||
if (dmarcRecord) {
|
|
||||||
result.found = true;
|
|
||||||
result.value = dmarcRecord;
|
|
||||||
|
|
||||||
// Basic validation - check for required fields
|
|
||||||
const hasPolicy = dmarcRecord.includes('p=');
|
|
||||||
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
result.error = 'DMARC record is missing required fields';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = 'No DMARC record found';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.error = `Error verifying DMARC: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
|
|
||||||
* @param domain Domain to check
|
|
||||||
* @param dkimSelector DKIM selector
|
|
||||||
* @returns Object with verification results for each record type
|
|
||||||
*/
|
|
||||||
public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{
|
|
||||||
spf: IDnsVerificationResult;
|
|
||||||
dkim: IDnsVerificationResult;
|
|
||||||
dmarc: IDnsVerificationResult;
|
|
||||||
}> {
|
|
||||||
const [spf, dkim, dmarc] = await Promise.all([
|
|
||||||
this.verifySpfRecord(domain),
|
|
||||||
this.verifyDkimRecord(domain, dkimSelector),
|
|
||||||
this.verifyDmarcRecord(domain)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { spf, dkim, dmarc };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a recommended SPF record for a domain
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param options Configuration options for the SPF record
|
|
||||||
* @returns Generated SPF record
|
|
||||||
*/
|
|
||||||
public generateSpfRecord(domain: string, options: {
|
|
||||||
includeMx?: boolean;
|
|
||||||
includeA?: boolean;
|
|
||||||
includeIps?: string[];
|
|
||||||
includeSpf?: string[];
|
|
||||||
policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject';
|
|
||||||
} = {}): IDnsRecord {
|
|
||||||
const {
|
|
||||||
includeMx = true,
|
|
||||||
includeA = true,
|
|
||||||
includeIps = [],
|
|
||||||
includeSpf = [],
|
|
||||||
policy = 'softfail'
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let value = 'v=spf1';
|
|
||||||
|
|
||||||
if (includeMx) {
|
|
||||||
value += ' mx';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeA) {
|
|
||||||
value += ' a';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add IP addresses
|
|
||||||
for (const ip of includeIps) {
|
|
||||||
if (ip.includes(':')) {
|
|
||||||
value += ` ip6:${ip}`;
|
|
||||||
} else {
|
|
||||||
value += ` ip4:${ip}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add includes
|
|
||||||
for (const include of includeSpf) {
|
|
||||||
value += ` include:${include}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add policy
|
|
||||||
const policyMap = {
|
|
||||||
'none': '?all',
|
|
||||||
'neutral': '~all',
|
|
||||||
'softfail': '~all',
|
|
||||||
'fail': '-all',
|
|
||||||
'reject': '-all'
|
|
||||||
};
|
|
||||||
|
|
||||||
value += ` ${policyMap[policy]}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: domain,
|
|
||||||
type: 'TXT',
|
|
||||||
value: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a recommended DMARC record for a domain
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param options Configuration options for the DMARC record
|
|
||||||
* @returns Generated DMARC record
|
|
||||||
*/
|
|
||||||
public generateDmarcRecord(domain: string, options: {
|
|
||||||
policy?: 'none' | 'quarantine' | 'reject';
|
|
||||||
subdomainPolicy?: 'none' | 'quarantine' | 'reject';
|
|
||||||
pct?: number;
|
|
||||||
rua?: string;
|
|
||||||
ruf?: string;
|
|
||||||
daysInterval?: number;
|
|
||||||
} = {}): IDnsRecord {
|
|
||||||
const {
|
|
||||||
policy = 'none',
|
|
||||||
subdomainPolicy,
|
|
||||||
pct = 100,
|
|
||||||
rua,
|
|
||||||
ruf,
|
|
||||||
daysInterval = 1
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let value = 'v=DMARC1; p=' + policy;
|
|
||||||
|
|
||||||
if (subdomainPolicy) {
|
|
||||||
value += `; sp=${subdomainPolicy}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pct !== 100) {
|
|
||||||
value += `; pct=${pct}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rua) {
|
|
||||||
value += `; rua=mailto:${rua}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ruf) {
|
|
||||||
value += `; ruf=mailto:${ruf}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (daysInterval !== 1) {
|
|
||||||
value += `; ri=${daysInterval * 86400}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reporting format and ADKIM/ASPF alignment
|
|
||||||
value += '; fo=1; adkim=r; aspf=r';
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `_dmarc.${domain}`,
|
|
||||||
type: 'TXT',
|
|
||||||
value: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save DNS record recommendations to a file
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param records DNS records to save
|
|
||||||
*/
|
|
||||||
public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`);
|
|
||||||
plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath);
|
|
||||||
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error saving DNS recommendations for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache key value
|
|
||||||
* @param key Cache key
|
|
||||||
* @returns Cached value or undefined if not found or expired
|
|
||||||
*/
|
|
||||||
private getFromCache<T>(key: string): T | undefined {
|
|
||||||
const cached = this.cache.get(key);
|
|
||||||
|
|
||||||
if (cached && cached.expires > Date.now()) {
|
|
||||||
return cached.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove expired entry
|
|
||||||
if (cached) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set cache key value
|
|
||||||
* @param key Cache key
|
|
||||||
* @param data Data to cache
|
|
||||||
* @param ttl TTL in milliseconds
|
|
||||||
*/
|
|
||||||
private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void {
|
|
||||||
if (ttl <= 0) return; // Don't cache if TTL is disabled
|
|
||||||
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
expires: Date.now() + ttl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the DNS cache
|
|
||||||
* @param key Optional specific key to clear, or all cache if not provided
|
|
||||||
*/
|
|
||||||
public clearCache(key?: string): void {
|
|
||||||
if (key) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
} else {
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based wrapper for dns.resolveMx
|
|
||||||
* @param domain Domain to resolve
|
|
||||||
* @param timeout Timeout in milliseconds
|
|
||||||
* @returns Promise resolving to MX records
|
|
||||||
*/
|
|
||||||
private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(`DNS MX lookup timeout for ${domain}`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(addresses);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based wrapper for dns.resolveTxt
|
|
||||||
* @param domain Domain to resolve
|
|
||||||
* @param timeout Timeout in milliseconds
|
|
||||||
* @returns Promise resolving to TXT records
|
|
||||||
*/
|
|
||||||
private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
plugins.dns.resolveTxt(domain, (err, records) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(records);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate all recommended DNS records for proper email authentication
|
|
||||||
* @param domain Domain to generate records for
|
|
||||||
* @returns Array of recommended DNS records
|
|
||||||
*/
|
|
||||||
public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> {
|
|
||||||
const records: IDnsRecord[] = [];
|
|
||||||
|
|
||||||
// Get DKIM record (already created by DKIMCreator)
|
|
||||||
try {
|
|
||||||
// Now using the public method
|
|
||||||
const dkimRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
|
||||||
records.push(dkimRecord);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error getting DKIM record for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate SPF record
|
|
||||||
const spfRecord = this.generateSpfRecord(domain, {
|
|
||||||
includeMx: true,
|
|
||||||
includeA: true,
|
|
||||||
policy: 'softfail'
|
|
||||||
});
|
|
||||||
records.push(spfRecord);
|
|
||||||
|
|
||||||
// Generate DMARC record
|
|
||||||
const dmarcRecord = this.generateDmarcRecord(domain, {
|
|
||||||
policy: 'none', // Start with monitoring mode
|
|
||||||
rua: `dmarc@${domain}` // Replace with appropriate report address
|
|
||||||
});
|
|
||||||
records.push(dmarcRecord);
|
|
||||||
|
|
||||||
// Save recommendations
|
|
||||||
await this.saveDnsRecommendations(domain, records);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
export interface IAttachment {
|
|
||||||
filename: string;
|
|
||||||
content: Buffer;
|
|
||||||
contentType: string;
|
|
||||||
contentId?: string; // Optional content ID for inline attachments
|
|
||||||
encoding?: string; // Optional encoding specification
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IEmailOptions {
|
|
||||||
from: string;
|
|
||||||
to: string | string[]; // Support multiple recipients
|
|
||||||
cc?: string | string[]; // Optional CC recipients
|
|
||||||
bcc?: string | string[]; // Optional BCC recipients
|
|
||||||
subject: string;
|
|
||||||
text: string;
|
|
||||||
html?: string; // Optional HTML version
|
|
||||||
attachments?: IAttachment[];
|
|
||||||
headers?: Record<string, string>; // Optional additional headers
|
|
||||||
mightBeSpam?: boolean;
|
|
||||||
priority?: 'high' | 'normal' | 'low'; // Optional email priority
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Email {
|
|
||||||
from: string;
|
|
||||||
to: string[];
|
|
||||||
cc: string[];
|
|
||||||
bcc: string[];
|
|
||||||
subject: string;
|
|
||||||
text: string;
|
|
||||||
html?: string;
|
|
||||||
attachments: IAttachment[];
|
|
||||||
headers: Record<string, string>;
|
|
||||||
mightBeSpam: boolean;
|
|
||||||
priority: 'high' | 'normal' | 'low';
|
|
||||||
|
|
||||||
constructor(options: IEmailOptions) {
|
|
||||||
// Validate and set the from address
|
|
||||||
if (!this.isValidEmail(options.from)) {
|
|
||||||
throw new Error(`Invalid sender email address: ${options.from}`);
|
|
||||||
}
|
|
||||||
this.from = options.from;
|
|
||||||
|
|
||||||
// Handle to addresses (single or multiple)
|
|
||||||
this.to = this.parseRecipients(options.to);
|
|
||||||
|
|
||||||
// Handle optional cc and bcc
|
|
||||||
this.cc = options.cc ? this.parseRecipients(options.cc) : [];
|
|
||||||
this.bcc = options.bcc ? this.parseRecipients(options.bcc) : [];
|
|
||||||
|
|
||||||
// Validate that we have at least one recipient
|
|
||||||
if (this.to.length === 0 && this.cc.length === 0 && this.bcc.length === 0) {
|
|
||||||
throw new Error('Email must have at least one recipient');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set subject with sanitization
|
|
||||||
this.subject = this.sanitizeString(options.subject || '');
|
|
||||||
|
|
||||||
// Set text content with sanitization
|
|
||||||
this.text = this.sanitizeString(options.text || '');
|
|
||||||
|
|
||||||
// Set optional HTML content
|
|
||||||
this.html = options.html ? this.sanitizeString(options.html) : undefined;
|
|
||||||
|
|
||||||
// Set attachments
|
|
||||||
this.attachments = Array.isArray(options.attachments) ? options.attachments : [];
|
|
||||||
|
|
||||||
// Set additional headers
|
|
||||||
this.headers = options.headers || {};
|
|
||||||
|
|
||||||
// Set spam flag
|
|
||||||
this.mightBeSpam = options.mightBeSpam || false;
|
|
||||||
|
|
||||||
// Set priority
|
|
||||||
this.priority = options.priority || 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an email address using a regex pattern
|
|
||||||
* @param email The email address to validate
|
|
||||||
* @returns boolean indicating if the email is valid
|
|
||||||
*/
|
|
||||||
private isValidEmail(email: string): boolean {
|
|
||||||
if (!email || typeof email !== 'string') return false;
|
|
||||||
|
|
||||||
// Basic but effective email regex
|
|
||||||
const emailRegex = /^[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,}$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses and validates recipient email addresses
|
|
||||||
* @param recipients A string or array of recipient emails
|
|
||||||
* @returns Array of validated email addresses
|
|
||||||
*/
|
|
||||||
private parseRecipients(recipients: string | string[]): string[] {
|
|
||||||
const result: string[] = [];
|
|
||||||
|
|
||||||
if (typeof recipients === 'string') {
|
|
||||||
// Handle single recipient
|
|
||||||
if (this.isValidEmail(recipients)) {
|
|
||||||
result.push(recipients);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid recipient email address: ${recipients}`);
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(recipients)) {
|
|
||||||
// Handle multiple recipients
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
if (this.isValidEmail(recipient)) {
|
|
||||||
result.push(recipient);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid recipient email address: ${recipient}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic sanitization for strings to prevent header injection
|
|
||||||
* @param input The string to sanitize
|
|
||||||
* @returns Sanitized string
|
|
||||||
*/
|
|
||||||
private sanitizeString(input: string): string {
|
|
||||||
if (!input) return '';
|
|
||||||
|
|
||||||
// Remove CR and LF characters to prevent header injection
|
|
||||||
return input.replace(/\r|\n/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the domain part of the from email address
|
|
||||||
* @returns The domain part of the from email or null if invalid
|
|
||||||
*/
|
|
||||||
public getFromDomain(): string | null {
|
|
||||||
try {
|
|
||||||
const parts = this.from.split('@');
|
|
||||||
if (parts.length !== 2 || !parts[1]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parts[1];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting domain from email:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all recipients (to, cc, bcc) as a unique array
|
|
||||||
* @returns Array of all unique recipient email addresses
|
|
||||||
*/
|
|
||||||
public getAllRecipients(): string[] {
|
|
||||||
// Combine all recipients and remove duplicates
|
|
||||||
return [...new Set([...this.to, ...this.cc, ...this.bcc])];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets primary recipient (first in the to field)
|
|
||||||
* @returns The primary recipient email or null if none exists
|
|
||||||
*/
|
|
||||||
public getPrimaryRecipient(): string | null {
|
|
||||||
return this.to.length > 0 ? this.to[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the email has attachments
|
|
||||||
* @returns Boolean indicating if the email has attachments
|
|
||||||
*/
|
|
||||||
public hasAttachments(): boolean {
|
|
||||||
return this.attachments.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the total size of all attachments in bytes
|
|
||||||
* @returns Total size of all attachments in bytes
|
|
||||||
*/
|
|
||||||
public getAttachmentsSize(): number {
|
|
||||||
return this.attachments.reduce((total, attachment) => {
|
|
||||||
return total + (attachment.content?.length || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an RFC822 compliant email string
|
|
||||||
* @returns The email formatted as an RFC822 compliant string
|
|
||||||
*/
|
|
||||||
public toRFC822String(): string {
|
|
||||||
// This is a simplified version - a complete implementation would be more complex
|
|
||||||
let result = '';
|
|
||||||
|
|
||||||
// Add headers
|
|
||||||
result += `From: ${this.from}\r\n`;
|
|
||||||
result += `To: ${this.to.join(', ')}\r\n`;
|
|
||||||
|
|
||||||
if (this.cc.length > 0) {
|
|
||||||
result += `Cc: ${this.cc.join(', ')}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
result += `Subject: ${this.subject}\r\n`;
|
|
||||||
result += `Date: ${new Date().toUTCString()}\r\n`;
|
|
||||||
|
|
||||||
// Add custom headers
|
|
||||||
for (const [key, value] of Object.entries(this.headers)) {
|
|
||||||
result += `${key}: ${value}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add priority if not normal
|
|
||||||
if (this.priority !== 'normal') {
|
|
||||||
const priorityValue = this.priority === 'high' ? '1' : '5';
|
|
||||||
result += `X-Priority: ${priorityValue}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add content type and body
|
|
||||||
result += `Content-Type: text/plain; charset=utf-8\r\n`;
|
|
||||||
result += `\r\n${this.text}\r\n`;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,623 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { Email } from './classes.email.js';
|
|
||||||
import { EmailSignJob } from './classes.emailsignjob.js';
|
|
||||||
import type { MtaService } from './classes.mta.js';
|
|
||||||
|
|
||||||
// Configuration options for email sending
|
|
||||||
export interface IEmailSendOptions {
|
|
||||||
maxRetries?: number;
|
|
||||||
retryDelay?: number; // in milliseconds
|
|
||||||
connectionTimeout?: number; // in milliseconds
|
|
||||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
|
||||||
debugMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email delivery status
|
|
||||||
export enum DeliveryStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
SENDING = 'sending',
|
|
||||||
DELIVERED = 'delivered',
|
|
||||||
FAILED = 'failed',
|
|
||||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detailed information about delivery attempts
|
|
||||||
export interface DeliveryInfo {
|
|
||||||
status: DeliveryStatus;
|
|
||||||
attempts: number;
|
|
||||||
error?: Error;
|
|
||||||
lastAttempt?: Date;
|
|
||||||
nextAttempt?: Date;
|
|
||||||
mxServer?: string;
|
|
||||||
deliveryTime?: Date;
|
|
||||||
logs: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailSendJob {
|
|
||||||
mtaRef: MtaService;
|
|
||||||
private email: Email;
|
|
||||||
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
|
|
||||||
private mxServers: string[] = [];
|
|
||||||
private currentMxIndex = 0;
|
|
||||||
private options: IEmailSendOptions;
|
|
||||||
public deliveryInfo: DeliveryInfo;
|
|
||||||
|
|
||||||
constructor(mtaRef: MtaService, emailArg: Email, options: IEmailSendOptions = {}) {
|
|
||||||
this.email = emailArg;
|
|
||||||
this.mtaRef = mtaRef;
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
this.options = {
|
|
||||||
maxRetries: options.maxRetries || 3,
|
|
||||||
retryDelay: options.retryDelay || 300000, // 5 minutes
|
|
||||||
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
|
|
||||||
tlsOptions: options.tlsOptions || { rejectUnauthorized: true },
|
|
||||||
debugMode: options.debugMode || false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize delivery info
|
|
||||||
this.deliveryInfo = {
|
|
||||||
status: DeliveryStatus.PENDING,
|
|
||||||
attempts: 0,
|
|
||||||
logs: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send the email with retry logic
|
|
||||||
*/
|
|
||||||
async send(): Promise<DeliveryStatus> {
|
|
||||||
try {
|
|
||||||
// Check if the email is valid before attempting to send
|
|
||||||
this.validateEmail();
|
|
||||||
|
|
||||||
// Resolve MX records for the recipient domain
|
|
||||||
await this.resolveMxRecords();
|
|
||||||
|
|
||||||
// Try to send the email
|
|
||||||
return await this.attemptDelivery();
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Critical error in send process: ${error.message}`);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for potential future retry or analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the email before sending
|
|
||||||
*/
|
|
||||||
private validateEmail(): void {
|
|
||||||
if (!this.email.to || this.email.to.length === 0) {
|
|
||||||
throw new Error('No recipients specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.email.from) {
|
|
||||||
throw new Error('No sender specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
if (!fromDomain) {
|
|
||||||
throw new Error('Invalid sender domain');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve MX records for the recipient domain
|
|
||||||
*/
|
|
||||||
private async resolveMxRecords(): Promise<void> {
|
|
||||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
|
||||||
if (!domain) {
|
|
||||||
throw new Error('Invalid recipient domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log(`Resolving MX records for domain: ${domain}`);
|
|
||||||
try {
|
|
||||||
const addresses = await this.resolveMx(domain);
|
|
||||||
|
|
||||||
// Sort by priority (lowest number = highest priority)
|
|
||||||
addresses.sort((a, b) => a.priority - b.priority);
|
|
||||||
|
|
||||||
this.mxServers = addresses.map(mx => mx.exchange);
|
|
||||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
|
||||||
|
|
||||||
if (this.mxServers.length === 0) {
|
|
||||||
throw new Error(`No MX records found for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
|
||||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to deliver the email with retries
|
|
||||||
*/
|
|
||||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
|
||||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
|
||||||
this.deliveryInfo.attempts++;
|
|
||||||
this.deliveryInfo.lastAttempt = new Date();
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
|
||||||
|
|
||||||
// Try each MX server in order of priority
|
|
||||||
while (this.currentMxIndex < this.mxServers.length) {
|
|
||||||
const currentMx = this.mxServers[this.currentMxIndex];
|
|
||||||
this.deliveryInfo.mxServer = currentMx;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
|
||||||
await this.connectAndSend(currentMx);
|
|
||||||
|
|
||||||
// If we get here, email was sent successfully
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
|
||||||
this.deliveryInfo.deliveryTime = new Date();
|
|
||||||
this.log(`Email delivered successfully to ${currentMx}`);
|
|
||||||
|
|
||||||
// Save successful email record
|
|
||||||
await this.saveSuccess();
|
|
||||||
return DeliveryStatus.DELIVERED;
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error with MX ${currentMx}: ${error.message}`);
|
|
||||||
|
|
||||||
// Clean up socket if it exists
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try the next MX server
|
|
||||||
this.currentMxIndex++;
|
|
||||||
|
|
||||||
// If this is a permanent failure, don't try other MX servers
|
|
||||||
if (this.isPermanentFailure(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we've tried all MX servers without success, throw an error
|
|
||||||
throw new Error('All MX servers failed');
|
|
||||||
} catch (error) {
|
|
||||||
// Check if this is a permanent failure
|
|
||||||
if (this.isPermanentFailure(error)) {
|
|
||||||
this.log(`Permanent failure: ${error.message}`);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a temporary failure, we can retry
|
|
||||||
this.log(`Temporary failure: ${error.message}`);
|
|
||||||
|
|
||||||
// If this is the last attempt, mark as failed
|
|
||||||
if (this.deliveryInfo.attempts >= this.options.maxRetries) {
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule the next retry
|
|
||||||
const nextRetryTime = new Date(Date.now() + this.options.retryDelay);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
|
||||||
this.deliveryInfo.nextAttempt = nextRetryTime;
|
|
||||||
this.log(`Will retry at ${nextRetryTime.toISOString()}`);
|
|
||||||
|
|
||||||
// Wait before retrying
|
|
||||||
await this.delay(this.options.retryDelay);
|
|
||||||
|
|
||||||
// Reset MX server index for the next attempt
|
|
||||||
this.currentMxIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, all retries failed
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to a specific MX server and send the email
|
|
||||||
*/
|
|
||||||
private async connectAndSend(mxServer: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let commandTimeout: NodeJS.Timeout;
|
|
||||||
|
|
||||||
// Function to clear timeouts and remove listeners
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.removeAllListeners();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to set a timeout for each command
|
|
||||||
const setCommandTimeout = () => {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
commandTimeout = setTimeout(() => {
|
|
||||||
this.log('Connection timed out');
|
|
||||||
cleanup();
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
}
|
|
||||||
reject(new Error('Connection timed out'));
|
|
||||||
}, this.options.connectionTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to the MX server
|
|
||||||
this.log(`Connecting to ${mxServer}:25`);
|
|
||||||
setCommandTimeout();
|
|
||||||
|
|
||||||
this.socket = plugins.net.connect(25, mxServer);
|
|
||||||
|
|
||||||
this.socket.on('error', (err) => {
|
|
||||||
this.log(`Socket error: ${err.message}`);
|
|
||||||
cleanup();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up the command sequence
|
|
||||||
this.socket.once('data', async (data) => {
|
|
||||||
try {
|
|
||||||
const greeting = data.toString();
|
|
||||||
this.log(`Server greeting: ${greeting.trim()}`);
|
|
||||||
|
|
||||||
if (!greeting.startsWith('220')) {
|
|
||||||
throw new Error(`Unexpected server greeting: ${greeting}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// EHLO command
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
|
||||||
|
|
||||||
// Try STARTTLS if available
|
|
||||||
try {
|
|
||||||
await this.sendCommand('STARTTLS\r\n', '220');
|
|
||||||
this.upgradeToTLS(mxServer, fromDomain);
|
|
||||||
// The TLS handshake and subsequent commands will continue in the upgradeToTLS method
|
|
||||||
// resolve will be called from there if successful
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`STARTTLS failed or not supported: ${error.message}`);
|
|
||||||
this.log('Continuing with unencrypted connection');
|
|
||||||
|
|
||||||
// Continue with unencrypted connection
|
|
||||||
await this.sendEmailCommands();
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
cleanup();
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade the connection to TLS
|
|
||||||
*/
|
|
||||||
private upgradeToTLS(mxServer: string, fromDomain: string): void {
|
|
||||||
this.log('Starting TLS handshake');
|
|
||||||
|
|
||||||
const tlsOptions = {
|
|
||||||
...this.options.tlsOptions,
|
|
||||||
socket: this.socket,
|
|
||||||
servername: mxServer
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create TLS socket
|
|
||||||
this.socket = plugins.tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
// Handle TLS connection
|
|
||||||
this.socket.once('secureConnect', async () => {
|
|
||||||
try {
|
|
||||||
this.log('TLS connection established');
|
|
||||||
|
|
||||||
// Send EHLO again over TLS
|
|
||||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
|
||||||
|
|
||||||
// Send the email
|
|
||||||
await this.sendEmailCommands();
|
|
||||||
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error in TLS session: ${error.message}`);
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('error', (err) => {
|
|
||||||
this.log(`TLS error: ${err.message}`);
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send SMTP commands to deliver the email
|
|
||||||
*/
|
|
||||||
private async sendEmailCommands(): Promise<void> {
|
|
||||||
// MAIL FROM command
|
|
||||||
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
|
|
||||||
|
|
||||||
// RCPT TO command for each recipient
|
|
||||||
for (const recipient of this.email.getAllRecipients()) {
|
|
||||||
await this.sendCommand(`RCPT TO:<${recipient}>\r\n`, '250');
|
|
||||||
}
|
|
||||||
|
|
||||||
// DATA command
|
|
||||||
await this.sendCommand('DATA\r\n', '354');
|
|
||||||
|
|
||||||
// Create the email message with DKIM signature
|
|
||||||
const message = await this.createEmailMessage();
|
|
||||||
|
|
||||||
// Send the message content
|
|
||||||
await this.sendCommand(message);
|
|
||||||
await this.sendCommand('\r\n.\r\n', '250');
|
|
||||||
|
|
||||||
// QUIT command
|
|
||||||
await this.sendCommand('QUIT\r\n', '221');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the full email message with headers and DKIM signature
|
|
||||||
*/
|
|
||||||
private async createEmailMessage(): Promise<string> {
|
|
||||||
this.log('Preparing email message');
|
|
||||||
|
|
||||||
const messageId = `<${plugins.uuid.v4()}@${this.email.getFromDomain()}>`;
|
|
||||||
const boundary = '----=_NextPart_' + plugins.uuid.v4();
|
|
||||||
|
|
||||||
// Prepare headers
|
|
||||||
const headers = {
|
|
||||||
'Message-ID': messageId,
|
|
||||||
'From': this.email.from,
|
|
||||||
'To': this.email.to.join(', '),
|
|
||||||
'Subject': this.email.subject,
|
|
||||||
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'Date': new Date().toUTCString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add CC header if present
|
|
||||||
if (this.email.cc && this.email.cc.length > 0) {
|
|
||||||
headers['Cc'] = this.email.cc.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom headers
|
|
||||||
for (const [key, value] of Object.entries(this.email.headers || {})) {
|
|
||||||
headers[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add priority header if not normal
|
|
||||||
if (this.email.priority && this.email.priority !== 'normal') {
|
|
||||||
const priorityValue = this.email.priority === 'high' ? '1' : '5';
|
|
||||||
headers['X-Priority'] = priorityValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create body
|
|
||||||
let body = '';
|
|
||||||
|
|
||||||
// Text part
|
|
||||||
body += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
|
|
||||||
|
|
||||||
// HTML part if present
|
|
||||||
if (this.email.html) {
|
|
||||||
body += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.html}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
for (const 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`;
|
|
||||||
|
|
||||||
// Add Content-ID for inline attachments if present
|
|
||||||
if (attachment.contentId) {
|
|
||||||
body += `Content-ID: <${attachment.contentId}>\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
body += '\r\n';
|
|
||||||
body += attachment.content.toString('base64') + '\r\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
// End of message
|
|
||||||
body += `--${boundary}--\r\n`;
|
|
||||||
|
|
||||||
// Create DKIM signature
|
|
||||||
const dkimSigner = new EmailSignJob(this.mtaRef, {
|
|
||||||
domain: this.email.getFromDomain(),
|
|
||||||
selector: 'mta',
|
|
||||||
headers: headers,
|
|
||||||
body: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build the message with headers
|
|
||||||
let headerString = '';
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
headerString += `${key}: ${value}\r\n`;
|
|
||||||
}
|
|
||||||
let message = headerString + '\r\n' + body;
|
|
||||||
|
|
||||||
// Add DKIM signature header
|
|
||||||
let signatureHeader = await dkimSigner.getSignatureHeader(message);
|
|
||||||
message = `${signatureHeader}${message}`;
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a command to the SMTP server and wait for the expected response
|
|
||||||
*/
|
|
||||||
private sendCommand(command: string, expectedResponseCode?: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!this.socket) {
|
|
||||||
return reject(new Error('Socket not connected'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug log for commands (except DATA which can be large)
|
|
||||||
if (this.options.debugMode && !command.startsWith('--')) {
|
|
||||||
const logCommand = command.length > 100
|
|
||||||
? command.substring(0, 97) + '...'
|
|
||||||
: command;
|
|
||||||
this.log(`Sending: ${logCommand.replace(/\r\n/g, '<CRLF>')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socket.write(command, (error) => {
|
|
||||||
if (error) {
|
|
||||||
this.log(`Write error: ${error.message}`);
|
|
||||||
return reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no response is expected, resolve immediately
|
|
||||||
if (!expectedResponseCode) {
|
|
||||||
return resolve('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a timeout for the response
|
|
||||||
const responseTimeout = setTimeout(() => {
|
|
||||||
this.log('Response timeout');
|
|
||||||
reject(new Error('Response timeout'));
|
|
||||||
}, this.options.connectionTimeout);
|
|
||||||
|
|
||||||
// Wait for the response
|
|
||||||
this.socket.once('data', (data) => {
|
|
||||||
clearTimeout(responseTimeout);
|
|
||||||
const response = data.toString();
|
|
||||||
|
|
||||||
if (this.options.debugMode) {
|
|
||||||
this.log(`Received: ${response.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.startsWith(expectedResponseCode)) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
const error = new Error(`Unexpected server response: ${response.trim()}`);
|
|
||||||
this.log(error.message);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if an error represents a permanent failure
|
|
||||||
*/
|
|
||||||
private isPermanentFailure(error: Error): boolean {
|
|
||||||
if (!error || !error.message) return false;
|
|
||||||
|
|
||||||
const message = error.message.toLowerCase();
|
|
||||||
|
|
||||||
// Check for permanent SMTP error codes (5xx)
|
|
||||||
if (message.match(/^5\d\d/)) return true;
|
|
||||||
|
|
||||||
// Check for specific permanent failure messages
|
|
||||||
const permanentFailurePatterns = [
|
|
||||||
'no such user',
|
|
||||||
'user unknown',
|
|
||||||
'domain not found',
|
|
||||||
'invalid domain',
|
|
||||||
'rejected',
|
|
||||||
'denied',
|
|
||||||
'prohibited',
|
|
||||||
'authentication required',
|
|
||||||
'authentication failed',
|
|
||||||
'unauthorized'
|
|
||||||
];
|
|
||||||
|
|
||||||
return permanentFailurePatterns.some(pattern => message.includes(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve MX records for a domain
|
|
||||||
*/
|
|
||||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(addresses);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a log entry
|
|
||||||
*/
|
|
||||||
private log(message: string): void {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const logEntry = `[${timestamp}] ${message}`;
|
|
||||||
this.deliveryInfo.logs.push(logEntry);
|
|
||||||
|
|
||||||
if (this.options.debugMode) {
|
|
||||||
console.log(`EmailSendJob: ${logEntry}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a successful email for record keeping
|
|
||||||
*/
|
|
||||||
private async saveSuccess(): Promise<void> {
|
|
||||||
try {
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
|
||||||
const emailContent = await this.createEmailMessage();
|
|
||||||
const fileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.eml`;
|
|
||||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.sentEmailsDir, fileName));
|
|
||||||
|
|
||||||
// Save delivery info
|
|
||||||
const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`;
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
JSON.stringify(this.deliveryInfo, null, 2),
|
|
||||||
plugins.path.join(paths.sentEmailsDir, infoFileName)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving successful email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a failed email for potential retry
|
|
||||||
*/
|
|
||||||
private async saveFailed(): Promise<void> {
|
|
||||||
try {
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
|
||||||
const emailContent = await this.createEmailMessage();
|
|
||||||
const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`;
|
|
||||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName));
|
|
||||||
|
|
||||||
// Save delivery info
|
|
||||||
const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`;
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
JSON.stringify(this.deliveryInfo, null, 2),
|
|
||||||
plugins.path.join(paths.failedEmailsDir, infoFileName)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving failed email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple delay function
|
|
||||||
*/
|
|
||||||
private delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,945 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
|
|
||||||
import { Email } from './classes.email.js';
|
|
||||||
import { EmailSendJob, DeliveryStatus } from './classes.emailsendjob.js';
|
|
||||||
import { DKIMCreator } from './classes.dkimcreator.js';
|
|
||||||
import { DKIMVerifier } from './classes.dkimverifier.js';
|
|
||||||
import { SMTPServer, type ISmtpServerOptions } from './classes.smtpserver.js';
|
|
||||||
import { DNSManager } from './classes.dnsmanager.js';
|
|
||||||
import { ApiManager } from './classes.apimanager.js';
|
|
||||||
import type { SzPlatformService } from '../platformservice.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for the MTA service
|
|
||||||
*/
|
|
||||||
export interface IMtaConfig {
|
|
||||||
/** SMTP server options */
|
|
||||||
smtp?: {
|
|
||||||
/** Whether to enable the SMTP server */
|
|
||||||
enabled?: boolean;
|
|
||||||
/** Port to listen on (default: 25) */
|
|
||||||
port?: number;
|
|
||||||
/** SMTP server hostname */
|
|
||||||
hostname?: string;
|
|
||||||
/** Maximum allowed email size in bytes */
|
|
||||||
maxSize?: number;
|
|
||||||
};
|
|
||||||
/** SSL/TLS configuration */
|
|
||||||
tls?: {
|
|
||||||
/** Domain for certificate */
|
|
||||||
domain?: string;
|
|
||||||
/** Whether to auto-renew certificates */
|
|
||||||
autoRenew?: boolean;
|
|
||||||
/** Custom key/cert paths (if not using auto-provision) */
|
|
||||||
keyPath?: string;
|
|
||||||
certPath?: string;
|
|
||||||
};
|
|
||||||
/** Outbound email sending configuration */
|
|
||||||
outbound?: {
|
|
||||||
/** Maximum concurrent sending jobs */
|
|
||||||
concurrency?: number;
|
|
||||||
/** Retry configuration */
|
|
||||||
retries?: {
|
|
||||||
/** Maximum number of retries per message */
|
|
||||||
max?: number;
|
|
||||||
/** Initial delay between retries (milliseconds) */
|
|
||||||
delay?: number;
|
|
||||||
/** Whether to use exponential backoff for retries */
|
|
||||||
useBackoff?: boolean;
|
|
||||||
};
|
|
||||||
/** Rate limiting configuration */
|
|
||||||
rateLimit?: {
|
|
||||||
/** Maximum emails per period */
|
|
||||||
maxPerPeriod?: number;
|
|
||||||
/** Time period in milliseconds */
|
|
||||||
periodMs?: number;
|
|
||||||
/** Whether to apply per domain (vs globally) */
|
|
||||||
perDomain?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** Security settings */
|
|
||||||
security?: {
|
|
||||||
/** Whether to use DKIM signing */
|
|
||||||
useDkim?: boolean;
|
|
||||||
/** Whether to verify inbound DKIM signatures */
|
|
||||||
verifyDkim?: boolean;
|
|
||||||
/** Whether to verify SPF on inbound */
|
|
||||||
verifySpf?: boolean;
|
|
||||||
/** Whether to use TLS for outbound when available */
|
|
||||||
useTls?: boolean;
|
|
||||||
/** Whether to require valid certificates */
|
|
||||||
requireValidCerts?: boolean;
|
|
||||||
};
|
|
||||||
/** Domains configuration */
|
|
||||||
domains?: {
|
|
||||||
/** List of domains that this MTA will handle as local */
|
|
||||||
local?: string[];
|
|
||||||
/** Whether to auto-create DNS records */
|
|
||||||
autoCreateDnsRecords?: boolean;
|
|
||||||
/** DKIM selector to use (default: "mta") */
|
|
||||||
dkimSelector?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email queue entry
|
|
||||||
*/
|
|
||||||
interface QueueEntry {
|
|
||||||
id: string;
|
|
||||||
email: Email;
|
|
||||||
addedAt: Date;
|
|
||||||
processing: boolean;
|
|
||||||
attempts: number;
|
|
||||||
lastAttempt?: Date;
|
|
||||||
nextAttempt?: Date;
|
|
||||||
error?: Error;
|
|
||||||
status: DeliveryStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Certificate information
|
|
||||||
*/
|
|
||||||
interface Certificate {
|
|
||||||
privateKey: string;
|
|
||||||
publicKey: string;
|
|
||||||
expiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stats for MTA monitoring
|
|
||||||
*/
|
|
||||||
interface MtaStats {
|
|
||||||
startTime: Date;
|
|
||||||
emailsReceived: number;
|
|
||||||
emailsSent: number;
|
|
||||||
emailsFailed: number;
|
|
||||||
activeConnections: number;
|
|
||||||
queueSize: number;
|
|
||||||
certificateInfo?: {
|
|
||||||
domain: string;
|
|
||||||
expiresAt: Date;
|
|
||||||
daysUntilExpiry: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main MTA Service class that coordinates all email functionality
|
|
||||||
*/
|
|
||||||
export class MtaService {
|
|
||||||
/** Reference to the platform service */
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
|
|
||||||
/** SMTP server instance */
|
|
||||||
public server: SMTPServer;
|
|
||||||
|
|
||||||
/** DKIM creator for signing outgoing emails */
|
|
||||||
public dkimCreator: DKIMCreator;
|
|
||||||
|
|
||||||
/** DKIM verifier for validating incoming emails */
|
|
||||||
public dkimVerifier: DKIMVerifier;
|
|
||||||
|
|
||||||
/** DNS manager for handling DNS records */
|
|
||||||
public dnsManager: DNSManager;
|
|
||||||
|
|
||||||
/** API manager for external integrations */
|
|
||||||
public apiManager: ApiManager;
|
|
||||||
|
|
||||||
/** Email queue for outbound emails */
|
|
||||||
private emailQueue: Map<string, QueueEntry> = new Map();
|
|
||||||
|
|
||||||
/** Email queue processing state */
|
|
||||||
private queueProcessing = false;
|
|
||||||
|
|
||||||
/** Rate limiters for outbound emails */
|
|
||||||
private rateLimiters: Map<string, {
|
|
||||||
tokens: number;
|
|
||||||
lastRefill: number;
|
|
||||||
}> = new Map();
|
|
||||||
|
|
||||||
/** Certificate cache */
|
|
||||||
private certificate: Certificate = null;
|
|
||||||
|
|
||||||
/** MTA configuration */
|
|
||||||
private config: IMtaConfig;
|
|
||||||
|
|
||||||
/** Stats for monitoring */
|
|
||||||
private stats: MtaStats;
|
|
||||||
|
|
||||||
/** Whether the service is currently running */
|
|
||||||
private running = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the MTA service
|
|
||||||
* @param platformServiceRefArg Reference to the platform service
|
|
||||||
* @param config Configuration options
|
|
||||||
*/
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, config: IMtaConfig = {}) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
|
|
||||||
// Initialize with default configuration
|
|
||||||
this.config = this.getDefaultConfig();
|
|
||||||
|
|
||||||
// Merge with provided configuration
|
|
||||||
this.config = this.mergeConfig(this.config, config);
|
|
||||||
|
|
||||||
// Initialize components
|
|
||||||
this.dkimCreator = new DKIMCreator(this);
|
|
||||||
this.dkimVerifier = new DKIMVerifier(this);
|
|
||||||
this.dnsManager = new DNSManager(this);
|
|
||||||
this.apiManager = new ApiManager();
|
|
||||||
|
|
||||||
// Initialize stats
|
|
||||||
this.stats = {
|
|
||||||
startTime: new Date(),
|
|
||||||
emailsReceived: 0,
|
|
||||||
emailsSent: 0,
|
|
||||||
emailsFailed: 0,
|
|
||||||
activeConnections: 0,
|
|
||||||
queueSize: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure required directories exist
|
|
||||||
this.ensureDirectories();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default configuration
|
|
||||||
*/
|
|
||||||
private getDefaultConfig(): IMtaConfig {
|
|
||||||
return {
|
|
||||||
smtp: {
|
|
||||||
enabled: true,
|
|
||||||
port: 25,
|
|
||||||
hostname: 'mta.lossless.one',
|
|
||||||
maxSize: 10 * 1024 * 1024 // 10MB
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
domain: 'mta.lossless.one',
|
|
||||||
autoRenew: true
|
|
||||||
},
|
|
||||||
outbound: {
|
|
||||||
concurrency: 5,
|
|
||||||
retries: {
|
|
||||||
max: 3,
|
|
||||||
delay: 300000, // 5 minutes
|
|
||||||
useBackoff: true
|
|
||||||
},
|
|
||||||
rateLimit: {
|
|
||||||
maxPerPeriod: 100,
|
|
||||||
periodMs: 60000, // 1 minute
|
|
||||||
perDomain: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
useDkim: true,
|
|
||||||
verifyDkim: true,
|
|
||||||
verifySpf: true,
|
|
||||||
useTls: true,
|
|
||||||
requireValidCerts: false
|
|
||||||
},
|
|
||||||
domains: {
|
|
||||||
local: ['lossless.one'],
|
|
||||||
autoCreateDnsRecords: true,
|
|
||||||
dkimSelector: 'mta'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge configurations
|
|
||||||
*/
|
|
||||||
private mergeConfig(defaultConfig: IMtaConfig, customConfig: IMtaConfig): IMtaConfig {
|
|
||||||
// Deep merge of configurations
|
|
||||||
// (A more robust implementation would use a dedicated deep-merge library)
|
|
||||||
const merged = { ...defaultConfig };
|
|
||||||
|
|
||||||
// Merge first level
|
|
||||||
for (const [key, value] of Object.entries(customConfig)) {
|
|
||||||
if (value === null || value === undefined) continue;
|
|
||||||
|
|
||||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
merged[key] = { ...merged[key], ...value };
|
|
||||||
} else {
|
|
||||||
merged[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure required directories exist
|
|
||||||
*/
|
|
||||||
private ensureDirectories(): void {
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.keysDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.logsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the MTA service
|
|
||||||
*/
|
|
||||||
public async start(): Promise<void> {
|
|
||||||
if (this.running) {
|
|
||||||
console.warn('MTA service is already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Starting MTA service...');
|
|
||||||
|
|
||||||
// Load or provision certificate
|
|
||||||
await this.loadOrProvisionCertificate();
|
|
||||||
|
|
||||||
// Start SMTP server if enabled
|
|
||||||
if (this.config.smtp.enabled) {
|
|
||||||
const smtpOptions: ISmtpServerOptions = {
|
|
||||||
port: this.config.smtp.port,
|
|
||||||
key: this.certificate.privateKey,
|
|
||||||
cert: this.certificate.publicKey,
|
|
||||||
hostname: this.config.smtp.hostname
|
|
||||||
};
|
|
||||||
|
|
||||||
this.server = new SMTPServer(this, smtpOptions);
|
|
||||||
this.server.start();
|
|
||||||
console.log(`SMTP server started on port ${smtpOptions.port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start queue processing
|
|
||||||
this.startQueueProcessing();
|
|
||||||
|
|
||||||
// Update DNS records for local domains if configured
|
|
||||||
if (this.config.domains.autoCreateDnsRecords) {
|
|
||||||
await this.updateDnsRecordsForLocalDomains();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.running = true;
|
|
||||||
console.log('MTA service started successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start MTA service:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the MTA service
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.running) {
|
|
||||||
console.warn('MTA service is not running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Stopping MTA service...');
|
|
||||||
|
|
||||||
// Stop SMTP server if running
|
|
||||||
if (this.server) {
|
|
||||||
await this.server.stop();
|
|
||||||
this.server = null;
|
|
||||||
console.log('SMTP server stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop queue processing
|
|
||||||
this.queueProcessing = false;
|
|
||||||
console.log('Email queue processing stopped');
|
|
||||||
|
|
||||||
this.running = false;
|
|
||||||
console.log('MTA service stopped successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping MTA service:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email (add to queue)
|
|
||||||
*/
|
|
||||||
public async send(email: Email): Promise<string> {
|
|
||||||
if (!this.running) {
|
|
||||||
throw new Error('MTA service is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a unique ID for this email
|
|
||||||
const id = plugins.uuid.v4();
|
|
||||||
|
|
||||||
// Validate email
|
|
||||||
this.validateEmail(email);
|
|
||||||
|
|
||||||
// Create DKIM keys if needed
|
|
||||||
if (this.config.security.useDkim) {
|
|
||||||
await this.dkimCreator.handleDKIMKeysForEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
this.emailQueue.set(id, {
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
addedAt: new Date(),
|
|
||||||
processing: false,
|
|
||||||
attempts: 0,
|
|
||||||
status: DeliveryStatus.PENDING
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.queueSize = this.emailQueue.size;
|
|
||||||
|
|
||||||
console.log(`Email added to queue: ${id}`);
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status of an email in the queue
|
|
||||||
*/
|
|
||||||
public getEmailStatus(id: string): QueueEntry | null {
|
|
||||||
return this.emailQueue.get(id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming email
|
|
||||||
*/
|
|
||||||
public async processIncomingEmail(email: Email): Promise<boolean> {
|
|
||||||
if (!this.running) {
|
|
||||||
throw new Error('MTA service is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Processing incoming email from ${email.from} to ${email.to}`);
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.emailsReceived++;
|
|
||||||
|
|
||||||
// Check if the recipient domain is local
|
|
||||||
const recipientDomain = email.to[0].split('@')[1];
|
|
||||||
const isLocalDomain = this.isLocalDomain(recipientDomain);
|
|
||||||
|
|
||||||
if (isLocalDomain) {
|
|
||||||
// Save to local mailbox
|
|
||||||
await this.saveToLocalMailbox(email);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Forward to another server
|
|
||||||
const forwardId = await this.send(email);
|
|
||||||
console.log(`Forwarding email to ${email.to} with queue ID ${forwardId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing incoming email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a domain is local
|
|
||||||
*/
|
|
||||||
private isLocalDomain(domain: string): boolean {
|
|
||||||
return this.config.domains.local.includes(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save an email to a local mailbox
|
|
||||||
*/
|
|
||||||
private async saveToLocalMailbox(email: Email): Promise<void> {
|
|
||||||
// Simplified implementation - in a real system, this would store to a user's mailbox
|
|
||||||
const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local');
|
|
||||||
plugins.smartfile.fs.ensureDirSync(mailboxPath);
|
|
||||||
|
|
||||||
const emailContent = email.toRFC822String();
|
|
||||||
const filename = `${Date.now()}_${email.to[0].replace('@', '_at_')}.eml`;
|
|
||||||
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
emailContent,
|
|
||||||
plugins.path.join(mailboxPath, filename)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Email saved to local mailbox: ${filename}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start processing the email queue
|
|
||||||
*/
|
|
||||||
private startQueueProcessing(): void {
|
|
||||||
if (this.queueProcessing) return;
|
|
||||||
|
|
||||||
this.queueProcessing = true;
|
|
||||||
this.processQueue();
|
|
||||||
console.log('Email queue processing started');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process emails in the queue
|
|
||||||
*/
|
|
||||||
private async processQueue(): Promise<void> {
|
|
||||||
if (!this.queueProcessing) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get pending emails ordered by next attempt time
|
|
||||||
const pendingEmails = Array.from(this.emailQueue.values())
|
|
||||||
.filter(entry =>
|
|
||||||
(entry.status === DeliveryStatus.PENDING || entry.status === DeliveryStatus.DEFERRED) &&
|
|
||||||
!entry.processing &&
|
|
||||||
(!entry.nextAttempt || entry.nextAttempt <= new Date())
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort by next attempt time, then by added time
|
|
||||||
if (a.nextAttempt && b.nextAttempt) {
|
|
||||||
return a.nextAttempt.getTime() - b.nextAttempt.getTime();
|
|
||||||
} else if (a.nextAttempt) {
|
|
||||||
return 1;
|
|
||||||
} else if (b.nextAttempt) {
|
|
||||||
return -1;
|
|
||||||
} else {
|
|
||||||
return a.addedAt.getTime() - b.addedAt.getTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine how many emails we can process concurrently
|
|
||||||
const availableSlots = Math.max(0, this.config.outbound.concurrency -
|
|
||||||
Array.from(this.emailQueue.values()).filter(e => e.processing).length);
|
|
||||||
|
|
||||||
// Process emails up to our concurrency limit
|
|
||||||
for (let i = 0; i < Math.min(availableSlots, pendingEmails.length); i++) {
|
|
||||||
const entry = pendingEmails[i];
|
|
||||||
|
|
||||||
// Check rate limits
|
|
||||||
if (!this.checkRateLimit(entry.email)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as processing
|
|
||||||
entry.processing = true;
|
|
||||||
|
|
||||||
// Process in background
|
|
||||||
this.processQueueEntry(entry).catch(error => {
|
|
||||||
console.error(`Error processing queue entry ${entry.id}:`, error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in queue processing:', error);
|
|
||||||
} finally {
|
|
||||||
// Schedule next processing cycle
|
|
||||||
setTimeout(() => this.processQueue(), 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a single queue entry
|
|
||||||
*/
|
|
||||||
private async processQueueEntry(entry: QueueEntry): Promise<void> {
|
|
||||||
try {
|
|
||||||
console.log(`Processing queue entry ${entry.id}`);
|
|
||||||
|
|
||||||
// Update attempt counters
|
|
||||||
entry.attempts++;
|
|
||||||
entry.lastAttempt = new Date();
|
|
||||||
|
|
||||||
// Create send job
|
|
||||||
const sendJob = new EmailSendJob(this, entry.email, {
|
|
||||||
maxRetries: 1, // We handle retries at the queue level
|
|
||||||
tlsOptions: {
|
|
||||||
rejectUnauthorized: this.config.security.requireValidCerts
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the email
|
|
||||||
const status = await sendJob.send();
|
|
||||||
entry.status = status;
|
|
||||||
|
|
||||||
if (status === DeliveryStatus.DELIVERED) {
|
|
||||||
// Success - remove from queue
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
this.stats.emailsSent++;
|
|
||||||
console.log(`Email ${entry.id} delivered successfully`);
|
|
||||||
} else if (status === DeliveryStatus.FAILED) {
|
|
||||||
// Permanent failure
|
|
||||||
entry.error = sendJob.deliveryInfo.error;
|
|
||||||
this.stats.emailsFailed++;
|
|
||||||
console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`);
|
|
||||||
|
|
||||||
// Remove from queue
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
} else if (status === DeliveryStatus.DEFERRED) {
|
|
||||||
// Temporary failure - schedule retry if attempts remain
|
|
||||||
entry.error = sendJob.deliveryInfo.error;
|
|
||||||
|
|
||||||
if (entry.attempts >= this.config.outbound.retries.max) {
|
|
||||||
// Max retries reached - mark as failed
|
|
||||||
entry.status = DeliveryStatus.FAILED;
|
|
||||||
this.stats.emailsFailed++;
|
|
||||||
console.log(`Email ${entry.id} failed after ${entry.attempts} attempts: ${entry.error.message}`);
|
|
||||||
|
|
||||||
// Remove from queue
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
} else {
|
|
||||||
// Schedule retry
|
|
||||||
const delay = this.calculateRetryDelay(entry.attempts);
|
|
||||||
entry.nextAttempt = new Date(Date.now() + delay);
|
|
||||||
console.log(`Email ${entry.id} deferred, next attempt at ${entry.nextAttempt}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Unexpected error processing queue entry ${entry.id}:`, error);
|
|
||||||
|
|
||||||
// Handle unexpected errors similarly to deferred
|
|
||||||
entry.error = error;
|
|
||||||
|
|
||||||
if (entry.attempts >= this.config.outbound.retries.max) {
|
|
||||||
entry.status = DeliveryStatus.FAILED;
|
|
||||||
this.stats.emailsFailed++;
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
} else {
|
|
||||||
entry.status = DeliveryStatus.DEFERRED;
|
|
||||||
const delay = this.calculateRetryDelay(entry.attempts);
|
|
||||||
entry.nextAttempt = new Date(Date.now() + delay);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Mark as no longer processing
|
|
||||||
entry.processing = false;
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.queueSize = this.emailQueue.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate delay before retry based on attempt number
|
|
||||||
*/
|
|
||||||
private calculateRetryDelay(attemptNumber: number): number {
|
|
||||||
const baseDelay = this.config.outbound.retries.delay;
|
|
||||||
|
|
||||||
if (this.config.outbound.retries.useBackoff) {
|
|
||||||
// Exponential backoff: base_delay * (2^(attempt-1))
|
|
||||||
return baseDelay * Math.pow(2, attemptNumber - 1);
|
|
||||||
} else {
|
|
||||||
return baseDelay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an email can be sent under rate limits
|
|
||||||
*/
|
|
||||||
private checkRateLimit(email: Email): boolean {
|
|
||||||
const config = this.config.outbound.rateLimit;
|
|
||||||
if (!config || !config.maxPerPeriod) {
|
|
||||||
return true; // No rate limit configured
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which limiter to use
|
|
||||||
const key = config.perDomain ? email.getFromDomain() : 'global';
|
|
||||||
|
|
||||||
// Initialize limiter if needed
|
|
||||||
if (!this.rateLimiters.has(key)) {
|
|
||||||
this.rateLimiters.set(key, {
|
|
||||||
tokens: config.maxPerPeriod,
|
|
||||||
lastRefill: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const limiter = this.rateLimiters.get(key);
|
|
||||||
|
|
||||||
// Refill tokens based on time elapsed
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsedMs = now - limiter.lastRefill;
|
|
||||||
const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod;
|
|
||||||
|
|
||||||
if (tokensToAdd > 0) {
|
|
||||||
limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd);
|
|
||||||
limiter.lastRefill = now - (elapsedMs % config.periodMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have tokens available
|
|
||||||
if (limiter.tokens > 0) {
|
|
||||||
limiter.tokens--;
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.log(`Rate limit exceeded for ${key}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load or provision a TLS certificate
|
|
||||||
*/
|
|
||||||
private async loadOrProvisionCertificate(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Check if we have manual cert paths specified
|
|
||||||
if (this.config.tls.keyPath && this.config.tls.certPath) {
|
|
||||||
console.log('Using manually specified certificate files');
|
|
||||||
|
|
||||||
const [privateKey, publicKey] = await Promise.all([
|
|
||||||
plugins.fs.promises.readFile(this.config.tls.keyPath, 'utf-8'),
|
|
||||||
plugins.fs.promises.readFile(this.config.tls.certPath, 'utf-8')
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.certificate = {
|
|
||||||
privateKey,
|
|
||||||
publicKey,
|
|
||||||
expiresAt: this.getCertificateExpiry(publicKey)
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`Certificate loaded, expires: ${this.certificate.expiresAt}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, use auto-provisioning
|
|
||||||
console.log(`Provisioning certificate for ${this.config.tls.domain}`);
|
|
||||||
this.certificate = await this.provisionCertificate(this.config.tls.domain);
|
|
||||||
console.log(`Certificate provisioned, expires: ${this.certificate.expiresAt}`);
|
|
||||||
|
|
||||||
// Set up auto-renewal if configured
|
|
||||||
if (this.config.tls.autoRenew) {
|
|
||||||
this.setupCertificateRenewal();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading or provisioning certificate:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provision a certificate from the certificate service
|
|
||||||
*/
|
|
||||||
private async provisionCertificate(domain: string): Promise<Certificate> {
|
|
||||||
try {
|
|
||||||
// Setup proper authentication
|
|
||||||
const authToken = await this.getAuthToken();
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
throw new Error('Failed to obtain authentication token for certificate provisioning');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize client
|
|
||||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
|
||||||
typedrouter,
|
|
||||||
'https://cloudly.lossless.one:443'
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Request certificate
|
|
||||||
const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate');
|
|
||||||
const typedResponse = await typedCertificateRequest.fire({
|
|
||||||
authToken,
|
|
||||||
requiredCertName: domain,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!typedResponse || !typedResponse.certificate) {
|
|
||||||
throw new Error('Invalid response from certificate service');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract certificate information
|
|
||||||
const cert = typedResponse.certificate;
|
|
||||||
|
|
||||||
// Determine expiry date
|
|
||||||
const expiresAt = this.getCertificateExpiry(cert.publicKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey: cert.privateKey,
|
|
||||||
publicKey: cert.publicKey,
|
|
||||||
expiresAt
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
// Always close the client
|
|
||||||
await typedsocketClient.stop();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Certificate provisioning failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get authentication token for certificate service
|
|
||||||
*/
|
|
||||||
private async getAuthToken(): Promise<string> {
|
|
||||||
// Implementation would depend on authentication mechanism
|
|
||||||
// This is a simplified example assuming the platform service has an auth method
|
|
||||||
try {
|
|
||||||
// For now, return a placeholder token - in production this would
|
|
||||||
// authenticate properly with the certificate service
|
|
||||||
return 'mta-service-auth-token';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to obtain auth token:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract certificate expiry date from public key
|
|
||||||
*/
|
|
||||||
private getCertificateExpiry(publicKey: string): Date {
|
|
||||||
try {
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In a real system, you would parse the certificate properly
|
|
||||||
// using a certificate parsing library
|
|
||||||
|
|
||||||
// For now, set expiry to 90 days from now
|
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
|
||||||
return expiresAt;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to extract certificate expiry:', error);
|
|
||||||
|
|
||||||
// Default to 30 days from now
|
|
||||||
const defaultExpiry = new Date();
|
|
||||||
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
|
||||||
return defaultExpiry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up certificate auto-renewal
|
|
||||||
*/
|
|
||||||
private setupCertificateRenewal(): void {
|
|
||||||
if (!this.certificate || !this.certificate.expiresAt) {
|
|
||||||
console.warn('Cannot setup certificate renewal: no valid certificate');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate time until renewal (30 days before expiry)
|
|
||||||
const now = new Date();
|
|
||||||
const renewalDate = new Date(this.certificate.expiresAt);
|
|
||||||
renewalDate.setDate(renewalDate.getDate() - 30);
|
|
||||||
|
|
||||||
const timeUntilRenewal = Math.max(0, renewalDate.getTime() - now.getTime());
|
|
||||||
|
|
||||||
console.log(`Certificate renewal scheduled for ${renewalDate}`);
|
|
||||||
|
|
||||||
// Schedule renewal
|
|
||||||
setTimeout(() => {
|
|
||||||
this.renewCertificate().catch(error => {
|
|
||||||
console.error('Certificate renewal failed:', error);
|
|
||||||
});
|
|
||||||
}, timeUntilRenewal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renew the certificate
|
|
||||||
*/
|
|
||||||
private async renewCertificate(): Promise<void> {
|
|
||||||
try {
|
|
||||||
console.log('Renewing certificate...');
|
|
||||||
|
|
||||||
// Provision new certificate
|
|
||||||
const newCertificate = await this.provisionCertificate(this.config.tls.domain);
|
|
||||||
|
|
||||||
// Replace current certificate
|
|
||||||
this.certificate = newCertificate;
|
|
||||||
|
|
||||||
console.log(`Certificate renewed, new expiry: ${newCertificate.expiresAt}`);
|
|
||||||
|
|
||||||
// Update SMTP server with new certificate if running
|
|
||||||
if (this.server) {
|
|
||||||
// Restart server with new certificate
|
|
||||||
await this.server.stop();
|
|
||||||
|
|
||||||
const smtpOptions: ISmtpServerOptions = {
|
|
||||||
port: this.config.smtp.port,
|
|
||||||
key: this.certificate.privateKey,
|
|
||||||
cert: this.certificate.publicKey,
|
|
||||||
hostname: this.config.smtp.hostname
|
|
||||||
};
|
|
||||||
|
|
||||||
this.server = new SMTPServer(this, smtpOptions);
|
|
||||||
this.server.start();
|
|
||||||
|
|
||||||
console.log('SMTP server restarted with new certificate');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule next renewal
|
|
||||||
this.setupCertificateRenewal();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Certificate renewal failed:', error);
|
|
||||||
|
|
||||||
// Schedule retry after 24 hours
|
|
||||||
setTimeout(() => {
|
|
||||||
this.renewCertificate().catch(err => {
|
|
||||||
console.error('Certificate renewal retry failed:', err);
|
|
||||||
});
|
|
||||||
}, 24 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update DNS records for all local domains
|
|
||||||
*/
|
|
||||||
private async updateDnsRecordsForLocalDomains(): Promise<void> {
|
|
||||||
if (!this.config.domains.local || this.config.domains.local.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Updating DNS records for local domains...');
|
|
||||||
|
|
||||||
for (const domain of this.config.domains.local) {
|
|
||||||
try {
|
|
||||||
console.log(`Updating DNS records for ${domain}`);
|
|
||||||
|
|
||||||
// Generate DKIM keys if needed
|
|
||||||
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
|
||||||
|
|
||||||
// Generate all recommended DNS records
|
|
||||||
const records = await this.dnsManager.generateAllRecommendedRecords(domain);
|
|
||||||
|
|
||||||
console.log(`Generated ${records.length} DNS records for ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error updating DNS records for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an email before sending
|
|
||||||
*/
|
|
||||||
private validateEmail(email: Email): void {
|
|
||||||
// The Email class constructor already performs basic validation
|
|
||||||
// Here we can add additional MTA-specific validation
|
|
||||||
|
|
||||||
if (!email.from) {
|
|
||||||
throw new Error('Email must have a sender address');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email.to || email.to.length === 0) {
|
|
||||||
throw new Error('Email must have at least one recipient');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the sender domain is allowed
|
|
||||||
const senderDomain = email.getFromDomain();
|
|
||||||
if (!senderDomain) {
|
|
||||||
throw new Error('Invalid sender domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the sender domain is one of our local domains, ensure we have DKIM keys
|
|
||||||
if (this.isLocalDomain(senderDomain) && this.config.security.useDkim) {
|
|
||||||
// DKIM keys will be created if needed in the send method
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get MTA service statistics
|
|
||||||
*/
|
|
||||||
public getStats(): MtaStats {
|
|
||||||
// Update queue size
|
|
||||||
this.stats.queueSize = this.emailQueue.size;
|
|
||||||
|
|
||||||
// Update certificate info if available
|
|
||||||
if (this.certificate) {
|
|
||||||
const now = new Date();
|
|
||||||
const daysUntilExpiry = Math.floor(
|
|
||||||
(this.certificate.expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.stats.certificateInfo = {
|
|
||||||
domain: this.config.tls.domain,
|
|
||||||
expiresAt: this.certificate.expiresAt,
|
|
||||||
daysUntilExpiry
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...this.stats };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { Email } from './classes.email.js';
|
|
||||||
import type { MtaService } from './classes.mta.js';
|
|
||||||
|
|
||||||
export interface ISmtpServerOptions {
|
|
||||||
port: number;
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
hostname?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SMTP Session States
|
|
||||||
enum SmtpState {
|
|
||||||
GREETING,
|
|
||||||
AFTER_EHLO,
|
|
||||||
MAIL_FROM,
|
|
||||||
RCPT_TO,
|
|
||||||
DATA,
|
|
||||||
DATA_RECEIVING,
|
|
||||||
FINISHED
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structure to store session information
|
|
||||||
interface SmtpSession {
|
|
||||||
state: SmtpState;
|
|
||||||
clientHostname: string;
|
|
||||||
mailFrom: string;
|
|
||||||
rcptTo: string[];
|
|
||||||
emailData: string;
|
|
||||||
useTLS: boolean;
|
|
||||||
connectionEnded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SMTPServer {
|
|
||||||
public mtaRef: MtaService;
|
|
||||||
private smtpServerOptions: ISmtpServerOptions;
|
|
||||||
private server: plugins.net.Server;
|
|
||||||
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, SmtpSession>;
|
|
||||||
private hostname: string;
|
|
||||||
|
|
||||||
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
|
||||||
console.log('SMTPServer instance is being created...');
|
|
||||||
|
|
||||||
this.mtaRef = mtaRefArg;
|
|
||||||
this.smtpServerOptions = optionsArg;
|
|
||||||
this.sessions = new Map();
|
|
||||||
this.hostname = optionsArg.hostname || 'mta.lossless.one';
|
|
||||||
|
|
||||||
this.server = plugins.net.createServer((socket) => {
|
|
||||||
this.handleNewConnection(socket);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleNewConnection(socket: plugins.net.Socket): void {
|
|
||||||
console.log(`New connection from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
|
|
||||||
// Initialize a new session
|
|
||||||
this.sessions.set(socket, {
|
|
||||||
state: SmtpState.GREETING,
|
|
||||||
clientHostname: '',
|
|
||||||
mailFrom: '',
|
|
||||||
rcptTo: [],
|
|
||||||
emailData: '',
|
|
||||||
useTLS: false,
|
|
||||||
connectionEnded: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send greeting
|
|
||||||
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
this.processData(socket, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('end', () => {
|
|
||||||
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (session) {
|
|
||||||
session.connectionEnded = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error(`Socket error: ${err.message}`);
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
|
||||||
try {
|
|
||||||
socket.write(`${response}\r\n`);
|
|
||||||
console.log(`→ ${response}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error sending response: ${error.message}`);
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) {
|
|
||||||
console.error('No session found for socket. Closing connection.');
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're in DATA_RECEIVING state, handle differently
|
|
||||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
|
||||||
return this.processEmailData(socket, data.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process normal SMTP commands
|
|
||||||
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
|
|
||||||
for (const line of lines) {
|
|
||||||
console.log(`← ${line}`);
|
|
||||||
this.processCommand(socket, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session || session.connectionEnded) return;
|
|
||||||
|
|
||||||
const [command, ...args] = commandLine.split(' ');
|
|
||||||
const upperCommand = command.toUpperCase();
|
|
||||||
|
|
||||||
switch (upperCommand) {
|
|
||||||
case 'EHLO':
|
|
||||||
case 'HELO':
|
|
||||||
this.handleEhlo(socket, args.join(' '));
|
|
||||||
break;
|
|
||||||
case 'STARTTLS':
|
|
||||||
this.handleStartTls(socket);
|
|
||||||
break;
|
|
||||||
case 'MAIL':
|
|
||||||
this.handleMailFrom(socket, args.join(' '));
|
|
||||||
break;
|
|
||||||
case 'RCPT':
|
|
||||||
this.handleRcptTo(socket, args.join(' '));
|
|
||||||
break;
|
|
||||||
case 'DATA':
|
|
||||||
this.handleData(socket);
|
|
||||||
break;
|
|
||||||
case 'RSET':
|
|
||||||
this.handleRset(socket);
|
|
||||||
break;
|
|
||||||
case 'QUIT':
|
|
||||||
this.handleQuit(socket);
|
|
||||||
break;
|
|
||||||
case 'NOOP':
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.sendResponse(socket, '502 Command not implemented');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (!clientHostname) {
|
|
||||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.clientHostname = clientHostname;
|
|
||||||
session.state = SmtpState.AFTER_EHLO;
|
|
||||||
|
|
||||||
// List available extensions
|
|
||||||
this.sendResponse(socket, `250-${this.hostname} Hello ${clientHostname}`);
|
|
||||||
this.sendResponse(socket, '250-SIZE 10485760'); // 10MB max
|
|
||||||
this.sendResponse(socket, '250-8BITMIME');
|
|
||||||
|
|
||||||
// Only offer STARTTLS if we haven't already established it
|
|
||||||
if (!session.useTLS) {
|
|
||||||
this.sendResponse(socket, '250-STARTTLS');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendResponse(socket, '250 HELP');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.useTLS) {
|
|
||||||
this.sendResponse(socket, '503 TLS already active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendResponse(socket, '220 Ready to start TLS');
|
|
||||||
this.startTLS(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email from MAIL FROM:<user@example.com>
|
|
||||||
const emailMatch = args.match(/FROM:<([^>]*)>/i);
|
|
||||||
if (!emailMatch) {
|
|
||||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailMatch[1];
|
|
||||||
if (!this.isValidEmail(email)) {
|
|
||||||
this.sendResponse(socket, '501 Invalid email address');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.mailFrom = email;
|
|
||||||
session.state = SmtpState.MAIL_FROM;
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email from RCPT TO:<user@example.com>
|
|
||||||
const emailMatch = args.match(/TO:<([^>]*)>/i);
|
|
||||||
if (!emailMatch) {
|
|
||||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailMatch[1];
|
|
||||||
if (!this.isValidEmail(email)) {
|
|
||||||
this.sendResponse(socket, '501 Invalid email address');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.rcptTo.push(email);
|
|
||||||
session.state = SmtpState.RCPT_TO;
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.RCPT_TO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.state = SmtpState.DATA_RECEIVING;
|
|
||||||
session.emailData = '';
|
|
||||||
this.sendResponse(socket, '354 End data with <CR><LF>.<CR><LF>');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
// Reset the session data but keep connection information
|
|
||||||
session.state = SmtpState.AFTER_EHLO;
|
|
||||||
session.mailFrom = '';
|
|
||||||
session.rcptTo = [];
|
|
||||||
session.emailData = '';
|
|
||||||
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
this.sendResponse(socket, '221 Goodbye');
|
|
||||||
|
|
||||||
// If we have collected email data, try to parse it before closing
|
|
||||||
if (session.state === SmtpState.FINISHED && session.emailData.length > 0) {
|
|
||||||
this.parseEmail(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.end();
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
private processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
// Check for end of data marker
|
|
||||||
if (data.endsWith('\r\n.\r\n')) {
|
|
||||||
// Remove the end of data marker
|
|
||||||
const emailData = data.slice(0, -5);
|
|
||||||
session.emailData += emailData;
|
|
||||||
session.state = SmtpState.FINISHED;
|
|
||||||
|
|
||||||
// Save and process the email
|
|
||||||
this.saveEmail(socket);
|
|
||||||
this.sendResponse(socket, '250 OK: Message accepted for delivery');
|
|
||||||
} else {
|
|
||||||
// Accumulate the data
|
|
||||||
session.emailData += data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure the directory exists
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
|
||||||
|
|
||||||
// Write the email to disk
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
session.emailData,
|
|
||||||
plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse the email
|
|
||||||
this.parseEmail(socket);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<void> {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session || !session.emailData) {
|
|
||||||
console.error('No email data found for session.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mightBeSpam = false;
|
|
||||||
|
|
||||||
// Verifying the email with DKIM
|
|
||||||
try {
|
|
||||||
const isVerified = await this.mtaRef.dkimVerifier.verify(session.emailData);
|
|
||||||
mightBeSpam = !isVerified;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to verify DKIM signature:', error);
|
|
||||||
mightBeSpam = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
|
||||||
|
|
||||||
const email = new Email({
|
|
||||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
|
||||||
to: session.rcptTo[0], // Use the first recipient
|
|
||||||
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('Email received and parsed:', {
|
|
||||||
from: email.from,
|
|
||||||
to: email.to,
|
|
||||||
subject: email.subject,
|
|
||||||
attachments: email.attachments.length,
|
|
||||||
mightBeSpam: email.mightBeSpam
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process or forward the email as needed
|
|
||||||
// this.mtaRef.processIncomingEmail(email); // You could add this method to your MTA service
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startTLS(socket: plugins.net.Socket): void {
|
|
||||||
try {
|
|
||||||
const secureContext = plugins.tls.createSecureContext({
|
|
||||||
key: this.smtpServerOptions.key,
|
|
||||||
cert: this.smtpServerOptions.cert,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
|
||||||
secureContext: secureContext,
|
|
||||||
isServer: true,
|
|
||||||
server: this.server
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalSession = this.sessions.get(socket);
|
|
||||||
if (!originalSession) {
|
|
||||||
console.error('No session found when upgrading to TLS');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transfer the session data to the new TLS socket
|
|
||||||
this.sessions.set(tlsSocket, {
|
|
||||||
...originalSession,
|
|
||||||
useTLS: true,
|
|
||||||
state: SmtpState.GREETING // Reset state to require a new EHLO
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
|
|
||||||
tlsSocket.on('secure', () => {
|
|
||||||
console.log('TLS negotiation successful');
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('data', (data: Buffer) => {
|
|
||||||
this.processData(tlsSocket, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('end', () => {
|
|
||||||
console.log('TLS socket ended');
|
|
||||||
const session = this.sessions.get(tlsSocket);
|
|
||||||
if (session) {
|
|
||||||
session.connectionEnded = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (err) => {
|
|
||||||
console.error('TLS socket error:', err);
|
|
||||||
this.sessions.delete(tlsSocket);
|
|
||||||
tlsSocket.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('close', () => {
|
|
||||||
console.log('TLS socket closed');
|
|
||||||
this.sessions.delete(tlsSocket);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error upgrading connection to TLS:', error);
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidEmail(email: string): boolean {
|
|
||||||
// Basic email validation - more comprehensive validation could be implemented
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
public start(): void {
|
|
||||||
this.server.listen(this.smtpServerOptions.port, () => {
|
|
||||||
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public stop(): void {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export * from './classes.dkimcreator.js';
|
|
||||||
export * from './classes.emailsignjob.js';
|
|
||||||
export * from './classes.dkimverifier.js';
|
|
||||||
export * from './classes.mta.js';
|
|
||||||
export * from './classes.smtpserver.js';
|
|
||||||
export * from './classes.emailsendjob.js';
|
|
||||||
export * from './classes.email.js';
|
|
||||||
69
ts/opsserver/classes.opsserver.ts
Normal file
69
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ts/opsserver/handlers/index.ts
Normal file
7
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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';
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
ts/opsserver/handlers/security.handler.ts
Normal file
317
ts/opsserver/handlers/security.handler.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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();
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||||
|
throughputRate: networkStats.throughputRate,
|
||||||
|
topIPs: networkStats.topIPs,
|
||||||
|
totalDataTransferred: networkStats.totalDataTransferred,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if MetricsManager not available
|
||||||
|
return {
|
||||||
|
connectionsByIP: [],
|
||||||
|
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
|
topIPs: [],
|
||||||
|
totalDataTransferred: { bytesIn: 0, bytesOut: 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
503
ts/opsserver/handlers/stats.handler.ts
Normal file
503
ts/opsserver/handlers/stats.handler.ts
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats().then(stats => {
|
||||||
|
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
|
||||||
|
stats.connectionsByIP.forEach((count, ip) => {
|
||||||
|
connectionDetails.push({
|
||||||
|
remoteAddress: ip,
|
||||||
|
protocol: 'https' as any,
|
||||||
|
state: 'established' as any,
|
||||||
|
startTime: Date.now(),
|
||||||
|
bytesIn: 0,
|
||||||
|
bytesOut: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics.network = {
|
||||||
|
totalBandwidth: {
|
||||||
|
in: stats.throughputRate.bytesInPerSecond,
|
||||||
|
out: stats.throughputRate.bytesOutPerSecond,
|
||||||
|
},
|
||||||
|
activeConnections: stats.connectionsByIP.size,
|
||||||
|
connectionDetails: connectionDetails.slice(0, 50), // Limit to 50 connections
|
||||||
|
topEndpoints: stats.topIPs.map(ip => ({
|
||||||
|
endpoint: ip.ip,
|
||||||
|
requests: ip.count,
|
||||||
|
bandwidth: {
|
||||||
|
in: 0,
|
||||||
|
out: 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;
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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';
|
||||||
41
ts/paths.ts
41
ts/paths.ts
@@ -6,7 +6,19 @@ export const packageDir = plugins.path.join(
|
|||||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||||
'../'
|
'../'
|
||||||
);
|
);
|
||||||
export const dataDir = plugins.path.join(baseDir, 'data');
|
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');
|
||||||
|
|
||||||
// MTA directories
|
// MTA directories
|
||||||
export const keysDir = plugins.path.join(dataDir, 'keys');
|
export const keysDir = plugins.path.join(dataDir, 'keys');
|
||||||
@@ -16,14 +28,27 @@ export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received'
|
|||||||
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
|
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
|
||||||
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
|
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
|
||||||
|
|
||||||
|
// Email template directories
|
||||||
|
export const emailTemplatesDir = plugins.path.join(dataDir, 'templates', 'email');
|
||||||
|
export const MtaAttachmentsDir = plugins.path.join(dataDir, 'attachments'); // For email attachments
|
||||||
|
|
||||||
|
// Configuration path
|
||||||
|
export const configPath = process.env.CONFIG_PATH
|
||||||
|
? process.env.CONFIG_PATH
|
||||||
|
: plugins.path.join(baseDir, 'config.json');
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
export function ensureDirectories() {
|
export function ensureDirectories() {
|
||||||
// Ensure data directories
|
// Ensure data directories
|
||||||
plugins.smartfile.fs.ensureDirSync(dataDir);
|
plugins.fsUtils.ensureDirSync(dataDir);
|
||||||
plugins.smartfile.fs.ensureDirSync(keysDir);
|
plugins.fsUtils.ensureDirSync(keysDir);
|
||||||
plugins.smartfile.fs.ensureDirSync(dnsRecordsDir);
|
plugins.fsUtils.ensureDirSync(dnsRecordsDir);
|
||||||
plugins.smartfile.fs.ensureDirSync(sentEmailsDir);
|
plugins.fsUtils.ensureDirSync(sentEmailsDir);
|
||||||
plugins.smartfile.fs.ensureDirSync(receivedEmailsDir);
|
plugins.fsUtils.ensureDirSync(receivedEmailsDir);
|
||||||
plugins.smartfile.fs.ensureDirSync(failedEmailsDir);
|
plugins.fsUtils.ensureDirSync(failedEmailsDir);
|
||||||
plugins.smartfile.fs.ensureDirSync(logsDir);
|
plugins.fsUtils.ensureDirSync(logsDir);
|
||||||
|
|
||||||
|
// Ensure email template directories
|
||||||
|
plugins.fsUtils.ensureDirSync(emailTemplatesDir);
|
||||||
|
plugins.fsUtils.ensureDirSync(MtaAttachmentsDir);
|
||||||
}
|
}
|
||||||
@@ -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/classes.emailservice.js';
|
|
||||||
import { SmsService } from './sms/classes.smsservice.js';
|
|
||||||
import { LetterService } from './letter/classes.letterservice.js';
|
|
||||||
import { MtaService } from './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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import * as fs from 'fs';
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as tls from 'tls';
|
import * as tls from 'tls';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
@@ -14,6 +15,7 @@ export {
|
|||||||
crypto,
|
crypto,
|
||||||
http,
|
http,
|
||||||
net,
|
net,
|
||||||
|
os,
|
||||||
path,
|
path,
|
||||||
tls,
|
tls,
|
||||||
util,
|
util,
|
||||||
@@ -40,24 +42,35 @@ export {
|
|||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as projectinfo from '@push.rocks/projectinfo';
|
import * as projectinfo from '@push.rocks/projectinfo';
|
||||||
import * as qenv from '@push.rocks/qenv';
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
import * as smartacme from '@push.rocks/smartacme';
|
||||||
import * as smartdata from '@push.rocks/smartdata';
|
import * as smartdata from '@push.rocks/smartdata';
|
||||||
|
import * as smartdns from '@push.rocks/smartdns';
|
||||||
import * as smartfile from '@push.rocks/smartfile';
|
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 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 smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartproxy from '@push.rocks/smartproxy';
|
import * as smartproxy from '@push.rocks/smartproxy';
|
||||||
import * as smartpromise from '@push.rocks/smartpromise';
|
import * as smartpromise from '@push.rocks/smartpromise';
|
||||||
|
import * as smartradius from '@push.rocks/smartradius';
|
||||||
import * as smartrequest from '@push.rocks/smartrequest';
|
import * as smartrequest from '@push.rocks/smartrequest';
|
||||||
import * as smartrule from '@push.rocks/smartrule';
|
|
||||||
import * as smartrx from '@push.rocks/smartrx';
|
import * as smartrx from '@push.rocks/smartrx';
|
||||||
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
|
|
||||||
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartproxy, smartpromise, smartrequest, smartrule, 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
|
// apiclient.xyz scope
|
||||||
import * as letterxpress from '@apiclient.xyz/letterxpress';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
letterxpress,
|
cloudflare,
|
||||||
}
|
}
|
||||||
|
|
||||||
// tsclass scope
|
// tsclass scope
|
||||||
@@ -68,14 +81,76 @@ export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// third party
|
// third party
|
||||||
import * as mailauth from 'mailauth';
|
|
||||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
|
||||||
import mailparser from 'mailparser';
|
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
mailauth,
|
|
||||||
dkimSign,
|
|
||||||
mailparser,
|
|
||||||
uuid,
|
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';
|
||||||
739
ts/security/classes.contentscanner.ts
Normal file
739
ts/security/classes.contentscanner.ts
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as paths from '../paths.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { Email, type Core } from '@push.rocks/smartmta';
|
||||||
|
type IAttachment = Core.IAttachment;
|
||||||
|
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 plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
import * as paths from '../paths.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import type { SzPlatformService } from '../platformservice.js';
|
|
||||||
|
|
||||||
export interface ISmsConstructorOptions {
|
import type { ISmsConfig } from './config/sms.config.js';
|
||||||
apiGatewayApiToken: string;
|
import { smsConfigSchema } from './config/sms.schema.js';
|
||||||
}
|
import { ConfigValidator } from '../config/validator.js';
|
||||||
|
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
public projectinfo: plugins.projectinfo.ProjectInfo;
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
public options: ISmsConstructorOptions;
|
public config: ISmsConfig;
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ISmsConstructorOptions) {
|
constructor(options: ISmsConfig) {
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
// Validate and apply defaults to configuration
|
||||||
this.options = optionsArg;
|
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
|
||||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
|
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) {
|
public async sendSms(toNumber: number, fromName: string, messageText: string) {
|
||||||
|
// Use default sender if not specified
|
||||||
|
const sender = fromName || this.config.defaultSender || 'PlatformService';
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
sender: fromName,
|
sender,
|
||||||
message: messageText,
|
message: messageText,
|
||||||
recipients: [{ msisdn: toNumber }],
|
recipients: [{ msisdn: toNumber }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const resp = await plugins.smartrequest.request('https://gatewayapi.com/rest/mtsms', {
|
const resp = await plugins.smartrequest.SmartRequest.create()
|
||||||
method: 'POST',
|
.url('https://gatewayapi.com/rest/mtsms')
|
||||||
requestBody: JSON.stringify(payload),
|
.header('Authorization', `Basic ${Buffer.from(`${this.config.apiGatewayApiToken}:`).toString('base64')}`)
|
||||||
headers: {
|
.header('Content-Type', 'application/json')
|
||||||
Authorization: `Basic ${Buffer.from(`${this.options.apiGatewayApiToken}:`).toString('base64')}`,
|
.json(payload)
|
||||||
'Content-Type': 'application/json',
|
.post();
|
||||||
},
|
const json = await resp.json();
|
||||||
});
|
|
||||||
const json = await resp.body;
|
|
||||||
logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, {
|
logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, {
|
||||||
eventType: 'sentSms',
|
eventType: 'sentSms',
|
||||||
sms: {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
2
ts_interfaces/data/index.ts
Normal file
2
ts_interfaces/data/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './auth.js';
|
||||||
|
export * from './stats.js';
|
||||||
131
ts_interfaces/data/stats.ts
Normal file
131
ts_interfaces/data/stats.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
export interface IServerStats {
|
||||||
|
uptime: number;
|
||||||
|
startTime: number;
|
||||||
|
memoryUsage: {
|
||||||
|
heapUsed: number;
|
||||||
|
heapTotal: number;
|
||||||
|
external: number;
|
||||||
|
rss: number;
|
||||||
|
// SmartMetrics memory data
|
||||||
|
maxMemoryMB?: number;
|
||||||
|
actualUsageBytes?: number;
|
||||||
|
actualUsagePercentage?: number;
|
||||||
|
};
|
||||||
|
cpuUsage: {
|
||||||
|
user: number;
|
||||||
|
system: number;
|
||||||
|
};
|
||||||
|
activeConnections: number;
|
||||||
|
totalConnections: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEmailStats {
|
||||||
|
sent: number;
|
||||||
|
received: number;
|
||||||
|
bounced: number;
|
||||||
|
queued: number;
|
||||||
|
failed: number;
|
||||||
|
averageDeliveryTime: number;
|
||||||
|
deliveryRate: number;
|
||||||
|
bounceRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDnsStats {
|
||||||
|
totalQueries: number;
|
||||||
|
cacheHits: number;
|
||||||
|
cacheMisses: number;
|
||||||
|
cacheHitRate: number;
|
||||||
|
activeDomains: number;
|
||||||
|
averageResponseTime: number;
|
||||||
|
queryTypes: {
|
||||||
|
[key: string]: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRateLimitInfo {
|
||||||
|
domain: string;
|
||||||
|
currentRate: number;
|
||||||
|
limit: number;
|
||||||
|
remaining: number;
|
||||||
|
resetTime: number;
|
||||||
|
blocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISecurityMetrics {
|
||||||
|
blockedIPs: string[];
|
||||||
|
reputationScores: {
|
||||||
|
[domain: string]: number;
|
||||||
|
};
|
||||||
|
spamDetected: number;
|
||||||
|
malwareDetected: number;
|
||||||
|
phishingDetected: number;
|
||||||
|
authenticationFailures: number;
|
||||||
|
suspiciousActivities: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILogEntry {
|
||||||
|
timestamp: number;
|
||||||
|
level: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
|
message: string;
|
||||||
|
metadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConnectionInfo {
|
||||||
|
id: string;
|
||||||
|
remoteAddress: string;
|
||||||
|
localAddress: string;
|
||||||
|
startTime: number;
|
||||||
|
protocol: 'smtp' | 'smtps' | 'http' | 'https';
|
||||||
|
state: 'connecting' | 'connected' | 'authenticated' | 'transmitting' | 'closing';
|
||||||
|
bytesReceived: number;
|
||||||
|
bytesSent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IQueueStatus {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
processing: number;
|
||||||
|
failed: number;
|
||||||
|
retrying: number;
|
||||||
|
averageProcessingTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IHealthStatus {
|
||||||
|
healthy: boolean;
|
||||||
|
uptime: number;
|
||||||
|
services: {
|
||||||
|
[service: string]: {
|
||||||
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
message?: string;
|
||||||
|
lastCheck: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INetworkMetrics {
|
||||||
|
totalBandwidth: {
|
||||||
|
in: number;
|
||||||
|
out: number;
|
||||||
|
};
|
||||||
|
activeConnections: number;
|
||||||
|
connectionDetails: IConnectionDetails[];
|
||||||
|
topEndpoints: Array<{
|
||||||
|
endpoint: string;
|
||||||
|
requests: number;
|
||||||
|
bandwidth: {
|
||||||
|
in: number;
|
||||||
|
out: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConnectionDetails {
|
||||||
|
remoteAddress: string;
|
||||||
|
protocol: 'http' | 'https' | 'smtp' | 'smtps';
|
||||||
|
state: 'connecting' | 'connected' | 'established' | 'closing';
|
||||||
|
startTime: number;
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
}
|
||||||
9
ts_interfaces/index.ts
Normal file
9
ts_interfaces/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './plugins.js';
|
||||||
|
|
||||||
|
// Data types
|
||||||
|
import * as data from './data/index.js';
|
||||||
|
export { data };
|
||||||
|
|
||||||
|
// Request interfaces
|
||||||
|
import * as requests from './requests/index.js';
|
||||||
|
export { requests };
|
||||||
6
ts_interfaces/plugins.ts
Normal file
6
ts_interfaces/plugins.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// @apiglobal scope
|
||||||
|
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||||
|
|
||||||
|
export {
|
||||||
|
typedrequestInterfaces
|
||||||
|
}
|
||||||
205
ts_interfaces/readme.md
Normal file
205
ts_interfaces/readme.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# @serve.zone/dcrouter-interfaces
|
||||||
|
|
||||||
|
TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡
|
||||||
|
|
||||||
|
This module provides strongly-typed interfaces for communicating with the DcRouter OpsServer via [TypedRequest](https://code.foss.global/api.global/typedrequest). Use these interfaces for type-safe API interactions in your frontend applications or integration code.
|
||||||
|
|
||||||
|
## Issue Reporting and Security
|
||||||
|
|
||||||
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @serve.zone/dcrouter-interfaces
|
||||||
|
```
|
||||||
|
|
||||||
|
Or import directly from the main package:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||||
|
|
||||||
|
// Use data interfaces for type definitions
|
||||||
|
const identity: data.IIdentity = {
|
||||||
|
jwt: 'your-jwt-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
name: 'Admin User',
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use request interfaces for API calls
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
|
||||||
|
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
|
||||||
|
'https://your-dcrouter:3000/typedrequest',
|
||||||
|
'getServerStatistics'
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = await statsClient.fire({
|
||||||
|
identity,
|
||||||
|
includeHistory: true,
|
||||||
|
timeRange: '24h'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
### Data Interfaces (`data`)
|
||||||
|
|
||||||
|
Core data types used throughout the DcRouter system:
|
||||||
|
|
||||||
|
#### `IIdentity`
|
||||||
|
Authentication identity for API requests:
|
||||||
|
```typescript
|
||||||
|
interface IIdentity {
|
||||||
|
jwt: string; // JWT token
|
||||||
|
userId: string; // Unique user ID
|
||||||
|
name: string; // Display name
|
||||||
|
expiresAt: number; // Token expiration timestamp
|
||||||
|
role?: string; // User role (e.g., 'admin')
|
||||||
|
type?: string; // Identity type
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Statistics Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `IServerStats` | Uptime, memory, CPU, connection counts |
|
||||||
|
| `IEmailStats` | Sent/received/bounced/queued/failed, delivery & bounce rates |
|
||||||
|
| `IDnsStats` | Total queries, cache hits/misses, query types |
|
||||||
|
| `IRateLimitInfo` | Domain rate limit status (current rate, limit, remaining) |
|
||||||
|
| `ISecurityMetrics` | Blocked IPs, spam/malware/phishing counts |
|
||||||
|
| `IConnectionInfo` | Connection ID, remote address, protocol, state, bytes |
|
||||||
|
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
||||||
|
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
||||||
|
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||||
|
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||||
|
|
||||||
|
### Request Interfaces (`requests`)
|
||||||
|
|
||||||
|
TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||||
|
|
||||||
|
#### 🔐 Authentication
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_AdminLoginWithUsernameAndPassword` | `adminLoginWithUsernameAndPassword` | Authenticate as admin |
|
||||||
|
| `IReq_AdminLogout` | `adminLogout` | End admin session |
|
||||||
|
| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token validity |
|
||||||
|
|
||||||
|
#### 📊 Statistics
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetServerStatistics` | `getServerStatistics` | Overall server stats |
|
||||||
|
| `IReq_GetEmailStatistics` | `getEmailStatistics` | Email throughput metrics |
|
||||||
|
| `IReq_GetDnsStatistics` | `getDnsStatistics` | DNS query stats |
|
||||||
|
| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Rate limit status |
|
||||||
|
| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Security event metrics |
|
||||||
|
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
||||||
|
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
||||||
|
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||||
|
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request |
|
||||||
|
|
||||||
|
#### ⚙️ Configuration
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetConfiguration` | `getConfiguration` | Current config (read-only) |
|
||||||
|
|
||||||
|
#### 📜 Logs
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetRecentLogs` | `getLogs` | Retrieve system logs |
|
||||||
|
| `IReq_GetLogStream` | `getLogStream` | Stream live logs |
|
||||||
|
|
||||||
|
#### 📧 Email Operations
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetQueuedEmails` | `getQueuedEmails` | List queued emails |
|
||||||
|
| `IReq_GetSentEmails` | `getSentEmails` | List delivered emails |
|
||||||
|
| `IReq_GetFailedEmails` | `getFailedEmails` | List failed emails |
|
||||||
|
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
|
||||||
|
| `IReq_GetSecurityIncidents` | `getSecurityIncidents` | Security events |
|
||||||
|
| `IReq_GetBounceRecords` | `getBounceRecords` | Bounce records |
|
||||||
|
| `IReq_RemoveFromSuppressionList` | `removeFromSuppressionList` | Unsuppress an address |
|
||||||
|
|
||||||
|
#### 📡 RADIUS
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetRadiusClients` | `getRadiusClients` | List NAS clients |
|
||||||
|
| `IReq_SetRadiusClient` | `setRadiusClient` | Add/update a NAS client |
|
||||||
|
| `IReq_RemoveRadiusClient` | `removeRadiusClient` | Remove a NAS client |
|
||||||
|
| `IReq_GetVlanMappings` | `getVlanMappings` | List VLAN mappings |
|
||||||
|
| `IReq_SetVlanMapping` | `setVlanMapping` | Add/update VLAN mapping |
|
||||||
|
| `IReq_RemoveVlanMapping` | `removeVlanMapping` | Remove VLAN mapping |
|
||||||
|
| `IReq_TestVlanAssignment` | `testVlanAssignment` | Test what VLAN a MAC gets |
|
||||||
|
| `IReq_GetRadiusSessions` | `getRadiusSessions` | List active sessions |
|
||||||
|
| `IReq_DisconnectRadiusSession` | `disconnectRadiusSession` | Force disconnect |
|
||||||
|
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
||||||
|
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
||||||
|
|
||||||
|
## Example: Full API Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||||
|
|
||||||
|
// 1. Login
|
||||||
|
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'https://your-dcrouter:3000/typedrequest',
|
||||||
|
'adminLogin'
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginResponse = await loginClient.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'your-password'
|
||||||
|
});
|
||||||
|
const identity = loginResponse.identity;
|
||||||
|
|
||||||
|
// 2. Fetch combined metrics
|
||||||
|
const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMetrics>(
|
||||||
|
'https://your-dcrouter:3000/typedrequest',
|
||||||
|
'getCombinedMetrics'
|
||||||
|
);
|
||||||
|
|
||||||
|
const metrics = await metricsClient.fire({ identity });
|
||||||
|
console.log('Server:', metrics.serverStats);
|
||||||
|
console.log('Email:', metrics.emailStats);
|
||||||
|
console.log('DNS:', metrics.dnsStats);
|
||||||
|
console.log('Security:', metrics.securityMetrics);
|
||||||
|
|
||||||
|
// 3. Check email queues
|
||||||
|
const queueClient = new typedrequest.TypedRequest<requests.IReq_GetQueuedEmails>(
|
||||||
|
'https://your-dcrouter:3000/typedrequest',
|
||||||
|
'getQueuedEmails'
|
||||||
|
);
|
||||||
|
|
||||||
|
const queued = await queueClient.fire({ identity });
|
||||||
|
console.log('Queued emails:', queued.emails.length);
|
||||||
|
```
|
||||||
|
|
||||||
|
## License and Legal Information
|
||||||
|
|
||||||
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||||
|
|
||||||
|
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
### Trademarks
|
||||||
|
|
||||||
|
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||||
|
|
||||||
|
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||||
|
|
||||||
|
### Company Information
|
||||||
|
|
||||||
|
Task Venture Capital GmbH
|
||||||
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||||
|
|
||||||
|
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||||
|
|
||||||
|
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||||
46
ts_interfaces/requests/admin.ts
Normal file
46
ts_interfaces/requests/admin.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
|
// Admin Login
|
||||||
|
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AdminLoginWithUsernameAndPassword
|
||||||
|
> {
|
||||||
|
method: 'adminLoginWithUsernameAndPassword';
|
||||||
|
request: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Logout
|
||||||
|
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AdminLogout
|
||||||
|
> {
|
||||||
|
method: 'adminLogout';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Identity
|
||||||
|
export interface IReq_VerifyIdentity extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_VerifyIdentity
|
||||||
|
> {
|
||||||
|
method: 'verifyIdentity';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
valid: boolean;
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user