Compare commits

...

244 Commits

Author SHA1 Message Date
d10896196d v5.2.0
Some checks failed
Docker (tags) / security (push) Has been cancelled
Docker (tags) / test (push) Has been cancelled
Docker (tags) / release (push) Has been cancelled
Docker (tags) / metadata (push) Has been cancelled
2026-02-13 14:19:19 +00:00
8be1e87bdc feat(monitoring): add throughput metrics and expose them in ops UI 2026-02-13 14:19:19 +00:00
96cefe984a v5.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-13 12:12:01 +00:00
ca112c3e42 feat(acme): Integrate SmartAcme DNS-01 handling and add certificate provisioning for SmartProxy 2026-02-13 12:12:01 +00:00
85b6c4fa51 v5.0.7
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-13 00:02:09 +00:00
ee550e6f25 fix(deps): bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2 2026-02-13 00:02:09 +00:00
108a8bb51d v5.0.6
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-12 22:51:55 +00:00
3c5b26d1c1 fix(deps): bump @push.rocks/smartproxy to ^23.1.4 2026-02-12 22:51:55 +00:00
01fbc3db95 v5.0.5
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-12 16:27:28 +00:00
8dd9770339 fix(dcrouter): remove legacy handling of emailConfig.routes that added domain-based routes 2026-02-12 16:27:28 +00:00
77842647fd v5.0.4
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-12 14:20:42 +00:00
a309145829 fix(cache): use user-writable ~/.serve.zone/dcrouter for TsmDB and centralize data path logic 2026-02-12 14:20:42 +00:00
5de8d38b78 v5.0.3
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-12 13:41:32 +00:00
2d6dbc552e fix(packaging): add files whitelist to package.json and remove Playwright-generated screenshots 2026-02-12 13:41:32 +00:00
f0fae866dc v5.0.2
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-12 10:15:26 +00:00
87c039a63f fix(docs): update documentation and packaging configuration: document smartmta/smartdns integrations, adjust API method names, and add release registry info 2026-02-12 10:15:26 +00:00
2c875cbb18 v5.0.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-11 17:24:17 +00:00
735464e8e6 fix(deps/tests): bump two dependencies and disable cache in tests 2026-02-11 17:24:17 +00:00
e6a1f50554 v5.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-11 16:32:49 +00:00
530ebbf3e4 BREAKING CHANGE(mta): migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation 2026-02-11 16:32:49 +00:00
048f038e36 v4.1.1
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-10 14:41:19 +00:00
e375adb80a fix(smartproxy): upgrade @push.rocks/smartproxy to ^23.1.0 and adapt code/tests for its async getStatistics() API 2026-02-10 14:41:19 +00:00
9d7da5bc25 v4.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-10 11:22:15 +00:00
41fe7a8a47 feat(cache): add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration 2026-02-10 11:22:15 +00:00
f3f1f58b67 v4.0.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-03 23:26:51 +00:00
9e0e77737b BREAKING CHANGE(config): convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing 2026-02-03 23:26:51 +00:00
5de3344905 v3.1.0
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-02 22:21:55 +00:00
ae34314f54 feat(web): determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies 2026-02-02 22:21:55 +00:00
5b473de354 v3.0.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-02 00:36:19 +00:00
1a108fa8b7 BREAKING CHANGE(deps): upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support 2026-02-02 00:36:19 +00:00
badabe753a v2.13.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-01 19:21:37 +00:00
c2d3ace0dd feat(radius): add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers 2026-02-01 19:21:37 +00:00
fcea194cf6 v2.12.6
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-01 18:10:30 +00:00
b90650c660 fix(tests): update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience 2026-02-01 18:10:30 +00:00
2206abd04b v2.12.5
Some checks failed
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-02-01 14:17:54 +00:00
d54831765b fix(mail): migrate filesystem helpers to fsUtils, update DKIM and mail APIs, harden SMTP client, and bump dependencies 2026-02-01 14:17:54 +00:00
dd4ac9fa3d update menu 2025-07-04 18:58:10 +00:00
aed9151998 update 2025-07-04 18:50:15 +00:00
5d4bf4eff8 update 2025-07-03 04:04:43 +00:00
9027125520 update 2025-07-03 01:53:50 +00:00
ee561c0823 update 2025-07-03 01:50:46 +00:00
95cb5d7840 update frontend 2025-07-02 19:18:14 +00:00
2f46b3c9f3 update 2025-07-02 11:33:50 +00:00
7bd94884f4 update 2025-06-29 18:47:44 +00:00
405990563b update UI 2025-06-27 09:28:07 +00:00
bf9f805c71 fix(metrics): fix metrics 2025-06-23 13:24:43 +00:00
28cbf84f97 fix(metrics): fix metrics 2025-06-23 00:19:47 +00:00
d24e51117d fix(metrics): fix metrics 2025-06-22 23:40:02 +00:00
92fde9d0d7 feat: Implement network metrics integration and UI updates for real-time data display 2025-06-20 10:56:53 +00:00
b81bda6ce8 update docs 2025-06-20 00:44:04 +00:00
9b3f5c458d Refactor code structure for improved readability and maintainability 2025-06-20 00:37:29 +00:00
3ba47f9a71 fix: update styles in various components to use dynamic theming and improve layout consistency 2025-06-19 12:14:52 +00:00
2ab2e30336 fix: update dependencies and improve email view layout in OpsViewEmails component 2025-06-17 14:37:05 +00:00
8ce6c88d58 feat: Integrate SmartMetrics for enhanced CPU and memory monitoring in UI 2025-06-12 11:22:18 +00:00
facae93e4b feat: Implement dees-statsgrid in DCRouter UI for enhanced stats visualization
- Added new readme.statsgrid.md outlining the implementation plan for dees-statsgrid component.
- Replaced custom stats cards in ops-view-overview.ts and ops-view-network.ts with dees-statsgrid for better visualization.
- Introduced consistent color scheme for success, warning, error, and info states.
- Enhanced interactive features including click actions, context menus, and real-time updates.
- Developed ops-view-emails.ts for email management with features like composing, searching, and viewing emails.
- Integrated mock data generation for emails and network requests to facilitate testing.
- Added responsive design elements and improved UI consistency across components.
2025-06-12 08:04:30 +00:00
0eb4963247 fix: update @push.rocks/smartproxy to version 19.6.2 and adjust refresh intervals in app state 2025-06-10 16:09:41 +00:00
02dd3c77b5 fix: update @push.rocks/smartproxy to version 19.6.1 and improve socket management in ConnectionManager
feat: enhance MetricsManager with reset interval and top domains tracking
2025-06-09 17:18:50 +00:00
93995d5031 Implement Metrics Manager and Integrate Metrics Collection
- Removed the existing readme.opsserver.md file as it is no longer needed.
- Added a new MetricsManager class to handle metrics collection using @push.rocks/smartmetrics.
- Integrated MetricsManager into the DcRouter and OpsServer classes.
- Updated StatsHandler and SecurityHandler to retrieve metrics from MetricsManager.
- Implemented methods for tracking email, DNS, and security metrics.
- Added connection tracking capabilities to the MetricsManager.
- Created a new readme.metrics.md file outlining the metrics implementation plan.
- Adjusted plugins.ts to include smartmetrics.
- Added a new monitoring directory with classes for metrics management.
- Created readme.module-adjustments.md to document necessary adjustments for SmartProxy and SmartDNS.
2025-06-09 16:03:27 +00:00
554d245c0c 2.12.4
Some checks failed
Docker (tags) / security (push) Failing after 20s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:51:57 +00:00
e3cb35a036 fix(web ui): login 2025-06-08 12:51:48 +00:00
3a95ea9f4e update 2025-06-08 12:39:53 +00:00
99f57dba76 2.12.3
Some checks failed
Docker (tags) / security (push) Failing after 26s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:09:39 +00:00
415e28038d feat: add TypeScript interfaces for authentication and server statistics 2025-06-08 12:09:09 +00:00
7bda406624 2.12.2
Some checks failed
Docker (tags) / security (push) Failing after 28s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:06:21 +00:00
8282610307 2.12.1
Some checks failed
Docker (tags) / security (push) Failing after 30s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-06-08 12:05:44 +00:00
5269c20770 fix(dependencies): update @push.rocks/smartproxy to version 19.5.25 2025-06-08 12:05:40 +00:00
f1fb4c8495 feat: Add operations view components for logs, overview, security, and stats
- Implemented `ops-view-logs` for displaying and filtering logs with streaming capabilities.
- Created `ops-view-overview` to show server, email, DNS statistics, and charts.
- Developed `ops-view-security` for monitoring security metrics, blocked IPs, and authentication attempts.
- Added `ops-view-stats` to present comprehensive statistics on server, email, DNS, and security metrics.
- Introduced shared styles and components including `ops-sectionheading` for consistent UI.
2025-06-08 12:03:17 +00:00
5faca8c1b6 feat(auth): implement JWT-based authentication with admin access controls 2025-06-08 07:19:31 +00:00
61778bdba8 feat(ops-server): implement TypedRouter integration and modular handler classes 2025-06-08 07:04:35 +00:00
ab19130904 feat(ts_interfaces): add TypedRequest interfaces for admin and configuration requests
fix(dependencies): include @api.global/typedrequest-interfaces in package.json
chore(docs): create OpsServer implementation plan in readme.opsserver.md
2025-06-07 17:28:15 +00:00
646aa7106b fix(appstate): update import statement to use plugins module for Smartstate initialization 2025-06-07 16:00:54 +00:00
b0f167f6da feat(appstate): initialize appState with Smartstate from domtools 2025-06-07 11:40:31 +00:00
4d8d802006 fix(dependencies): update @types/node and other dependencies to latest versions 2025-06-07 11:25:03 +00:00
6ee1d6e917 feat(ops-dashboard): implement OpsServer and dashboard component with initial rendering 2025-06-01 19:46:10 +00:00
f877ad9676 fix(dependencies): add ui catalog for minimal local dashboard with statistics. 2025-05-31 16:34:39 +00:00
fe817dde00 feat(logging): add professional startup logging to DcRouter
- Add logStartupSummary() method with clean ASCII art header
- Display service status for SmartProxy, Email, DNS, and Storage
- Show detailed configuration info for each service
- Replace verbose console logs with structured startup summary
2025-05-31 15:58:56 +00:00
272973702e feat(dns): implement DKIM record serving and proactive key generation
- Add loadDkimRecords() method to read DKIM records from JSON files
- Integrate DKIM records into DNS server during startup
- Add initializeDkimForEmailDomains() for proactive DKIM key generation
- Ensure DKIM records are available immediately after server startup
- Update documentation with DKIM implementation status

DKIM records are now automatically loaded from .nogit/data/dns/*.dkimrecord.json
and served via DNS. Keys are generated for all configured email domains at startup.
2025-05-31 12:53:29 +00:00
c776dab2c0 fix(nameservers): fix ip records 2025-05-30 20:11:44 +00:00
74692c4aa5 fix(dns): Fixed Soa records 2025-05-30 19:54:48 +00:00
71183b35c0 fix(dns): register separate handlers for each DNS record to serve multiple records
The previous implementation grouped records by domain and only returned the first
matching record. This prevented serving multiple NS records for a domain, which
caused GoDaddy to reject the nameservers.

Changes:
- Modified registerDnsRecords to register a separate handler for each record
- This works around smartdns limitation where it breaks after first handler match
- Now all NS records are properly served in DNS responses
- Added readme.smartdns.md documenting the underlying issue in smartdns module

The root cause is in smartdns DnsServer which breaks after finding the first
matching handler, preventing multiple records of the same type from being served.
2025-05-30 16:44:10 +00:00
ae73de19b2 fix(dns): update DnsManager to use new DNS configuration properties
The DnsManager was still checking for the old dnsDomain property that was
replaced by dnsNsDomains and dnsScopes in the DNS Architecture Improvements.

Changes:
- Replace dnsDomain checks with dnsNsDomains and dnsScopes validation
- Add check to ensure email domain is included in dnsScopes array
- Update NS delegation check to work with multiple nameservers
- Update error messages to guide users to the new configuration format
2025-05-30 16:26:31 +00:00
a2b413a78f fix(test): repair SMTP test suite after rate limiter integration
The test helper's mock email server was missing the getRateLimiter() method
that was added during the rate limiting feature implementation. This caused
all SMTP tests to fail with "getRateLimiter is not a function" error.

Changes:
- Add getRateLimiter() method to mock email server that returns a mock rate limiter
- Update mock rate limiter method signatures to match actual implementation
- Fix TypeScript type issue with auth options by adding explicit casting
2025-05-30 16:17:02 +00:00
739eeb63aa update 2025-05-30 15:04:12 +00:00
eb26a62a87 fix(config): Update dns config interface within DcRouter 2025-05-30 10:34:50 +00:00
ad0ab6c103 test(dns): add comprehensive tests for DNS record creation
- Add test.dns-manager-creation.ts to verify DNS record creation
- Test MX, SPF, DMARC, and DKIM record registration
- Verify records are stored in StorageManager
- Update readme.hints.md with DNS architecture refactoring notes
2025-05-30 09:29:03 +00:00
37e1ecefd2 refactor(dns): extend DnsValidator to DnsManager with DNS record creation
- Rename DnsValidator to DnsManager to better reflect its expanded responsibilities
- Move DNS record creation logic from UnifiedEmailServer to DnsManager
- Add ensureDnsRecords() method that handles both validation and creation
- Consolidate internal DNS record creation (MX, SPF, DMARC) in one place
- Keep DKIM key generation in UnifiedEmailServer but move DNS registration to DnsManager
- Update all imports and tests to use DnsManager instead of DnsValidator
- Improve code organization and discoverability of DNS functionality
2025-05-30 08:52:07 +00:00
e6251ab655 docs(readme): update documentation with StorageManager and domain configuration features
- Add flexible storage system section with backend examples
- Document email domain configuration with DNS modes
- Update configuration interfaces with storage options
- Add examples for filesystem, custom, and memory storage
- Include data migration examples between backends
- Document storage usage patterns and key structure
- Update test suite documentation with new test categories
2025-05-30 07:06:00 +00:00
53b64025f3 feat(storage): implement StorageManager with filesystem support and component integration
- Add StorageManager with filesystem, custom, and memory backends
- Update DKIMCreator and BounceManager to use StorageManager
- Remove component-level storage warnings (handled by StorageManager)
- Fix list() method for filesystem backend
- Add comprehensive storage and integration tests
- Implement DNS mode switching tests
- Complete Phase 4 testing tasks from plan
2025-05-30 07:00:59 +00:00
40db395591 feat(integration): components now play nicer with each other 2025-05-30 05:30:06 +00:00
2c244c4a9a update 2025-05-29 21:21:59 +00:00
0baf2562b7 fix(plan): create plan for improving email setup. 2025-05-29 21:21:27 +00:00
64da8d9100 test(socket-handler): add comprehensive tests for DNS and email socket-handler functionality
- Add unit tests for DNS route generation and socket handler creation
- Add unit tests for email route generation in both modes
- Add integration tests for combined DNS and email configuration
- Test TLS handling differences between email ports
- Verify socket-handler vs traditional forwarding mode behavior
- All tests pass without requiring actual port binding
- Mark implementation plan as complete with full test coverage
2025-05-29 16:44:34 +00:00
b11fea7334 feat(socket-handler): implement direct socket passing for DNS and email services
- Add socket-handler mode eliminating internal port binding for improved performance
- Add `dnsDomain` config option for automatic DNS-over-HTTPS (DoH) setup
- Add `useSocketHandler` flag to email config for direct socket processing
- Update SmartProxy route generation to support socket-handler actions
- Integrate smartdns with manual HTTPS mode for DoH without port binding
- Add automatic route creation for DNS paths when dnsDomain is configured
- Update documentation with socket-handler configuration and benefits
- Improve resource efficiency by eliminating internal port forwarding
2025-05-29 16:26:19 +00:00
6c8458f63c update 2025-05-28 18:07:07 +00:00
455b0085ec update 2025-05-28 15:32:35 +00:00
2b2fe940c4 fix(test): update tests 2025-05-28 15:24:34 +00:00
e1a7b3e8f7 Complete email router implementation and documentation
- Cleaned up interface definitions to only include implemented features
- Updated readme.md with comprehensive route-based configuration examples
- Added common email routing patterns and troubleshooting guide
- Removed legacy DomainRouter and IDomainRule interfaces
- Updated all imports and exports to use new EmailRouter system
- Verified build and core functionality tests pass

The match/action pattern implementation is now complete and production-ready.
2025-05-28 14:12:50 +00:00
191c4160c1 Complete match/action pattern integration testing
 All integration tests passing
- Route-based forwarding with priority: 5/5 scenarios
- CIDR IP matching: 4/4 test cases
- Authentication-based routing: 3/3 scenarios
- Pattern caching performance: Working
- Dynamic route updates: Working

The match/action pattern implementation is now complete and fully functional.
2025-05-28 13:45:03 +00:00
2e75961d1c feat: implement comprehensive route-based email routing system
Replace legacy domain-rule based routing with flexible route-based system that supports:
- Multi-criteria matching (recipients, senders, IPs, authentication)
- Four action types (forward, process, deliver, reject)
- Moved DKIM signing to delivery phase for signature validity
- Connection pooling for efficient email forwarding
- Pattern caching for improved performance

This provides more granular control over email routing with priority-based matching and comprehensive test coverage.
2025-05-28 13:23:45 +00:00
88099e120a feat: implement route-based email routing system
- Add core interfaces (IEmailRoute, IEmailMatch, IEmailAction, IEmailContext)
- Create EmailRouter class with comprehensive matching capabilities
- Support for recipient/sender patterns, IP/CIDR matching, auth checks
- Add content matching (headers, size, subject, attachments)
- Implement pattern caching for performance
- Update plan with completed steps
2025-05-28 12:07:37 +00:00
77ff948404 update 2025-05-28 11:39:54 +00:00
0e610cba16 update 2025-05-28 11:39:46 +00:00
8d59d617f1 fix(interfaces): Remove legacy interfaces 2025-05-27 21:03:17 +00:00
6aa54d974e fix(mail options): simplify mail options 2025-05-27 19:28:12 +00:00
2aeb52bf13 fix(structure): Unify structure even further 2025-05-27 18:00:14 +00:00
243a45d24c feat(structure): Use unified Email class 2025-05-27 15:38:34 +00:00
cfea44742a update 2025-05-27 15:06:44 +00:00
073c8378c7 update 2025-05-27 14:06:22 +00:00
af408d38c9 feat(plan): simplify structure 2025-05-27 12:56:12 +00:00
c3b14c0f58 update 2025-05-27 10:39:29 +00:00
69304dc839 update 2025-05-26 16:14:49 +00:00
a3721f7a74 update 2025-05-26 14:50:55 +00:00
20583beb35 update 2025-05-26 12:23:19 +00:00
b8ea8f660e update 2025-05-26 10:35:50 +00:00
5a45d6cd45 update 2025-05-26 04:09:29 +00:00
84196f9b13 update 2025-05-25 19:05:43 +00:00
4c9fd22a86 update 2025-05-25 19:02:18 +00:00
5b33623c2d update 2025-05-25 11:18:12 +00:00
58f4a123d2 update 2025-05-24 18:12:08 +00:00
11a2ae6b27 update 2025-05-24 17:00:59 +00:00
4e4c7df558 update 2025-05-24 16:19:19 +00:00
3d669ed9dd update 2025-05-24 14:50:24 +00:00
6e19e30f87 update 2025-05-24 14:39:48 +00:00
dc5c0b2584 update 2025-05-24 13:37:19 +00:00
35712b18bc update 2025-05-24 11:34:05 +00:00
9958c036a0 update 2025-05-24 08:59:30 +00:00
14c9fbdc3c update 2025-05-24 02:38:45 +00:00
4fd3ec2958 update 2025-05-24 02:27:50 +00:00
f2e9ff0a51 update 2025-05-24 01:00:30 +00:00
cb52446f65 update 2025-05-24 00:23:35 +00:00
0907949f8a update 2025-05-23 21:20:39 +00:00
9629329bc2 update 2025-05-23 21:20:32 +00:00
f651cd1c2f update 2025-05-23 20:40:20 +00:00
a7438a7cd6 update 2025-05-23 19:49:25 +00:00
e0f6e3237b update 2025-05-23 19:09:30 +00:00
1b141ec8f3 update 2025-05-23 19:03:44 +00:00
7d28d23bbd update 2025-05-23 08:52:02 +00:00
53f5e30b23 update 2025-05-23 08:17:34 +00:00
7344bf0f70 update 2025-05-23 01:00:37 +00:00
4905595cbb update 2025-05-23 00:06:07 +00:00
f058b2d1e7 update 2025-05-22 23:09:41 +00:00
6fcc3feb73 update 2025-05-22 23:02:51 +00:00
50350bd78d update 2025-05-22 23:02:37 +00:00
f065a9c952 update 2025-05-22 18:38:04 +00:00
72898c67b7 fix(update package naming): Now exported correctly as @serve.zone/dcrouter 2025-05-22 13:10:45 +00:00
ca53816b41 BREAKING_CHANGE(structure): rebrand as dcrouter 2025-05-22 13:03:09 +00:00
ac419e7b79 update 2025-05-22 10:18:02 +00:00
7c0f9b4e44 update 2025-05-22 09:39:31 +00:00
d584f3584c update 2025-05-22 09:22:55 +00:00
a4353b10bb update 2025-05-22 00:38:04 +00:00
b2f25c49b6 update 2025-05-22 00:11:33 +00:00
d3255a7e14 update 2025-05-21 23:37:29 +00:00
2564d0874b update 2025-05-21 21:29:04 +00:00
ca111f4783 update 2025-05-21 19:08:50 +00:00
b6dd281a54 update 2025-05-21 18:52:04 +00:00
645790d0c2 update 2025-05-21 17:33:16 +00:00
535b055664 update 2025-05-21 17:05:42 +00:00
2eeb731669 update 2025-05-21 16:17:17 +00:00
c3ae995372 update 2025-05-21 14:45:17 +00:00
15e7a3032c update 2025-05-21 14:38:58 +00:00
10ab09894b update 2025-05-21 14:28:33 +00:00
38811dbf23 update 2025-05-21 13:42:12 +00:00
3f220996ee update 2025-05-21 12:52:24 +00:00
b0a0078ad0 update 2025-05-21 10:38:22 +00:00
ecb913843c update 2025-05-21 10:00:06 +00:00
162795802f update 2025-05-21 02:17:18 +00:00
b1890f59ee update 2025-05-21 00:12:49 +00:00
5c85188183 update 2025-05-21 00:12:39 +00:00
f37cddf26d update 2025-05-20 19:46:59 +00:00
f3f06ed06d update 2025-05-20 11:04:09 +00:00
07f03eb834 update 2025-05-19 23:43:21 +00:00
e7174e8630 update 2025-05-19 17:34:48 +00:00
186e94c1a2 2.12.0 2025-05-16 15:50:46 +00:00
fb424d814c feat(smartproxy): Update documentation and configuration guides to adopt new route-based SmartProxy architecture 2025-05-16 15:50:46 +00:00
0ad5dfd6ee 2.11.2 2025-05-16 15:26:47 +00:00
fbaafa909b fix(dependencies): Update dependency versions and adjust test imports to use new packages 2025-05-16 15:26:47 +00:00
f1cc7fd340 2.11.1 2025-05-08 13:00:11 +00:00
deec61da42 fix(platform): Update commit info with no functional changes; regenerated commit information. 2025-05-08 13:00:10 +00:00
190ae11667 2.11.0 2025-05-08 12:56:17 +00:00
f4ace3999d 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. 2025-05-08 12:56:17 +00:00
8b857e3d1d update 2025-05-08 12:46:10 +00:00
7aaf8f2595 2.8.9 2025-05-08 10:39:43 +00:00
39b634b6bb fix(types): Fix TypeScript build errors and improve API type safety across platformservice interfaces 2025-05-08 10:39:43 +00:00
4624fdbe10 2.8.6 2025-05-08 10:24:50 +00:00
858794799b 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. 2025-05-08 10:24:50 +00:00
cb33dd26d0 2.8.4 2025-05-08 01:37:38 +00:00
d3d197d9d3 fix(mail): refactor(mail): Remove Mailgun references from PlatformService. Update keywords, error messages, and documentation to use MTA exclusively. 2025-05-08 01:37:38 +00:00
0e914a3366 2.8.2 2025-05-08 01:24:03 +00:00
747478f0f9 fix(tests): Fix outdated import paths in test files for dcrouter and ratelimiter modules 2025-05-08 01:24:03 +00:00
b61de33ee0 2.8.1 2025-05-08 01:16:21 +00:00
970c0d5c60 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.
2025-05-08 01:16:21 +00:00
fe2069c48e update 2025-05-08 01:13:54 +00:00
63781ab1bd 2.8.0 2025-05-08 00:39:43 +00:00
0b155d6925 feat(docs): Update documentation to include consolidated email handling and pattern‑based routing details 2025-05-08 00:39:43 +00:00
076aac27ce 2.7.0 2025-05-08 00:12:36 +00:00
7f84405279 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. 2025-05-08 00:12:36 +00:00
13ef31c13f 2.6.0 2025-05-07 23:45:20 +00:00
5cf4c0f150 feat(dcrouter): Implement integrated DcRouter with comprehensive SmartProxy configuration, enhanced SMTP processing, and robust store‐and‐forward email routing 2025-05-07 23:45:19 +00:00
04b7552b34 update plan 2025-05-07 23:30:04 +00:00
1528d29b0d 2.5.0 2025-05-07 23:04:54 +00:00
9d895898b1 feat(dcrouter): Enhance DcRouter configuration and update documentation 2025-05-07 23:04:54 +00:00
45be1e0a42 2.4.2 2025-05-07 22:15:08 +00:00
ba39392c1b fix(tests): Update test assertions and singleton instance references in DMARC, integration, and IP warmup manager tests 2025-05-07 22:15:08 +00:00
f704dc78aa 2.4.1 2025-05-07 22:06:55 +00:00
7e931d6c52 fix(tests): Update test assertions and refine service interfaces 2025-05-07 22:06:55 +00:00
630e911589 update 2025-05-07 20:20:17 +00:00
f6377d1973 2.4.0 2025-05-07 17:41:04 +00:00
c852e954c9 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. 2025-05-07 17:41:04 +00:00
2ee66ef967 update 2025-05-07 14:33:20 +00:00
5ad43470f3 2.3.1 2025-05-04 10:10:07 +00:00
efd64d6304 fix(platformservice): Update dependency versions and refactor import paths for improved compatibility; add initial DcRouter plan documentation. 2025-05-04 10:10:07 +00:00
a29cff2fc5 2.3.0 2025-03-15 16:24:56 +00:00
d161fe4f19 feat(platformservice): Add AIBridge module and refactor service file paths for improved module organization 2025-03-15 16:24:56 +00:00
df9a8ad14e 2.2.1 2025-03-15 16:21:37 +00:00
8ddad6e652 fix(platformservice): Refactor module structure to update import paths and file organization 2025-03-15 16:21:37 +00:00
3d36d3d1c5 2.2.0 2025-03-15 16:14:49 +00:00
329320cd40 feat(plugins): Add smartproxy support by including the @push.rocks/smartproxy dependency and exporting it in the plugins module. 2025-03-15 16:14:49 +00:00
63ecf60543 2.1.0 2025-03-15 16:09:18 +00:00
87917f68fb feat(MTA): Update readme with detailed Mail Transfer Agent usage and examples 2025-03-15 16:09:18 +00:00
018b499010 2.0.0 2025-03-15 16:04:03 +00:00
a4d79c2d01 BREAKING CHANGE(platformservice): Remove deprecated AIBridge module and update email service to use the MTA connector; update dependency versions and adjust build scripts in package.json. 2025-03-15 16:04:03 +00:00
90d3e75963 1.1.2 2025-03-15 14:13:02 +00:00
4887ec9d93 fix(mta): Expose HttpResponse.statusCode and add explicit generic type annotations in DNSManager cache retrieval 2025-03-15 14:13:02 +00:00
983e6cb623 1.1.1 2025-03-15 13:57:21 +00:00
e9b2ec0f59 fix(paths): Update directory paths to use a dedicated data directory and add ensureDirectories function for proper directory creation. 2025-03-15 13:57:21 +00:00
c084de9c78 fix(meta): type improvements 2025-03-15 13:52:48 +00:00
2b207833ce 1.1.0 2025-03-15 13:45:29 +00:00
4dc095e662 feat(mta): Enhance MTA service and SMTP server with robust session management, advanced email handling, and integrated API routes 2025-03-15 13:45:29 +00:00
c1311f493f 1.0.11 2024-05-11 12:33:15 +02:00
97cbe6e398 fix(core): update 2024-05-11 12:33:14 +02:00
0bb9c5e1e5 1.0.10 2024-05-11 12:29:04 +02:00
cf90560243 fix(core): update 2024-05-11 12:29:03 +02:00
8def86494a 1.0.9 2024-05-11 12:27:02 +02:00
db46e01f6e fix(core): update 2024-05-11 12:27:01 +02:00
7baf747972 1.0.8 2024-04-01 02:58:27 +02:00
4a17a1073e fix(core): update 2024-04-01 02:58:27 +02:00
8997ded81d 1.0.7 2024-03-19 18:37:25 +01:00
f177d8e9ab fix(core): update 2024-03-19 18:37:24 +01:00
808a9cc856 1.0.6 2024-02-16 20:47:25 +01:00
be1c8d1164 fix(core): update 2024-02-16 20:47:25 +01:00
2ecb2f3aa0 1.0.5 2024-02-16 20:42:26 +01:00
01dcdebda5 fix(core): update 2024-02-16 20:42:26 +01:00
2adcc249de 1.0.4 2024-02-16 13:41:05 +01:00
543e696bfc fix(core): update 2024-02-16 13:41:04 +01:00
125 changed files with 30978 additions and 6967 deletions

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ dist/
dist_*/
# custom
**/.claude/settings.local.json
.nogit/data/
readme.plan.md

403
changelog.md Normal file
View File

@@ -0,0 +1,403 @@
# Changelog
## 2026-02-13 - 5.2.0 - feat(monitoring)
add throughput metrics and expose them in ops UI
- MetricsManager now reports bytesInPerSecond and bytesOutPerSecond as part of throughput
- Extended IServerStats with requestsPerSecond and throughput {bytesIn, bytesOut, bytesInPerSecond, bytesOutPerSecond}
- Stats handler updated to include requestsPerSecond and throughput; fallback stats initialize throughput fields to zero
- Web UI ops overview displays Throughput In/Out (bits/s) and total bytes with new formatting helper
- Bumped dependency @push.rocks/smartproxy to ^23.1.6
## 2026-02-13 - 5.1.0 - feat(acme)
Integrate SmartAcme DNS-01 handling and add certificate provisioning for SmartProxy
- Add smartAcme property and lifecycle management (start/stop) in DcRouter
- Create SmartAcme instance when DNS challenge handlers are present and wire certProvisionFunction to SmartProxy to return certificates for domains
- Fall back to http-01 provisioning on SmartAcme errors for a domain
- Stop SmartAcme during shutdown sequence to clean up resources
- Bump dependency @push.rocks/smartproxy to ^23.1.5
## 2026-02-13 - 5.0.7 - fix(deps)
bump @push.rocks/smartdns to ^7.8.1 and @push.rocks/smartmta to ^5.2.2
- package.json: updated @push.rocks/smartdns from ^7.8.0 to ^7.8.1 (patch)
- package.json: updated @push.rocks/smartmta from ^5.2.1 to ^5.2.2 (patch)
## 2026-02-12 - 5.0.6 - fix(deps)
bump @push.rocks/smartproxy to ^23.1.4
- package.json: @push.rocks/smartproxy ^23.1.2 → ^23.1.4
- Dependency-only version bump, no source code changes
## 2026-02-12 - 5.0.5 - fix(dcrouter)
remove legacy handling of emailConfig.routes that added domain-based routes
- Removed loop that added domain-based email routes from emailConfig.routes into emailRoutes
- Previously created match.domains by extracting the recipient domain (split on '@') and defaulted forward target port to 25
- Removed creation of TLS passthrough configuration for those forwarded routes
- This prevents duplicate or incorrect domain-based routes being appended during email route construction
## 2026-02-12 - 5.0.4 - fix(cache)
use user-writable ~/.serve.zone/dcrouter for TsmDB and centralize data path logic
- Default TsmDB storage changed from /etc/dcrouter/tsmdb to ~/.serve.zone/dcrouter/tsmdb
- Introduced dcrouterHomeDir, dataDir, and defaultTsmDbPath in ts/paths.ts
- CacheDb now defaults to defaultTsmDbPath when no storagePath is provided
- DcRouter initialization updated to use paths.defaultTsmDbPath; README and readme.hints updated to document the new defaults
- Avoids /etc permission issues and prevents starting a real MongoDB process in tests by using a user-writable default path
## 2026-02-12 - 5.0.3 - fix(packaging)
add files whitelist to package.json and remove Playwright-generated screenshots
- Add a "files" array to package.json to control published package contents (includes ts/, ts_web/, dist/, dist_*/**, dist_ts/, dist_ts_web/, assets/, cli.js, npmextra.json, readme.md).
- Remove multiple .playwright-mcp/*.png screenshot files (clean up Playwright test artifacts and reduce repository noise/size).
## 2026-02-12 - 5.0.2 - fix(docs)
update documentation and packaging configuration: document smartmta/smartdns integrations, adjust API method names, and add release registry info
- README: document SmartDNS as Rust-powered DNS engine and smartmta as TypeScript+Rust MTA; add Rust-powered architecture section and component package table
- README: update Node.js requirement from 18+ to 20+; replace embedded cache DB TsmDb with LocalTsmDb and reduce listed cached document types
- README & ts_interfaces: rename typedrequest API adminLogin -> adminLoginWithUsernameAndPassword and add/clarify several API methods (logout, suppression management, RADIUS client/VLAN helpers)
- README: update test instructions, change test file references and add a test coverage table
- npmextra.json: re-key package configs (@git.zone/cli, @ship.zone/szci), tidy watch array formatting, and add release.registries and accessLevel for publishing
## 2026-02-11 - 5.0.1 - fix(deps/tests)
bump two dependencies and disable cache in tests
- Bumped @api.global/typedrequest from ^3.2.5 to ^3.2.6
- Bumped @push.rocks/smartradius from ^1.1.0 to ^1.1.1
- Disabled cache in tests by adding cacheConfig: { enabled: false } to DcRouter instantiation in test/test.jwt-auth.ts, test/test.opsserver-api.ts, and test/test.protected-endpoint.ts
## 2026-02-11 - 5.0.0 - BREAKING CHANGE(mta)
migrate internal MTA to @push.rocks/smartmta and remove legacy mail/deliverability implementation
- Replace ~27k LOC custom MTA (ts/mail/, ts/deliverability/) with @push.rocks/smartmta v5.2.1 (TypeScript+Rust hybrid)
- Remove many SMTP client/server test suites and test helpers; testing approach and fixtures changed/removed
- Upgrade dependencies: @push.rocks/smartproxy -> 23.1.2, @push.rocks/smartdns -> 7.8.0, add @push.rocks/smartmta@5.2.1; bump other minor deps
- API differences: updateEmailRoutes() replaces updateRoutes(); UnifiedEmailServer exposes dkimCreator publicly; bounce/suppression APIs moved to emailServer.* helpers; Email class and IAttachment types moved into @push.rocks/smartmta exports
- SmartProxy route validation stricter: forward actions must use targets (array) instead of target (singular) — tests/configs updated accordingly
- DKIM generation/serving moved to smartmta (dcrouter no longer manages DKIM keys directly)
## 2026-02-10 - 4.1.1 - fix(smartproxy)
upgrade @push.rocks/smartproxy to ^23.1.0 and adapt code/tests for its async getStatistics() API
- Bumped dependency @push.rocks/smartproxy 22.4.2 → 23.1.0 in package.json
- Changed ts/monitoring/classes.metricsmanager.ts to await smartProxy.getStatistics() (was synchronous)
- Updated multiple tests to set cacheConfig: { enabled: false } and added socketTimeouts where appropriate
- Improved SMTP test servers: handle multi-line input, drop data for packet-loss simulation, and ignore socket errors to make tests more robust
- Added migration notes to readme.hints.md documenting SmartProxy v23.1.0 changes (async getStatistics, Rust proxy behavior)
## 2026-02-10 - 4.1.0 - feat(cache)
add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration
- Introduce CacheDb and CacheCleaner using @push.rocks/smartdata and @push.rocks/smartmongo (LocalTsmDb) for persistent caching
- Integrate cache initialization, console summary, and graceful shutdown into DcRouter (options.cacheConfig and setupCacheDb())
- Require svDb() decorators on concrete cache document classes; remove decorators from the abstract CachedDocument base class
- Switch CacheCleaner to smartdata getInstances() + per-document delete() instead of deleteMany
- Adapt to LocalTsmDb API changes (folderPath option and start() returning connectionUri) and initialize SmartdataDb with mongoDbUrl/mongoDbName
- Remove experimentalDecorators and emitDecoratorMetadata from tsconfig to use TC39 Stage 3 decorators (smartdata v7+ compatibility)
- Add package.json exports mapping (remove main/typings entries) to expose dist entry points
- Add README documentation for the Smartdata Cache System and configuration/usage examples
## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config)
convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing
- Removed server-side 'updateConfiguration' TypedHandler and the private updateConfiguration() method; getConfiguration remains as a read-only handler.
- Removed IReq_UpdateConfiguration interface from request typings; IReq_GetConfiguration marked as read-only.
- Removed client-side editing functionality: ops-view-config editing state and methods, Edit/Save/Cancel buttons, and updateConfigurationAction; ops-view-config enhanced to display read-only configuration (badges for booleans, array pills, icons, formatted numbers/bytes, empty states, etc.).
- Tests updated: replaced configuration update tests with verifyIdentity tests and added a read-only configuration access test.
- Documentation updated to reflect configuration is read-only (readme.md, ts_web/readme.md, ts_interfaces/readme.md, readme.hints.md).
- Dependencies adjusted: bumped @push.rocks/smartdata to ^7.0.15 and added @push.rocks/smartmongo ^5.1.0; ts/plugins updated to import/export smartmongo.
## 2026-02-02 - 3.1.0 - feat(web)
determine initial UI view from URL and wire selected view to appdash; add interface and web README files; bump various dependencies
- UI: derive initial active view from window.location.pathname so the dashboard supports deep linking and bookmarks (ts_web/appstate.ts)
- UI: pass selectedView to dees-simple-appdash by adding a currentViewTab getter in ops-dashboard (ts_web/elements/ops-dashboard.ts)
- Docs: add TypeScript interfaces README for @serve.zone/dcrouter-interfaces (ts_interfaces/readme.md)
- Docs: add/update web module README detailing features, routing, and build instructions (ts_web/readme.md) and expand main project README
- Deps: bump multiple dependencies in package.json (notable bumps: @api.global/typedrequest -> ^3.2.5, @design.estate/dees-catalog -> ^3.42.0, @design.estate/dees-element -> ^2.1.6, @push.rocks/projectinfo -> ^5.0.2, @push.rocks/smartdata -> ^5.16.7, @push.rocks/smartpromise -> ^4.2.3, @push.rocks/smartradius -> ^1.1.0, @push.rocks/smartstate -> ^2.0.30, mailauth -> ^4.12.1)
## 2026-02-01 - 3.0.0 - BREAKING CHANGE(deps)
upgrade major dependencies, migrate action.target to action.targets (array), adapt to SmartRequest API changes, and add RADIUS server support
- Bumped many major dependencies: @api.global/typedserver 3.x → 8.3.0, @api.global/typedsocket 3.x → 4.1.0, @apiclient.xyz/cloudflare 6.x → 7.1.0, @design.estate/dees-catalog 1.x → 3.41.4, @push.rocks/smartpath 5.x → 6.x, @push.rocks/smartproxy 19.x → 22.x, @push.rocks/smartrequest 2.x → 5.x, uuid 11.x → 13.x, @types/node 25.1.0 → 25.2.0
## 2026-02-01 - 2.13.0 - feat(radius)
add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers
- Introduce full RADIUS module under ts/radius: classes.radius.server, classes.vlan.manager, classes.accounting.manager (authentication, VLAN mapping, OUI patterns, accounting, persistence).
- Integrate RADIUS into DcRouter: add radiusConfig option, setupRadiusServer(), updateRadiusConfig(), start/stop lifecycle handling and startup summary output.
- Add OpsServer RadiusHandler (ts/opsserver/handlers/radius.handler.ts) exposing TypedRequest endpoints for client management, VLAN mappings, accounting reports and statistics.
- Add typed request interfaces for RADIUS under ts_interfaces/requests/radius.ts and export them from the requests index.
- Wire smartradius into plugins (ts/plugins.ts) and export the new module; export RADIUS from ts/index.ts and re-export RADIUS types from classes.dcrouter.
- Update package.json & npmextra.json: add tswatch script and dev watcher configuration, add @push.rocks/smartradius dependency and a test_watch/devserver.ts dev server entrypoint.
- Refactor several web UI components (ops-dashboard, ops-view-*) to use 'accessor' for @state properties (small UI state API adjustments).
- Documentation: update readme.hints.md with RADIUS integration notes and examples.
## 2026-02-01 - 2.12.6 - fix(tests)
update tests and test helpers to current email/DNS APIs, use non-privileged ports, and improve robustness and resilience
- Email tests: switch to IEmailConfig properties (domains, routes), use router.emailServer (not unifiedEmailServer), change to non-privileged ports (e.g. 2525) and use fs.rmSync for cleanup.
- SMTP client helper: add pool and domain options; adjust tests to use STARTTLS (secure: false) and tolerate TLS/cipher negotiation failures with try/catch fallbacks.
- DNS tests: replace dnsDomain with dnsNsDomains and dnsScopes; test route generation without starting services, verify route names/domains, and create socket handlers without binding privileged ports.
- Socket-handler tests: use high non-standard ports for route/handler tests, verify route naming (email-port-<port>-route), ensure handlers are functions and handle errors gracefully without starting full routers.
- Integration/storage/rate-limit tests: add waits for async persistence, create/cleanup test directories, return and manage test server instances, relax strict assertions (memory threshold, rate-limiting enforcement) and make tests tolerant of implementation differences.
- Misc: use getAvailablePort in perf test setup, export tap.start() where appropriate, and generally make tests less brittle by adding try/catch, fallbacks and clearer logs for expected non-deterministic behavior.
## 2026-02-01 - 2.12.5 - fix(mail)
migrate filesystem helpers to fsUtils, update DKIM and mail APIs, harden SMTP client, and bump dependencies
- Introduce plugins.fsUtils compatibility layer and replace usages of plugins.smartfile.* with plugins.fsUtils.* across storage, routing, deliverability, and paths to support newer smartfile behaviour
- Update DKIM signing/verifying to new mailauth API: use signingDomain/selector/privateKey and read keys from dkimCreator before signing; adjust verifier fields to use signingDomain
- Harden SMTP client CommandHandler: add MAX_BUFFER_SIZE, socket close/error handlers, robust cleanup, clear response buffer, and adjust command/data timeouts; reduce default SOCKET_TIMEOUT to 45s
- Use SmartFileFactory for creating SmartFile attachments and update saving/loading to use fsUtils async/sync helpers
- Switch test runners to export default tap.start(), relax some memory-test thresholds, and add test helper methods (recordAuthFailure, recordError)
- Update package.json: simplify bundle script and bump multiple devDependencies/dependencies to compatible versions
## 2025-01-29 - 2.13.0 - feat(socket-handler)
Implement socket-handler mode for DNS and email services, enabling direct socket passing from SmartProxy
- Add `dnsDomain` configuration option that automatically sets up DNS server with DNS-over-HTTPS (DoH) support
- Implement socket-handler mode for email services with `useSocketHandler` flag in email configuration
- Update SmartProxy route generation to create socket-handler actions instead of port forwarding
- Add automatic route creation for DNS paths `/dns-query` and `/resolve` when dnsDomain is configured
- Enhance UnifiedEmailServer with `handleSocket` method for direct socket processing
- Configure DnsServer with `manualHttpsMode: true` to prevent HTTPS port binding while enabling DoH
- Improve performance by eliminating internal port forwarding overhead
- Update documentation with socket-handler mode configuration and benefits
## 2025-05-16 - 2.12.0 - feat(smartproxy)
Update documentation and configuration guides to adopt new route-based SmartProxy architecture
- Revise SmartProxy implementation hints in readme.hints.md to describe route-based configuration with glob pattern matching
- Add migration examples showing transition from old direct configuration to new route-based style
- Update DcRouter and SMTP port configuration to generate SmartProxy routes for email handling (ports 25, 587, 465 mapped to internal services)
- Enhance integration documentation with examples for HTTP and email services using the new SmartProxy routes
## 2025-05-16 - 2.11.2 - fix(dependencies)
Update dependency versions and adjust test imports to use new packages
- Upgraded @git.zone/tsbuild from ^2.3.2 to ^2.5.1
- Upgraded @git.zone/tstest/tapbundle from ^1.0.88 to ^1.9.0 and replaced @push.rocks/tapbundle imports in tests
- Upgraded @push.rocks/smartlog from ^3.0.3 to ^3.1.2
- Upgraded @push.rocks/smartproxy from ^10.2.0 to ^18.1.0
- Upgraded mailauth from ^4.8.4 to ^4.8.5
## 2025-05-08 - 2.11.1 - fix(platform)
Update commit info with no functional changes; regenerated commit information.
## 2025-05-08 - 2.11.0 - feat(platformservice)
Expose DcRouter and update package visibility. Changed package.json 'private' flag from true to false to allow public publication, and added export of DcRouter in ts/index.ts for improved API accessibility.
- Changed package.json: set 'private' to false
- Added export for DcRouter in ts/index.ts
## 2025-05-08 - 2.10.0 - feat(config): Implement standardized configuration system
Create a comprehensive configuration system with validation, defaults, and documentation
- Added consistent configuration interfaces across all services
- Implemented validation for all configuration objects with detailed error reporting
- Added default values for optional configuration parameters
- Created an extensive documentation system for configuration options
- Added migration helpers for managing configuration format changes
- Enhanced platform service to load configuration from multiple sources (file, environment, code)
- Updated email and SMS services to use the new configuration system
## 2025-05-08 - 2.9.0 - feat(errors): Implement comprehensive error handling system
Enhance error handling with structured errors, consistent patterns, and improved logging
- Added domain-specific error classes for better error categorization and handling
- Created comprehensive error codes for all service types (email, MTA, security, etc.)
- Implemented detailed error context with severity, category, and recoverability classification
- Added utilities for error conversion, formatting, and handling with automatic retry mechanisms
- Enhanced logging with correlation tracking, context support, and structured data
- Created middleware for handling errors in HTTP requests with proper status code mapping
- Added retry with exponential backoff for transient failures
## 2025-05-08 - 2.8.9 - fix(types)
Fix TypeScript build errors and improve API type safety across platformservice interfaces
- Fixed interface placement in EmailService and MtaConnector classes
- Aligned DeliveryStatus enum and updated ApiManager handlers with proper type-safe signatures
- Added comprehensive TypeScript interfaces for ISendEmailOptions, ITemplateContext, IValidateEmailOptions, IValidationResult, and IEmailServiceStats
- Removed circular dependencies in type definitions and added proper type assertions
- Improved test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager; external DNS lookups are disabled under test environment
## 2025-05-08 - 2.8.8 - fix(types): Fix TypeScript build errors and improve API interfaces
Fix TypeScript build errors caused by interface placement and improve API type alignment
- Fixed interface placement in EmailService and MtaConnector classes
- Aligned DeliveryStatus enum with EmailSendJob implementation
- Added proper method signatures for API endpoint handlers in ApiManager class
- Updated getStats and checkEmailStatus methods to conform to API contracts
- Implemented type-safe return values for all API methods
- Fixed circular dependencies in type definitions
- Added proper type assertion where needed to satisfy TypeScript compiler
## 2025-05-08 - 2.8.7 - feat(types): Add comprehensive TypeScript interfaces for API types
Improve type safety across the platform by adding detailed TypeScript interfaces for APIs
- Added ISendEmailOptions interface with complete documentation for email sending options
- Created ITemplateContext interface for email template rendering with full type safety
- Added IValidateEmailOptions and IValidationResult interfaces for email validation
- Improved IEmailServiceStats interface with detailed statistics types
- Added IEmailStatusResponse and IEmailStatusDetails interfaces for MTA status checking
- Updated sendEmail and other methods to use these new interfaces instead of 'any'
- Removed need for type assertions in various components
## 2025-05-08 - 2.8.6 - fix(tests)
fix: Improve test stability by handling race conditions in SenderReputationMonitor and IPWarmupManager. Disable filesystem operations and external DNS lookups during tests by checking NODE_ENV, add proper cleanup of singleton instances and active timeouts to ensure consistent test environment.
- Bumped version from 2.8.4 to 2.8.5 in package.json and changelog.md
- Improved SenderReputationMonitor to skip filesystem operations and DNS record loading when NODE_ENV is set to test
- Added cleanup of singleton instances and active timeouts in test files
- Updated readme.plan.md with roadmap items for test stability
## 2025-05-08 - 2.8.5 - fix(tests): Improve test stability by fixing race conditions
Enhance the SenderReputationMonitor tests to prevent race conditions and make tests more reliable
- Modified SenderReputationMonitor to detect test environment and disable filesystem operations
- Added proper cleanup of singleton instances and timeouts between tests
- Disabled DNS lookups during tests to prevent external dependencies
- Set a consistent test environment using NODE_ENV=test
- Made all tests independent of each other to prevent shared state issues
## 2025-05-08 - 2.8.4 - fix(mail)
refactor(mail): Remove Mailgun references from PlatformService. Update keywords, error messages, and documentation to use MTA exclusively.
- Removed Mailgun integration from keywords in package.json and npmextra.json
- Updated EmailService to remove Mailgun API key usage and reference MTA instead
- Updated changelog.md and readme.md to reflect removal of Mailgun and update examples
- Revised error messages to mention 'MTA not configured' instead of generic provider errors
- Updated readme.plan.md to document Mailgun removal
## 2025-05-08 - 2.8.3 - refactor(mail): Remove Mailgun references
Remove all Mailgun references from the codebase since it's no longer used as an email provider
- Removed "mailgun integration" from keywords in package.json and npmextra.json
- Updated comments and documentation in EmailService to remove Mailgun mentions
- Updated error messages to reference MTA instead of generic email providers
- Updated the readme email example to use PlatformService reference instead of Mailgun API key
## 2025-05-08 - 2.8.2 - fix(tests)
Fix outdated import paths in test files for dcrouter and ratelimiter modules
- Updated dcrouter import from '../ts/dcrouter/index.js' to '../ts/classes.dcrouter.js'
- Updated ratelimiter import from '../ts/mta/classes.ratelimiter.js' to '../ts/mail/delivery/classes.ratelimiter.js'
## 2025-05-08 - 2.8.1 - fix(readme)
Update readme with consolidated email system improvements and modular directory structure
Clarify that the platform now organizes email functionality into distinct directories (mail/core, mail/delivery, mail/routing, mail/security, mail/services) and update the diagram and key features list accordingly. Adjust code examples to reflect explicit module imports and the use of SzPlatformService.
- Changed description of consolidated email configuration to include 'streamlined directory structure'.
- Updated mermaid diagram to show 'Mail System Structure' with separate components for core, delivery, routing, security, and services.
- Modified key features list to document modular directory structure.
- Revised code sample imports to use explicit paths and SzPlatformService.
## 2025-05-08 - 2.8.0 - feat(docs)
Update documentation to include consolidated email handling and patternbased 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, multimodal processing, unified queue, and DcRouter integration
## 2025-05-08 - 2.7.0 - feat(dcrouter)
Implement unified email configuration with patternbased routing and consolidated email processing. Migrate SMTP forwarding and storeandforward 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 storeandforward 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 storeandforward components.
- Enhanced inline documentation and code comments for configuration interfaces and integration details.
## 2025-05-07 - 2.5.0 - feat(dcrouter)
Enhance DcRouter configuration and update documentation
- Added new implementation hints (readme.hints.md) and planning documentation (readme.plan.md) outlining removal of SzPlatformService dependency and improvements in SMTP forwarding, domain routing, and certificate management.
- Introduced new interfaces: ISmtpForwardingConfig and IDomainRoutingConfig for precise SMTP and HTTP domain routing configuration.
- Refactored DcRouter classes to support direct integration with SmartProxy and enhanced MTA functionality, including SMTP port configuration and improved TLS handling.
- Updated supporting modules such as SmtpPortConfig and EmailDomainRouter to provide better routing and security options.
- Enhanced test coverage across dcrouter, rate limiter, IP warmup manager, and email authentication, ensuring backward compatibility and improved quality.
## 2025-05-07 - 2.4.2 - fix(tests)
Update test assertions and singleton instance references in DMARC, integration, and IP warmup manager tests
- In test.emailauth.ts, update expected DMARC policy from 'none' to 'reject' and verify actualPolicy and action accordingly
- In test.integration.ts, remove deprecated casting and adjust dedicated policy naming (use 'dedicated' instead of 'dedicatedDomain')
- In test.ipwarmupmanager.ts and test.reputationmonitor.ts, replace singleton reset from '_instance' to 'instance' for proper instance access
- Update round robin allocation tests to verify IP cycle returns one of the available IPs
- Enhance daily limit tests by verifying getBestIPForSending returns null when limit is reached
- General refactoring across tests for improved clarity and consistency
## 2025-05-07 - 2.4.1 - fix(tests)
Update test assertions and refine service interfaces
- Converted outdated chai assertions to use tap's toBeTruthy, toEqual, and toBeGreaterThan methods in multiple test files
- Appended tap.stopForcefully() tests to ensure proper cleanup in test suites
- Added stop() method to PlatformService for graceful shutdown
- Exposed certificate property in MtaService from private to public
- Refactored dcrouter smartProxy configuration to better handle MTA service integration and certificate provisioning
## 2025-05-07 - 2.4.0 - feat(email)
Enhance email integration by updating @push.rocks/smartmail to ^2.1.0 and improving the entire email stack including validation, DKIM verification, templating, MIME conversion, and attachment handling.
- Updated smartmail dependency from ^2.0.1 to ^2.1.0 in package.json
- Enhanced EmailValidator with comprehensive checks (syntax, MX, disposable and role validations)
- Refactored TemplateManager to support dynamic variable substitution and loading templates from directory
- Improved conversion between internal Email and smartmail.Smartmail, streamlining MIME handling and attachment mapping
- Augmented DKIM verification with caching and custom header injection for improved security reporting
- Updated readme.plan.md with detailed roadmap for further performance, security, analytics, and deliverability enhancements
- Expanded test suite to cover smartmail integration, validation, templating, and conversion between formats
## 2025-05-04 - 1.0.10 to 1.0.8 - core
Applied core fixes across several versions on this day.
- Fixed core issues in versions 1.0.10, 1.0.9, and 1.0.8
## 2024-04-01 - 1.0.7 - core
Applied a core fix.
- Fixed core functionality for version 1.0.7
## 2024-03-19 - 1.0.6 - core
Applied a core fix.
- Fixed core functionality for version 1.0.6
## 2024-02-16 - 1.0.5 to 1.0.2 - core
Applied multiple core fixes in a contiguous range of versions.
- Fixed core functionality for versions 1.0.5, 1.0.4, 1.0.3, and 1.0.2
## 2024-02-15 - 1.0.1 - core
Applied a core fix.
- Fixed core functionality for version 1.0.1
Note: Versions that only contained version bumps (for example, 1.0.11 and the plain "1.0.x" commits) have been omitted from individual entries and are implicitly included in the version ranges above.

4
cli.child.js Normal file
View 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
View 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>

View File

@@ -1,20 +1,75 @@
{
"gitzone": {
"@git.zone/tswatch": {
"watchers": [
{
"name": "dcrouter-dev",
"watch": [
"ts/**/*.ts",
"ts_*/**/*.ts",
"test_watch/devserver.ts"
],
"command": "pnpm run build && tsrun test_watch/devserver.ts",
"restart": true,
"debounce": 500,
"runOnStart": true
}
]
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild",
"production": true
}
]
},
"@git.zone/cli": {
"projectType": "service",
"module": {
"githost": "gitlab.com",
"gitscope": "serve.zone",
"gitrepo": "platformservice",
"description": "contains the platformservice container with mail, sms, letter, ai services.",
"npmPackagename": "@serve.zone/platformservice",
"gitrepo": "dcrouter",
"description": "A traffic router intended to be gating your datacenter.",
"npmPackagename": "@serve.zone/dcrouter",
"license": "MIT",
"projectDomain": "serve.zone"
"projectDomain": "serve.zone",
"keywords": [
"mail service",
"SMS",
"letter delivery",
"AI services",
"SMTP server",
"mail parsing",
"DKIM",
"traffic router",
"letterXpress",
"OpenAI",
"Anthropic AI",
"DKIM signing",
"mail forwarding",
"SMTP TLS",
"domain management",
"email templating",
"rule management",
"SMTP STARTTLS",
"DNS management"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"npmci": {
"@ship.zone/szci": {
"npmGlobalTools": [],
"dockerRegistryRepoMap": {
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
},
"dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"

View File

@@ -1,37 +1,109 @@
{
"name": "@serve.zone/platformservice",
"version": "1.0.3",
"description": "contains the platformservice container with mail, sms, letter, ai services.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"name": "@serve.zone/dcrouter",
"private": false,
"version": "5.2.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js"
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/)",
"start": "(node --max_old_space_size=100 ./cli.js)",
"test": "(tstest test/ --logfile --timeout 60)",
"start": "(node --max_old_space_size=250 ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild --web --allowimplicitany)"
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"bundle": "(tsbundle)",
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.17",
"@git.zone/tsrun": "^1.2.8",
"@git.zone/tstest": "^1.0.28",
"@git.zone/tswatch": "^2.0.1",
"@push.rocks/tapbundle": "^5.0.3"
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.8",
"@git.zone/tswatch": "^3.1.0",
"@types/node": "^25.2.3"
},
"dependencies": {
"@api.global/typedrequest": "^3.0.4",
"@api.global/typedserver": "^3.0.20",
"@push.rocks/projectinfo": "^5.0.1",
"@push.rocks/qenv": "^6.0.5",
"@push.rocks/smartdata": "^5.0.7",
"@push.rocks/smartfile": "^11.0.4",
"@push.rocks/smartlog": "^3.0.3",
"@push.rocks/smartmail": "^1.0.24",
"@push.rocks/smartpath": "^5.0.5",
"@push.rocks/smartrequest": "^2.0.21",
"@push.rocks/smartstate": "^2.0.0",
"@serve.zone/interfaces": "^1.0.34"
}
"@api.global/typedrequest": "^3.2.6",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.3.0",
"@api.global/typedsocket": "^4.1.0",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.42.0",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/projectinfo": "^5.0.2",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^8.0.0",
"@push.rocks/smartdata": "^7.0.15",
"@push.rocks/smartdns": "^7.8.1",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartmetrics": "^2.0.10",
"@push.rocks/smartmongo": "^5.1.0",
"@push.rocks/smartmta": "^5.2.2",
"@push.rocks/smartnetwork": "^4.4.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^23.1.6",
"@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": [
"mail service",
"SMS",
"letter delivery",
"AI services",
"SMTP server",
"mail parsing",
"DKIM",
"mail router",
"letterXpress",
"OpenAI",
"Anthropic AI",
"DKIM signing",
"mail forwarding",
"SMTP TLS",
"domain management",
"email templating",
"rule management",
"SMTP STARTTLS",
"DNS management",
"RADIUS",
"AAA",
"network authentication",
"VLAN assignment",
"MAC authentication"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.11.0",
"files": [
"ts/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
"readme.md"
]
}

15013
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

773
readme.hints.md Normal file
View File

@@ -0,0 +1,773 @@
# Implementation Hints and Learnings
## smartmta Migration (2026-02-11)
### Overview
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.
### Architecture
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer
### Key API Differences (smartmta vs old custom MTA)
- `updateEmailRoutes()` instead of `updateRoutes()`
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
- `bounceManager` is private, but exposed via public methods:
- `emailServer.getSuppressionList()`
- `emailServer.getHardBouncedAddresses()`
- `emailServer.getBounceHistory(email)`
- `emailServer.removeFromSuppressionList(email)`
- `Email` class imported from `@push.rocks/smartmta`
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`
### Deleted Directories
- `ts/mail/` (60 files) — replaced by smartmta
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence
### Remaining Cache Documents
- `CachedEmail` — kept (dcrouter-level queue persistence)
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)
### Dependencies Removed
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge
### Pre-existing Test Failures (not caused by migration)
- `test/test.jwt-auth.ts``response.text is not a function` (webrequest compatibility issue)
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts
### smartmta Location
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
## Dependency Upgrade (2026-02-11)
### SmartProxy v23.1.2 Route Validation
- SmartProxy 23.1.2 enforces stricter route validation
- Forward actions MUST use `targets` (array) instead of `target` (singular)
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests
```typescript
// WRONG - will fail validation
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }
// CORRECT
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
```
**Files Fixed:**
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`
## Dependency Upgrade (2026-02-10)
### SmartProxy v23.1.0 Upgrade
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
**Key Changes:**
- Rust-based proxy components for improved performance
- Rust binary runs as separate process via IPC
- `getStatistics()` now returns `Promise<any>` (was synchronous)
- nftables-proxy removed (not used by dcrouter)
**Code Changes Required:**
```typescript
// Old (synchronous)
const proxyStats = this.dcRouter.smartProxy.getStatistics();
// New (async)
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
```
**Files Modified:**
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
## Dependency Upgrade (2026-02-01)
### Major Upgrades Completed
- `@api.global/typedserver`: 3.0.80 → 8.3.0
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
- `uuid`: 11.1.0 → 13.0.0
### Breaking Changes Fixed
1. **SmartProxy v22**: `target``targets` (array)
```typescript
// Old
action: { type: 'forward', target: { host: 'x', port: 25 } }
// New
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
```
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
```typescript
// Old
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
const json = resp.body;
// New
const resp = await plugins.smartrequest.SmartRequest.create()...post();
const json = await resp.json();
```
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
```typescript
// Old (deprecated but supported)
<dees-icon iconFA="check"></dees-icon>
// New
<dees-icon icon="fa:check"></dees-icon>
<dees-icon icon="lucide:menu"></dees-icon>
```
### TC39 Decorators
- ts_web components updated to use `accessor` keyword for `@state()` decorators
- Required for TC39 standard decorator support
### tswatch Configuration
The project now uses tswatch for development:
```bash
pnpm run watch
```
Configuration in `npmextra.json`:
```json
{
"@git.zone/tswatch": {
"watchers": [{
"name": "dcrouter-dev",
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
"command": "pnpm run build && tsrun test_watch/devserver.ts",
"restart": true,
"debounce": 500,
"runOnStart": true
}]
}
}
```
## RADIUS Server Integration (2026-02-01)
### Overview
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
### Key Features
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
- **RADIUS Accounting** - Track sessions, data usage, and billing
### Configuration Example
```typescript
const dcRouter = new DcRouter({
radiusConfig: {
authPort: 1812, // Authentication port (default)
acctPort: 1813, // Accounting port (default)
clients: [
{
name: 'switch-1',
ipRange: '192.168.1.0/24',
secret: 'shared-secret',
enabled: true
}
],
vlanAssignment: {
defaultVlan: 100, // VLAN for unknown MACs
allowUnknownMacs: true,
mappings: [
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
]
},
accounting: {
enabled: true,
retentionDays: 30
}
}
});
```
### Components
- `RadiusServer` - Main server wrapping smartradius
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
- `AccountingManager` - Session tracking and billing data
### OpsServer API Endpoints
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
- `testVlanAssignment` - Test what VLAN a MAC would get
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
### Files
- `ts/radius/` - RADIUS module
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
## Test Fix: test.dcrouter.email.ts (2026-02-01)
### Issue
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
### Root Cause
The test was using outdated email config properties:
- Used `domainRules: []` (non-existent property)
- Used `defaultMode` (non-existent property)
- Missing required `domains: []` property
- Missing required `routes: []` property
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
### Fix
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
```typescript
const emailConfig: IEmailConfig = {
ports: [2525],
hostname: 'mail.example.com',
domains: [], // Required: domain configurations
routes: [] // Required: email routing rules
};
```
And fixed the property name:
```typescript
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
```
### Key Learning
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
- `routes: IEmailRoute[]` is required (email routing rules)
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
## Network Metrics Implementation (2025-06-23)
### SmartProxy Metrics API Integration
- Updated to use new SmartProxy metrics API (v19.6.7)
- Use `getMetrics()` for detailed metrics with grouped methods:
```typescript
const metrics = smartProxy.getMetrics();
metrics.connections.active() // Current active connections
metrics.throughput.instant() // Real-time throughput {in, out}
metrics.connections.topIPs(10) // Top 10 IPs by connection count
```
- Use `getStatistics()` for basic stats
### Network Traffic Display
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
- Network graph shows separate lines for inbound (green) and outbound (purple)
- Throughput tiles and graph use same data source for consistency
### Requests/sec vs Connections
- Requests/sec shows HTTP request counts (derived from connections)
- Single connection can handle multiple requests
- Current implementation tracks connections, not individual requests
- Trend line shows historical request counts, not throughput
## DKIM Implementation Status (2025-05-30)
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
## SmartProxy Usage
### New Route-Based Architecture (v18+)
- SmartProxy now uses a route-based configuration system
- Routes define match criteria and actions instead of simple port-to-port forwarding
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
```typescript
// NEW: Route-based SmartProxy configuration
const smartProxy = new plugins.smartproxy.SmartProxy({
routes: [
{
name: 'https-traffic',
match: {
ports: 443,
domains: ['example.com', '*.example.com']
},
action: {
type: 'forward',
target: {
host: 'backend.server.com',
port: 8080
}
},
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
],
defaults: {
target: {
host: 'fallback.server.com',
port: 8080
}
},
acme: {
accountEmail: 'admin@example.com',
enabled: true,
useProduction: true
}
});
```
### Migration from Old to New
```typescript
// OLD configuration style (deprecated)
{
fromPort: 443,
toPort: 8080,
targetIP: 'backend.server.com',
domainConfigs: [...]
}
// NEW route-based style
{
routes: [{
name: 'main-route',
match: { ports: 443 },
action: {
type: 'forward',
target: { host: 'backend.server.com', port: 8080 }
}
}]
}
```
### Direct Component Usage
- Use SmartProxy components directly instead of creating your own wrappers
- SmartProxy already includes Port80Handler and NetworkProxy functionality
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
### Certificate Management
- SmartProxy has built-in ACME certificate management
- Configure it in the `acme` property of SmartProxy options
- Use `accountEmail` (not `email`) for the ACME contact email
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
## qenv Usage
### Direct Usage
- Use qenv directly instead of creating environment variable wrappers
- Instantiate qenv with appropriate basePath and nogitPath:
```typescript
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
```
## TypeScript Interfaces
### SmartProxy Interfaces
- Always check the interfaces from the node_modules to ensure correct property names
- Important interfaces for the new architecture:
- `ISmartProxyOptions`: Main configuration with `routes` array
- `IRouteConfig`: Individual route configuration
- `IRouteMatch`: Match criteria for routes
- `IRouteTarget`: Target configuration for forwarding
- `IAcmeOptions`: ACME certificate configuration
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
### New Route Configuration
```typescript
interface IRouteConfig {
name: string;
match: {
ports: number | number[];
domains?: string | string[];
path?: string;
headers?: Record<string, string | RegExp>;
};
action: {
type: 'forward' | 'redirect' | 'block' | 'static';
target?: {
host: string | string[] | ((context) => string);
port: number | 'preserve' | ((context) => number);
};
};
tls?: {
mode: TTlsMode;
certificate?: 'auto' | { key: string; cert: string; };
};
security?: {
authentication?: IRouteAuthentication;
rateLimit?: IRouteRateLimit;
ipAllowList?: string[];
ipBlockList?: string[];
};
}
```
### Required Properties
- For `ISmartProxyOptions`, `routes` array is the main configuration
- For `IAcmeOptions`, use `accountEmail` for the contact email
- Routes must have `name`, `match`, and `action` properties
## Testing
### Test Structure
- Follow the project's test structure, using `@push.rocks/tapbundle`
- Use `expect(value).toEqual(expected)` for equality checks
- Use `expect(value).toBeTruthy()` for boolean assertions
```typescript
tap.test('test description', async () => {
const result = someFunction();
expect(result.property).toEqual('expected value');
expect(result.valid).toBeTruthy();
});
```
### Cleanup
- Include a cleanup test to ensure proper test resource handling
- Add a `stop` test to forcefully end the test when needed:
```typescript
tap.test('stop', async () => {
await tap.stopForcefully();
});
```
## Architecture Principles
### Simplicity
- Prefer direct usage of libraries instead of creating wrappers
- Don't reinvent functionality that already exists in dependencies
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
### Component Integration
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
- Use parallel operations for performance (like in the `stop()` method)
- Separate concerns clearly (HTTP handling vs. SMTP handling)
## Email Integration with SmartProxy
### Architecture (Post-Migration)
- Email traffic is routed through SmartProxy using automatic route generation
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
- smartmta's Rust SMTP bridge handles SMTP protocol processing
### Port Mapping
- External port 25 → Internal port 10025 (SMTP)
- External port 587 → Internal port 10587 (Submission)
- External port 465 → Internal port 10465 (SMTPS)
### TLS Handling
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
## SmartMetrics Integration (2025-06-12) - COMPLETED
### Overview
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
### Key Findings
1. **CPU Metrics:**
- SmartMetrics provides `cpuUsageText` as a string percentage
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
- UI was incorrectly dividing by 2, showing half the actual CPU usage
2. **Memory Metrics:**
- SmartMetrics calculates `maxMemoryMB` as minimum of:
- V8 heap size limit
- System total memory
- Docker memory limit (if available)
- Provides `memoryUsageBytes` (total process memory including children)
- Provides `memoryPercentage` (pre-calculated percentage)
- UI was only showing heap usage, missing actual memory constraints
### Changes Made
1. **MetricsManager Enhanced:**
- Added `maxMemoryMB` from SmartMetrics instance
- Added `actualUsageBytes` from SmartMetrics data
- Added `actualUsagePercentage` from SmartMetrics data
- Kept existing memory fields for compatibility
2. **Interface Updated:**
- Added optional fields to `IServerStats.memoryUsage`
- Fields are optional to maintain backward compatibility
3. **UI Fixed:**
- Removed incorrect CPU division by 2
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
- Shows actual memory usage vs max memory limit (not just heap)
### Result
- CPU now shows accurate usage percentage
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
- Better monitoring for containerized environments
## Network UI Implementation (2025-06-20) - COMPLETED
### Overview
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
### Architecture
1. **MetricsManager Integration:**
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
- Extended with `getNetworkStats()` method to expose unused metrics:
- `getConnectionsByIP()` - Connection counts by IP address
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
- `getTopIPs()` - Top connecting IPs sorted by connection count
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
2. **Existing Infrastructure Leveraged:**
- `getActiveConnections` endpoint already exists in security.handler.ts
- Enhanced to include real SmartProxy data via MetricsManager
- IConnectionInfo interface already supports network data structures
3. **State Management:**
- Added `INetworkState` interface following existing patterns
- Created `networkStatePart` with connections, throughput, and IP data
- Integrated with existing auto-refresh mechanism
4. **UI Changes (Minimal):**
- Removed `generateMockData()` method and all mock generation
- Connected to real `networkStatePart` state
- Added `renderTopIPs()` section to display top connected IPs
- Updated traffic chart to show real request data
- Kept all existing UI components (DeesTable, DeesChartArea)
### Implementation Details
1. **Data Transformation:**
- Converts IConnectionInfo[] to INetworkRequest[] for table display
- Calculates traffic buckets based on selected time range
- Maps connection data to chart-compatible format
2. **Real Metrics Displayed:**
- Active connections count (from server stats)
- Requests per second (calculated from recent connections)
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
- Top IPs with connection counts and percentages
3. **TypeScript Fixes:**
- SmartProxy methods like `getThroughputRate()` not in base interface
- Implemented manual fallbacks for missing methods
- Fixed `publicIpv4` → `publicIp` property name
### Result
- Network view now shows real connection activity
- Auto-refreshes with other stats every second
- Displays actual IPs and connection counts
- No more mock/demo data
- Minimal code changes (streamlined approach)
### Throughput Data Fix (2025-06-20)
The throughput was showing 0 because:
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
3. `getThroughputRate()` only exists in the extended interface
**Solution implemented:**
1. Updated MetricsManager to check if methods exist at runtime and call them
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
3. Created new `getNetworkStats` endpoint in security.handler.ts
4. Updated frontend to call the new endpoint for complete network metrics
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
## Email Operations Dashboard (2026-02-01)
### Overview
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
### New Files Created
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
### Key Interfaces
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
- `IReq_GetSentEmails` - Fetch delivered emails
- `IReq_GetFailedEmails` - Fetch failed emails
- `IReq_ResendEmail` - Re-queue a failed email for retry
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
### UI Changes (ops-view-emails.ts)
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
- **Queued**: Emails pending delivery
- **Sent**: Successfully delivered emails
- **Failed**: Failed emails with resend capability
- **Security**: Security incidents from SecurityLogger
- Removed `generateMockEmails()` method
- Added state management via `emailOpsStatePart` in appstate.ts
- Added resend button for failed emails
- Added security incident detail view
### Data Flow
```
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
```
### Backend Data Access
The handler accesses data from:
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
- `emailServer.bounceManager` - Bounce records and suppression list
## OpsServer UI Fixes (2026-02-02)
### Configuration Page Fix
The configuration page had field name mismatches between frontend and backend:
- Frontend expected `server` and `storage` sections
- Backend returns `proxy` section (not `server`)
- Backend has no `storage` section
**Fix**: Updated `ops-view-config.ts` to use correct section names:
- `proxy` instead of `server`
- Removed non-existent `storage` section
- Added optional chaining (`?.`) for safety
### Auth Persistence Fix
Login state was using `'soft'` mode in Smartstate which is memory-only:
- User login was lost on page refresh
- State reset to logged out after browser restart
**Changes**:
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
- Now uses IndexedDB to persist across browser sessions
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
- Validates stored JWT hasn't expired before auto-logging in
- Clears expired sessions and shows login form
## Config UI Read-Only Conversion (2026-02-03)
### Overview
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
### Changes Made
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
- Removed `updateConfiguration` handler
- Removed `updateConfiguration()` private method
- Kept `getConfiguration` handler (read-only)
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
- Removed `IReq_UpdateConfiguration` interface
- Kept `IReq_GetConfiguration` interface
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
- Removed `editingSection` and `editedConfig` state properties
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
- Removed Edit/Save/Cancel buttons
- Removed warning banner about immediate changes
- Enhanced read-only display with:
- Status badges for boolean values (enabled/disabled)
- Array display as pills/tags with counts
- Section icons (mail, globe, network, shield)
- Better formatting for numbers and byte sizes
- Empty state handling ("Not configured", "None configured")
- Info note explaining configuration is read-only
4. **State Management (`ts_web/appstate.ts`)**:
- Removed `updateConfigurationAction`
- Kept `fetchConfigurationAction` (read-only)
5. **Tests (`test/test.protected-endpoint.ts`)**:
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
- Added test for read-only config access
- Kept auth flow testing with different protected endpoint
6. **Documentation**:
- `readme.md`: Updated API endpoints to show config as read-only
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
### Visual Display Features
- Boolean values shown as colored badges (green=enabled, red=disabled)
- Arrays displayed as pills with count summaries
- Section headers with relevant Lucide icons
- Numbers formatted with locale separators
- Byte sizes auto-formatted (B, KB, MB, GB)
- Time values shown with "seconds" suffix
- Nested objects with visual indentation
## Smartdata Cache System (2026-02-03)
### Overview
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
### Technology Stack
| Layer | Package | Purpose |
|-------|---------|---------|
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
### TC39 Decorators
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
- Removed `experimentalDecorators: true`
- Removed `emitDecoratorMetadata: true`
This is required for smartdata v7+ compatibility.
### Cache Document Classes
Located in `ts/cache/documents/`:
| Class | Purpose | Default TTL |
|-------|---------|-------------|
| `CachedEmail` | Email queue items | 30 days |
| `CachedIPReputation` | IP reputation lookups | 24 hours |
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
### Usage Pattern
```typescript
// Document classes use smartdata decorators
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id: string;
// ...
}
// Query examples
const email = await CachedEmail.getInstance({ id: 'abc123' });
const pending = await CachedEmail.getInstances({ status: 'pending' });
await email.save();
await email.delete();
```
### Configuration
```typescript
const dcRouter = new DcRouter({
cacheConfig: {
enabled: true,
storagePath: '~/.serve.zone/dcrouter/tsmdb',
dbName: 'dcrouter',
cleanupIntervalHours: 1,
ttlConfig: {
emails: 30, // days
ipReputation: 1, // days
bounces: 30, // days
dkimKeys: 90, // days
suppression: 30 // days
}
}
});
```
### Cache Cleaner
- Runs hourly by default (configurable via `cleanupIntervalHours`)
- Finds and deletes documents where `expiresAt < now()`
- Uses smartdata's `getInstances()` + `delete()` pattern
### Key Files
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
- `ts/cache/documents/*.ts` - Document class definitions

996
readme.md

File diff suppressed because it is too large Load Diff

443
test/readme.md Normal file
View 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
View 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
View 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
View 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();

View 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
});

View 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
View 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();

View 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
View 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();

View 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();

View 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
View File

@@ -0,0 +1,289 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as paths from '../ts/paths.js';
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
import { promises as fs } from 'fs';
import * as path from 'path';
// Test data
const testData = {
string: 'Hello, World!',
json: { name: 'test', value: 42, nested: { data: true } },
largeString: 'x'.repeat(10000)
};
tap.test('Storage Manager - Memory Backend', async () => {
// Create StorageManager without config (defaults to memory)
const storage = new StorageManager();
// Test basic get/set
await storage.set('/test/key', testData.string);
const value = await storage.get('/test/key');
expect(value).toEqual(testData.string);
// Test JSON helpers
await storage.setJSON('/test/json', testData.json);
const jsonValue = await storage.getJSON('/test/json');
expect(jsonValue).toEqual(testData.json);
// Test exists
expect(await storage.exists('/test/key')).toEqual(true);
expect(await storage.exists('/nonexistent')).toEqual(false);
// Test delete
await storage.delete('/test/key');
expect(await storage.exists('/test/key')).toEqual(false);
// Test list
await storage.set('/items/1', 'one');
await storage.set('/items/2', 'two');
await storage.set('/other/3', 'three');
const items = await storage.list('/items');
expect(items.length).toEqual(2);
expect(items).toContain('/items/1');
expect(items).toContain('/items/2');
// Verify memory backend
expect(storage.getBackend()).toEqual('memory');
});
tap.test('Storage Manager - Filesystem Backend', async () => {
const testDir = path.join(paths.dataDir, '.test-storage');
// Clean up test directory if it exists
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {}
// Create StorageManager with filesystem path
const storage = new StorageManager({ fsPath: testDir });
// Test basic operations
await storage.set('/test/file', testData.string);
const value = await storage.get('/test/file');
expect(value).toEqual(testData.string);
// Verify file exists on disk
const filePath = path.join(testDir, 'test', 'file');
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
expect(fileExists).toEqual(true);
// Test atomic writes (temp file should not exist)
const tempPath = filePath + '.tmp';
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
expect(tempExists).toEqual(false);
// Test nested paths
await storage.set('/deeply/nested/path/to/file', testData.largeString);
const nestedValue = await storage.get('/deeply/nested/path/to/file');
expect(nestedValue).toEqual(testData.largeString);
// Test list with filesystem
await storage.set('/fs/items/a', 'alpha');
await storage.set('/fs/items/b', 'beta');
await storage.set('/fs/other/c', 'gamma');
// Filesystem backend now properly supports list
const fsItems = await storage.list('/fs/items');
expect(fsItems.length).toEqual(2); // Should find both items
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
tap.test('Storage Manager - Custom Function Backend', async () => {
// Create in-memory storage for custom functions
const customStore = new Map<string, string>();
const storage = new StorageManager({
readFunction: async (key: string) => {
return customStore.get(key) || null;
},
writeFunction: async (key: string, value: string) => {
customStore.set(key, value);
}
});
// Test basic operations
await storage.set('/custom/key', testData.string);
expect(customStore.has('/custom/key')).toEqual(true);
const value = await storage.get('/custom/key');
expect(value).toEqual(testData.string);
// Test that delete sets empty value (as per implementation)
await storage.delete('/custom/key');
expect(customStore.get('/custom/key')).toEqual('');
// Verify custom backend (filesystem is implemented as custom backend internally)
expect(storage.getBackend()).toEqual('custom');
});
tap.test('Storage Manager - Key Validation', async () => {
const storage = new StorageManager();
// Test key normalization
await storage.set('test/key', 'value1'); // Missing leading slash
const value1 = await storage.get('/test/key');
expect(value1).toEqual('value1');
// Test dangerous path elements are removed
await storage.set('/test/../danger/key', 'value2');
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
expect(value2).toEqual('value2');
// Test multiple slashes are normalized
await storage.set('/test///multiple////slashes', 'value3');
const value3 = await storage.get('/test/multiple/slashes');
expect(value3).toEqual('value3');
// Test invalid keys throw errors
let emptyKeyError: Error | null = null;
try {
await storage.set('', 'value');
} catch (error) {
emptyKeyError = error as Error;
}
expect(emptyKeyError).toBeTruthy();
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
let nullKeyError: Error | null = null;
try {
await storage.set(null as any, 'value');
} catch (error) {
nullKeyError = error as Error;
}
expect(nullKeyError).toBeTruthy();
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
});
tap.test('Storage Manager - Concurrent Access', async () => {
const storage = new StorageManager();
const promises: Promise<void>[] = [];
// Simulate concurrent writes
for (let i = 0; i < 100; i++) {
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
}
await Promise.all(promises);
// Verify all writes succeeded
for (let i = 0; i < 100; i++) {
const value = await storage.get(`/concurrent/key${i}`);
expect(value).toEqual(`value${i}`);
}
// Test concurrent reads
const readPromises: Promise<string | null>[] = [];
for (let i = 0; i < 100; i++) {
readPromises.push(storage.get(`/concurrent/key${i}`));
}
const results = await Promise.all(readPromises);
for (let i = 0; i < 100; i++) {
expect(results[i]).toEqual(`value${i}`);
}
});
tap.test('Storage Manager - Backend Priority', async () => {
const testDir = path.join(paths.dataDir, '.test-storage-priority');
// Test that custom functions take priority over fsPath
let warningLogged = false;
const originalWarn = console.warn;
console.warn = (message: string) => {
if (message.includes('Using custom read/write functions')) {
warningLogged = true;
}
};
const storage = new StorageManager({
fsPath: testDir,
readFunction: async () => 'custom-value',
writeFunction: async () => {}
});
console.warn = originalWarn;
expect(warningLogged).toEqual(true);
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
// Clean up
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch {}
});
tap.test('Storage Manager - Error Handling', async () => {
// Test filesystem errors
const storage = new StorageManager({
readFunction: async () => {
throw new Error('Read error');
},
writeFunction: async () => {
throw new Error('Write error');
}
});
// Read errors should return null
const value = await storage.get('/error/key');
expect(value).toEqual(null);
// Write errors should propagate
let writeError: Error | null = null;
try {
await storage.set('/error/key', 'value');
} catch (error) {
writeError = error as Error;
}
expect(writeError).toBeTruthy();
expect(writeError?.message).toEqual('Write error');
// Test JSON parse errors
const jsonStorage = new StorageManager({
readFunction: async () => 'invalid json',
writeFunction: async () => {}
});
// Test JSON parse errors
let jsonError: Error | null = null;
try {
await jsonStorage.getJSON('/invalid/json');
} catch (error) {
jsonError = error as Error;
}
expect(jsonError).toBeTruthy();
expect(jsonError?.message).toContain('JSON');
});
tap.test('Storage Manager - List Operations', async () => {
const storage = new StorageManager();
// Populate storage with hierarchical data
await storage.set('/app/config/database', 'db-config');
await storage.set('/app/config/cache', 'cache-config');
await storage.set('/app/data/users/1', 'user1');
await storage.set('/app/data/users/2', 'user2');
await storage.set('/app/logs/error.log', 'errors');
// List root
const rootItems = await storage.list('/');
expect(rootItems.length).toBeGreaterThanOrEqual(5);
// List specific paths
const configItems = await storage.list('/app/config');
expect(configItems.length).toEqual(2);
expect(configItems).toContain('/app/config/database');
expect(configItems).toContain('/app/config/cache');
const userItems = await storage.list('/app/data/users');
expect(userItems.length).toEqual(2);
// List non-existent path
const emptyList = await storage.list('/nonexistent/path');
expect(emptyList.length).toEqual(0);
});
export default tap.start();

35
test_watch/devserver.ts Normal file
View 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.');

View File

@@ -1,8 +1,8 @@
/**
* autocreated commitinfo by @pushrocks/commitinfo
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/platformservice',
version: '1.0.3',
description: 'contains the platformservice container with mail, sms, letter, ai services.'
name: '@serve.zone/dcrouter',
version: '5.2.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

166
ts/cache/classes.cache.cleaner.ts vendored Normal file
View 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
View 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
View 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;
}
}

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { PlatformServiceDb } from './classes.platformservicedb.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();
public async start() {
this.platformserviceDb = new PlatformServiceDb(this);
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
this.typedserver = new plugins.typedserver.TypedServer({
cors: true,
});
await this.typedserver.start();
}
}

View File

@@ -1,27 +0,0 @@
import * as plugins from './plugins.js';
import { SzPlatformService } from './classes.platformservice.js';
export class PlatformServiceDb {
public smartdataDb: plugins.smartdata.SmartdataDb;
public platformserviceRef: SzPlatformService;
constructor(platformserviceRefArg: SzPlatformService) {
this.platformserviceRef = platformserviceRefArg;
}
public async start() {
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
});
await this.smartdataDb.init();
}
public async stop() {
await this.smartdataDb.close();
}
}

2
ts/config/index.ts Normal file
View File

@@ -0,0 +1,2 @@
// Export validation tools only
export * from './validator.js';

266
ts/config/validator.ts Normal file
View 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;
}
}

View File

@@ -1,51 +0,0 @@
import * as plugins from '../plugins.js';
import { EmailService } from './email.classes.emailservice.js';
import { logger } from '../logger.js';
export class ApiManager {
public emailRef: EmailService;
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
this.emailRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IRequest_SendEmail>(
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
const mailToSend = new plugins.smartmail.Smartmail({
body: requestData.body,
from: requestData.from,
subject: requestData.title,
});
if (requestData.attachments) {
for (const attachment of requestData.attachments) {
mailToSend.addAttachment(
await plugins.smartfile.SmartFile.fromString(
attachment.name,
attachment.binaryAttachmentString,
'binary'
)
);
}
}
await this.emailRef.mailgunConnector.sendEmail(mailToSend, requestData.to, {});
logger.log(
'info',
`send an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
{
eventType: 'sentEmail',
email: {
to: requestData.to,
subject: mailToSend.getSubject(),
},
}
);
return {
responseId: 'abc', // TODO: generate proper response id
};
})
);
}
}

View File

@@ -1,30 +0,0 @@
import * as plugins from './email.plugins.js';
import { EmailService } from './email.classes.emailservice.js';
export class MailgunConnector {
public emailRef: EmailService;
public mailgunAccount: plugins.mailgun.MailgunAccount;
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
this.mailgunAccount = new plugins.mailgun.MailgunAccount({
apiToken: this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_API_TOKEN'),
region: 'eu',
});
this.mailgunAccount.addSmtpCredentials(
this.emailRef.qenv.getEnvVarOnDemand('MAILGUN_SMTP_CREDENTIALS')
);
}
public async sendEmail(
smartMailArg: plugins.smartmail.Smartmail<any>,
toArg: string,
dataArg: any = {}
) {
this.mailgunAccount.sendSmartMail(smartMailArg, toArg, dataArg);
}
public async receiveEmail(messageUrl: string) {
return await this.mailgunAccount.retrieveSmartMailFromMessageUrl(messageUrl);
}
}

View File

@@ -1,48 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { MailgunConnector } from './email.classes.connector.mailgun.js';
import { RuleManager } from './email.classes.rulemanager.js';
import { ApiManager } from './email.classes.apimanager.js';
import { logger } from '../logger.js';
import type { SzPlatformService } from '../classes.platformservice.js';
export class EmailService {
public platformServiceRef: SzPlatformService;
// typedrouter
public typedrouter = new plugins.typedrequest.TypedRouter();
// connectors
public mailgunConnector: MailgunConnector;
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
// server
public apiManager = new ApiManager(this);
public ruleManager: RuleManager;
constructor(platformServiceRefArg: SzPlatformService) {
this.platformServiceRef = platformServiceRefArg;
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
this.mailgunConnector = new MailgunConnector(this);
this.ruleManager = new RuleManager(this);
this.platformServiceRef.typedserver.server.addRoute(
'/mailgun-notify',
new plugins.typedserver.servertools.Handler('POST', async (req, res) => {
console.log('Got a mailgun email notification');
res.status(200);
res.end();
this.ruleManager.handleNotification(req.body);
})
);
}
public async start() {
await this.ruleManager.init();
logger.log('success', `Started email service`);
}
public async stop() {
}
}

View File

@@ -1,137 +0,0 @@
import * as plugins from './email.plugins.js';
import { EmailService } from './email.classes.emailservice.js';
import { logger } from './email.logging.js';
export class RuleManager {
public emailRef: EmailService;
public smartruleInstance = new plugins.smartrule.SmartRule<
plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>
>();
constructor(emailRefArg: EmailService) {
this.emailRef = emailRefArg;
}
public async handleNotification(notification: plugins.mailgun.IMailgunNotification) {
console.log(notification['message-url']);
// basic checks here
// none for now
const fetchedSmartmail = await this.emailRef.mailgunConnector.receiveEmail(
notification['message-url']
);
console.log('=======================');
console.log('Received a mail:');
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
logger.log(
'info',
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
{
eventType: 'receivedEmail',
email: {
from: fetchedSmartmail.options.creationObjectRef.From,
to: fetchedSmartmail.options.creationObjectRef.To,
subject: fetchedSmartmail.options.creationObjectRef.Subject,
},
}
);
this.smartruleInstance.makeDecision(fetchedSmartmail);
}
public async init() {
// lets forward stuff
await this.createForwards();
}
/**
* creates the default forwards
*/
public async createForwards() {
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [
{
originalToAddress: ['bot@mail.nevermind.group'],
forwardedToAddress: ['phil@metadata.company', 'dominik@metadata.company'],
},
{
originalToAddress: ['legal@mail.lossless.com'],
forwardedToAddress: ['phil@lossless.com'],
},
{
originalToAddress: ['christine.nyamwaro@mail.lossless.com', 'christine@nyamwaro.com'],
forwardedToAddress: ['phil@lossless.com'],
},
];
console.log(`${forwards.length} forward rules configured:`);
for (const forward of forwards) {
console.log(forward);
}
for (const forward of forwards) {
this.smartruleInstance.createRule(
10,
async (smartmailArg) => {
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
}, false);
if (matched) {
console.log('Forward rule matched');
console.log(forward);
return 'apply-continue';
} else {
return 'continue';
}
},
async (smartmailArg: plugins.smartmail.Smartmail<plugins.mailgun.IMailgunMessage>) => {
forward.forwardedToAddress.map(async (toArg) => {
const forwardedSmartMail = new plugins.smartmail.Smartmail({
body:
`
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
<div><b>Original Sender:</b></div>
<div>${smartmailArg.options.creationObjectRef.From}</div>
<div><b>Original Recipient:</b></div>
<div>${smartmailArg.options.creationObjectRef.To}</div>
<div><b>Forwarded to:</b></div>
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
return `${pVal ? pVal + ', ' : ''}${cVal}`;
}, null)}</div>
<div><b>Subject:</b></div>
<div>${smartmailArg.getSubject()}</div>
<div><b>The original body can be found below.</b></div>
</div>
` + smartmailArg.getBody(),
from: 'forwarder@mail.lossless.one',
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
});
for (const attachment of smartmailArg.attachments) {
forwardedSmartMail.addAttachment(attachment);
}
await this.emailRef.mailgunConnector.sendEmail(forwardedSmartMail, toArg);
console.log(`forwarded mail to ${toArg}`);
logger.log(
'info',
`email from ${
smartmailArg.options.creationObjectRef.From
} to phil@lossless.com with subject '${smartmailArg.getSubject()}'`,
{
eventType: 'forwardedEmail',
email: {
from: smartmailArg.options.creationObjectRef.From,
to: smartmailArg.options.creationObjectRef.To,
forwardedTo: toArg,
subject: smartmailArg.options.creationObjectRef.Subject,
},
}
);
});
}
);
}
}
}

View File

@@ -1,13 +0,0 @@
import * as plugins from './email.plugins.js';
export class TemplateManager {
public smartmailDefault = new plugins.smartmail.Smartmail({
body: `
`,
from: `noreply@mail.lossless.com`,
subject: `{{subject}}`,
});
public createSmartmailFromData(tempalteTypeArg: plugins.lointEmail.TTemplates) {}
}

View File

@@ -1,3 +0,0 @@
import { EmailService } from './email.classes.emailservice.js';
export { EmailService as Email };

525
ts/errors/base.errors.ts Normal file
View 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
View 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
View 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
View 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!;
}

View 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
);
}
}

View File

@@ -1,4 +1,13 @@
export * from './00_commitinfo_data.js';
import { SzPlatformService } from './classes.platformservice.js';
export const runCli = async () => {}
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
export { UnifiedEmailServer } from '@push.rocks/smartmta';
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
// DcRouter
export * from './classes.dcrouter.js';
// RADIUS module
export * from './radius/index.js';
export const runCli = async () => {};

View File

@@ -1,9 +1,91 @@
import * as plugins from './plugins.js';
import { randomUUID } from 'node:crypto';
export const logger = new plugins.smartlog.Smartlog({
// Map NODE_ENV to valid TEnvironment
const nodeEnv = process.env.NODE_ENV || 'production';
const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
'development': 'local',
'test': 'test',
'staging': 'staging',
'production': 'production'
};
// Default Smartlog instance
const baseLogger = new plugins.smartlog.Smartlog({
logContext: {
environment: 'production',
environment: envMap[nodeEnv] || 'production',
runtime: 'node',
zone: 'serve.zone',
}
});
// Extended logger compatible with the original enhanced logger API
class StandardLogger {
private defaultContext: Record<string, any> = {};
private correlationId: string | null = null;
constructor() {}
// Log methods
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
const combinedContext = {
...this.defaultContext,
...context
};
if (this.correlationId) {
combinedContext.correlation_id = this.correlationId;
}
baseLogger.log(level, message, combinedContext);
}
public error(message: string, context: Record<string, any> = {}) {
this.log('error', message, context);
}
public warn(message: string, context: Record<string, any> = {}) {
this.log('warn', message, context);
}
public info(message: string, context: Record<string, any> = {}) {
this.log('info', message, context);
}
public success(message: string, context: Record<string, any> = {}) {
this.log('success', message, context);
}
public debug(message: string, context: Record<string, any> = {}) {
this.log('debug', message, context);
}
// Context management
public setContext(context: Record<string, any>, overwrite: boolean = false) {
if (overwrite) {
this.defaultContext = context;
} else {
this.defaultContext = {
...this.defaultContext,
...context
};
}
}
// Correlation ID management
public setCorrelationId(id: string | null = null): string {
this.correlationId = id || randomUUID();
return this.correlationId;
}
public getCorrelationId(): string | null {
return this.correlationId;
}
public clearCorrelationId(): void {
this.correlationId = null;
}
}
// Export a singleton instance
export const logger = new StandardLogger();

View 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);
}
}
}
}

View File

@@ -0,0 +1,524 @@
import * as plugins from '../plugins.js';
import { DcRouter } from '../classes.dcrouter.js';
import { MetricsCache } from './classes.metricscache.js';
export class MetricsManager {
private logger: plugins.smartlog.Smartlog;
private smartMetrics: plugins.smartmetrics.SmartMetrics;
private dcRouter: DcRouter;
private resetInterval?: NodeJS.Timeout;
private metricsCache: MetricsCache;
// Constants
private readonly MAX_TOP_DOMAINS = 1000; // Limit topDomains Map size
// Track email-specific metrics
private emailMetrics = {
sentToday: 0,
receivedToday: 0,
failedToday: 0,
bouncedToday: 0,
queueSize: 0,
lastResetDate: new Date().toDateString(),
deliveryTimes: [] as number[], // Track delivery times in ms
recipients: new Map<string, number>(), // Track email count by recipient
recentActivity: [] as Array<{ timestamp: number; type: string; details: string }>,
};
// Track DNS-specific metrics
private dnsMetrics = {
totalQueries: 0,
cacheHits: 0,
cacheMisses: 0,
queryTypes: {} as Record<string, number>,
topDomains: new Map<string, number>(),
lastResetDate: new Date().toDateString(),
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
responseTimes: [] as number[], // Track response times in ms
};
// Track security-specific metrics
private securityMetrics = {
blockedIPs: 0,
authFailures: 0,
spamDetected: 0,
malwareDetected: 0,
phishingDetected: 0,
lastResetDate: new Date().toDateString(),
incidents: [] as Array<{ timestamp: number; type: string; severity: string; details: string }>,
};
constructor(dcRouter: DcRouter) {
this.dcRouter = dcRouter;
// Create a new Smartlog instance for metrics
this.logger = new plugins.smartlog.Smartlog({
logContext: {
environment: 'production',
runtime: 'node',
zone: 'dcrouter-metrics',
}
});
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
// Initialize metrics cache with 500ms TTL
this.metricsCache = new MetricsCache(500);
}
public async start(): Promise<void> {
// Start SmartMetrics collection
this.smartMetrics.start();
// Reset daily counters at midnight
this.resetInterval = setInterval(() => {
const currentDate = new Date().toDateString();
if (currentDate !== this.emailMetrics.lastResetDate) {
this.emailMetrics.sentToday = 0;
this.emailMetrics.receivedToday = 0;
this.emailMetrics.failedToday = 0;
this.emailMetrics.bouncedToday = 0;
this.emailMetrics.deliveryTimes = [];
this.emailMetrics.recipients.clear();
this.emailMetrics.recentActivity = [];
this.emailMetrics.lastResetDate = currentDate;
}
if (currentDate !== this.dnsMetrics.lastResetDate) {
this.dnsMetrics.totalQueries = 0;
this.dnsMetrics.cacheHits = 0;
this.dnsMetrics.cacheMisses = 0;
this.dnsMetrics.queryTypes = {};
this.dnsMetrics.topDomains.clear();
this.dnsMetrics.queryTimestamps = [];
this.dnsMetrics.responseTimes = [];
this.dnsMetrics.lastResetDate = currentDate;
}
if (currentDate !== this.securityMetrics.lastResetDate) {
this.securityMetrics.blockedIPs = 0;
this.securityMetrics.authFailures = 0;
this.securityMetrics.spamDetected = 0;
this.securityMetrics.malwareDetected = 0;
this.securityMetrics.phishingDetected = 0;
this.securityMetrics.incidents = [];
this.securityMetrics.lastResetDate = currentDate;
}
}, 60000); // Check every minute
this.logger.log('info', 'MetricsManager started');
}
public async stop(): Promise<void> {
// Clear the reset interval
if (this.resetInterval) {
clearInterval(this.resetInterval);
this.resetInterval = undefined;
}
this.smartMetrics.stop();
this.logger.log('info', 'MetricsManager stopped');
}
// Get server metrics from SmartMetrics and SmartProxy
public async getServerStats() {
return this.metricsCache.get('serverStats', async () => {
const smartMetricsData = await this.smartMetrics.getMetrics();
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
return {
uptime: process.uptime(),
startTime: Date.now() - (process.uptime() * 1000),
memoryUsage: {
heapUsed: process.memoryUsage().heapUsed,
heapTotal: process.memoryUsage().heapTotal,
external: process.memoryUsage().external,
rss: process.memoryUsage().rss,
// Add SmartMetrics memory data
maxMemoryMB: this.smartMetrics.maxMemoryMB,
actualUsageBytes: smartMetricsData.memoryUsageBytes,
actualUsagePercentage: smartMetricsData.memoryPercentage,
},
cpuUsage: {
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
system: 0, // SmartMetrics doesn't separate user/system
},
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
requestsPerSecond: proxyMetrics ? proxyMetrics.requests.perSecond() : 0,
throughput: proxyMetrics ? {
bytesIn: proxyMetrics.totals.bytesIn(),
bytesOut: proxyMetrics.totals.bytesOut(),
bytesInPerSecond: proxyMetrics.throughput.instant().in,
bytesOutPerSecond: proxyMetrics.throughput.instant().out,
} : { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
};
});
}
// Get email metrics
public async getEmailStats() {
return this.metricsCache.get('emailStats', () => {
// Calculate average delivery time
const avgDeliveryTime = this.emailMetrics.deliveryTimes.length > 0
? this.emailMetrics.deliveryTimes.reduce((a, b) => a + b, 0) / this.emailMetrics.deliveryTimes.length
: 0;
// Get top recipients
const topRecipients = Array.from(this.emailMetrics.recipients.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([email, count]) => ({ email, count }));
// Get recent activity (last 50 entries)
const recentActivity = this.emailMetrics.recentActivity.slice(-50);
return {
sentToday: this.emailMetrics.sentToday,
receivedToday: this.emailMetrics.receivedToday,
failedToday: this.emailMetrics.failedToday,
bounceRate: this.emailMetrics.bouncedToday > 0
? (this.emailMetrics.bouncedToday / this.emailMetrics.sentToday) * 100
: 0,
deliveryRate: this.emailMetrics.sentToday > 0
? ((this.emailMetrics.sentToday - this.emailMetrics.failedToday) / this.emailMetrics.sentToday) * 100
: 100,
queueSize: this.emailMetrics.queueSize,
averageDeliveryTime: Math.round(avgDeliveryTime),
topRecipients,
recentActivity,
};
});
}
// Get DNS metrics
public async getDnsStats() {
return this.metricsCache.get('dnsStats', () => {
const cacheHitRate = this.dnsMetrics.totalQueries > 0
? (this.dnsMetrics.cacheHits / this.dnsMetrics.totalQueries) * 100
: 0;
const topDomains = Array.from(this.dnsMetrics.topDomains.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }));
// Calculate queries per second from recent timestamps
const now = Date.now();
const oneMinuteAgo = now - 60000;
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
const queriesPerSecond = recentQueries.length / 60;
// Calculate average response time
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
? this.dnsMetrics.responseTimes.reduce((a, b) => a + b, 0) / this.dnsMetrics.responseTimes.length
: 0;
return {
queriesPerSecond: Math.round(queriesPerSecond * 10) / 10,
totalQueries: this.dnsMetrics.totalQueries,
cacheHits: this.dnsMetrics.cacheHits,
cacheMisses: this.dnsMetrics.cacheMisses,
cacheHitRate: cacheHitRate,
topDomains: topDomains,
queryTypes: this.dnsMetrics.queryTypes,
averageResponseTime: Math.round(avgResponseTime),
activeDomains: this.dnsMetrics.topDomains.size,
};
});
}
// Get security metrics
public async getSecurityStats() {
return this.metricsCache.get('securityStats', () => {
// Get recent incidents (last 20)
const recentIncidents = this.securityMetrics.incidents.slice(-20);
return {
blockedIPs: this.securityMetrics.blockedIPs,
authFailures: this.securityMetrics.authFailures,
spamDetected: this.securityMetrics.spamDetected,
malwareDetected: this.securityMetrics.malwareDetected,
phishingDetected: this.securityMetrics.phishingDetected,
totalThreatsBlocked: this.securityMetrics.spamDetected +
this.securityMetrics.malwareDetected +
this.securityMetrics.phishingDetected,
recentIncidents,
};
});
}
// Get connection info from SmartProxy
public async getConnectionInfo() {
return this.metricsCache.get('connectionInfo', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return [];
}
const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo = [];
for (const [routeName, count] of connectionsByRoute) {
connectionInfo.push({
type: 'https',
count,
source: routeName,
lastActivity: new Date(),
});
}
return connectionInfo;
});
}
// Email event tracking methods
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
this.emailMetrics.sentToday++;
if (recipient) {
const count = this.emailMetrics.recipients.get(recipient) || 0;
this.emailMetrics.recipients.set(recipient, count + 1);
}
if (deliveryTimeMs) {
this.emailMetrics.deliveryTimes.push(deliveryTimeMs);
// Keep only last 1000 delivery times
if (this.emailMetrics.deliveryTimes.length > 1000) {
this.emailMetrics.deliveryTimes.shift();
}
}
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'sent',
details: recipient || 'unknown',
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public trackEmailReceived(sender?: string): void {
this.emailMetrics.receivedToday++;
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'received',
details: sender || 'unknown',
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public trackEmailFailed(recipient?: string, reason?: string): void {
this.emailMetrics.failedToday++;
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'failed',
details: `${recipient || 'unknown'}: ${reason || 'unknown error'}`,
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public trackEmailBounced(recipient?: string): void {
this.emailMetrics.bouncedToday++;
this.emailMetrics.recentActivity.push({
timestamp: Date.now(),
type: 'bounced',
details: recipient || 'unknown',
});
// Keep only last 1000 activities
if (this.emailMetrics.recentActivity.length > 1000) {
this.emailMetrics.recentActivity.shift();
}
}
public updateQueueSize(size: number): void {
this.emailMetrics.queueSize = size;
}
// DNS event tracking methods
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
this.dnsMetrics.totalQueries++;
if (cacheHit) {
this.dnsMetrics.cacheHits++;
} else {
this.dnsMetrics.cacheMisses++;
}
// Track query timestamp
this.dnsMetrics.queryTimestamps.push(Date.now());
// Keep only timestamps from last 5 minutes
const fiveMinutesAgo = Date.now() - 300000;
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
// Track response time if provided
if (responseTimeMs) {
this.dnsMetrics.responseTimes.push(responseTimeMs);
// Keep only last 1000 response times
if (this.dnsMetrics.responseTimes.length > 1000) {
this.dnsMetrics.responseTimes.shift();
}
}
// Track query types
this.dnsMetrics.queryTypes[queryType] = (this.dnsMetrics.queryTypes[queryType] || 0) + 1;
// Track top domains with size limit
const currentCount = this.dnsMetrics.topDomains.get(domain) || 0;
this.dnsMetrics.topDomains.set(domain, currentCount + 1);
// If we've exceeded the limit, remove the least accessed domains
if (this.dnsMetrics.topDomains.size > this.MAX_TOP_DOMAINS) {
// Convert to array, sort by count, and keep only top domains
const sortedDomains = Array.from(this.dnsMetrics.topDomains.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); // Keep 80% to avoid frequent cleanup
// Clear and repopulate with top domains
this.dnsMetrics.topDomains.clear();
sortedDomains.forEach(([domain, count]) => {
this.dnsMetrics.topDomains.set(domain, count);
});
}
}
// Security event tracking methods
public trackBlockedIP(ip?: string, reason?: string): void {
this.securityMetrics.blockedIPs++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'ip_blocked',
severity: 'medium',
details: `IP ${ip || 'unknown'} blocked: ${reason || 'security policy'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackAuthFailure(username?: string, ip?: string): void {
this.securityMetrics.authFailures++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'auth_failure',
severity: 'low',
details: `Authentication failed for ${username || 'unknown'} from ${ip || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackSpamDetected(sender?: string): void {
this.securityMetrics.spamDetected++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'spam_detected',
severity: 'low',
details: `Spam detected from ${sender || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackMalwareDetected(source?: string): void {
this.securityMetrics.malwareDetected++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'malware_detected',
severity: 'high',
details: `Malware detected from ${source || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
public trackPhishingDetected(source?: string): void {
this.securityMetrics.phishingDetected++;
this.securityMetrics.incidents.push({
timestamp: Date.now(),
type: 'phishing_detected',
severity: 'high',
details: `Phishing attempt from ${source || 'unknown'}`,
});
// Keep only last 1000 incidents
if (this.securityMetrics.incidents.length > 1000) {
this.securityMetrics.incidents.shift();
}
}
// Get network metrics from SmartProxy
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return {
connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
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
View File

@@ -0,0 +1 @@
export * from './classes.metricsmanager.js';

View File

@@ -1,8 +0,0 @@
export * from './mta.classes.dkimcreator.js';
export * from './mta.classes.emailsignjob.js';
export * from './mta.classes.dkimverifier.js';
export * from './mta.classes.mta.js';
export * from './mta.classes.smtpserver.js';
export * from './mta.classes.emailsendjob.js';
export * from './mta.classes.mta.js';
export * from './mta.classes.email.js';

View File

@@ -1,7 +0,0 @@
import * as plugins from './plugins.js';
export class ApiManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
}

View File

@@ -1,119 +0,0 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { Email } from './mta.classes.email.js';
import type { MTA } from './mta.classes.mta.js';
const readFile = plugins.util.promisify(plugins.fs.readFile);
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
const generateKeyPair = plugins.util.promisify(plugins.crypto.generateKeyPair);
export interface IKeyPaths {
privateKeyPath: string;
publicKeyPath: string;
}
export class DKIMCreator {
private keysDir: string;
constructor(metaRef: MTA, keysDir = paths.keysDir) {
this.keysDir = keysDir;
}
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
return {
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
publicKeyPath: plugins.path.join(this.keysDir, `${domainArg}-public.pem`),
};
}
// Check if a DKIM key is present and creates one and stores it to disk otherwise
public async handleDKIMKeysForDomain(domainArg: string): Promise<void> {
try {
await this.readDKIMKeys(domainArg);
} catch (error) {
console.log(`No DKIM keys found for ${domainArg}. Generating...`);
await this.createAndStoreDKIMKeys(domainArg);
const dnsValue = await this.getDNSRecordForDomain(domainArg);
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
plugins.smartfile.memory.toFsSync(JSON.stringify(dnsValue, null, 2), plugins.path.join(paths.dnsRecordsDir, `${domainArg}.dkimrecord.json`));
}
}
public async handleDKIMKeysForEmail(email: Email): Promise<void> {
const domain = email.from.split('@')[1];
await this.handleDKIMKeysForDomain(domain);
}
// Read DKIM keys from disk
public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> {
const keyPaths = await this.getKeyPathsForDomain(domainArg);
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
readFile(keyPaths.privateKeyPath),
readFile(keyPaths.publicKeyPath),
]);
// Convert the buffers to strings
const privateKey = privateKeyBuffer.toString();
const publicKey = publicKeyBuffer.toString();
return { privateKey, publicKey };
}
// Create a DKIM key pair
private async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
const { privateKey, publicKey } = await generateKeyPair('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
});
return { privateKey, publicKey };
}
// Store a DKIM key pair to disk
private async storeDKIMKeys(
privateKey: string,
publicKey: string,
privateKeyPath: string,
publicKeyPath: string
): Promise<void> {
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
}
// Create a DKIM key pair and store it to disk
private async createAndStoreDKIMKeys(domain: string): Promise<void> {
const { privateKey, publicKey } = await this.createDKIMKeys();
const keyPaths = await this.getKeyPathsForDomain(domain);
await this.storeDKIMKeys(
privateKey,
publicKey,
keyPaths.privateKeyPath,
keyPaths.publicKeyPath
);
console.log(`DKIM keys for ${domain} created and stored.`);
}
private async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
await this.handleDKIMKeysForDomain(domainArg);
const keys = await this.readDKIMKeys(domainArg);
// Remove the PEM header and footer and newlines
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
const keyContents = keys.publicKey
.replace(pemHeader, '')
.replace(pemFooter, '')
.replace(/\n/g, '');
// Now generate the DKIM DNS TXT record
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
return {
name: `mta._domainkey.${domainArg}`,
type: 'TXT',
dnsSecEnabled: null,
value: dnsRecordValue,
};
}
}

View File

@@ -1,35 +0,0 @@
import * as plugins from './plugins.js';
import { MTA } from './mta.classes.mta.js';
class DKIMVerifier {
public mtaRef: MTA;
constructor(mtaRefArg: MTA) {
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 };

View File

@@ -1,10 +0,0 @@
import type { MTA } from './mta.classes.mta.js';
import * as plugins from './plugins.js';
export class DNSManager {
public mtaRef: MTA;
constructor(mtaRefArg: MTA) {
this.mtaRef = mtaRefArg;
}
}

View File

@@ -1,36 +0,0 @@
export interface IAttachment {
filename: string;
content: Buffer;
contentType: string;
}
export interface IEmailOptions {
from: string;
to: string;
subject: string;
text: string;
attachments: IAttachment[];
mightBeSpam?: boolean;
}
export class Email {
from: string;
to: string;
subject: string;
text: string;
attachments: IAttachment[];
mightBeSpam: boolean;
constructor(options: IEmailOptions) {
this.from = options.from;
this.to = options.to;
this.subject = options.subject;
this.text = options.text;
this.attachments = options.attachments;
this.mightBeSpam = options.mightBeSpam || false;
}
public getFromDomain() {
return this.from.split('@')[1]
}
}

View File

@@ -1,173 +0,0 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { Email } from './mta.classes.email.js';
import { EmailSignJob } from './mta.classes.emailsignjob.js';
import type { MTA } from './mta.classes.mta.js';
export class EmailSendJob {
mtaRef: MTA;
private email: Email;
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
private mxRecord: string = null;
constructor(mtaRef: MTA, emailArg: Email) {
this.email = emailArg;
this.mtaRef = mtaRef;
}
async send(): Promise<void> {
const domain = this.email.to.split('@')[1];
const addresses = await this.resolveMx(domain);
addresses.sort((a, b) => a.priority - b.priority);
this.mxRecord = addresses[0].exchange;
console.log(`Using ${this.mxRecord} as mail server for domain ${domain}`);
this.socket = plugins.net.connect(25, this.mxRecord);
await this.processInitialResponse();
await this.sendCommand(`EHLO ${this.email.from.split('@')[1]}\r\n`, '250');
try {
await this.sendCommand('STARTTLS\r\n', '220');
this.socket = plugins.tls.connect({ socket: this.socket, rejectUnauthorized: false });
await this.processTLSUpgrade(this.email.from.split('@')[1]);
} catch (error) {
console.log('Error sending STARTTLS command:', error);
console.log('Continuing with unencrypted connection...');
}
await this.sendMessage();
}
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
return new Promise((resolve, reject) => {
plugins.dns.resolveMx(domain, (err, addresses) => {
if (err) {
console.error('Error resolving MX:', err);
reject(err);
} else {
resolve(addresses);
}
});
});
}
private processInitialResponse(): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.once('data', (data) => {
const response = data.toString();
if (!response.startsWith('220')) {
console.error('Unexpected initial server response:', response);
reject(new Error(`Unexpected initial server response: ${response}`));
} else {
console.log('Received initial server response:', response);
console.log('Connected to server, sending EHLO...');
resolve();
}
});
});
}
private processTLSUpgrade(domain: string): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.once('secureConnect', async () => {
console.log('TLS started successfully');
try {
await this.sendCommand(`EHLO ${domain}\r\n`, '250');
resolve();
} catch (err) {
console.error('Error sending EHLO after TLS upgrade:', err);
reject(err);
}
});
});
}
private sendCommand(command: string, expectedResponseCode?: string): Promise<void> {
return new Promise((resolve, reject) => {
this.socket.write(command, (error) => {
if (error) {
reject(error);
return;
}
if (!expectedResponseCode) {
resolve();
return;
}
this.socket.once('data', (data) => {
const response = data.toString();
if (response.startsWith('221')) {
this.socket.destroy();
resolve();
}
if (!response.startsWith(expectedResponseCode)) {
reject(new Error(`Unexpected server response: ${response}`));
} else {
resolve();
}
});
});
});
}
private async sendMessage(): Promise<void> {
console.log('Preparing email message...');
const messageId = `<${plugins.uuid.v4()}@${this.email.from.split('@')[1]}>`;
// Create a boundary for the email parts
const boundary = '----=_NextPart_' + plugins.uuid.v4();
const headers = {
From: this.email.from,
To: this.email.to,
Subject: this.email.subject,
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
};
// Construct the body of the message
let body = `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
// Then, the attachments
for (let attachment of this.email.attachments) {
body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`;
body += 'Content-Transfer-Encoding: base64\r\n';
body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n\r\n`;
body += attachment.content.toString('base64') + '\r\n';
}
// End of email
body += `--${boundary}--\r\n`;
// Create an instance of DKIMSigner
const dkimSigner = new EmailSignJob(this.mtaRef, {
domain: this.email.getFromDomain(), // Replace with your domain
selector: `mta`, // Replace with your DKIM selector
headers: headers,
body: body,
});
// Construct the message with DKIM-Signature header
let message = `Message-ID: ${messageId}\r\nFrom: ${this.email.from}\r\nTo: ${this.email.to}\r\nSubject: ${this.email.subject}\r\nContent-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
message += body;
let signatureHeader = await dkimSigner.getSignatureHeader(message);
message = `${signatureHeader}${message}`;
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
plugins.smartfile.memory.toFsSync(message, plugins.path.join(paths.sentEmailsDir, `${Date.now()}.eml`));
// Adding necessary commands before sending the actual email message
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
await this.sendCommand(`RCPT TO:<${this.email.to}>\r\n`, '250');
await this.sendCommand(`DATA\r\n`, '354');
// Now send the message content
await this.sendCommand(message);
await this.sendCommand('\r\n.\r\n', '250');
await this.sendCommand('QUIT\r\n', '221');
console.log('Email message sent successfully!');
}
}

View File

@@ -1,69 +0,0 @@
import * as plugins from './plugins.js';
import type { MTA } from './mta.classes.mta.js';
interface Headers {
[key: string]: string;
}
interface IEmailSignJobOptions {
domain: string;
selector: string;
headers: Headers;
body: string;
}
export class EmailSignJob {
mtaRef: MTA;
jobOptions: IEmailSignJobOptions;
constructor(mtaRefArg: MTA, 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;
}
}

View File

@@ -1,66 +0,0 @@
import * as plugins from './plugins.js';
import { Email } from './mta.classes.email.js';
import { EmailSendJob } from './mta.classes.emailsendjob.js';
import { DKIMCreator } from './mta.classes.dkimcreator.js';
import { DKIMVerifier } from './mta.classes.dkimverifier.js';
import { SMTPServer } from './mta.classes.smtpserver.js';
import { DNSManager } from './mta.classes.dnsmanager.js';
export class MTA {
public server: SMTPServer;
public dkimCreator: DKIMCreator;
public dkimVerifier: DKIMVerifier;
public dnsManager: DNSManager;
constructor() {
this.dkimCreator = new DKIMCreator(this);
this.dkimVerifier = new DKIMVerifier(this);
this.dnsManager = new DNSManager(this);
}
public async start() {
// lets get the certificate
/**
* gets a certificate for a domain used by a service
* @param serviceNameArg
* @param domainNameArg
*/
const typedrouter = new plugins.typedrequest.TypedRouter();
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
typedrouter,
'https://cloudly.lossless.one:443'
);
const getCertificateForDomainOverHttps = async (domainNameArg: string) => {
const typedCertificateRequest =
typedsocketClient.createTypedRequest<any>('getSslCertificate');
const typedResponse = await typedCertificateRequest.fire({
authToken: '', // do proper auth here
requiredCertName: domainNameArg,
});
return typedResponse.certificate;
};
const certificate = await getCertificateForDomainOverHttps('mta.lossless.one');
await typedsocketClient.stop();
this.server = new SMTPServer(this, {
port: 25,
key: certificate.privateKey,
cert: certificate.publicKey,
});
await this.server.start();
}
public async stop() {
if (!this.server) {
console.error('Server is not running');
return;
}
await this.server.stop();
}
public async send(email: Email): Promise<void> {
await this.dkimCreator.handleDKIMKeysForEmail(email);
const sendJob = new EmailSendJob(this, email);
await sendJob.send();
}
}

View File

@@ -1,191 +0,0 @@
import * as plugins from './plugins.js';
import * as paths from './paths.js';
import { Email } from './mta.classes.email.js';
import type { MTA } from './mta.classes.mta.js';
export interface ISmtpServerOptions {
port: number;
key: string;
cert: string;
}
export class SMTPServer {
public mtaRef: MTA;
private smtpServerOptions: ISmtpServerOptions;
private server: plugins.net.Server;
private emailBufferStringMap: Map<plugins.net.Socket, string>;
constructor(mtaRefArg: MTA, optionsArg: ISmtpServerOptions) {
console.log('SMTPServer instance is being created...');
this.mtaRef = mtaRefArg;
this.smtpServerOptions = optionsArg;
this.emailBufferStringMap = new Map();
this.server = plugins.net.createServer((socket) => {
console.log('New connection established...');
socket.write('220 mta.lossless.one ESMTP Postfix\r\n');
socket.on('data', (data) => {
this.processData(socket, data);
});
socket.on('end', () => {
console.log('Socket closed. Deleting related emailBuffer...');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
socket.on('error', () => {
console.error('Socket error occurred. Deleting related emailBuffer...');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
socket.on('close', () => {
console.log('Connection was closed by the client');
socket.destroy();
this.emailBufferStringMap.delete(socket);
});
});
}
private startTLS(socket: plugins.net.Socket) {
const secureContext = plugins.tls.createSecureContext({
key: this.smtpServerOptions.key,
cert: this.smtpServerOptions.cert,
});
const tlsSocket = new plugins.tls.TLSSocket(socket, {
secureContext: secureContext,
isServer: true,
});
tlsSocket.on('secure', () => {
console.log('Connection secured.');
this.emailBufferStringMap.set(tlsSocket, this.emailBufferStringMap.get(socket) || '');
this.emailBufferStringMap.delete(socket);
});
// Use the same handler for the 'data' event as for the unsecured socket.
tlsSocket.on('data', (data: Buffer) => {
this.processData(tlsSocket, Buffer.from(data));
});
tlsSocket.on('end', () => {
console.log('TLS socket closed. Deleting related emailBuffer...');
this.emailBufferStringMap.delete(tlsSocket);
});
tlsSocket.on('error', (err) => {
console.error('TLS socket error occurred. Deleting related emailBuffer...');
this.emailBufferStringMap.delete(tlsSocket);
});
}
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer) {
const dataString = data.toString();
console.log(`Received data:`);
console.log(`${dataString}`)
if (dataString.startsWith('EHLO')) {
socket.write('250-mta.lossless.one Hello\r\n250 STARTTLS\r\n');
} else if (dataString.startsWith('MAIL FROM')) {
socket.write('250 Ok\r\n');
} else if (dataString.startsWith('RCPT TO')) {
socket.write('250 Ok\r\n');
} else if (dataString.startsWith('STARTTLS')) {
socket.write('220 Ready to start TLS\r\n');
this.startTLS(socket);
} else if (dataString.startsWith('DATA')) {
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
let emailBuffer = this.emailBufferStringMap.get(socket);
if (!emailBuffer) {
this.emailBufferStringMap.set(socket, '');
}
} else if (dataString.startsWith('QUIT')) {
socket.write('221 Bye\r\n');
console.log('Received QUIT command, closing the socket...');
socket.destroy();
this.parseEmail(socket);
} else {
let emailBuffer = this.emailBufferStringMap.get(socket);
if (typeof emailBuffer === 'string') {
emailBuffer += dataString;
this.emailBufferStringMap.set(socket, emailBuffer);
}
socket.write('250 Ok\r\n');
}
if (dataString.endsWith('\r\n.\r\n') ) { // End of data
console.log('Received end of data.');
}
}
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket) {
let emailData = this.emailBufferStringMap.get(socket);
// lets strip the end sequence
emailData = emailData?.replace(/\r\n\.\r\n$/, '');
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
plugins.smartfile.memory.toFsSync(emailData, plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`));
if (!emailData) {
console.error('No email data found for socket.');
return;
}
let mightBeSpam = false;
// Verifying the email with DKIM
try {
const isVerified = await this.mtaRef.dkimVerifier.verify(emailData);
mightBeSpam = !isVerified;
} catch (error) {
console.error('Failed to verify DKIM signature:', error);
mightBeSpam = true;
}
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
console.log(parsedEmail)
const email = new Email({
from: parsedEmail.from?.value[0].address || '',
to:
parsedEmail.to instanceof Array
? parsedEmail.to[0].value[0].address
: parsedEmail.to?.value[0].address,
subject: parsedEmail.subject || '',
text: parsedEmail.html || parsedEmail.text,
attachments:
parsedEmail.attachments?.map((attachment) => ({
filename: attachment.filename || '',
content: attachment.content,
contentType: attachment.contentType,
})) || [],
mightBeSpam: mightBeSpam,
});
console.log('mail received!');
console.log(email);
this.emailBufferStringMap.delete(socket);
}
public start() {
this.server.listen(this.smtpServerOptions.port, () => {
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
});
}
public stop() {
this.server.getConnections((err, count) => {
if (err) throw err;
console.log('Number of active connections: ', count);
});
this.server.close(() => {
console.log('SMTP Server is now stopped');
});
}
}

View File

@@ -1,12 +0,0 @@
import * as plugins from './plugins.js';
export const cwd = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);
export const assetsDir = plugins.path.join(cwd, './assets');
export const keysDir = plugins.path.join(assetsDir, './keys');
export const dnsRecordsDir = plugins.path.join(assetsDir, './dns-records');
export const sentEmailsDir = plugins.path.join(assetsDir, './sent-emails');
export const receivedEmailsDir = plugins.path.join(assetsDir, './received-emails');
plugins.smartfile.fs.ensureDirSync(keysDir);

View File

@@ -1,67 +0,0 @@
// node native
import * as dns from 'dns';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as net from 'net';
import * as path from 'path';
import * as tls from 'tls';
import * as util from 'util';
export {
dns,
fs,
crypto,
net,
path,
tls,
util,
}
// @apiclient.xyz/cloudflare
import * as cloudflare from '@apiclient.xyz/cloudflare';
export {
cloudflare,
}
// @apiglobal scope
import * as typedrequest from '@apiglobal/typedrequest';
import * as typedsocket from '@apiglobal/typedsocket';
export {
typedrequest,
typedsocket,
}
// pushrocks scope
import * as smartfile from '@pushrocks/smartfile';
import * as smartpath from '@pushrocks/smartpath';
import * as smartpromise from '@pushrocks/smartpromise';
import * as smartrx from '@pushrocks/smartrx';
export {
smartfile,
smartpath,
smartpromise,
smartrx,
}
// tsclass scope
import * as tsclass from '@tsclass/tsclass';
export {
tsclass,
}
// third party
import * as mailauth from 'mailauth';
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
import mailparser from 'mailparser';
import * as uuid from 'uuid';
export {
mailauth,
dkimSign,
mailparser,
uuid,
}

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

View 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',
}
);
}

View 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,
},
};
}
}

View 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);
}
}

View 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';

View 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 };
}
}

View 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,
};
}
)
);
}
}

View 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: [],
};
}
}

View File

@@ -0,0 +1,510 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
export class StatsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// Server Statistics Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
'getServerStatistics',
async (dataArg, toolsArg) => {
const stats = await this.collectServerStats();
return {
stats: {
uptime: stats.uptime,
startTime: Date.now() - (stats.uptime * 1000),
memoryUsage: stats.memoryUsage,
cpuUsage: stats.cpuUsage,
activeConnections: stats.activeConnections,
totalConnections: stats.totalConnections,
requestsPerSecond: stats.requestsPerSecond,
throughput: stats.throughput,
},
history: dataArg.includeHistory ? stats.history : undefined,
};
}
)
);
// Email Statistics Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
'getEmailStatistics',
async (dataArg, toolsArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
if (!emailServer) {
return {
stats: {
sent: 0,
received: 0,
bounced: 0,
queued: 0,
failed: 0,
averageDeliveryTime: 0,
deliveryRate: 0,
bounceRate: 0,
},
};
}
const stats = await this.collectEmailStats();
return {
stats: {
sent: stats.sentToday,
received: stats.receivedToday,
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
queued: stats.queueSize,
failed: 0,
averageDeliveryTime: 0,
deliveryRate: stats.deliveryRate,
bounceRate: stats.bounceRate,
},
domainBreakdown: dataArg.includeDetails ? stats.domainBreakdown : undefined,
};
}
)
);
// DNS Statistics Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
'getDnsStatistics',
async (dataArg, toolsArg) => {
const dnsServer = this.opsServerRef.dcRouterRef.dnsServer;
if (!dnsServer) {
return {
stats: {
totalQueries: 0,
cacheHits: 0,
cacheMisses: 0,
cacheHitRate: 0,
activeDomains: 0,
averageResponseTime: 0,
queryTypes: {},
},
};
}
const stats = await this.collectDnsStats();
return {
stats: {
totalQueries: stats.totalQueries,
cacheHits: stats.cacheHits,
cacheMisses: stats.cacheMisses,
cacheHitRate: stats.cacheHitRate,
activeDomains: stats.topDomains.length,
averageResponseTime: 0,
queryTypes: stats.queryTypes,
},
domainBreakdown: dataArg.includeQueryTypes ? stats.domainBreakdown : undefined,
};
}
)
);
// Queue Status Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
'getQueueStatus',
async (dataArg, toolsArg) => {
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
const queues: interfaces.data.IQueueStatus[] = [];
if (emailServer) {
const status = await this.getQueueStatus();
queues.push({
name: dataArg.queueName || 'default',
size: status.pending,
processing: status.active,
failed: status.failed,
retrying: status.retrying,
averageProcessingTime: 0,
});
}
return {
queues,
totalItems: queues.reduce((sum, q) => sum + q.size + q.processing + q.failed + q.retrying, 0),
};
}
)
);
// Health Status Handler
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
'getHealthStatus',
async (dataArg, toolsArg) => {
const health = await this.checkHealthStatus();
return {
health: {
healthy: health.healthy,
uptime: process.uptime(),
services: health.services.reduce((acc, service) => {
acc[service.name] = {
status: service.status,
message: service.message,
lastCheck: Date.now(),
};
return acc;
}, {} as any),
version: '2.12.0', // TODO: Get from package.json
},
};
}
)
);
// Combined Metrics Handler - More efficient for frontend polling
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
'getCombinedMetrics',
async (dataArg, toolsArg) => {
const sections = dataArg.sections || {
server: true,
email: true,
dns: true,
security: true,
network: true,
};
const metrics: any = {};
// Run all metrics collection in parallel
const promises: Promise<void>[] = [];
if (sections.server) {
promises.push(
this.collectServerStats().then(stats => {
metrics.server = {
uptime: stats.uptime,
startTime: Date.now() - (stats.uptime * 1000),
memoryUsage: stats.memoryUsage,
cpuUsage: stats.cpuUsage,
activeConnections: stats.activeConnections,
totalConnections: stats.totalConnections,
requestsPerSecond: stats.requestsPerSecond,
throughput: stats.throughput,
};
})
);
}
if (sections.email) {
promises.push(
this.collectEmailStats().then(stats => {
metrics.email = {
sent: stats.sentToday,
received: stats.receivedToday,
bounced: Math.floor(stats.sentToday * stats.bounceRate / 100),
queued: stats.queueSize,
failed: 0,
averageDeliveryTime: 0,
deliveryRate: stats.deliveryRate,
bounceRate: stats.bounceRate,
};
})
);
}
if (sections.dns) {
promises.push(
this.collectDnsStats().then(stats => {
metrics.dns = {
totalQueries: stats.totalQueries,
cacheHits: stats.cacheHits,
cacheMisses: stats.cacheMisses,
cacheHitRate: stats.cacheHitRate,
activeDomains: stats.topDomains.length,
averageResponseTime: 0,
queryTypes: stats.queryTypes,
};
})
);
}
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push(
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
metrics.security = {
blockedIPs: stats.blockedIPs,
reputationScores: {},
spamDetected: stats.spamDetected,
malwareDetected: stats.malwareDetected,
phishingDetected: stats.phishingDetected,
authenticationFailures: stats.authFailures,
suspiciousActivities: stats.totalThreatsBlocked,
};
})
);
}
if (sections.network && this.opsServerRef.dcRouterRef.metricsManager) {
promises.push(
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;
throughput: interfaces.data.IServerStats['throughput'];
history: Array<{
timestamp: number;
value: number;
}>;
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const serverStats = await this.opsServerRef.dcRouterRef.metricsManager.getServerStats();
return {
uptime: serverStats.uptime,
cpuUsage: serverStats.cpuUsage,
memoryUsage: serverStats.memoryUsage,
requestsPerSecond: serverStats.requestsPerSecond,
activeConnections: serverStats.activeConnections,
totalConnections: serverStats.totalConnections,
throughput: serverStats.throughput,
history: [], // TODO: Implement history tracking
};
}
// Fallback to basic stats if MetricsManager not available
const uptime = process.uptime();
const memUsage = process.memoryUsage();
const cpuUsage = plugins.os.loadavg()[0] * 100 / plugins.os.cpus().length;
return {
uptime,
cpuUsage: {
user: cpuUsage * 0.7,
system: cpuUsage * 0.3,
},
memoryUsage: {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
external: memUsage.external,
rss: memUsage.rss,
},
requestsPerSecond: 0,
activeConnections: 0,
totalConnections: 0,
throughput: { bytesIn: 0, bytesOut: 0, bytesInPerSecond: 0, bytesOutPerSecond: 0 },
history: [],
};
}
private async collectEmailStats(): Promise<{
sentToday: number;
receivedToday: number;
bounceRate: number;
deliveryRate: number;
queueSize: number;
domainBreakdown?: { [domain: string]: interfaces.data.IEmailStats };
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const emailStats = await this.opsServerRef.dcRouterRef.metricsManager.getEmailStats();
return {
sentToday: emailStats.sentToday,
receivedToday: emailStats.receivedToday,
bounceRate: emailStats.bounceRate,
deliveryRate: emailStats.deliveryRate,
queueSize: emailStats.queueSize,
};
}
// Fallback if MetricsManager not available
return {
sentToday: 0,
receivedToday: 0,
bounceRate: 0,
deliveryRate: 100,
queueSize: 0,
};
}
private async collectDnsStats(): Promise<{
queriesPerSecond: number;
totalQueries: number;
cacheHits: number;
cacheMisses: number;
cacheHitRate: number;
topDomains: Array<{
domain: string;
count: number;
}>;
queryTypes: { [key: string]: number };
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
}> {
// Get metrics from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const dnsStats = await this.opsServerRef.dcRouterRef.metricsManager.getDnsStats();
return {
queriesPerSecond: dnsStats.queriesPerSecond,
totalQueries: dnsStats.totalQueries,
cacheHits: dnsStats.cacheHits,
cacheMisses: dnsStats.cacheMisses,
cacheHitRate: dnsStats.cacheHitRate,
topDomains: dnsStats.topDomains,
queryTypes: dnsStats.queryTypes,
};
}
// Fallback if MetricsManager not available
return {
queriesPerSecond: 0,
totalQueries: 0,
cacheHits: 0,
cacheMisses: 0,
cacheHitRate: 0,
topDomains: [],
queryTypes: {},
};
}
private async getQueueStatus(): Promise<{
pending: number;
active: number;
failed: number;
retrying: number;
items: Array<{
id: string;
recipient: string;
subject: string;
status: string;
attempts: number;
nextRetry?: number;
}>;
}> {
// TODO: Implement actual queue status collection
return {
pending: 0,
active: 0,
failed: 0,
retrying: 0,
items: [],
};
}
private async checkHealthStatus(): Promise<{
healthy: boolean;
services: Array<{
name: string;
status: 'healthy' | 'degraded' | 'unhealthy';
message?: string;
}>;
checks: Array<{
name: string;
passed: boolean;
message?: string;
}>;
}> {
const services: Array<{
name: string;
status: 'healthy' | 'degraded' | 'unhealthy';
message?: string;
}> = [];
// Check HTTP Proxy
if (this.opsServerRef.dcRouterRef.smartProxy) {
services.push({
name: 'HTTP/HTTPS Proxy',
status: 'healthy',
});
}
// Check Email Server
if (this.opsServerRef.dcRouterRef.emailServer) {
services.push({
name: 'Email Server',
status: 'healthy',
});
}
// Check DNS Server
if (this.opsServerRef.dcRouterRef.dnsServer) {
services.push({
name: 'DNS Server',
status: 'healthy',
});
}
// Check OpsServer
services.push({
name: 'OpsServer',
status: 'healthy',
});
const healthy = services.every(s => s.status === 'healthy');
return {
healthy,
services,
checks: [
{
name: 'Memory Usage',
passed: process.memoryUsage().heapUsed < (plugins.os.totalmem() * 0.9),
message: 'Memory usage within limits',
},
],
};
}
}

View 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
View File

@@ -0,0 +1 @@
export * from './classes.opsserver.js';

View File

@@ -1,6 +1,54 @@
import * as plugins from './plugins.js';
// Base directories
export const baseDir = process.cwd();
export const packageDir = plugins.path.join(
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
'../'
);
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
export const keysDir = plugins.path.join(dataDir, 'keys');
export const dnsRecordsDir = plugins.path.join(dataDir, 'dns');
export const sentEmailsDir = plugins.path.join(dataDir, 'emails', 'sent');
export const receivedEmailsDir = plugins.path.join(dataDir, 'emails', 'received');
export const failedEmailsDir = plugins.path.join(dataDir, 'emails', 'failed'); // For failed emails
export const logsDir = plugins.path.join(dataDir, 'logs'); // For logs
// Email template directories
export const emailTemplatesDir = plugins.path.join(dataDir, 'templates', 'email');
export const MtaAttachmentsDir = plugins.path.join(dataDir, 'attachments'); // For email attachments
// Configuration path
export const configPath = process.env.CONFIG_PATH
? process.env.CONFIG_PATH
: plugins.path.join(baseDir, 'config.json');
// Create directories if they don't exist
export function ensureDirectories() {
// Ensure data directories
plugins.fsUtils.ensureDirSync(dataDir);
plugins.fsUtils.ensureDirSync(keysDir);
plugins.fsUtils.ensureDirSync(dnsRecordsDir);
plugins.fsUtils.ensureDirSync(sentEmailsDir);
plugins.fsUtils.ensureDirSync(receivedEmailsDir);
plugins.fsUtils.ensureDirSync(failedEmailsDir);
plugins.fsUtils.ensureDirSync(logsDir);
// Ensure email template directories
plugins.fsUtils.ensureDirSync(emailTemplatesDir);
plugins.fsUtils.ensureDirSync(MtaAttachmentsDir);
}

View File

@@ -1,8 +1,24 @@
// node native
import * as dns from 'dns';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as http from 'http';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import * as tls from 'tls';
import * as util from 'util';
export {
path
dns,
fs,
crypto,
http,
net,
os,
path,
tls,
util,
}
// @serve.zone scope
@@ -15,20 +31,126 @@ export {
// @api.global scope
import * as typedrequest from '@api.global/typedrequest';
import * as typedserver from '@api.global/typedserver';
import * as typedsocket from '@api.global/typedsocket';
export {
typedrequest,
typedserver,
typedsocket,
}
// @push.rocks scope
import * as projectinfo from '@push.rocks/projectinfo';
import * as qenv from '@push.rocks/qenv';
import * as smartacme from '@push.rocks/smartacme';
import * as smartdata from '@push.rocks/smartdata';
import * as smartdns from '@push.rocks/smartdns';
import * as smartfile from '@push.rocks/smartfile';
import * as smartguard from '@push.rocks/smartguard';
import * as smartjwt from '@push.rocks/smartjwt';
import * as smartlog from '@push.rocks/smartlog';
import * as smartmail from '@push.rocks/smartmail';
import * as smartmetrics from '@push.rocks/smartmetrics';
import * as smartmta from '@push.rocks/smartmta';
import * as smartmongo from '@push.rocks/smartmongo';
import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
export { projectinfo, qenv, smartdata, smartfile, smartlog, smartmail, smartpath, smartrequest };
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmetrics, smartmongo, smartmta, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrx, smartunique };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';
// apiclient.xyz scope
import * as cloudflare from '@apiclient.xyz/cloudflare';
export {
cloudflare,
}
// tsclass scope
import * as tsclass from '@tsclass/tsclass';
export {
tsclass,
}
// third party
import * as uuid from 'uuid';
export {
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;
},
};

View 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;
}
}

View 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;
}
}

View 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
View 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';

View 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(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&apos;/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';
}
}
}

View 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}`);
});
}
}
}

View 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
View 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';

View File

@@ -1,22 +1,28 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { logger } from '../logger.js';
import type { SzPlatformService } from '../classes.platformservice.js';
export interface ISmsConstructorOptions {
apiToken: string;
}
import type { ISmsConfig } from './config/sms.config.js';
import { smsConfigSchema } from './config/sms.schema.js';
import { ConfigValidator } from '../config/validator.js';
export class SmsService {
public platformServiceRef: SzPlatformService;
public projectinfo: plugins.projectinfo.ProjectInfo;
public typedrouter = new plugins.typedrequest.TypedRouter();
public options: ISmsConstructorOptions;
public config: ISmsConfig;
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ISmsConstructorOptions) {
this.platformServiceRef = platformServiceRefArg;
this.options = optionsArg;
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
constructor(options: ISmsConfig) {
// Validate and apply defaults to configuration
const validationResult = ConfigValidator.validate(options, smsConfigSchema);
if (!validationResult.valid) {
logger.warn(`SMS service configuration has validation errors: ${validationResult.errors.join(', ')}`);
}
// Set configuration with defaults
this.config = validationResult.config;
// Add router to platform service
}
/**
@@ -53,21 +59,22 @@ export class SmsService {
}
public async sendSms(toNumber: number, fromName: string, messageText: string) {
// Use default sender if not specified
const sender = fromName || this.config.defaultSender || 'PlatformService';
const payload = {
sender: fromName,
sender,
message: messageText,
recipients: [{ msisdn: toNumber }],
};
const resp = await plugins.smartrequest.request('https://gatewayapi.com/rest/mtsms', {
method: 'POST',
requestBody: JSON.stringify(payload),
headers: {
Authorization: `Basic ${Buffer.from(`${this.options.apiToken}:`).toString('base64')}`,
'Content-Type': 'application/json',
},
});
const json = await resp.body;
const resp = await plugins.smartrequest.SmartRequest.create()
.url('https://gatewayapi.com/rest/mtsms')
.header('Authorization', `Basic ${Buffer.from(`${this.config.apiGatewayApiToken}:`).toString('base64')}`)
.header('Content-Type', 'application/json')
.json(payload)
.post();
const json = await resp.json();
logger.log('info', `sent an sms to ${toNumber} with text '${messageText}'`, {
eventType: 'sentSms',
sms: {

109
ts/sms/config/sms.config.ts Normal file
View 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
View 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
}
}
}
};

View File

@@ -1 +1 @@
export * from './smsservice.js';
export * from './classes.smsservice.js';

View 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
View File

@@ -0,0 +1,2 @@
// Storage module exports
export * from './classes.storagemanager.js';

3
ts/tspublish.json Normal file
View File

@@ -0,0 +1,3 @@
{
"order": 2
}

View File

@@ -0,0 +1,8 @@
export interface IIdentity {
jwt: string;
userId: string;
name: string;
expiresAt: number;
role?: string;
type?: string;
}

View File

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

138
ts_interfaces/data/stats.ts Normal file
View File

@@ -0,0 +1,138 @@
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;
requestsPerSecond: number;
throughput: {
bytesIn: number;
bytesOut: number;
bytesInPerSecond: number;
bytesOutPerSecond: 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
View 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
View File

@@ -0,0 +1,6 @@
// @apiglobal scope
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
export {
typedrequestInterfaces
}

205
ts_interfaces/readme.md Normal file
View 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.

View 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;
};
}

View File

@@ -0,0 +1,25 @@
import type * as data from '../data/index.js';
export interface IReq_GetCombinedMetrics {
method: 'getCombinedMetrics';
request: {
identity: data.IIdentity;
sections?: {
server?: boolean;
email?: boolean;
dns?: boolean;
security?: boolean;
network?: boolean;
};
};
response: {
metrics: {
server?: data.IServerStats;
email?: data.IEmailStats;
dns?: data.IDnsStats;
security?: data.ISecurityMetrics;
network?: data.INetworkMetrics;
};
timestamp: number;
};
}

View File

@@ -0,0 +1,18 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
// Get Configuration (read-only)
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetConfiguration
> {
method: 'getConfiguration';
request: {
identity?: authInterfaces.IIdentity;
section?: string;
};
response: {
config: any;
section?: string;
};
}

Some files were not shown because too many files have changed in this diff Show More