Compare commits
367 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 670b67eecf | |||
| 174af5cf86 | |||
| a1f5e45e94 | |||
| d06165bd0c | |||
| 8f3c6fdf23 | |||
| 106ef2919e | |||
| 3d7fd233cf | |||
| 34d40f7370 | |||
| 89b9d01628 | |||
| ed3964e892 | |||
| baab152fd3 | |||
| 9baf09ff61 | |||
| 71f23302d3 | |||
| ecbaab3000 | |||
| 8cb1f3c12d | |||
| c7d7f92759 | |||
| 02e1b9231f | |||
| 4ec4dd2bdb | |||
| aa543160e2 | |||
| 94fa0f04d8 | |||
| 17deb481e0 | |||
| e452ffd38e | |||
| 865b4a53e6 | |||
| c07f3975e9 | |||
| 476505537a | |||
| 74ad5cec90 | |||
| 59a3f7978e | |||
| 7dc976b59e | |||
| 345effee13 | |||
| dee6897931 | |||
| 56f41d70b3 | |||
| 8f570ae8a0 | |||
| e58e24a92d | |||
| 12070bc7b5 | |||
| 37d62c51f3 | |||
| ea9427d46b | |||
| bc77321752 | |||
| 65aa546c1c | |||
| 54484518dc | |||
| 6fe1247d4d | |||
| e59d80a3b3 | |||
| 6c4feba711 | |||
| 006a9af20c | |||
| dfb3b0ac37 | |||
| 44c1a3a928 | |||
| 0c4e28455e | |||
| cfc4cf378f | |||
| a09e69a28b | |||
| 82dd19e274 | |||
| c1d8afdbf7 | |||
| 9b7426f1e6 | |||
| 3c9c865841 | |||
| 8421c9fe46 | |||
| 907e3df156 | |||
| aaa0956148 | |||
| 118019fcf5 | |||
| deb80f4fd0 | |||
| 7d28cea937 | |||
| 2bd5e5c7c5 | |||
| 4d6ac81c59 | |||
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff | |||
| 40a34073e9 | |||
| 9ac297c197 | |||
| ddd0662fb8 | |||
| 11bc0dde6c | |||
| 610d691244 | |||
| c88410ea53 | |||
| 9cbdd24281 | |||
| dce1de8c4b | |||
| 86e6c4f600 | |||
| 0618755236 | |||
| b21f3385e1 | |||
| dd61e0c962 | |||
| ac3a42fc41 | |||
| c23f16149c | |||
| 529a4bae00 | |||
| 49606ae007 | |||
| 31a6510d8b | |||
| b5e760ae07 | |||
| ea32babaac | |||
| a4ddedaf46 | |||
| 7ce09c53ca | |||
| 69be2295f1 | |||
| 018efa32f6 | |||
| 2530918dc6 | |||
| 0b09ea1573 | |||
| 21157477b4 | |||
| fcf36e5cd5 | |||
| f5740fa565 | |||
| 4a9fba53a9 | |||
| da61adc9a2 | |||
| 616066ffd0 | |||
| bd5cccb405 | |||
| fbade85cda | |||
| 9060d26f3a | |||
| c889141ec3 | |||
| fb472f353c | |||
| 090bd747e1 | |||
| 4d77a94bbb | |||
| 7f5284b10f | |||
| 9cd5db2d81 | |||
| de0b7d1fe0 | |||
| 4e32745a8f | |||
| 121573de2f | |||
| cd957526e2 | |||
| 7aa5f07731 | |||
| 5b6f7b30c3 | |||
| 18cc21a49e | |||
| 46fa2f6ade | |||
| 0a6315f177 | |||
| 841f99e19d | |||
| 8e9de46cd2 | |||
| 2d44528345 | |||
| 28a38252da | |||
| dfb268bbfc | |||
| 6532c7ff22 | |||
| d2c63cf170 | |||
| 09d66e4528 | |||
| 3078fa9d7b | |||
| 57fbb128e6 | |||
| d73266eeb8 | |||
| 2dbdf2d2b1 | |||
| 383e0adc23 | |||
| d7789f5a44 | |||
| 2638990667 | |||
| c33ecdc26f | |||
| b033d80927 | |||
| cf5d616769 | |||
| 8e722f5ab6 | |||
| 2b75709161 | |||
| c5e2c262b7 | |||
| d10896196d | |||
| 8be1e87bdc | |||
| 96cefe984a | |||
| ca112c3e42 | |||
| 85b6c4fa51 | |||
| ee550e6f25 | |||
| 108a8bb51d | |||
| 3c5b26d1c1 | |||
| 01fbc3db95 | |||
| 8dd9770339 | |||
| 77842647fd | |||
| a309145829 | |||
| 5de8d38b78 | |||
| 2d6dbc552e | |||
| f0fae866dc | |||
| 87c039a63f | |||
| 2c875cbb18 | |||
| 735464e8e6 | |||
| e6a1f50554 | |||
| 530ebbf3e4 | |||
| 048f038e36 | |||
| e375adb80a | |||
| 9d7da5bc25 | |||
| 41fe7a8a47 | |||
| f3f1f58b67 | |||
| 9e0e77737b | |||
| 5de3344905 | |||
| ae34314f54 | |||
| 5b473de354 | |||
| 1a108fa8b7 | |||
| badabe753a | |||
| c2d3ace0dd | |||
| fcea194cf6 | |||
| b90650c660 | |||
| 2206abd04b | |||
| d54831765b | |||
| dd4ac9fa3d | |||
| aed9151998 | |||
| 5d4bf4eff8 | |||
| 9027125520 | |||
| ee561c0823 | |||
| 95cb5d7840 | |||
| 2f46b3c9f3 | |||
| 7bd94884f4 | |||
| 405990563b | |||
| bf9f805c71 | |||
| 28cbf84f97 | |||
| d24e51117d | |||
| 92fde9d0d7 | |||
| b81bda6ce8 | |||
| 9b3f5c458d | |||
| 3ba47f9a71 | |||
| 2ab2e30336 | |||
| 8ce6c88d58 | |||
| facae93e4b | |||
| 0eb4963247 | |||
| 02dd3c77b5 | |||
| 93995d5031 | |||
| 554d245c0c | |||
| e3cb35a036 | |||
| 3a95ea9f4e | |||
| 99f57dba76 | |||
| 415e28038d | |||
| 7bda406624 | |||
| 8282610307 | |||
| 5269c20770 | |||
| f1fb4c8495 | |||
| 5faca8c1b6 | |||
| 61778bdba8 | |||
| ab19130904 | |||
| 646aa7106b | |||
| b0f167f6da | |||
| 4d8d802006 | |||
| 6ee1d6e917 | |||
| f877ad9676 | |||
| fe817dde00 | |||
| 272973702e | |||
| c776dab2c0 | |||
| 74692c4aa5 | |||
| 71183b35c0 | |||
| ae73de19b2 | |||
| a2b413a78f | |||
| 739eeb63aa | |||
| eb26a62a87 | |||
| ad0ab6c103 | |||
| 37e1ecefd2 | |||
| e6251ab655 | |||
| 53b64025f3 | |||
| 40db395591 | |||
| 2c244c4a9a | |||
| 0baf2562b7 | |||
| 64da8d9100 | |||
| b11fea7334 | |||
| 6c8458f63c | |||
| 455b0085ec | |||
| 2b2fe940c4 | |||
| e1a7b3e8f7 | |||
| 191c4160c1 | |||
| 2e75961d1c | |||
| 88099e120a | |||
| 77ff948404 | |||
| 0e610cba16 | |||
| 8d59d617f1 | |||
| 6aa54d974e | |||
| 2aeb52bf13 | |||
| 243a45d24c | |||
| cfea44742a | |||
| 073c8378c7 | |||
| af408d38c9 | |||
| c3b14c0f58 | |||
| 69304dc839 | |||
| a3721f7a74 | |||
| 20583beb35 | |||
| b8ea8f660e | |||
| 5a45d6cd45 | |||
| 84196f9b13 | |||
| 4c9fd22a86 | |||
| 5b33623c2d | |||
| 58f4a123d2 | |||
| 11a2ae6b27 | |||
| 4e4c7df558 | |||
| 3d669ed9dd | |||
| 6e19e30f87 | |||
| dc5c0b2584 | |||
| 35712b18bc | |||
| 9958c036a0 | |||
| 14c9fbdc3c | |||
| 4fd3ec2958 | |||
| f2e9ff0a51 | |||
| cb52446f65 | |||
| 0907949f8a | |||
| 9629329bc2 | |||
| f651cd1c2f | |||
| a7438a7cd6 | |||
| e0f6e3237b | |||
| 1b141ec8f3 | |||
| 7d28d23bbd | |||
| 53f5e30b23 | |||
| 7344bf0f70 | |||
| 4905595cbb | |||
| f058b2d1e7 | |||
| 6fcc3feb73 | |||
| 50350bd78d | |||
| f065a9c952 | |||
| 72898c67b7 | |||
| ca53816b41 | |||
| ac419e7b79 | |||
| 7c0f9b4e44 | |||
| d584f3584c | |||
| a4353b10bb | |||
| b2f25c49b6 | |||
| d3255a7e14 | |||
| 2564d0874b | |||
| ca111f4783 | |||
| b6dd281a54 | |||
| 645790d0c2 | |||
| 535b055664 | |||
| 2eeb731669 | |||
| c3ae995372 | |||
| 15e7a3032c | |||
| 10ab09894b | |||
| 38811dbf23 | |||
| 3f220996ee | |||
| b0a0078ad0 | |||
| ecb913843c | |||
| 162795802f | |||
| b1890f59ee | |||
| 5c85188183 | |||
| f37cddf26d | |||
| f3f06ed06d | |||
| 07f03eb834 | |||
| e7174e8630 | |||
| 186e94c1a2 | |||
| fb424d814c | |||
| 0ad5dfd6ee | |||
| fbaafa909b | |||
| f1cc7fd340 | |||
| deec61da42 | |||
| 190ae11667 | |||
| f4ace3999d | |||
| 8b857e3d1d | |||
| 7aaf8f2595 | |||
| 39b634b6bb | |||
| 4624fdbe10 | |||
| 858794799b | |||
| cb33dd26d0 | |||
| d3d197d9d3 | |||
| 0e914a3366 | |||
| 747478f0f9 | |||
| b61de33ee0 | |||
| 970c0d5c60 | |||
| fe2069c48e | |||
| 63781ab1bd | |||
| 0b155d6925 | |||
| 076aac27ce | |||
| 7f84405279 | |||
| 13ef31c13f | |||
| 5cf4c0f150 | |||
| 04b7552b34 | |||
| 1528d29b0d | |||
| 9d895898b1 | |||
| 45be1e0a42 | |||
| ba39392c1b | |||
| f704dc78aa | |||
| 7e931d6c52 | |||
| 630e911589 | |||
| f6377d1973 | |||
| c852e954c9 | |||
| 2ee66ef967 | |||
| 5ad43470f3 | |||
| efd64d6304 | |||
| a29cff2fc5 | |||
| d161fe4f19 | |||
| df9a8ad14e | |||
| 8ddad6e652 |
5
.gitignore
vendored
@@ -17,4 +17,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist_*/
|
dist_*/
|
||||||
|
|
||||||
# custom
|
# custom
|
||||||
|
**/.claude/settings.local.json
|
||||||
|
.nogit/data/
|
||||||
|
readme.plan.md
|
||||||
|
|||||||
7
.playwright-mcp/console-2026-02-23T10-44-24-024Z.log
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[ 74ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 697ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
12
.playwright-mcp/console-2026-02-23T11-19-21-255Z.log
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541
|
||||||
|
[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||||
|
[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
|
[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||||
|
[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||||
6
.playwright-mcp/console-2026-02-23T11-20-31-682Z.log
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[ 55ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 791ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
50
.playwright-mcp/console-2026-02-23T11-21-09-382Z.log
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||||
|
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||||
|
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||||
|
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 377ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 78237ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 141910ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
23
.playwright-mcp/console-2026-02-23T11-23-44-606Z.log
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[ 437ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 52895ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 52896ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 99401ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
31
.playwright-mcp/console-2026-02-23T12-47-06-007Z.log
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[ 75ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 763ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 22315ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 22315ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 22316ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 22316ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 22321ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 22322ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 22322ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 22322ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 65371ms] [ERROR] method: >>createApiToken<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 65371ms] [ERROR] Failed to create token: zs @ http://localhost:3000/bundle.js:38142
|
||||||
25
.playwright-mcp/console-2026-02-23T12-48-31-563Z.log
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[ 642ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 114916ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 179731ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 179731ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 179732ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 179737ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 179738ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 179738ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 179738ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
1
.playwright-mcp/console-2026-02-23T12-53-33-702Z.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[ 603ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
24
.playwright-mcp/console-2026-02-23T12-55-40-311Z.log
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[ 308ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 309ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 309ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 310ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 349ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 350ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 350ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 351ms] [ERROR] method: >>listApiTokens<< got an ERROR: "admin access required" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 500ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/apitokens:0
|
||||||
30
.playwright-mcp/console-2026-02-23T12-57-47-953Z.log
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[ 427ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 44124ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59106ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59106ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 59107ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59107ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 59116ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 59116ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
|
[ 89192ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||||
|
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||||
|
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||||
|
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||||
|
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||||
|
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 89192ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||||
6
.playwright-mcp/console-2026-03-02T19-29-32-708Z.log
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[ 95ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||||
|
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||||
|
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||||
|
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||||
|
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||||
|
[ 992ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
5
.playwright-mcp/console-2026-03-02T19-30-09-759Z.log
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[ 329ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 727ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 260513ms] [ERROR] method: >>adminLoginWithUsernameAndPassword<< got an ERROR: "login failed" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 260514ms] [ERROR] Login failed: Ns @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 260518ms] [WARNING] FontAwesome icon not found: circle-xmark @ http://localhost:3000/bundle.js:1203
|
||||||
3
.playwright-mcp/console-2026-03-02T19-34-55-496Z.log
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[ 397ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||||
|
[ 657ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||||
|
[ 24180ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
15
.playwright-mcp/console-2026-03-03T21-47-08-122Z.log
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[ 916ms] [ERROR] method: >>getCombinedMetrics<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
|
||||||
|
[ 972ms] [ERROR] method: >>getConfiguration<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
|
||||||
|
[ 973ms] [ERROR] method: >>getRecentLogs<< got an ERROR: "Valid identity required" with data {} @ http://localhost:3000/bundle.js:15
|
||||||
|
[ 990ms] K2
|
||||||
|
[ 1024ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 37030ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 37031ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 37923ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 37923ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 39699ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 39699ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 44287ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 44288ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 53685ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 53685ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
90
.playwright-mcp/console-2026-03-03T22-02-52-436Z.log
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
[ 1146ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||||
|
[ 26151ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||||
|
[ 257684ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 257684ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 257684ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 257685ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 258151ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 258500ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 258500ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 258568ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 258568ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 259149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 260149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 260245ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 260245ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 260324ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 260324ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 261149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 262149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 263149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 263917ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 263917ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 264149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 264781ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 264781ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 265169ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 266149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 267149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 268149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 269149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 270149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 271149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 272149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 272565ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 272565ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 273149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 273647ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 273647ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 274149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 275149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 276149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 277149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 278149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 279149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 280149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 281149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 282149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 283149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 284149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 285149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 286149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 287149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 288150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 289149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 290149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 290179ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 290179ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/bundle.js:38066
|
||||||
|
[ 291147ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||||
|
[ 291147ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||||
|
[ 291149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 292149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 293149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 294149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 295149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 296149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 297149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 298149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 299149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 300149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 301149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 302149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 303149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 304149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 305149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 306149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 307149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 308149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 309149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 310149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 311149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 312150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 313149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 314149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 315149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 316149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 317149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 318150ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 319149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 320149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
|
[ 321149ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedrequest:0
|
||||||
BIN
.playwright-mcp/page-2026-02-23T11-25-39-255Z.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
.playwright-mcp/page-2026-02-23T11-26-10-952Z.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.playwright-mcp/page-2026-02-23T11-26-15-885Z.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.playwright-mcp/page-2026-03-02T19-32-32-890Z.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
.playwright-mcp/page-2026-03-02T19-33-32-637Z.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
.playwright-mcp/page-2026-03-03T21-48-01-361Z.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
.playwright-mcp/page-2026-03-03T22-07-21-001Z.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
1010
changelog.md
4
cli.child.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
process.env.CLI_CALL = 'true';
|
||||||
|
import * as cliTool from './ts/index.js';
|
||||||
|
cliTool.runCli();
|
||||||
121
html/index.html
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!--gitzone default-->
|
||||||
|
<!-- made by Lossless GmbH -->
|
||||||
|
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!--Lets set some basic meta tags-->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="user-scalable=0, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
|
||||||
|
/>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
|
||||||
|
<!--Lets make sure we recognize this as an PWA-->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
|
||||||
|
|
||||||
|
<!--Lets load standard fonts-->
|
||||||
|
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
|
||||||
|
|
||||||
|
|
||||||
|
<!--Lets avoid a rescaling flicker due to default body margins-->
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
background: #000;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
projectVersion = '';
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #303f9f;
|
||||||
|
font-family: Inter, Roboto, sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #ffffff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-top: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 600px;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 20px;
|
||||||
|
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #4357d9;
|
||||||
|
}
|
||||||
|
.contentHeader {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 25px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="logo">
|
||||||
|
<img src="https://assetbroker.lossless.one/brandfiles/lossless/svg-minimal-bright.svg" />
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="contentHeader">We need JavaScript to run properly!</div>
|
||||||
|
<div class="content">
|
||||||
|
This site is being built using lit-element (made by Google). This technology works with
|
||||||
|
JavaScript. Subsequently this website does not work as intended by Lossless GmbH without
|
||||||
|
JavaScript.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<a href="https://lossless.gmbh">Legal Info</a> |
|
||||||
|
<a href="https://lossless.gmbh/privacy">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<script type="text/javascript" async defer>
|
||||||
|
window.revenueEnabled = true;
|
||||||
|
const runRevenueCheck = async () => {
|
||||||
|
var e = document.createElement('div');
|
||||||
|
e.id = '476kjuhzgtr764';
|
||||||
|
e.style.display = 'none';
|
||||||
|
document.body.appendChild(e);
|
||||||
|
if (document.getElementById('476kjuhzgtr764')) {
|
||||||
|
window.revenueEnabled = true;
|
||||||
|
} else {
|
||||||
|
window.revenueEnabled = false;
|
||||||
|
}
|
||||||
|
console.log(`revenue enabled: ${window.revenueEnabled}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
runRevenueCheck();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
<script defer type="module" src="/bundle.js"></script>
|
||||||
|
</html>
|
||||||
@@ -1,12 +1,39 @@
|
|||||||
{
|
{
|
||||||
"gitzone": {
|
"@git.zone/tswatch": {
|
||||||
|
"watchers": [
|
||||||
|
{
|
||||||
|
"name": "dcrouter-dev",
|
||||||
|
"watch": [
|
||||||
|
"ts/**/*.ts",
|
||||||
|
"ts_*/**/*.ts",
|
||||||
|
"test_watch/devserver.ts"
|
||||||
|
],
|
||||||
|
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||||
|
"restart": true,
|
||||||
|
"debounce": 500,
|
||||||
|
"runOnStart": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "./ts_web/index.ts",
|
||||||
|
"to": "./dist_serve/bundle.js",
|
||||||
|
"outputMode": "bundle",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
"projectType": "service",
|
"projectType": "service",
|
||||||
"module": {
|
"module": {
|
||||||
"githost": "gitlab.com",
|
"githost": "gitlab.com",
|
||||||
"gitscope": "serve.zone",
|
"gitscope": "serve.zone",
|
||||||
"gitrepo": "platformservice",
|
"gitrepo": "dcrouter",
|
||||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
"description": "A traffic router intended to be gating your datacenter.",
|
||||||
"npmPackagename": "@serve.zone/platformservice",
|
"npmPackagename": "@serve.zone/dcrouter",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"projectDomain": "serve.zone",
|
"projectDomain": "serve.zone",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -17,8 +44,7 @@
|
|||||||
"SMTP server",
|
"SMTP server",
|
||||||
"mail parsing",
|
"mail parsing",
|
||||||
"DKIM",
|
"DKIM",
|
||||||
"platform service",
|
"traffic router",
|
||||||
"mailgun integration",
|
|
||||||
"letterXpress",
|
"letterXpress",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"Anthropic AI",
|
"Anthropic AI",
|
||||||
@@ -31,12 +57,19 @@
|
|||||||
"SMTP STARTTLS",
|
"SMTP STARTTLS",
|
||||||
"DNS management"
|
"DNS management"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"registries": [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org"
|
||||||
|
],
|
||||||
|
"accessLevel": "public"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"npmci": {
|
"@ship.zone/szci": {
|
||||||
"npmGlobalTools": [],
|
"npmGlobalTools": [],
|
||||||
"dockerRegistryRepoMap": {
|
"dockerRegistryRepoMap": {
|
||||||
"registry.gitlab.com": "code.foss.global/serve.zone/platformservice"
|
"registry.gitlab.com": "code.foss.global/serve.zone/dcrouter"
|
||||||
},
|
},
|
||||||
"dockerBuildargEnvMap": {
|
"dockerBuildargEnvMap": {
|
||||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||||
|
|||||||
116
package.json
@@ -1,52 +1,67 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/platformservice",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": true,
|
"private": false,
|
||||||
"version": "2.2.0",
|
"version": "11.0.4",
|
||||||
"description": "A multifaceted platform service handling mail, SMS, letter delivery, and AI services.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"main": "dist_ts/index.js",
|
|
||||||
"typings": "dist_ts/index.d.ts",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist_ts/index.js",
|
||||||
|
"./interfaces": "./dist_ts_interfaces/index.js"
|
||||||
|
},
|
||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/)",
|
"test": "(tstest test/ --logfile --timeout 60)",
|
||||||
"start": "(node --max_old_space_size=250 ./cli.js)",
|
"start": "(node --max_old_space_size=250 ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
"localPublish": ""
|
"bundle": "(tsbundle)",
|
||||||
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.1.17",
|
"@git.zone/tsbuild": "^4.1.2",
|
||||||
"@git.zone/tsrun": "^1.2.8",
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
"@git.zone/tstest": "^1.0.88",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tswatch": "^2.0.1",
|
"@git.zone/tstest": "^3.2.0",
|
||||||
"@push.rocks/tapbundle": "^5.0.22"
|
"@git.zone/tswatch": "^3.2.5",
|
||||||
|
"@types/node": "^25.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.0.19",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedserver": "^3.0.27",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedsocket": "^3.0.0",
|
"@api.global/typedserver": "^8.4.2",
|
||||||
"@apiclient.xyz/cloudflare": "^6.0.3",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/letterxpress": "^1.0.20",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@design.estate/dees-catalog": "^3.43.3",
|
||||||
"@push.rocks/qenv": "^6.0.5",
|
"@design.estate/dees-element": "^2.1.6",
|
||||||
"@push.rocks/smartdata": "^5.0.7",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/smartfile": "^11.0.4",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/smartlog": "^3.0.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartmail": "^1.0.24",
|
"@push.rocks/smartacme": "^9.1.3",
|
||||||
"@push.rocks/smartpath": "^5.0.5",
|
"@push.rocks/smartdata": "^7.1.0",
|
||||||
"@push.rocks/smartpromise": "^4.0.3",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartproxy": "^4.1.0",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartrequest": "^2.0.21",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartrule": "^2.0.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartrx": "^3.0.7",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
"@push.rocks/smartstate": "^2.0.0",
|
"@push.rocks/smartmetrics": "^3.0.2",
|
||||||
"@serve.zone/interfaces": "^4.12.1",
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@tsclass/tsclass": "^5.0.0",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@types/mailparser": "^3.4.5",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"mailauth": "^4.6.5",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"mailparser": "^3.6.9",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"uuid": "^11.1.0"
|
"@push.rocks/smartproxy": "^25.9.1",
|
||||||
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
|
"@push.rocks/smartstate": "^2.2.0",
|
||||||
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"@serve.zone/catalog": "^2.5.0",
|
||||||
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
|
"@serve.zone/remoteingress": "^4.4.0",
|
||||||
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
|
"lru-cache": "^11.2.6",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"mail service",
|
"mail service",
|
||||||
@@ -56,8 +71,7 @@
|
|||||||
"SMTP server",
|
"SMTP server",
|
||||||
"mail parsing",
|
"mail parsing",
|
||||||
"DKIM",
|
"DKIM",
|
||||||
"platform service",
|
"mail router",
|
||||||
"mailgun integration",
|
|
||||||
"letterXpress",
|
"letterXpress",
|
||||||
"OpenAI",
|
"OpenAI",
|
||||||
"Anthropic AI",
|
"Anthropic AI",
|
||||||
@@ -68,7 +82,12 @@
|
|||||||
"email templating",
|
"email templating",
|
||||||
"rule management",
|
"rule management",
|
||||||
"SMTP STARTTLS",
|
"SMTP STARTTLS",
|
||||||
"DNS management"
|
"DNS management",
|
||||||
|
"RADIUS",
|
||||||
|
"AAA",
|
||||||
|
"network authentication",
|
||||||
|
"VLAN assignment",
|
||||||
|
"MAC authentication"
|
||||||
],
|
],
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
@@ -76,5 +95,18 @@
|
|||||||
"mongodb-memory-server",
|
"mongodb-memory-server",
|
||||||
"puppeteer"
|
"puppeteer"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.11.0",
|
||||||
|
"files": [
|
||||||
|
"ts/**/*",
|
||||||
|
"ts_web/**/*",
|
||||||
|
"dist/**/*",
|
||||||
|
"dist_*/**/*",
|
||||||
|
"dist_ts/**/*",
|
||||||
|
"dist_ts_web/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"cli.js",
|
||||||
|
"npmextra.json",
|
||||||
|
"readme.md"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
10091
pnpm-lock.yaml
generated
773
readme.hints.md
@@ -0,0 +1,773 @@
|
|||||||
|
# Implementation Hints and Learnings
|
||||||
|
|
||||||
|
## smartmta Migration (2026-02-11)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
|
||||||
|
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
|
||||||
|
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer
|
||||||
|
|
||||||
|
### Key API Differences (smartmta vs old custom MTA)
|
||||||
|
- `updateEmailRoutes()` instead of `updateRoutes()`
|
||||||
|
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
|
||||||
|
- `bounceManager` is private, but exposed via public methods:
|
||||||
|
- `emailServer.getSuppressionList()`
|
||||||
|
- `emailServer.getHardBouncedAddresses()`
|
||||||
|
- `emailServer.getBounceHistory(email)`
|
||||||
|
- `emailServer.removeFromSuppressionList(email)`
|
||||||
|
- `Email` class imported from `@push.rocks/smartmta`
|
||||||
|
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`
|
||||||
|
|
||||||
|
### Deleted Directories
|
||||||
|
- `ts/mail/` (60 files) — replaced by smartmta
|
||||||
|
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
|
||||||
|
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
|
||||||
|
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence
|
||||||
|
|
||||||
|
### Remaining Cache Documents
|
||||||
|
- `CachedEmail` — kept (dcrouter-level queue persistence)
|
||||||
|
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)
|
||||||
|
|
||||||
|
### Dependencies Removed
|
||||||
|
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge
|
||||||
|
|
||||||
|
### Pre-existing Test Failures (not caused by migration)
|
||||||
|
- `test/test.jwt-auth.ts` — `response.text is not a function` (webrequest compatibility issue)
|
||||||
|
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts
|
||||||
|
|
||||||
|
### smartmta Location
|
||||||
|
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`
|
||||||
|
|
||||||
|
## Dependency Upgrade (2026-02-11)
|
||||||
|
|
||||||
|
### SmartProxy v23.1.2 Route Validation
|
||||||
|
- SmartProxy 23.1.2 enforces stricter route validation
|
||||||
|
- Forward actions MUST use `targets` (array) instead of `target` (singular)
|
||||||
|
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WRONG - will fail validation
|
||||||
|
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files Fixed:**
|
||||||
|
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
|
||||||
|
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`
|
||||||
|
|
||||||
|
## Dependency Upgrade (2026-02-10)
|
||||||
|
|
||||||
|
### SmartProxy v23.1.0 Upgrade
|
||||||
|
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
- Rust-based proxy components for improved performance
|
||||||
|
- Rust binary runs as separate process via IPC
|
||||||
|
- `getStatistics()` now returns `Promise<any>` (was synchronous)
|
||||||
|
- nftables-proxy removed (not used by dcrouter)
|
||||||
|
|
||||||
|
**Code Changes Required:**
|
||||||
|
```typescript
|
||||||
|
// Old (synchronous)
|
||||||
|
const proxyStats = this.dcRouter.smartProxy.getStatistics();
|
||||||
|
|
||||||
|
// New (async)
|
||||||
|
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call
|
||||||
|
|
||||||
|
## Dependency Upgrade (2026-02-01)
|
||||||
|
|
||||||
|
### Major Upgrades Completed
|
||||||
|
- `@api.global/typedserver`: 3.0.80 → 8.3.0
|
||||||
|
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
|
||||||
|
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
|
||||||
|
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
|
||||||
|
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
|
||||||
|
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
|
||||||
|
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
|
||||||
|
- `uuid`: 11.1.0 → 13.0.0
|
||||||
|
|
||||||
|
### Breaking Changes Fixed
|
||||||
|
|
||||||
|
1. **SmartProxy v22**: `target` → `targets` (array)
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
action: { type: 'forward', target: { host: 'x', port: 25 } }
|
||||||
|
// New
|
||||||
|
action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
|
||||||
|
```typescript
|
||||||
|
// Old
|
||||||
|
const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
|
||||||
|
const json = resp.body;
|
||||||
|
// New
|
||||||
|
const resp = await plugins.smartrequest.SmartRequest.create()...post();
|
||||||
|
const json = await resp.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **dees-catalog v3**: Icon naming changed to library-prefixed format
|
||||||
|
```typescript
|
||||||
|
// Old (deprecated but supported)
|
||||||
|
<dees-icon iconFA="check"></dees-icon>
|
||||||
|
// New
|
||||||
|
<dees-icon icon="fa:check"></dees-icon>
|
||||||
|
<dees-icon icon="lucide:menu"></dees-icon>
|
||||||
|
```
|
||||||
|
|
||||||
|
### TC39 Decorators
|
||||||
|
- ts_web components updated to use `accessor` keyword for `@state()` decorators
|
||||||
|
- Required for TC39 standard decorator support
|
||||||
|
|
||||||
|
### tswatch Configuration
|
||||||
|
The project now uses tswatch for development:
|
||||||
|
```bash
|
||||||
|
pnpm run watch
|
||||||
|
```
|
||||||
|
Configuration in `npmextra.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@git.zone/tswatch": {
|
||||||
|
"watchers": [{
|
||||||
|
"name": "dcrouter-dev",
|
||||||
|
"watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
|
||||||
|
"command": "pnpm run build && tsrun test_watch/devserver.ts",
|
||||||
|
"restart": true,
|
||||||
|
"debounce": 500,
|
||||||
|
"runOnStart": true
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## RADIUS Server Integration (2026-02-01)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
|
||||||
|
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
|
||||||
|
- **RADIUS Accounting** - Track sessions, data usage, and billing
|
||||||
|
|
||||||
|
### Configuration Example
|
||||||
|
```typescript
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
radiusConfig: {
|
||||||
|
authPort: 1812, // Authentication port (default)
|
||||||
|
acctPort: 1813, // Accounting port (default)
|
||||||
|
clients: [
|
||||||
|
{
|
||||||
|
name: 'switch-1',
|
||||||
|
ipRange: '192.168.1.0/24',
|
||||||
|
secret: 'shared-secret',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
vlanAssignment: {
|
||||||
|
defaultVlan: 100, // VLAN for unknown MACs
|
||||||
|
allowUnknownMacs: true,
|
||||||
|
mappings: [
|
||||||
|
{ mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
|
||||||
|
{ mac: '00:11:22', vlan: 20, enabled: true } // OUI pattern
|
||||||
|
]
|
||||||
|
},
|
||||||
|
accounting: {
|
||||||
|
enabled: true,
|
||||||
|
retentionDays: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `RadiusServer` - Main server wrapping smartradius
|
||||||
|
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
|
||||||
|
- `AccountingManager` - Session tracking and billing data
|
||||||
|
|
||||||
|
### OpsServer API Endpoints
|
||||||
|
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
|
||||||
|
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
|
||||||
|
- `testVlanAssignment` - Test what VLAN a MAC would get
|
||||||
|
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
|
||||||
|
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics
|
||||||
|
|
||||||
|
### Files
|
||||||
|
- `ts/radius/` - RADIUS module
|
||||||
|
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
|
||||||
|
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces
|
||||||
|
|
||||||
|
## Test Fix: test.dcrouter.email.ts (2026-02-01)
|
||||||
|
|
||||||
|
### Issue
|
||||||
|
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
The test was using outdated email config properties:
|
||||||
|
- Used `domainRules: []` (non-existent property)
|
||||||
|
- Used `defaultMode` (non-existent property)
|
||||||
|
- Missing required `domains: []` property
|
||||||
|
- Missing required `routes: []` property
|
||||||
|
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
|
||||||
|
```typescript
|
||||||
|
const emailConfig: IEmailConfig = {
|
||||||
|
ports: [2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [], // Required: domain configurations
|
||||||
|
routes: [] // Required: email routing rules
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
And fixed the property name:
|
||||||
|
```typescript
|
||||||
|
expect(router.emailServer).toBeTruthy(); // Not unifiedEmailServer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Learning
|
||||||
|
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
|
||||||
|
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
|
||||||
|
- `routes: IEmailRoute[]` is required (email routing rules)
|
||||||
|
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`
|
||||||
|
|
||||||
|
## Network Metrics Implementation (2025-06-23)
|
||||||
|
|
||||||
|
### SmartProxy Metrics API Integration
|
||||||
|
- Updated to use new SmartProxy metrics API (v19.6.7)
|
||||||
|
- Use `getMetrics()` for detailed metrics with grouped methods:
|
||||||
|
```typescript
|
||||||
|
const metrics = smartProxy.getMetrics();
|
||||||
|
metrics.connections.active() // Current active connections
|
||||||
|
metrics.throughput.instant() // Real-time throughput {in, out}
|
||||||
|
metrics.connections.topIPs(10) // Top 10 IPs by connection count
|
||||||
|
```
|
||||||
|
- Use `getStatistics()` for basic stats
|
||||||
|
|
||||||
|
### Network Traffic Display
|
||||||
|
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
|
||||||
|
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
|
||||||
|
- Network graph shows separate lines for inbound (green) and outbound (purple)
|
||||||
|
- Throughput tiles and graph use same data source for consistency
|
||||||
|
|
||||||
|
### Requests/sec vs Connections
|
||||||
|
- Requests/sec shows HTTP request counts (derived from connections)
|
||||||
|
- Single connection can handle multiple requests
|
||||||
|
- Current implementation tracks connections, not individual requests
|
||||||
|
- Trend line shows historical request counts, not throughput
|
||||||
|
|
||||||
|
## DKIM Implementation Status (2025-05-30)
|
||||||
|
|
||||||
|
**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.
|
||||||
|
|
||||||
|
## SmartProxy Usage
|
||||||
|
|
||||||
|
### New Route-Based Architecture (v18+)
|
||||||
|
- SmartProxy now uses a route-based configuration system
|
||||||
|
- Routes define match criteria and actions instead of simple port-to-port forwarding
|
||||||
|
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// NEW: Route-based SmartProxy configuration
|
||||||
|
const smartProxy = new plugins.smartproxy.SmartProxy({
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'https-traffic',
|
||||||
|
match: {
|
||||||
|
ports: 443,
|
||||||
|
domains: ['example.com', '*.example.com']
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: {
|
||||||
|
host: 'backend.server.com',
|
||||||
|
port: 8080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
mode: 'terminate',
|
||||||
|
certificate: 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaults: {
|
||||||
|
target: {
|
||||||
|
host: 'fallback.server.com',
|
||||||
|
port: 8080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
acme: {
|
||||||
|
accountEmail: 'admin@example.com',
|
||||||
|
enabled: true,
|
||||||
|
useProduction: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration from Old to New
|
||||||
|
```typescript
|
||||||
|
// OLD configuration style (deprecated)
|
||||||
|
{
|
||||||
|
fromPort: 443,
|
||||||
|
toPort: 8080,
|
||||||
|
targetIP: 'backend.server.com',
|
||||||
|
domainConfigs: [...]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW route-based style
|
||||||
|
{
|
||||||
|
routes: [{
|
||||||
|
name: 'main-route',
|
||||||
|
match: { ports: 443 },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
target: { host: 'backend.server.com', port: 8080 }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Component Usage
|
||||||
|
- Use SmartProxy components directly instead of creating your own wrappers
|
||||||
|
- SmartProxy already includes Port80Handler and NetworkProxy functionality
|
||||||
|
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately
|
||||||
|
|
||||||
|
### Certificate Management
|
||||||
|
- SmartProxy has built-in ACME certificate management
|
||||||
|
- Configure it in the `acme` property of SmartProxy options
|
||||||
|
- Use `accountEmail` (not `email`) for the ACME contact email
|
||||||
|
- SmartProxy handles both HTTP-01 challenges and certificate application automatically
|
||||||
|
|
||||||
|
## qenv Usage
|
||||||
|
|
||||||
|
### Direct Usage
|
||||||
|
- Use qenv directly instead of creating environment variable wrappers
|
||||||
|
- Instantiate qenv with appropriate basePath and nogitPath:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||||
|
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Interfaces
|
||||||
|
|
||||||
|
### SmartProxy Interfaces
|
||||||
|
- Always check the interfaces from the node_modules to ensure correct property names
|
||||||
|
- Important interfaces for the new architecture:
|
||||||
|
- `ISmartProxyOptions`: Main configuration with `routes` array
|
||||||
|
- `IRouteConfig`: Individual route configuration
|
||||||
|
- `IRouteMatch`: Match criteria for routes
|
||||||
|
- `IRouteTarget`: Target configuration for forwarding
|
||||||
|
- `IAcmeOptions`: ACME certificate configuration
|
||||||
|
- `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')
|
||||||
|
|
||||||
|
### New Route Configuration
|
||||||
|
```typescript
|
||||||
|
interface IRouteConfig {
|
||||||
|
name: string;
|
||||||
|
match: {
|
||||||
|
ports: number | number[];
|
||||||
|
domains?: string | string[];
|
||||||
|
path?: string;
|
||||||
|
headers?: Record<string, string | RegExp>;
|
||||||
|
};
|
||||||
|
action: {
|
||||||
|
type: 'forward' | 'redirect' | 'block' | 'static';
|
||||||
|
target?: {
|
||||||
|
host: string | string[] | ((context) => string);
|
||||||
|
port: number | 'preserve' | ((context) => number);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tls?: {
|
||||||
|
mode: TTlsMode;
|
||||||
|
certificate?: 'auto' | { key: string; cert: string; };
|
||||||
|
};
|
||||||
|
security?: {
|
||||||
|
authentication?: IRouteAuthentication;
|
||||||
|
rateLimit?: IRouteRateLimit;
|
||||||
|
ipAllowList?: string[];
|
||||||
|
ipBlockList?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Properties
|
||||||
|
- For `ISmartProxyOptions`, `routes` array is the main configuration
|
||||||
|
- For `IAcmeOptions`, use `accountEmail` for the contact email
|
||||||
|
- Routes must have `name`, `match`, and `action` properties
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
- Follow the project's test structure, using `@push.rocks/tapbundle`
|
||||||
|
- Use `expect(value).toEqual(expected)` for equality checks
|
||||||
|
- Use `expect(value).toBeTruthy()` for boolean assertions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('test description', async () => {
|
||||||
|
const result = someFunction();
|
||||||
|
expect(result.property).toEqual('expected value');
|
||||||
|
expect(result.valid).toBeTruthy();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
- Include a cleanup test to ensure proper test resource handling
|
||||||
|
- Add a `stop` test to forcefully end the test when needed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Principles
|
||||||
|
|
||||||
|
### Simplicity
|
||||||
|
- Prefer direct usage of libraries instead of creating wrappers
|
||||||
|
- Don't reinvent functionality that already exists in dependencies
|
||||||
|
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers
|
||||||
|
|
||||||
|
### Component Integration
|
||||||
|
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
|
||||||
|
- Use parallel operations for performance (like in the `stop()` method)
|
||||||
|
- Separate concerns clearly (HTTP handling vs. SMTP handling)
|
||||||
|
|
||||||
|
## Email Integration with SmartProxy
|
||||||
|
|
||||||
|
### Architecture (Post-Migration)
|
||||||
|
- Email traffic is routed through SmartProxy using automatic route generation
|
||||||
|
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
|
||||||
|
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
|
||||||
|
- smartmta's Rust SMTP bridge handles SMTP protocol processing
|
||||||
|
|
||||||
|
### Port Mapping
|
||||||
|
- External port 25 → Internal port 10025 (SMTP)
|
||||||
|
- External port 587 → Internal port 10587 (Submission)
|
||||||
|
- External port 465 → Internal port 10465 (SMTPS)
|
||||||
|
|
||||||
|
### TLS Handling
|
||||||
|
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
|
||||||
|
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)
|
||||||
|
|
||||||
|
## SmartMetrics Integration (2025-06-12) - COMPLETED
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.
|
||||||
|
|
||||||
|
### Key Findings
|
||||||
|
1. **CPU Metrics:**
|
||||||
|
- SmartMetrics provides `cpuUsageText` as a string percentage
|
||||||
|
- MetricsManager parses it as `cpuUsage.user` (system is always 0)
|
||||||
|
- UI was incorrectly dividing by 2, showing half the actual CPU usage
|
||||||
|
|
||||||
|
2. **Memory Metrics:**
|
||||||
|
- SmartMetrics calculates `maxMemoryMB` as minimum of:
|
||||||
|
- V8 heap size limit
|
||||||
|
- System total memory
|
||||||
|
- Docker memory limit (if available)
|
||||||
|
- Provides `memoryUsageBytes` (total process memory including children)
|
||||||
|
- Provides `memoryPercentage` (pre-calculated percentage)
|
||||||
|
- UI was only showing heap usage, missing actual memory constraints
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
1. **MetricsManager Enhanced:**
|
||||||
|
- Added `maxMemoryMB` from SmartMetrics instance
|
||||||
|
- Added `actualUsageBytes` from SmartMetrics data
|
||||||
|
- Added `actualUsagePercentage` from SmartMetrics data
|
||||||
|
- Kept existing memory fields for compatibility
|
||||||
|
|
||||||
|
2. **Interface Updated:**
|
||||||
|
- Added optional fields to `IServerStats.memoryUsage`
|
||||||
|
- Fields are optional to maintain backward compatibility
|
||||||
|
|
||||||
|
3. **UI Fixed:**
|
||||||
|
- Removed incorrect CPU division by 2
|
||||||
|
- Uses `actualUsagePercentage` when available (falls back to heap percentage)
|
||||||
|
- Shows actual memory usage vs max memory limit (not just heap)
|
||||||
|
|
||||||
|
### Result
|
||||||
|
- CPU now shows accurate usage percentage
|
||||||
|
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
|
||||||
|
- Better monitoring for containerized environments
|
||||||
|
|
||||||
|
## Network UI Implementation (2025-06-20) - COMPLETED
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Revamped the Network UI to display real network data from SmartProxy instead of mock data.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
1. **MetricsManager Integration:**
|
||||||
|
- Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
|
||||||
|
- Extended with `getNetworkStats()` method to expose unused metrics:
|
||||||
|
- `getConnectionsByIP()` - Connection counts by IP address
|
||||||
|
- `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
|
||||||
|
- `getTopIPs()` - Top connecting IPs sorted by connection count
|
||||||
|
- Note: SmartProxy base interface doesn't include all methods, manual implementation required
|
||||||
|
|
||||||
|
2. **Existing Infrastructure Leveraged:**
|
||||||
|
- `getActiveConnections` endpoint already exists in security.handler.ts
|
||||||
|
- Enhanced to include real SmartProxy data via MetricsManager
|
||||||
|
- IConnectionInfo interface already supports network data structures
|
||||||
|
|
||||||
|
3. **State Management:**
|
||||||
|
- Added `INetworkState` interface following existing patterns
|
||||||
|
- Created `networkStatePart` with connections, throughput, and IP data
|
||||||
|
- Integrated with existing auto-refresh mechanism
|
||||||
|
|
||||||
|
4. **UI Changes (Minimal):**
|
||||||
|
- Removed `generateMockData()` method and all mock generation
|
||||||
|
- Connected to real `networkStatePart` state
|
||||||
|
- Added `renderTopIPs()` section to display top connected IPs
|
||||||
|
- Updated traffic chart to show real request data
|
||||||
|
- Kept all existing UI components (DeesTable, DeesChartArea)
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
1. **Data Transformation:**
|
||||||
|
- Converts IConnectionInfo[] to INetworkRequest[] for table display
|
||||||
|
- Calculates traffic buckets based on selected time range
|
||||||
|
- Maps connection data to chart-compatible format
|
||||||
|
|
||||||
|
2. **Real Metrics Displayed:**
|
||||||
|
- Active connections count (from server stats)
|
||||||
|
- Requests per second (calculated from recent connections)
|
||||||
|
- Throughput rates (currently showing 0 until SmartProxy exposes rates)
|
||||||
|
- Top IPs with connection counts and percentages
|
||||||
|
|
||||||
|
3. **TypeScript Fixes:**
|
||||||
|
- SmartProxy methods like `getThroughputRate()` not in base interface
|
||||||
|
- Implemented manual fallbacks for missing methods
|
||||||
|
- Fixed `publicIpv4` → `publicIp` property name
|
||||||
|
|
||||||
|
### Result
|
||||||
|
- Network view now shows real connection activity
|
||||||
|
- Auto-refreshes with other stats every second
|
||||||
|
- Displays actual IPs and connection counts
|
||||||
|
- No more mock/demo data
|
||||||
|
- Minimal code changes (streamlined approach)
|
||||||
|
|
||||||
|
### Throughput Data Fix (2025-06-20)
|
||||||
|
The throughput was showing 0 because:
|
||||||
|
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
|
||||||
|
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
|
||||||
|
3. `getThroughputRate()` only exists in the extended interface
|
||||||
|
|
||||||
|
**Solution implemented:**
|
||||||
|
1. Updated MetricsManager to check if methods exist at runtime and call them
|
||||||
|
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
|
||||||
|
3. Created new `getNetworkStats` endpoint in security.handler.ts
|
||||||
|
4. Updated frontend to call the new endpoint for complete network metrics
|
||||||
|
|
||||||
|
The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.
|
||||||
|
|
||||||
|
## Email Operations Dashboard (2026-02-01)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.
|
||||||
|
|
||||||
|
### New Files Created
|
||||||
|
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
|
||||||
|
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations
|
||||||
|
|
||||||
|
### Key Interfaces
|
||||||
|
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
|
||||||
|
- `IReq_GetSentEmails` - Fetch delivered emails
|
||||||
|
- `IReq_GetFailedEmails` - Fetch failed emails
|
||||||
|
- `IReq_ResendEmail` - Re-queue a failed email for retry
|
||||||
|
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
|
||||||
|
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
|
||||||
|
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list
|
||||||
|
|
||||||
|
### UI Changes (ops-view-emails.ts)
|
||||||
|
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
|
||||||
|
- **Queued**: Emails pending delivery
|
||||||
|
- **Sent**: Successfully delivered emails
|
||||||
|
- **Failed**: Failed emails with resend capability
|
||||||
|
- **Security**: Security incidents from SecurityLogger
|
||||||
|
- Removed `generateMockEmails()` method
|
||||||
|
- Added state management via `emailOpsStatePart` in appstate.ts
|
||||||
|
- Added resend button for failed emails
|
||||||
|
- Added security incident detail view
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||||
|
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||||
|
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Data Access
|
||||||
|
The handler accesses data from:
|
||||||
|
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
|
||||||
|
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
|
||||||
|
- `emailServer.bounceManager` - Bounce records and suppression list
|
||||||
|
|
||||||
|
## OpsServer UI Fixes (2026-02-02)
|
||||||
|
|
||||||
|
### Configuration Page Fix
|
||||||
|
The configuration page had field name mismatches between frontend and backend:
|
||||||
|
- Frontend expected `server` and `storage` sections
|
||||||
|
- Backend returns `proxy` section (not `server`)
|
||||||
|
- Backend has no `storage` section
|
||||||
|
|
||||||
|
**Fix**: Updated `ops-view-config.ts` to use correct section names:
|
||||||
|
- `proxy` instead of `server`
|
||||||
|
- Removed non-existent `storage` section
|
||||||
|
- Added optional chaining (`?.`) for safety
|
||||||
|
|
||||||
|
### Auth Persistence Fix
|
||||||
|
Login state was using `'soft'` mode in Smartstate which is memory-only:
|
||||||
|
- User login was lost on page refresh
|
||||||
|
- State reset to logged out after browser restart
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
|
||||||
|
- Now uses IndexedDB to persist across browser sessions
|
||||||
|
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
|
||||||
|
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
|
||||||
|
- Validates stored JWT hasn't expired before auto-logging in
|
||||||
|
- Clears expired sessions and shows login form
|
||||||
|
|
||||||
|
## Config UI Read-Only Conversion (2026-02-03)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
|
||||||
|
- Removed `updateConfiguration` handler
|
||||||
|
- Removed `updateConfiguration()` private method
|
||||||
|
- Kept `getConfiguration` handler (read-only)
|
||||||
|
|
||||||
|
2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
|
||||||
|
- Removed `IReq_UpdateConfiguration` interface
|
||||||
|
- Kept `IReq_GetConfiguration` interface
|
||||||
|
|
||||||
|
3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
|
||||||
|
- Removed `editingSection` and `editedConfig` state properties
|
||||||
|
- Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
|
||||||
|
- Removed Edit/Save/Cancel buttons
|
||||||
|
- Removed warning banner about immediate changes
|
||||||
|
- Enhanced read-only display with:
|
||||||
|
- Status badges for boolean values (enabled/disabled)
|
||||||
|
- Array display as pills/tags with counts
|
||||||
|
- Section icons (mail, globe, network, shield)
|
||||||
|
- Better formatting for numbers and byte sizes
|
||||||
|
- Empty state handling ("Not configured", "None configured")
|
||||||
|
- Info note explaining configuration is read-only
|
||||||
|
|
||||||
|
4. **State Management (`ts_web/appstate.ts`)**:
|
||||||
|
- Removed `updateConfigurationAction`
|
||||||
|
- Kept `fetchConfigurationAction` (read-only)
|
||||||
|
|
||||||
|
5. **Tests (`test/test.protected-endpoint.ts`)**:
|
||||||
|
- Replaced `updateConfiguration` tests with `verifyIdentity` tests
|
||||||
|
- Added test for read-only config access
|
||||||
|
- Kept auth flow testing with different protected endpoint
|
||||||
|
|
||||||
|
6. **Documentation**:
|
||||||
|
- `readme.md`: Updated API endpoints to show config as read-only
|
||||||
|
- `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
|
||||||
|
- `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table
|
||||||
|
|
||||||
|
### Visual Display Features
|
||||||
|
- Boolean values shown as colored badges (green=enabled, red=disabled)
|
||||||
|
- Arrays displayed as pills with count summaries
|
||||||
|
- Section headers with relevant Lucide icons
|
||||||
|
- Numbers formatted with locale separators
|
||||||
|
- Byte sizes auto-formatted (B, KB, MB, GB)
|
||||||
|
- Time values shown with "seconds" suffix
|
||||||
|
- Nested objects with visual indentation
|
||||||
|
|
||||||
|
## Smartdata Cache System (2026-02-03)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
| Layer | Package | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
|
||||||
|
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
|
||||||
|
|
||||||
|
### TC39 Decorators
|
||||||
|
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
|
||||||
|
- Removed `experimentalDecorators: true`
|
||||||
|
- Removed `emitDecoratorMetadata: true`
|
||||||
|
|
||||||
|
This is required for smartdata v7+ compatibility.
|
||||||
|
|
||||||
|
### Cache Document Classes
|
||||||
|
Located in `ts/cache/documents/`:
|
||||||
|
|
||||||
|
| Class | Purpose | Default TTL |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `CachedEmail` | Email queue items | 30 days |
|
||||||
|
| `CachedIPReputation` | IP reputation lookups | 24 hours |
|
||||||
|
|
||||||
|
Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).
|
||||||
|
|
||||||
|
### Usage Pattern
|
||||||
|
```typescript
|
||||||
|
// Document classes use smartdata decorators
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id: string;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query examples
|
||||||
|
const email = await CachedEmail.getInstance({ id: 'abc123' });
|
||||||
|
const pending = await CachedEmail.getInstances({ status: 'pending' });
|
||||||
|
await email.save();
|
||||||
|
await email.delete();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
```typescript
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
cacheConfig: {
|
||||||
|
enabled: true,
|
||||||
|
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
||||||
|
dbName: 'dcrouter',
|
||||||
|
cleanupIntervalHours: 1,
|
||||||
|
ttlConfig: {
|
||||||
|
emails: 30, // days
|
||||||
|
ipReputation: 1, // days
|
||||||
|
bounces: 30, // days
|
||||||
|
dkimKeys: 90, // days
|
||||||
|
suppression: 30 // days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Cleaner
|
||||||
|
- Runs hourly by default (configurable via `cleanupIntervalHours`)
|
||||||
|
- Finds and deletes documents where `expiresAt < now()`
|
||||||
|
- Uses smartdata's `getInstances()` + `delete()` pattern
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
|
||||||
|
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
|
||||||
|
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
|
||||||
|
- `ts/cache/documents/*.ts` - Document class definitions
|
||||||
443
test/readme.md
Normal file
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,159 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||||
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
|
||||||
|
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||||
|
// Define custom port mapping
|
||||||
|
const customPortMapping: Record<number, number> = {
|
||||||
|
25: 11025, // Custom SMTP port mapping
|
||||||
|
587: 11587, // Custom submission port mapping
|
||||||
|
465: 11465, // Custom SMTPS port mapping
|
||||||
|
2525: 12525 // Additional custom port
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a custom email configuration using smartmta interfaces
|
||||||
|
const emailConfig: IUnifiedEmailServerOptions = {
|
||||||
|
ports: [25, 587, 465, 2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
maxMessageSize: 50 * 1024 * 1024, // 50MB
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'example.com',
|
||||||
|
dnsMode: 'external-dns',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: 'example.org',
|
||||||
|
dnsMode: 'external-dns',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'forward-example-com',
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.com',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
forward: {
|
||||||
|
host: 'mail1.example.com',
|
||||||
|
port: 25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deliver-example-org',
|
||||||
|
match: {
|
||||||
|
recipients: '*@example.org',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'deliver',
|
||||||
|
process: {
|
||||||
|
dkim: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DcRouter options with custom email port configuration
|
||||||
|
const options: IDcRouterOptions = {
|
||||||
|
emailConfig,
|
||||||
|
emailPortConfig: {
|
||||||
|
portMapping: customPortMapping,
|
||||||
|
portSettings: {
|
||||||
|
2525: {
|
||||||
|
terminateTls: false,
|
||||||
|
routeName: 'custom-smtp-route'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
contactEmail: 'test@example.com'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DcRouter instance
|
||||||
|
const router = new DcRouter(options);
|
||||||
|
|
||||||
|
// Verify the options are correctly set
|
||||||
|
expect(router.options.emailPortConfig).toBeTruthy();
|
||||||
|
expect(router.options.emailPortConfig!.portMapping).toEqual(customPortMapping);
|
||||||
|
|
||||||
|
// Test the generateEmailRoutes method
|
||||||
|
if (typeof (router as any)['generateEmailRoutes'] === 'function') {
|
||||||
|
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||||
|
|
||||||
|
// Verify that all ports are configured
|
||||||
|
expect(routes.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check the custom port configuration
|
||||||
|
const customPortRoute = routes.find((r: any) => {
|
||||||
|
const ports = r.match.ports;
|
||||||
|
return ports === 2525 || (Array.isArray(ports) && (ports as number[]).includes(2525));
|
||||||
|
});
|
||||||
|
expect(customPortRoute).toBeTruthy();
|
||||||
|
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||||
|
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||||
|
|
||||||
|
// Check standard port mappings
|
||||||
|
const smtpRoute = routes.find((r: any) => {
|
||||||
|
const ports = r.match.ports;
|
||||||
|
return ports === 25 || (Array.isArray(ports) && (ports as number[]).includes(25));
|
||||||
|
});
|
||||||
|
expect(smtpRoute?.action.targets[0].port).toEqual(11025);
|
||||||
|
|
||||||
|
const submissionRoute = routes.find((r: any) => {
|
||||||
|
const ports = r.match.ports;
|
||||||
|
return ports === 587 || (Array.isArray(ports) && (ports as number[]).includes(587));
|
||||||
|
});
|
||||||
|
expect(submissionRoute?.action.targets[0].port).toEqual(11587);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||||
|
// Create a basic email configuration
|
||||||
|
const emailConfig: IUnifiedEmailServerOptions = {
|
||||||
|
ports: [2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [],
|
||||||
|
routes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DcRouter options
|
||||||
|
const options: IDcRouterOptions = {
|
||||||
|
emailConfig,
|
||||||
|
tls: {
|
||||||
|
contactEmail: 'test@example.com'
|
||||||
|
},
|
||||||
|
cacheConfig: {
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DcRouter instance
|
||||||
|
const router = new DcRouter(options);
|
||||||
|
|
||||||
|
// Start the router to initialize email services
|
||||||
|
await router.start();
|
||||||
|
|
||||||
|
// Verify unified email server was initialized
|
||||||
|
expect(router.emailServer).toBeTruthy();
|
||||||
|
|
||||||
|
// Stop the router
|
||||||
|
await router.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final clean-up test
|
||||||
|
tap.test('clean up after tests', async () => {
|
||||||
|
// No-op
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
140
test/test.dns-server-config.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test DNS server configuration and record registration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
// Test DNS configuration
|
||||||
|
const testDnsConfig = {
|
||||||
|
udpPort: 5353, // Use non-privileged port for testing
|
||||||
|
httpsPort: 8443,
|
||||||
|
httpsKey: './test/fixtures/test-key.pem',
|
||||||
|
httpsCert: './test/fixtures/test-cert.pem',
|
||||||
|
dnssecZone: 'test.example.com',
|
||||||
|
records: [
|
||||||
|
{ name: 'test.example.com', type: 'A', value: '192.168.1.1' },
|
||||||
|
{ name: 'mail.test.example.com', type: 'A', value: '192.168.1.2' },
|
||||||
|
{ name: 'test.example.com', type: 'MX', value: '10 mail.test.example.com' },
|
||||||
|
{ name: 'test.example.com', type: 'TXT', value: 'v=spf1 a:mail.test.example.com ~all' },
|
||||||
|
{ name: 'test.example.com', type: 'NS', value: 'ns1.test.example.com' },
|
||||||
|
{ name: 'ns1.test.example.com', type: 'A', value: '192.168.1.1' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should extract records correctly', async () => {
|
||||||
|
const { records, ...dnsServerOptions } = testDnsConfig;
|
||||||
|
|
||||||
|
expect(dnsServerOptions.udpPort).toEqual(5353);
|
||||||
|
expect(dnsServerOptions.httpsPort).toEqual(8443);
|
||||||
|
expect(dnsServerOptions.dnssecZone).toEqual('test.example.com');
|
||||||
|
expect(records).toBeArray();
|
||||||
|
expect(records.length).toEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should handle record parsing', async () => {
|
||||||
|
const parseDnsRecordData = (type: string, value: string): any => {
|
||||||
|
switch (type) {
|
||||||
|
case 'A':
|
||||||
|
return value;
|
||||||
|
case 'MX':
|
||||||
|
const [priority, exchange] = value.split(' ');
|
||||||
|
return { priority: parseInt(priority), exchange };
|
||||||
|
case 'TXT':
|
||||||
|
return value;
|
||||||
|
case 'NS':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test A record parsing
|
||||||
|
const aRecord = parseDnsRecordData('A', '192.168.1.1');
|
||||||
|
expect(aRecord).toEqual('192.168.1.1');
|
||||||
|
|
||||||
|
// Test MX record parsing
|
||||||
|
const mxRecord = parseDnsRecordData('MX', '10 mail.test.example.com');
|
||||||
|
expect(mxRecord).toHaveProperty('priority', 10);
|
||||||
|
expect(mxRecord).toHaveProperty('exchange', 'mail.test.example.com');
|
||||||
|
|
||||||
|
// Test TXT record parsing
|
||||||
|
const txtRecord = parseDnsRecordData('TXT', 'v=spf1 a:mail.test.example.com ~all');
|
||||||
|
expect(txtRecord).toEqual('v=spf1 a:mail.test.example.com ~all');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should group records by domain', async () => {
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordsByDomain = new Map<string, typeof records>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||||
|
if (!recordsByDomain.has(pattern)) {
|
||||||
|
recordsByDomain.set(pattern, []);
|
||||||
|
}
|
||||||
|
recordsByDomain.get(pattern)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check grouping
|
||||||
|
expect(recordsByDomain.size).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify each group has records
|
||||||
|
for (const [pattern, domainRecords] of recordsByDomain) {
|
||||||
|
expect(domainRecords.length).toBeGreaterThan(0);
|
||||||
|
console.log(`Pattern: ${pattern}, Records: ${domainRecords.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server configuration - should extract unique record types', async () => {
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordTypes = [...new Set(records.map(r => r.type))];
|
||||||
|
|
||||||
|
expect(recordTypes).toContain('A');
|
||||||
|
expect(recordTypes).toContain('MX');
|
||||||
|
expect(recordTypes).toContain('TXT');
|
||||||
|
expect(recordTypes).toContain('NS');
|
||||||
|
|
||||||
|
console.log('Unique record types:', recordTypes.join(', '));
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DNS server - mock handler registration', async () => {
|
||||||
|
// Mock DNS server for testing
|
||||||
|
const mockDnsServer = {
|
||||||
|
handlers: new Map<string, any>(),
|
||||||
|
registerHandler: function(pattern: string, types: string[], handler: Function) {
|
||||||
|
this.handlers.set(pattern, { types, handler });
|
||||||
|
console.log(`Registered handler for pattern: ${pattern}, types: ${types.join(', ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate record registration
|
||||||
|
const records = testDnsConfig.records;
|
||||||
|
const recordsByDomain = new Map<string, typeof records>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const pattern = record.name.includes('*') ? record.name : `*.${record.name}`;
|
||||||
|
if (!recordsByDomain.has(pattern)) {
|
||||||
|
recordsByDomain.set(pattern, []);
|
||||||
|
}
|
||||||
|
recordsByDomain.get(pattern)!.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register handlers
|
||||||
|
for (const [domainPattern, domainRecords] of recordsByDomain) {
|
||||||
|
const recordTypes = [...new Set(domainRecords.map(r => r.type))];
|
||||||
|
mockDnsServer.registerHandler(domainPattern, recordTypes, (question: any) => {
|
||||||
|
const matchingRecord = domainRecords.find(
|
||||||
|
r => r.name === question.name && r.type === question.type
|
||||||
|
);
|
||||||
|
return matchingRecord || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockDnsServer.handlers.size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.start({
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
148
test/test.dns-socket-handler.ts
Normal file
@@ -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
@@ -0,0 +1,274 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as errors from '../ts/errors/index.js';
|
||||||
|
import {
|
||||||
|
PlatformError,
|
||||||
|
ValidationError,
|
||||||
|
NetworkError,
|
||||||
|
ResourceError,
|
||||||
|
OperationError
|
||||||
|
} from '../ts/errors/base.errors.js';
|
||||||
|
import {
|
||||||
|
ErrorSeverity,
|
||||||
|
ErrorCategory,
|
||||||
|
ErrorRecoverability
|
||||||
|
} from '../ts/errors/error.codes.js';
|
||||||
|
import {
|
||||||
|
ErrorHandler
|
||||||
|
} from '../ts/errors/error-handler.js';
|
||||||
|
|
||||||
|
// Test base error classes
|
||||||
|
tap.test('Base error classes should set properties correctly', async () => {
|
||||||
|
const message = 'Test error message';
|
||||||
|
const code = 'TEST_ERROR_CODE';
|
||||||
|
const context = {
|
||||||
|
component: 'TestComponent',
|
||||||
|
operation: 'testOperation',
|
||||||
|
data: { foo: 'bar' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test PlatformError
|
||||||
|
const platformError = new PlatformError(
|
||||||
|
message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.MAYBE_RECOVERABLE,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(platformError.message).toEqual(message);
|
||||||
|
expect(platformError.code).toEqual(code);
|
||||||
|
expect(platformError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||||
|
expect(platformError.category).toEqual(ErrorCategory.OPERATION);
|
||||||
|
expect(platformError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||||
|
expect(platformError.context?.component).toEqual(context.component);
|
||||||
|
expect(platformError.context?.operation).toEqual(context.operation);
|
||||||
|
expect(platformError.context?.data?.foo).toEqual('bar');
|
||||||
|
expect(platformError.name).toEqual('PlatformError');
|
||||||
|
|
||||||
|
// Test ValidationError
|
||||||
|
const validationError = new ValidationError(message, code, context);
|
||||||
|
expect(validationError.category).toEqual(ErrorCategory.VALIDATION);
|
||||||
|
expect(validationError.severity).toEqual(ErrorSeverity.LOW);
|
||||||
|
|
||||||
|
// Test NetworkError
|
||||||
|
const networkError = new NetworkError(message, code, context);
|
||||||
|
expect(networkError.category).toEqual(ErrorCategory.CONNECTIVITY);
|
||||||
|
expect(networkError.severity).toEqual(ErrorSeverity.MEDIUM);
|
||||||
|
expect(networkError.recoverability).toEqual(ErrorRecoverability.MAYBE_RECOVERABLE);
|
||||||
|
|
||||||
|
// Test ResourceError
|
||||||
|
const resourceError = new ResourceError(message, code, context);
|
||||||
|
expect(resourceError.category).toEqual(ErrorCategory.RESOURCE);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error handler utility
|
||||||
|
tap.test('ErrorHandler should properly handle and format errors', async () => {
|
||||||
|
// Configure error handler
|
||||||
|
ErrorHandler.configure({
|
||||||
|
logErrors: false, // Disable for testing
|
||||||
|
includeStacksInProd: false,
|
||||||
|
retry: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
baseDelay: 100,
|
||||||
|
maxDelay: 1000,
|
||||||
|
backoffFactor: 2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test converting regular Error to PlatformError
|
||||||
|
const regularError = new Error('Something went wrong');
|
||||||
|
const platformError = ErrorHandler.toPlatformError(
|
||||||
|
regularError,
|
||||||
|
'PLATFORM_OPERATION_ERROR',
|
||||||
|
{ component: 'TestHandler' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(platformError).toBeInstanceOf(PlatformError);
|
||||||
|
expect(platformError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||||
|
expect(platformError.context?.component).toEqual('TestHandler');
|
||||||
|
|
||||||
|
// Test formatting error for API response
|
||||||
|
const formattedError = ErrorHandler.formatErrorForResponse(platformError, true);
|
||||||
|
expect(formattedError.code).toEqual('PLATFORM_OPERATION_ERROR');
|
||||||
|
expect(formattedError.message).toEqual('An unexpected error occurred.');
|
||||||
|
expect(formattedError.details?.rawMessage).toEqual('Something went wrong');
|
||||||
|
|
||||||
|
// Test executing a function with error handling
|
||||||
|
let executed = false;
|
||||||
|
try {
|
||||||
|
await ErrorHandler.execute(async () => {
|
||||||
|
executed = true;
|
||||||
|
throw new Error('Execution failed');
|
||||||
|
}, 'TEST_EXECUTION_ERROR', { operation: 'testExecution' });
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
expect(error.code).toEqual('TEST_EXECUTION_ERROR');
|
||||||
|
expect(error.context.operation).toEqual('testExecution');
|
||||||
|
}
|
||||||
|
expect(executed).toEqual(true);
|
||||||
|
|
||||||
|
// Test executeWithRetry successful after retries
|
||||||
|
let attempts = 0;
|
||||||
|
const result = await ErrorHandler.executeWithRetry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) {
|
||||||
|
throw new Error('Temporary failure');
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
'TEST_RETRY_ERROR',
|
||||||
|
{
|
||||||
|
maxAttempts: 5,
|
||||||
|
baseDelay: 10, // Use small delay for tests
|
||||||
|
retryableErrorPatterns: [/Temporary failure/], // Add pattern to make error retryable
|
||||||
|
onRetry: (error, attempt, delay) => {
|
||||||
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
expect(attempt).toBeGreaterThan(0);
|
||||||
|
expect(delay).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual('success');
|
||||||
|
expect(attempts).toEqual(3);
|
||||||
|
|
||||||
|
// Test executeWithRetry that fails after max attempts
|
||||||
|
attempts = 0;
|
||||||
|
try {
|
||||||
|
await ErrorHandler.executeWithRetry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
throw new Error('Persistent failure');
|
||||||
|
},
|
||||||
|
'TEST_RETRY_ERROR',
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 10,
|
||||||
|
retryableErrorPatterns: [/Persistent failure/] // Make error retryable so it tries all attempts
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(PlatformError);
|
||||||
|
expect(attempts).toEqual(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test retry utilities
|
||||||
|
tap.test('Error retry utilities should work correctly', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await errors.retry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) {
|
||||||
|
throw new Error('Temporary error');
|
||||||
|
}
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: 5,
|
||||||
|
initialDelay: 20,
|
||||||
|
backoffFactor: 1.5,
|
||||||
|
retryableErrors: [/Temporary/]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Should not reach here
|
||||||
|
expect(false).toEqual(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(attempts).toEqual(3);
|
||||||
|
|
||||||
|
// Test retry with non-retryable error
|
||||||
|
attempts = 0;
|
||||||
|
try {
|
||||||
|
await errors.retry(
|
||||||
|
async () => {
|
||||||
|
attempts++;
|
||||||
|
throw new Error('Critical error');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 10,
|
||||||
|
retryableErrors: [/Temporary/] // Won't match "Critical"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toEqual('Critical error');
|
||||||
|
expect(attempts).toEqual(1); // Should only attempt once
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function that will reject first n times, then resolve
|
||||||
|
interface FlakyFunction {
|
||||||
|
(failTimes: number, result?: any): Promise<any>;
|
||||||
|
counter: number;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flaky: FlakyFunction = Object.assign(
|
||||||
|
async function (failTimes: number, result: any = 'success'): Promise<any> {
|
||||||
|
if (flaky.counter < failTimes) {
|
||||||
|
flaky.counter++;
|
||||||
|
throw new Error(`Flaky failure ${flaky.counter}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
counter: 0,
|
||||||
|
reset: () => { flaky.counter = 0; }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test error wrapping and retry combination
|
||||||
|
tap.test('Error handling can be combined with retry for robust operations', async () => {
|
||||||
|
// Reset counter for the test
|
||||||
|
flaky.reset();
|
||||||
|
|
||||||
|
// Create a wrapped version of the flaky function
|
||||||
|
const wrapped = errors.withErrorHandling(
|
||||||
|
() => flaky(2, 'wrapped success'),
|
||||||
|
'TEST_WRAPPED_ERROR',
|
||||||
|
{ component: 'TestComponent' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute with retry
|
||||||
|
const result = await errors.retry(
|
||||||
|
wrapped,
|
||||||
|
{
|
||||||
|
maxRetries: 3,
|
||||||
|
initialDelay: 10,
|
||||||
|
retryableErrors: [/Flaky failure/]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result).toEqual('wrapped success');
|
||||||
|
expect(flaky.counter).toEqual(2);
|
||||||
|
|
||||||
|
// Reset and test failure case
|
||||||
|
flaky.reset();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await errors.retry(
|
||||||
|
() => flaky(5, 'never reached'),
|
||||||
|
{
|
||||||
|
maxRetries: 2, // Only retry twice, but we need 5 attempts to succeed
|
||||||
|
initialDelay: 10,
|
||||||
|
retryableErrors: [/Flaky failure/] // Add pattern to make it retry
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Should not reach here
|
||||||
|
expect(false).toEqual(true);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toContain('Flaky failure');
|
||||||
|
expect(flaky.counter).toEqual(3); // Initial + 2 retries = 3 attempts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stop', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
179
test/test.ipreputationchecker.ts
Normal file
@@ -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
@@ -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();
|
||||||
123
test/test.opsserver-api.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
identity: adminIdentity,
|
||||||
|
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({
|
||||||
|
identity: adminIdentity,
|
||||||
|
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({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
|
expect(response.config).toHaveProperty('email');
|
||||||
|
expect(response.config).toHaveProperty('dns');
|
||||||
|
expect(response.config).toHaveProperty('tls');
|
||||||
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
||||||
|
identity: adminIdentity,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('logs');
|
||||||
|
expect(response).toHaveProperty('total');
|
||||||
|
expect(response).toHaveProperty('hasMore');
|
||||||
|
expect(response.logs).toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should reject unauthenticated requests', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
127
test/test.protected-endpoint.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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 reject protected endpoints without auth', async () => {
|
||||||
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getHealthStatus'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// No identity provided — should be rejected
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||||
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
|
'http://localhost:3000/typedrequest',
|
||||||
|
'getConfiguration'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
|
expect(response.config).toHaveProperty('email');
|
||||||
|
expect(response.config).toHaveProperty('dns');
|
||||||
|
expect(response.config).toHaveProperty('tls');
|
||||||
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
console.log('Authenticated access to config successful');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
289
test/test.storagemanager.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import * as paths from '../ts/paths.js';
|
||||||
|
import { StorageManager } from '../ts/storage/classes.storagemanager.js';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const testData = {
|
||||||
|
string: 'Hello, World!',
|
||||||
|
json: { name: 'test', value: 42, nested: { data: true } },
|
||||||
|
largeString: 'x'.repeat(10000)
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Memory Backend', async () => {
|
||||||
|
// Create StorageManager without config (defaults to memory)
|
||||||
|
const storage = new StorageManager();
|
||||||
|
|
||||||
|
// Test basic get/set
|
||||||
|
await storage.set('/test/key', testData.string);
|
||||||
|
const value = await storage.get('/test/key');
|
||||||
|
expect(value).toEqual(testData.string);
|
||||||
|
|
||||||
|
// Test JSON helpers
|
||||||
|
await storage.setJSON('/test/json', testData.json);
|
||||||
|
const jsonValue = await storage.getJSON('/test/json');
|
||||||
|
expect(jsonValue).toEqual(testData.json);
|
||||||
|
|
||||||
|
// Test exists
|
||||||
|
expect(await storage.exists('/test/key')).toEqual(true);
|
||||||
|
expect(await storage.exists('/nonexistent')).toEqual(false);
|
||||||
|
|
||||||
|
// Test delete
|
||||||
|
await storage.delete('/test/key');
|
||||||
|
expect(await storage.exists('/test/key')).toEqual(false);
|
||||||
|
|
||||||
|
// Test list
|
||||||
|
await storage.set('/items/1', 'one');
|
||||||
|
await storage.set('/items/2', 'two');
|
||||||
|
await storage.set('/other/3', 'three');
|
||||||
|
|
||||||
|
const items = await storage.list('/items');
|
||||||
|
expect(items.length).toEqual(2);
|
||||||
|
expect(items).toContain('/items/1');
|
||||||
|
expect(items).toContain('/items/2');
|
||||||
|
|
||||||
|
// Verify memory backend
|
||||||
|
expect(storage.getBackend()).toEqual('memory');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Filesystem Backend', async () => {
|
||||||
|
const testDir = path.join(paths.dataDir, '.test-storage');
|
||||||
|
|
||||||
|
// Clean up test directory if it exists
|
||||||
|
try {
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Create StorageManager with filesystem path
|
||||||
|
const storage = new StorageManager({ fsPath: testDir });
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.set('/test/file', testData.string);
|
||||||
|
const value = await storage.get('/test/file');
|
||||||
|
expect(value).toEqual(testData.string);
|
||||||
|
|
||||||
|
// Verify file exists on disk
|
||||||
|
const filePath = path.join(testDir, 'test', 'file');
|
||||||
|
const fileExists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||||
|
expect(fileExists).toEqual(true);
|
||||||
|
|
||||||
|
// Test atomic writes (temp file should not exist)
|
||||||
|
const tempPath = filePath + '.tmp';
|
||||||
|
const tempExists = await fs.access(tempPath).then(() => true).catch(() => false);
|
||||||
|
expect(tempExists).toEqual(false);
|
||||||
|
|
||||||
|
// Test nested paths
|
||||||
|
await storage.set('/deeply/nested/path/to/file', testData.largeString);
|
||||||
|
const nestedValue = await storage.get('/deeply/nested/path/to/file');
|
||||||
|
expect(nestedValue).toEqual(testData.largeString);
|
||||||
|
|
||||||
|
// Test list with filesystem
|
||||||
|
await storage.set('/fs/items/a', 'alpha');
|
||||||
|
await storage.set('/fs/items/b', 'beta');
|
||||||
|
await storage.set('/fs/other/c', 'gamma');
|
||||||
|
|
||||||
|
// Filesystem backend now properly supports list
|
||||||
|
const fsItems = await storage.list('/fs/items');
|
||||||
|
expect(fsItems.length).toEqual(2); // Should find both items
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Custom Function Backend', async () => {
|
||||||
|
// Create in-memory storage for custom functions
|
||||||
|
const customStore = new Map<string, string>();
|
||||||
|
|
||||||
|
const storage = new StorageManager({
|
||||||
|
readFunction: async (key: string) => {
|
||||||
|
return customStore.get(key) || null;
|
||||||
|
},
|
||||||
|
writeFunction: async (key: string, value: string) => {
|
||||||
|
customStore.set(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test basic operations
|
||||||
|
await storage.set('/custom/key', testData.string);
|
||||||
|
expect(customStore.has('/custom/key')).toEqual(true);
|
||||||
|
|
||||||
|
const value = await storage.get('/custom/key');
|
||||||
|
expect(value).toEqual(testData.string);
|
||||||
|
|
||||||
|
// Test that delete sets empty value (as per implementation)
|
||||||
|
await storage.delete('/custom/key');
|
||||||
|
expect(customStore.get('/custom/key')).toEqual('');
|
||||||
|
|
||||||
|
// Verify custom backend (filesystem is implemented as custom backend internally)
|
||||||
|
expect(storage.getBackend()).toEqual('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Key Validation', async () => {
|
||||||
|
const storage = new StorageManager();
|
||||||
|
|
||||||
|
// Test key normalization
|
||||||
|
await storage.set('test/key', 'value1'); // Missing leading slash
|
||||||
|
const value1 = await storage.get('/test/key');
|
||||||
|
expect(value1).toEqual('value1');
|
||||||
|
|
||||||
|
// Test dangerous path elements are removed
|
||||||
|
await storage.set('/test/../danger/key', 'value2');
|
||||||
|
const value2 = await storage.get('/test/danger/key'); // .. is removed, not the whole path segment
|
||||||
|
expect(value2).toEqual('value2');
|
||||||
|
|
||||||
|
// Test multiple slashes are normalized
|
||||||
|
await storage.set('/test///multiple////slashes', 'value3');
|
||||||
|
const value3 = await storage.get('/test/multiple/slashes');
|
||||||
|
expect(value3).toEqual('value3');
|
||||||
|
|
||||||
|
// Test invalid keys throw errors
|
||||||
|
let emptyKeyError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await storage.set('', 'value');
|
||||||
|
} catch (error) {
|
||||||
|
emptyKeyError = error as Error;
|
||||||
|
}
|
||||||
|
expect(emptyKeyError).toBeTruthy();
|
||||||
|
expect(emptyKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||||
|
|
||||||
|
let nullKeyError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await storage.set(null as any, 'value');
|
||||||
|
} catch (error) {
|
||||||
|
nullKeyError = error as Error;
|
||||||
|
}
|
||||||
|
expect(nullKeyError).toBeTruthy();
|
||||||
|
expect(nullKeyError?.message).toEqual('Storage key must be a non-empty string');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Concurrent Access', async () => {
|
||||||
|
const storage = new StorageManager();
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// Simulate concurrent writes
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
promises.push(storage.set(`/concurrent/key${i}`, `value${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Verify all writes succeeded
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const value = await storage.get(`/concurrent/key${i}`);
|
||||||
|
expect(value).toEqual(`value${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test concurrent reads
|
||||||
|
const readPromises: Promise<string | null>[] = [];
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
readPromises.push(storage.get(`/concurrent/key${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(readPromises);
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
expect(results[i]).toEqual(`value${i}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Backend Priority', async () => {
|
||||||
|
const testDir = path.join(paths.dataDir, '.test-storage-priority');
|
||||||
|
|
||||||
|
// Test that custom functions take priority over fsPath
|
||||||
|
let warningLogged = false;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = (message: string) => {
|
||||||
|
if (message.includes('Using custom read/write functions')) {
|
||||||
|
warningLogged = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storage = new StorageManager({
|
||||||
|
fsPath: testDir,
|
||||||
|
readFunction: async () => 'custom-value',
|
||||||
|
writeFunction: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.warn = originalWarn;
|
||||||
|
|
||||||
|
expect(warningLogged).toEqual(true);
|
||||||
|
expect(storage.getBackend()).toEqual('custom'); // Custom functions take priority
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
try {
|
||||||
|
await fs.rm(testDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - Error Handling', async () => {
|
||||||
|
// Test filesystem errors
|
||||||
|
const storage = new StorageManager({
|
||||||
|
readFunction: async () => {
|
||||||
|
throw new Error('Read error');
|
||||||
|
},
|
||||||
|
writeFunction: async () => {
|
||||||
|
throw new Error('Write error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read errors should return null
|
||||||
|
const value = await storage.get('/error/key');
|
||||||
|
expect(value).toEqual(null);
|
||||||
|
|
||||||
|
// Write errors should propagate
|
||||||
|
let writeError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await storage.set('/error/key', 'value');
|
||||||
|
} catch (error) {
|
||||||
|
writeError = error as Error;
|
||||||
|
}
|
||||||
|
expect(writeError).toBeTruthy();
|
||||||
|
expect(writeError?.message).toEqual('Write error');
|
||||||
|
|
||||||
|
// Test JSON parse errors
|
||||||
|
const jsonStorage = new StorageManager({
|
||||||
|
readFunction: async () => 'invalid json',
|
||||||
|
writeFunction: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test JSON parse errors
|
||||||
|
let jsonError: Error | null = null;
|
||||||
|
try {
|
||||||
|
await jsonStorage.getJSON('/invalid/json');
|
||||||
|
} catch (error) {
|
||||||
|
jsonError = error as Error;
|
||||||
|
}
|
||||||
|
expect(jsonError).toBeTruthy();
|
||||||
|
expect(jsonError?.message).toContain('JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('Storage Manager - List Operations', async () => {
|
||||||
|
const storage = new StorageManager();
|
||||||
|
|
||||||
|
// Populate storage with hierarchical data
|
||||||
|
await storage.set('/app/config/database', 'db-config');
|
||||||
|
await storage.set('/app/config/cache', 'cache-config');
|
||||||
|
await storage.set('/app/data/users/1', 'user1');
|
||||||
|
await storage.set('/app/data/users/2', 'user2');
|
||||||
|
await storage.set('/app/logs/error.log', 'errors');
|
||||||
|
|
||||||
|
// List root
|
||||||
|
const rootItems = await storage.list('/');
|
||||||
|
expect(rootItems.length).toBeGreaterThanOrEqual(5);
|
||||||
|
|
||||||
|
// List specific paths
|
||||||
|
const configItems = await storage.list('/app/config');
|
||||||
|
expect(configItems.length).toEqual(2);
|
||||||
|
expect(configItems).toContain('/app/config/database');
|
||||||
|
expect(configItems).toContain('/app/config/cache');
|
||||||
|
|
||||||
|
const userItems = await storage.list('/app/data/users');
|
||||||
|
expect(userItems.length).toEqual(2);
|
||||||
|
|
||||||
|
// List non-existent path
|
||||||
|
const emptyList = await storage.list('/nonexistent/path');
|
||||||
|
expect(emptyList.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { tap, expect } from '@push.rocks/tapbundle';
|
|
||||||
|
|
||||||
tap.test('should create a platform service', async () => {});
|
|
||||||
|
|
||||||
tap.start();
|
|
||||||
46
test_watch/devserver.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
|
||||||
|
const devRouter = new DcRouter({
|
||||||
|
// SmartProxy routes for development/demo
|
||||||
|
smartProxyConfig: {
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'web-traffic',
|
||||||
|
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'api-gateway',
|
||||||
|
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||||
|
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tls-passthrough',
|
||||||
|
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: 'localhost', port: 4443 }],
|
||||||
|
tls: { mode: 'passthrough' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Disable cache/mongo for dev
|
||||||
|
cacheConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|
||||||
|
await devRouter.start();
|
||||||
|
|
||||||
|
// Graceful shutdown handlers
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
await devRouter.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
|
console.log('DcRouter dev server running. Press Ctrl+C to stop.');
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* autocreated commitinfo by @push.rocks/commitinfo
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/platformservice',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '2.2.0',
|
version: '11.0.4',
|
||||||
description: 'A multifaceted platform service handling mail, SMS, letter delivery, and AI services.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
166
ts/cache/classes.cache.cleaner.ts
vendored
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { CacheDb } from './classes.cachedb.js';
|
||||||
|
|
||||||
|
// Import document classes for cleanup
|
||||||
|
import { CachedEmail } from './documents/classes.cached.email.js';
|
||||||
|
import { CachedIPReputation } from './documents/classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the cache cleaner
|
||||||
|
*/
|
||||||
|
export interface ICacheCleanerOptions {
|
||||||
|
/** Cleanup interval in milliseconds (default: 1 hour) */
|
||||||
|
intervalMs?: number;
|
||||||
|
/** Enable verbose logging */
|
||||||
|
verbose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CacheCleaner - Periodically removes expired documents from the cache
|
||||||
|
*
|
||||||
|
* Runs on a configurable interval (default: hourly) and queries each
|
||||||
|
* collection for documents where expiresAt < now(), then deletes them.
|
||||||
|
*/
|
||||||
|
export class CacheCleaner {
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private isRunning: boolean = false;
|
||||||
|
private options: Required<ICacheCleanerOptions>;
|
||||||
|
private cacheDb: CacheDb;
|
||||||
|
|
||||||
|
constructor(cacheDb: CacheDb, options: ICacheCleanerOptions = {}) {
|
||||||
|
this.cacheDb = cacheDb;
|
||||||
|
this.options = {
|
||||||
|
intervalMs: options.intervalMs || 60 * 60 * 1000, // 1 hour default
|
||||||
|
verbose: options.verbose || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the periodic cleanup process
|
||||||
|
*/
|
||||||
|
public start(): void {
|
||||||
|
if (this.isRunning) {
|
||||||
|
logger.log('warn', 'CacheCleaner already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
// Run cleanup immediately on start
|
||||||
|
this.runCleanup().catch((error) => {
|
||||||
|
logger.log('error', `Initial cache cleanup failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule periodic cleanup
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.runCleanup().catch((error) => {
|
||||||
|
logger.log('error', `Cache cleanup failed: ${error.message}`);
|
||||||
|
});
|
||||||
|
}, this.options.intervalMs);
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`CacheCleaner started with interval: ${this.options.intervalMs / 1000 / 60} minutes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the periodic cleanup process
|
||||||
|
*/
|
||||||
|
public stop(): void {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
logger.log('info', 'CacheCleaner stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single cleanup cycle
|
||||||
|
*/
|
||||||
|
public async runCleanup(): Promise<void> {
|
||||||
|
if (!this.cacheDb.isReady()) {
|
||||||
|
logger.log('warn', 'CacheDb not ready, skipping cleanup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const results: { collection: string; deleted: number }[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
|
||||||
|
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
|
||||||
|
|
||||||
|
const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
|
||||||
|
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
|
||||||
|
|
||||||
|
// Log results
|
||||||
|
const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
|
||||||
|
if (totalDeleted > 0 || this.options.verbose) {
|
||||||
|
const summary = results
|
||||||
|
.filter((r) => r.deleted > 0)
|
||||||
|
.map((r) => `${r.collection}: ${r.deleted}`)
|
||||||
|
.join(', ');
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`Cache cleanup completed. Deleted ${totalDeleted} expired documents. ${summary || 'No deletions.'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Cache cleanup error: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean expired documents from a specific collection using smartdata API
|
||||||
|
*/
|
||||||
|
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
|
||||||
|
documentClass: { getInstances: (filter: any) => Promise<T[]> },
|
||||||
|
now: Date
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Find all expired documents
|
||||||
|
const expiredDocs = await documentClass.getInstances({
|
||||||
|
expiresAt: { $lt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete each expired document
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const doc of expiredDocs) {
|
||||||
|
try {
|
||||||
|
await doc.delete();
|
||||||
|
deletedCount++;
|
||||||
|
} catch (deleteError) {
|
||||||
|
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error cleaning collection: ${error.message}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the cleaner is running
|
||||||
|
*/
|
||||||
|
public isActive(): boolean {
|
||||||
|
return this.isRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cleanup interval in milliseconds
|
||||||
|
*/
|
||||||
|
public getIntervalMs(): number {
|
||||||
|
return this.options.intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
ts/cache/classes.cached.document.ts
vendored
Normal file
@@ -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
@@ -0,0 +1,155 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { defaultTsmDbPath } from '../paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for CacheDb
|
||||||
|
*/
|
||||||
|
export interface ICacheDbOptions {
|
||||||
|
/** Base storage path for TsmDB data (default: ~/.serve.zone/dcrouter/tsmdb) */
|
||||||
|
storagePath?: string;
|
||||||
|
/** Database name (default: dcrouter) */
|
||||||
|
dbName?: string;
|
||||||
|
/** Enable debug logging */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CacheDb - Wrapper around LocalTsmDb and smartdata
|
||||||
|
*
|
||||||
|
* Provides persistent caching using smartdata as the ORM layer
|
||||||
|
* and LocalTsmDb as the embedded database engine.
|
||||||
|
*/
|
||||||
|
export class CacheDb {
|
||||||
|
private static instance: CacheDb | null = null;
|
||||||
|
|
||||||
|
private localTsmDb: plugins.smartmongo.LocalTsmDb;
|
||||||
|
private smartdataDb: plugins.smartdata.SmartdataDb;
|
||||||
|
private options: Required<ICacheDbOptions>;
|
||||||
|
private isStarted: boolean = false;
|
||||||
|
|
||||||
|
constructor(options: ICacheDbOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
storagePath: options.storagePath || defaultTsmDbPath,
|
||||||
|
dbName: options.dbName || 'dcrouter',
|
||||||
|
debug: options.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the singleton instance
|
||||||
|
*/
|
||||||
|
public static getInstance(options?: ICacheDbOptions): CacheDb {
|
||||||
|
if (!CacheDb.instance) {
|
||||||
|
CacheDb.instance = new CacheDb(options);
|
||||||
|
}
|
||||||
|
return CacheDb.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (useful for testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
CacheDb.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the cache database
|
||||||
|
* - Initializes LocalTsmDb with file persistence
|
||||||
|
* - Connects smartdata to the LocalTsmDb via Unix socket
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.isStarted) {
|
||||||
|
logger.log('warn', 'CacheDb already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ensure storage directory exists
|
||||||
|
await plugins.fsUtils.ensureDir(this.options.storagePath);
|
||||||
|
|
||||||
|
// Create LocalTsmDb instance
|
||||||
|
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
|
||||||
|
folderPath: this.options.storagePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start LocalTsmDb and get connection info
|
||||||
|
const connectionInfo = await this.localTsmDb.start();
|
||||||
|
|
||||||
|
if (this.options.debug) {
|
||||||
|
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize smartdata with the connection URI
|
||||||
|
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
||||||
|
mongoDbUrl: connectionInfo.connectionUri,
|
||||||
|
mongoDbName: this.options.dbName,
|
||||||
|
});
|
||||||
|
await this.smartdataDb.init();
|
||||||
|
|
||||||
|
this.isStarted = true;
|
||||||
|
logger.log('info', `CacheDb started at ${this.options.storagePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Failed to start CacheDb: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the cache database
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close smartdata connection
|
||||||
|
if (this.smartdataDb) {
|
||||||
|
await this.smartdataDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop LocalTsmDb
|
||||||
|
if (this.localTsmDb) {
|
||||||
|
await this.localTsmDb.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = false;
|
||||||
|
logger.log('info', 'CacheDb stopped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `Error stopping CacheDb: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the smartdata database instance
|
||||||
|
*/
|
||||||
|
public getDb(): plugins.smartdata.SmartdataDb {
|
||||||
|
if (!this.isStarted) {
|
||||||
|
throw new Error('CacheDb not started. Call start() first.');
|
||||||
|
}
|
||||||
|
return this.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the database is ready
|
||||||
|
*/
|
||||||
|
public isReady(): boolean {
|
||||||
|
return this.isStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the storage path
|
||||||
|
*/
|
||||||
|
public getStoragePath(): string {
|
||||||
|
return this.options.storagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the database name
|
||||||
|
*/
|
||||||
|
public getDbName(): string {
|
||||||
|
return this.options.dbName;
|
||||||
|
}
|
||||||
|
}
|
||||||
240
ts/cache/documents/classes.cached.email.ts
vendored
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { CachedDocument, TTL } from '../classes.cached.document.js';
|
||||||
|
import { CacheDb } from '../classes.cachedb.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email status in the cache
|
||||||
|
*/
|
||||||
|
export type TCachedEmailStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get the smartdata database instance
|
||||||
|
*/
|
||||||
|
const getDb = () => CacheDb.getInstance().getDb();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CachedEmail - Stores email queue items in the cache
|
||||||
|
*
|
||||||
|
* Used for persistent email queue storage, tracking delivery status,
|
||||||
|
* and maintaining email history for the configured TTL period.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class CachedEmail extends CachedDocument<CachedEmail> {
|
||||||
|
// TTL fields from base class (decorators required on concrete class)
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastAccessedAt: Date = new Date();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique identifier for this email
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email message ID (RFC 822 Message-ID header)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public messageId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender email address (envelope from)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public from: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipient email addresses
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public to: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CC recipients
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public cc: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCC recipients
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public bcc: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email subject
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public subject: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw RFC822 email content
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public rawContent: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current status of the email
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status: TCachedEmailStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of delivery attempts
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public attempts: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of delivery attempts
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public maxAttempts: number = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp for next delivery attempt
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nextAttempt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last error message if delivery failed
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp when the email was successfully delivered
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public deliveredAt: Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender domain (for querying/filtering)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public senderDomain: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Priority level (higher = more important)
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public priority: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-serialized route data
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public routeData: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DKIM signature status
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public dkimSigned: boolean = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setTTL(TTL.DAYS_30); // Default 30-day TTL
|
||||||
|
this.status = 'pending';
|
||||||
|
this.to = [];
|
||||||
|
this.cc = [];
|
||||||
|
this.bcc = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new CachedEmail with a unique ID
|
||||||
|
*/
|
||||||
|
public static createNew(): CachedEmail {
|
||||||
|
const email = new CachedEmail();
|
||||||
|
email.id = plugins.uuid.v4();
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an email by ID
|
||||||
|
*/
|
||||||
|
public static async findById(id: string): Promise<CachedEmail | null> {
|
||||||
|
return await CachedEmail.getInstance({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all emails with a specific status
|
||||||
|
*/
|
||||||
|
public static async findByStatus(status: TCachedEmailStatus): Promise<CachedEmail[]> {
|
||||||
|
return await CachedEmail.getInstances({
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all emails pending delivery (status = pending and nextAttempt <= now)
|
||||||
|
*/
|
||||||
|
public static async findPendingForDelivery(): Promise<CachedEmail[]> {
|
||||||
|
const now = new Date();
|
||||||
|
return await CachedEmail.getInstances({
|
||||||
|
status: 'pending',
|
||||||
|
nextAttempt: { $lte: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find emails by sender domain
|
||||||
|
*/
|
||||||
|
public static async findBySenderDomain(domain: string): Promise<CachedEmail[]> {
|
||||||
|
return await CachedEmail.getInstances({
|
||||||
|
senderDomain: domain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark as delivered
|
||||||
|
*/
|
||||||
|
public markDelivered(): void {
|
||||||
|
this.status = 'delivered';
|
||||||
|
this.deliveredAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark as failed with error
|
||||||
|
*/
|
||||||
|
public markFailed(error: string): void {
|
||||||
|
this.status = 'failed';
|
||||||
|
this.lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment attempt counter and schedule next attempt
|
||||||
|
*/
|
||||||
|
public scheduleRetry(delayMs: number = 5 * 60 * 1000): void {
|
||||||
|
this.attempts++;
|
||||||
|
this.status = 'deferred';
|
||||||
|
this.nextAttempt = new Date(Date.now() + delayMs);
|
||||||
|
|
||||||
|
// If max attempts reached, mark as failed
|
||||||
|
if (this.attempts >= this.maxAttempts) {
|
||||||
|
this.status = 'failed';
|
||||||
|
this.lastError = `Max attempts (${this.maxAttempts}) reached`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract sender domain from email address
|
||||||
|
*/
|
||||||
|
public updateSenderDomain(): void {
|
||||||
|
if (this.from) {
|
||||||
|
const match = this.from.match(/@([^>]+)>?$/);
|
||||||
|
if (match) {
|
||||||
|
this.senderDomain = match[1].toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
247
ts/cache/documents/classes.cached.ip.reputation.ts
vendored
Normal file
@@ -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
@@ -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
@@ -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';
|
||||||
137
ts/classes.cert-provision-scheduler.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { logger } from './logger.js';
|
||||||
|
import type { StorageManager } from './storage/index.js';
|
||||||
|
|
||||||
|
interface IBackoffEntry {
|
||||||
|
failures: number;
|
||||||
|
lastFailure: string; // ISO string
|
||||||
|
retryAfter: string; // ISO string
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages certificate provisioning scheduling with:
|
||||||
|
* - Per-domain exponential backoff persisted in StorageManager
|
||||||
|
*
|
||||||
|
* Note: Serial stagger queue was removed — smartacme v9 handles
|
||||||
|
* concurrency, per-domain dedup, and rate limiting internally.
|
||||||
|
*/
|
||||||
|
export class CertProvisionScheduler {
|
||||||
|
private storageManager: StorageManager;
|
||||||
|
private maxBackoffHours: number;
|
||||||
|
|
||||||
|
// In-memory backoff cache (mirrors storage for fast lookups)
|
||||||
|
private backoffCache = new Map<string, IBackoffEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
storageManager: StorageManager,
|
||||||
|
options?: { maxBackoffHours?: number }
|
||||||
|
) {
|
||||||
|
this.storageManager = storageManager;
|
||||||
|
this.maxBackoffHours = options?.maxBackoffHours ?? 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for a domain's backoff entry
|
||||||
|
*/
|
||||||
|
private backoffKey(domain: string): string {
|
||||||
|
const clean = domain.replace(/\*/g, '_wildcard_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
return `/cert-backoff/${clean}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load backoff entry from storage (with in-memory cache)
|
||||||
|
*/
|
||||||
|
private async loadBackoff(domain: string): Promise<IBackoffEntry | null> {
|
||||||
|
const cached = this.backoffCache.get(domain);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const entry = await this.storageManager.getJSON<IBackoffEntry>(this.backoffKey(domain));
|
||||||
|
if (entry) {
|
||||||
|
this.backoffCache.set(domain, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save backoff entry to both cache and storage
|
||||||
|
*/
|
||||||
|
private async saveBackoff(domain: string, entry: IBackoffEntry): Promise<void> {
|
||||||
|
this.backoffCache.set(domain, entry);
|
||||||
|
await this.storageManager.setJSON(this.backoffKey(domain), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is currently in backoff
|
||||||
|
*/
|
||||||
|
async isInBackoff(domain: string): Promise<boolean> {
|
||||||
|
const entry = await this.loadBackoff(domain);
|
||||||
|
if (!entry) return false;
|
||||||
|
|
||||||
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
|
return retryAfter.getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a provisioning failure for a domain.
|
||||||
|
* Sets exponential backoff: min(failures^2 * 1h, maxBackoffHours)
|
||||||
|
*/
|
||||||
|
async recordFailure(domain: string, error?: string): Promise<void> {
|
||||||
|
const existing = await this.loadBackoff(domain);
|
||||||
|
const failures = (existing?.failures ?? 0) + 1;
|
||||||
|
|
||||||
|
// Exponential backoff: failures^2 hours, capped
|
||||||
|
const backoffHours = Math.min(failures * failures, this.maxBackoffHours);
|
||||||
|
const retryAfter = new Date(Date.now() + backoffHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const entry: IBackoffEntry = {
|
||||||
|
failures,
|
||||||
|
lastFailure: new Date().toISOString(),
|
||||||
|
retryAfter: retryAfter.toISOString(),
|
||||||
|
lastError: error,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.saveBackoff(domain, entry);
|
||||||
|
logger.log('warn', `Cert backoff for ${domain}: ${failures} failures, retry after ${retryAfter.toISOString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear backoff for a domain (on success or manual override)
|
||||||
|
*/
|
||||||
|
async clearBackoff(domain: string): Promise<void> {
|
||||||
|
this.backoffCache.delete(domain);
|
||||||
|
try {
|
||||||
|
await this.storageManager.delete(this.backoffKey(domain));
|
||||||
|
} catch {
|
||||||
|
// Ignore delete errors (key may not exist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all in-memory backoff cache entries
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.backoffCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get backoff info for UI display
|
||||||
|
*/
|
||||||
|
async getBackoffInfo(domain: string): Promise<{
|
||||||
|
failures: number;
|
||||||
|
retryAfter?: string;
|
||||||
|
lastError?: string;
|
||||||
|
} | null> {
|
||||||
|
const entry = await this.loadBackoff(domain);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
// Only return if still in backoff
|
||||||
|
const retryAfter = new Date(entry.retryAfter);
|
||||||
|
if (retryAfter.getTime() <= Date.now()) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
failures: entry.failures,
|
||||||
|
retryAfter: entry.retryAfter,
|
||||||
|
lastError: entry.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1824
ts/classes.dcrouter.ts
Normal file
@@ -1,44 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import * as paths from './paths.js';
|
|
||||||
import { PlatformServiceDb } from './classes.platformservicedb.js'
|
|
||||||
import { EmailService } from './email/email.classes.emailservice.js';
|
|
||||||
import { SmsService } from './sms/smsservice.js';
|
|
||||||
import { LetterService } from './letter/classes.letterservice.js';
|
|
||||||
import { MtaService } from './mta/mta.classes.mta.js';
|
|
||||||
|
|
||||||
export class SzPlatformService {
|
|
||||||
public projectinfo: plugins.projectinfo.ProjectInfo;
|
|
||||||
public serviceQenv = new plugins.qenv.Qenv('./', './.nogit');
|
|
||||||
public platformserviceDb: PlatformServiceDb;
|
|
||||||
|
|
||||||
public typedserver: plugins.typedserver.TypedServer;
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
// SubServices
|
|
||||||
public emailService: EmailService;
|
|
||||||
public letterService: LetterService;
|
|
||||||
public mtaService: MtaService;
|
|
||||||
public smsService: SmsService;
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
this.platformserviceDb = new PlatformServiceDb(this);
|
|
||||||
this.projectinfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
|
|
||||||
|
|
||||||
// lets start the sub services
|
|
||||||
this.emailService = new EmailService(this);
|
|
||||||
this.letterService = new LetterService(this, {
|
|
||||||
letterxpressUser: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_USER'),
|
|
||||||
letterxpressToken: await this.serviceQenv.getEnvVarOnDemand('LETTER_API_TOKEN')
|
|
||||||
});
|
|
||||||
this.mtaService = new MtaService(this);
|
|
||||||
this.smsService = new SmsService(this, {
|
|
||||||
apiGatewayApiToken: await this.serviceQenv.getEnvVarOnDemand('SMS_API_TOKEN'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// lets start the server finally
|
|
||||||
this.typedserver = new plugins.typedserver.TypedServer({
|
|
||||||
cors: true,
|
|
||||||
});
|
|
||||||
await this.typedserver.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import * as plugins from './plugins.js';
|
|
||||||
import { SzPlatformService } from './classes.platformservice.js';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class PlatformServiceDb {
|
|
||||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
|
||||||
public platformserviceRef: SzPlatformService;
|
|
||||||
|
|
||||||
constructor(platformserviceRefArg: SzPlatformService) {
|
|
||||||
this.platformserviceRef = platformserviceRefArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({
|
|
||||||
mongoDbUser: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_USER'),
|
|
||||||
mongoDbName: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_NAME'),
|
|
||||||
mongoDbPass: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_PASS'),
|
|
||||||
mongoDbUrl: await this.platformserviceRef.serviceQenv.getEnvVarOnDemand('MONGO_DB_URL'),
|
|
||||||
});
|
|
||||||
await this.smartdataDb.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {
|
|
||||||
await this.smartdataDb.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
ts/classes.storage-cert-manager.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { StorageManager } from './storage/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICertManager implementation backed by StorageManager.
|
||||||
|
* Persists SmartAcme certificates under a /certs/ key prefix so they
|
||||||
|
* survive process restarts without re-hitting ACME.
|
||||||
|
*/
|
||||||
|
export class StorageBackedCertManager implements plugins.smartacme.ICertManager {
|
||||||
|
private keyPrefix = '/certs/';
|
||||||
|
|
||||||
|
constructor(private storageManager: StorageManager) {}
|
||||||
|
|
||||||
|
async init(): Promise<void> {}
|
||||||
|
|
||||||
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||||
|
const data = await this.storageManager.getJSON(this.keyPrefix + domainName);
|
||||||
|
if (!data) return null;
|
||||||
|
return new plugins.smartacme.Cert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(this.keyPrefix + cert.domainName, {
|
||||||
|
id: cert.id,
|
||||||
|
domainName: cert.domainName,
|
||||||
|
created: cert.created,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
csr: cert.csr,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCertificate(domainName: string): Promise<void> {
|
||||||
|
await this.storageManager.delete(this.keyPrefix + domainName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
|
||||||
|
async wipe(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(this.keyPrefix);
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.storageManager.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredApiToken,
|
||||||
|
IApiTokenInfo,
|
||||||
|
TApiTokenScope,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const TOKENS_PREFIX = '/config-api/tokens/';
|
||||||
|
const TOKEN_PREFIX_STR = 'dcr_';
|
||||||
|
|
||||||
|
export class ApiTokenManager {
|
||||||
|
private tokens = new Map<string, IStoredApiToken>();
|
||||||
|
|
||||||
|
constructor(private storageManager: StorageManager) {}
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadTokens();
|
||||||
|
if (this.tokens.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Token lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API token. Returns the raw token value (shown once).
|
||||||
|
*/
|
||||||
|
public async createToken(
|
||||||
|
name: string,
|
||||||
|
scopes: TApiTokenScope[],
|
||||||
|
expiresInDays: number | null,
|
||||||
|
createdBy: string,
|
||||||
|
): Promise<{ id: string; rawToken: string }> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||||
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||||
|
|
||||||
|
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const stored: IStoredApiToken = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
tokenHash,
|
||||||
|
scopes,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdBy,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.tokens.set(id, stored);
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${name}' created (id: ${id})`);
|
||||||
|
return { id, rawToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a raw token string. Returns the stored token if valid, null otherwise.
|
||||||
|
* Also updates lastUsedAt.
|
||||||
|
*/
|
||||||
|
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||||
|
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||||
|
|
||||||
|
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
|
||||||
|
for (const stored of this.tokens.values()) {
|
||||||
|
if (stored.tokenHash === hash) {
|
||||||
|
if (!stored.enabled) return null;
|
||||||
|
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
|
||||||
|
|
||||||
|
// Update lastUsedAt (fire and forget)
|
||||||
|
stored.lastUsedAt = Date.now();
|
||||||
|
this.persistToken(stored).catch(() => {});
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a token has a specific scope.
|
||||||
|
*/
|
||||||
|
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||||
|
return token.scopes.includes(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all tokens (safe info only, no hashes).
|
||||||
|
*/
|
||||||
|
public listTokens(): IApiTokenInfo[] {
|
||||||
|
const result: IApiTokenInfo[] = [];
|
||||||
|
for (const stored of this.tokens.values()) {
|
||||||
|
result.push({
|
||||||
|
id: stored.id,
|
||||||
|
name: stored.name,
|
||||||
|
scopes: stored.scopes,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
expiresAt: stored.expiresAt,
|
||||||
|
lastUsedAt: stored.lastUsedAt,
|
||||||
|
enabled: stored.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (delete) a token.
|
||||||
|
*/
|
||||||
|
public async revokeToken(id: string): Promise<boolean> {
|
||||||
|
if (!this.tokens.has(id)) return false;
|
||||||
|
const token = this.tokens.get(id)!;
|
||||||
|
this.tokens.delete(id);
|
||||||
|
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
||||||
|
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll (regenerate) a token's secret while keeping its identity.
|
||||||
|
* Returns the new raw token value (shown once).
|
||||||
|
*/
|
||||||
|
public async rollToken(id: string): Promise<{ id: string; rawToken: string } | null> {
|
||||||
|
const stored = this.tokens.get(id);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
|
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||||
|
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||||
|
|
||||||
|
stored.tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${stored.name}' rolled (id: ${id})`);
|
||||||
|
return { id, rawToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable a token.
|
||||||
|
*/
|
||||||
|
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
||||||
|
const stored = this.tokens.get(id);
|
||||||
|
if (!stored) return false;
|
||||||
|
stored.enabled = enabled;
|
||||||
|
await this.persistToken(stored);
|
||||||
|
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadTokens(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(TOKENS_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
||||||
|
if (stored?.id) {
|
||||||
|
this.tokens.set(stored.id, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
271
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import type { StorageManager } from '../storage/index.js';
|
||||||
|
import type {
|
||||||
|
IStoredRoute,
|
||||||
|
IRouteOverride,
|
||||||
|
IMergedRoute,
|
||||||
|
IRouteWarning,
|
||||||
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
|
const ROUTES_PREFIX = '/config-api/routes/';
|
||||||
|
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
||||||
|
|
||||||
|
export class RouteConfigManager {
|
||||||
|
private storedRoutes = new Map<string, IStoredRoute>();
|
||||||
|
private overrides = new Map<string, IRouteOverride>();
|
||||||
|
private warnings: IRouteWarning[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private storageManager: StorageManager,
|
||||||
|
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||||
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.loadStoredRoutes();
|
||||||
|
await this.loadOverrides();
|
||||||
|
this.computeWarnings();
|
||||||
|
this.logWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Merged view
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||||
|
const merged: IMergedRoute[] = [];
|
||||||
|
|
||||||
|
// Hardcoded routes
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
merged.push({
|
||||||
|
route,
|
||||||
|
source: 'hardcoded',
|
||||||
|
enabled: override ? override.enabled : true,
|
||||||
|
overridden: !!override,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
merged.push({
|
||||||
|
route: stored.route,
|
||||||
|
source: 'programmatic',
|
||||||
|
enabled: stored.enabled,
|
||||||
|
overridden: false,
|
||||||
|
storedRouteId: stored.id,
|
||||||
|
createdAt: stored.createdAt,
|
||||||
|
updatedAt: stored.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routes: merged, warnings: [...this.warnings] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Programmatic route CRUD
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async createRoute(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
createdBy: string,
|
||||||
|
enabled = true,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Ensure route has a name
|
||||||
|
if (!route.name) {
|
||||||
|
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored: IStoredRoute = {
|
||||||
|
id,
|
||||||
|
route,
|
||||||
|
enabled,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.storedRoutes.set(id, stored);
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRoute(
|
||||||
|
id: string,
|
||||||
|
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const stored = this.storedRoutes.get(id);
|
||||||
|
if (!stored) return false;
|
||||||
|
|
||||||
|
if (patch.route) {
|
||||||
|
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
||||||
|
}
|
||||||
|
if (patch.enabled !== undefined) {
|
||||||
|
stored.enabled = patch.enabled;
|
||||||
|
}
|
||||||
|
stored.updatedAt = Date.now();
|
||||||
|
|
||||||
|
await this.persistRoute(stored);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRoute(id: string): Promise<boolean> {
|
||||||
|
if (!this.storedRoutes.has(id)) return false;
|
||||||
|
this.storedRoutes.delete(id);
|
||||||
|
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
||||||
|
return this.updateRoute(id, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Hardcoded route overrides
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||||
|
const override: IRouteOverride = {
|
||||||
|
routeName,
|
||||||
|
enabled,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
updatedBy,
|
||||||
|
};
|
||||||
|
this.overrides.set(routeName, override);
|
||||||
|
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeOverride(routeName: string): Promise<boolean> {
|
||||||
|
if (!this.overrides.has(routeName)) return false;
|
||||||
|
this.overrides.delete(routeName);
|
||||||
|
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
||||||
|
this.computeWarnings();
|
||||||
|
await this.applyRoutes();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: persistence
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async loadStoredRoutes(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
||||||
|
if (stored?.id) {
|
||||||
|
this.storedRoutes.set(stored.id, stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.storedRoutes.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOverrides(): Promise<void> {
|
||||||
|
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith('.json')) continue;
|
||||||
|
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
||||||
|
if (override?.routeName) {
|
||||||
|
this.overrides.set(override.routeName, override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.overrides.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||||
|
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: warnings
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private computeWarnings(): void {
|
||||||
|
this.warnings = [];
|
||||||
|
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||||
|
|
||||||
|
// Check overrides
|
||||||
|
for (const [routeName, override] of this.overrides) {
|
||||||
|
if (!hardcodedNames.has(routeName)) {
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'orphaned-override',
|
||||||
|
routeName,
|
||||||
|
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||||
|
});
|
||||||
|
} else if (!override.enabled) {
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'disabled-hardcoded',
|
||||||
|
routeName,
|
||||||
|
message: `Route '${routeName}' is disabled via API override`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check disabled programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (!stored.enabled) {
|
||||||
|
const name = stored.route.name || stored.id;
|
||||||
|
this.warnings.push({
|
||||||
|
type: 'disabled-programmatic',
|
||||||
|
routeName: name,
|
||||||
|
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private logWarnings(): void {
|
||||||
|
for (const w of this.warnings) {
|
||||||
|
logger.log('warn', w.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Private: apply merged routes to SmartProxy
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private async applyRoutes(): Promise<void> {
|
||||||
|
const smartProxy = this.getSmartProxy();
|
||||||
|
if (!smartProxy) return;
|
||||||
|
|
||||||
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
|
// Add enabled hardcoded routes (respecting overrides)
|
||||||
|
for (const route of this.getHardcodedRoutes()) {
|
||||||
|
const name = route.name || '';
|
||||||
|
const override = this.overrides.get(name);
|
||||||
|
if (override && !override.enabled) {
|
||||||
|
continue; // Skip disabled hardcoded route
|
||||||
|
}
|
||||||
|
enabledRoutes.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add enabled programmatic routes
|
||||||
|
for (const stored of this.storedRoutes.values()) {
|
||||||
|
if (stored.enabled) {
|
||||||
|
enabledRoutes.push(stored.route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await smartProxy.updateRoutes(enabledRoutes);
|
||||||
|
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ts/config/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Export validation tools only
|
||||||
|
export * from './validator.js';
|
||||||
|
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||||
|
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||||
266
ts/config/validator.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { ValidationError } from '../errors/base.errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface IValidationResult {
|
||||||
|
/**
|
||||||
|
* Whether the validation passed
|
||||||
|
*/
|
||||||
|
valid: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation errors if any
|
||||||
|
*/
|
||||||
|
errors?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated configuration (may include defaults)
|
||||||
|
*/
|
||||||
|
config?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation schema types
|
||||||
|
*/
|
||||||
|
export type ValidationSchema = Record<string, {
|
||||||
|
/**
|
||||||
|
* Type of the value
|
||||||
|
*/
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the field is required
|
||||||
|
*/
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default value if not specified
|
||||||
|
*/
|
||||||
|
default?: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum value (for numbers)
|
||||||
|
*/
|
||||||
|
min?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum value (for numbers)
|
||||||
|
*/
|
||||||
|
max?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum length (for strings or arrays)
|
||||||
|
*/
|
||||||
|
minLength?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum length (for strings or arrays)
|
||||||
|
*/
|
||||||
|
maxLength?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pattern to match (for strings)
|
||||||
|
*/
|
||||||
|
pattern?: RegExp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed values (for strings, numbers)
|
||||||
|
*/
|
||||||
|
enum?: any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nested schema (for objects)
|
||||||
|
*/
|
||||||
|
schema?: ValidationSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item schema (for arrays)
|
||||||
|
*/
|
||||||
|
items?: {
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object';
|
||||||
|
schema?: ValidationSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom validation function
|
||||||
|
*/
|
||||||
|
validate?: (value: any) => boolean | string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration validator
|
||||||
|
* Validates configuration objects against schemas and provides default values
|
||||||
|
*/
|
||||||
|
export class ConfigValidator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a configuration object against a schema
|
||||||
|
*
|
||||||
|
* @param config Configuration object to validate
|
||||||
|
* @param schema Validation schema
|
||||||
|
* @returns Validation result
|
||||||
|
*/
|
||||||
|
public static validate<T>(config: T, schema: ValidationSchema): IValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const validatedConfig = { ...config };
|
||||||
|
|
||||||
|
// Validate each field against the schema
|
||||||
|
for (const [key, rules] of Object.entries(schema)) {
|
||||||
|
const value = config[key];
|
||||||
|
|
||||||
|
// Check if required
|
||||||
|
if (rules.required && (value === undefined || value === null)) {
|
||||||
|
errors.push(`${key} is required`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not present and not required, apply default if available
|
||||||
|
if ((value === undefined || value === null)) {
|
||||||
|
if (rules.default !== undefined) {
|
||||||
|
validatedConfig[key] = rules.default;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type validation
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
const valueType = Array.isArray(value) ? 'array' : typeof value;
|
||||||
|
if (valueType !== rules.type) {
|
||||||
|
errors.push(`${key} must be of type ${rules.type}, got ${valueType}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific validations
|
||||||
|
switch (rules.type) {
|
||||||
|
case 'number':
|
||||||
|
if (rules.min !== undefined && value < rules.min) {
|
||||||
|
errors.push(`${key} must be at least ${rules.min}`);
|
||||||
|
}
|
||||||
|
if (rules.max !== undefined && value > rules.max) {
|
||||||
|
errors.push(`${key} must be at most ${rules.max}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||||
|
errors.push(`${key} must be at least ${rules.minLength} characters`);
|
||||||
|
}
|
||||||
|
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||||
|
errors.push(`${key} must be at most ${rules.maxLength} characters`);
|
||||||
|
}
|
||||||
|
if (rules.pattern && !rules.pattern.test(value)) {
|
||||||
|
errors.push(`${key} must match pattern ${rules.pattern}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'array':
|
||||||
|
if (rules.minLength !== undefined && value.length < rules.minLength) {
|
||||||
|
errors.push(`${key} must have at least ${rules.minLength} items`);
|
||||||
|
}
|
||||||
|
if (rules.maxLength !== undefined && value.length > rules.maxLength) {
|
||||||
|
errors.push(`${key} must have at most ${rules.maxLength} items`);
|
||||||
|
}
|
||||||
|
if (rules.items && value.length > 0) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const itemType = Array.isArray(value[i]) ? 'array' : typeof value[i];
|
||||||
|
if (itemType !== rules.items.type) {
|
||||||
|
errors.push(`${key}[${i}] must be of type ${rules.items.type}, got ${itemType}`);
|
||||||
|
} else if (rules.items.schema && itemType === 'object') {
|
||||||
|
const itemResult = this.validate(value[i], rules.items.schema);
|
||||||
|
if (!itemResult.valid) {
|
||||||
|
errors.push(...itemResult.errors.map(err => `${key}[${i}].${err}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'object':
|
||||||
|
if (rules.schema) {
|
||||||
|
const nestedResult = this.validate(value, rules.schema);
|
||||||
|
if (!nestedResult.valid) {
|
||||||
|
errors.push(...nestedResult.errors.map(err => `${key}.${err}`));
|
||||||
|
}
|
||||||
|
validatedConfig[key] = nestedResult.config;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum validation
|
||||||
|
if (rules.enum && !rules.enum.includes(value)) {
|
||||||
|
errors.push(`${key} must be one of [${rules.enum.join(', ')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom validation
|
||||||
|
if (rules.validate) {
|
||||||
|
const result = rules.validate(value);
|
||||||
|
if (result !== true) {
|
||||||
|
errors.push(typeof result === 'string' ? result : `${key} failed custom validation`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
config: validatedConfig
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply defaults to a configuration object based on a schema
|
||||||
|
*
|
||||||
|
* @param config Configuration object to apply defaults to
|
||||||
|
* @param schema Validation schema with defaults
|
||||||
|
* @returns Configuration with defaults applied
|
||||||
|
*/
|
||||||
|
public static applyDefaults<T>(config: T, schema: ValidationSchema): T {
|
||||||
|
const result = { ...config };
|
||||||
|
|
||||||
|
for (const [key, rules] of Object.entries(schema)) {
|
||||||
|
if (result[key] === undefined && rules.default !== undefined) {
|
||||||
|
result[key] = rules.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults to nested objects
|
||||||
|
if (result[key] && rules.type === 'object' && rules.schema) {
|
||||||
|
result[key] = this.applyDefaults(result[key], rules.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults to array items
|
||||||
|
if (result[key] && rules.type === 'array' && rules.items && rules.items.schema) {
|
||||||
|
result[key] = result[key].map(item =>
|
||||||
|
typeof item === 'object' ? this.applyDefaults(item, rules.items.schema) : item
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw a validation error if the configuration is invalid
|
||||||
|
*
|
||||||
|
* @param config Configuration to validate
|
||||||
|
* @param schema Validation schema
|
||||||
|
* @returns Validated configuration with defaults
|
||||||
|
* @throws ValidationError if validation fails
|
||||||
|
*/
|
||||||
|
public static validateOrThrow<T>(config: T, schema: ValidationSchema): T {
|
||||||
|
const result = this.validate(config, schema);
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Configuration validation failed: ${result.errors.join(', ')}`,
|
||||||
|
'CONFIG_VALIDATION_ERROR',
|
||||||
|
{ data: { errors: result.errors } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './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);
|
|
||||||
|
|
||||||
// Register API endpoints
|
|
||||||
this.registerApiEndpoints();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register API endpoints for email functionality
|
|
||||||
*/
|
|
||||||
private registerApiEndpoints() {
|
|
||||||
// Register the SendEmail endpoint
|
|
||||||
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.platformservice.mta.IRequest_SendEmail>(
|
|
||||||
new plugins.typedrequest.TypedHandler('sendEmail', async (requestData) => {
|
|
||||||
const mailToSend = new plugins.smartmail.Smartmail({
|
|
||||||
body: requestData.body,
|
|
||||||
from: requestData.from,
|
|
||||||
subject: requestData.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestData.attachments) {
|
|
||||||
for (const attachment of requestData.attachments) {
|
|
||||||
mailToSend.addAttachment(
|
|
||||||
await plugins.smartfile.SmartFile.fromString(
|
|
||||||
attachment.name,
|
|
||||||
attachment.binaryAttachmentString,
|
|
||||||
'binary'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email through the service which will route to the appropriate connector
|
|
||||||
const emailId = await this.emailRef.sendEmail(mailToSend, requestData.to, {});
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`sent an email to ${requestData.to} with subject '${mailToSend.getSubject()}'`,
|
|
||||||
{
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
email: {
|
|
||||||
to: requestData.to,
|
|
||||||
subject: mailToSend.getSubject(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseId: emailId,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add endpoint to check email status
|
|
||||||
this.typedRouter.addTypedHandler<{ emailId: string }>(
|
|
||||||
new plugins.typedrequest.TypedHandler('checkEmailStatus', async (requestData) => {
|
|
||||||
// If MTA is enabled, use it to check status
|
|
||||||
if (this.emailRef.mtaConnector) {
|
|
||||||
const status = await this.emailRef.mtaConnector.checkEmailStatus(requestData.emailId);
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Mailgun, we don't have a status check implementation currently
|
|
||||||
return {
|
|
||||||
status: 'unknown',
|
|
||||||
details: { message: 'Status tracking not available for current provider' }
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add statistics endpoint
|
|
||||||
this.typedRouter.addTypedHandler<void>(
|
|
||||||
new plugins.typedrequest.TypedHandler('getEmailStats', async () => {
|
|
||||||
return this.emailRef.getStats();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './email.classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
// Import MTA classes
|
|
||||||
import {
|
|
||||||
MtaService,
|
|
||||||
Email as MtaEmail,
|
|
||||||
type IEmailOptions,
|
|
||||||
DeliveryStatus,
|
|
||||||
type IAttachment
|
|
||||||
} from '../mta/index.js';
|
|
||||||
|
|
||||||
export class MtaConnector {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
private mtaService: MtaService;
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService, mtaService?: MtaService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
this.mtaService = mtaService || this.emailRef.mtaService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using the MTA service
|
|
||||||
* @param smartmail The email to send
|
|
||||||
* @param toAddresses Recipients (comma-separated or array)
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendEmail(
|
|
||||||
smartmail: plugins.smartmail.Smartmail<>,
|
|
||||||
toAddresses: string | string[],
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// Process recipients
|
|
||||||
const toArray = Array.isArray(toAddresses)
|
|
||||||
? toAddresses
|
|
||||||
: toAddresses.split(',').map(addr => addr.trim());
|
|
||||||
|
|
||||||
// Map SmartMail attachments to MTA attachments
|
|
||||||
const attachments: IAttachment[] = smartmail.attachments.map(attachment => {
|
|
||||||
return {
|
|
||||||
filename: attachment.parsedPath.base,
|
|
||||||
content: Buffer.from(attachment.contentBuffer),
|
|
||||||
contentType: (attachment as any)?.getContentType?.() || 'application/octet-stream' // TODO: revisit after smartfile has been updated
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create MTA Email
|
|
||||||
const mtaEmail = new MtaEmail({
|
|
||||||
from: smartmail.options.from,
|
|
||||||
to: toArray,
|
|
||||||
subject: smartmail.getSubject(),
|
|
||||||
text: smartmail.getBody(false), // Plain text version
|
|
||||||
html: smartmail.getBody(true), // HTML version
|
|
||||||
attachments
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send using MTA
|
|
||||||
const emailId = await this.mtaService.send(mtaEmail);
|
|
||||||
|
|
||||||
logger.log('info', `Email sent via MTA to ${toAddresses}`, {
|
|
||||||
eventType: 'sentEmail',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
to: toAddresses
|
|
||||||
});
|
|
||||||
|
|
||||||
return emailId;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to send email via MTA: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve and process an incoming email
|
|
||||||
* For MTA, this would handle an email already received by the SMTP server
|
|
||||||
* @param emailData The raw email data or identifier
|
|
||||||
*/
|
|
||||||
public async receiveEmail(emailData: string): Promise<plugins.smartmail.Smartmail<>> {
|
|
||||||
try {
|
|
||||||
// In a real implementation, this would retrieve an email from the MTA storage
|
|
||||||
// For now, we can use a simplified approach:
|
|
||||||
|
|
||||||
// Parse the email (assuming emailData is a raw email or a file path)
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
|
||||||
|
|
||||||
// Create a Smartmail from the parsed email
|
|
||||||
const smartmail = new plugins.smartmail.Smartmail({
|
|
||||||
from: parsedEmail.from?.text || '',
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
body: parsedEmail.html || parsedEmail.text || '',
|
|
||||||
creationObjectRef: {
|
|
||||||
From: parsedEmail.from?.text || '',
|
|
||||||
To: parsedEmail.to?.text || '',
|
|
||||||
Subject: parsedEmail.subject || ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add attachments if present
|
|
||||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
|
||||||
for (const attachment of parsedEmail.attachments) {
|
|
||||||
smartmail.addAttachment(
|
|
||||||
await plugins.smartfile.SmartFile.fromBuffer(
|
|
||||||
attachment.filename || 'attachment',
|
|
||||||
attachment.content
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return smartmail;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to receive email via MTA: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check the status of a sent email
|
|
||||||
* @param emailId The email ID to check
|
|
||||||
*/
|
|
||||||
public async checkEmailStatus(emailId: string): Promise<{
|
|
||||||
status: string;
|
|
||||||
details?: any;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const status = this.mtaService.getEmailStatus(emailId);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return {
|
|
||||||
status: 'unknown',
|
|
||||||
details: { message: 'Email not found' }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: status.status,
|
|
||||||
details: {
|
|
||||||
attempts: status.attempts,
|
|
||||||
lastAttempt: status.lastAttempt,
|
|
||||||
nextAttempt: status.nextAttempt,
|
|
||||||
error: status.error?.message
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to check email status: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
emailId,
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
details: { message: error.message }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { MtaConnector } from './email.classes.connector.mta.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';
|
|
||||||
|
|
||||||
// Import MTA service
|
|
||||||
import { MtaService, type IMtaConfig } from '../mta/index.js';
|
|
||||||
|
|
||||||
export interface IEmailConstructorOptions {
|
|
||||||
useMta?: boolean;
|
|
||||||
mtaConfig?: IMtaConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email service with support for both Mailgun and local MTA
|
|
||||||
*/
|
|
||||||
export class EmailService {
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
|
|
||||||
// typedrouter
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
// connectors
|
|
||||||
public mtaConnector: MtaConnector;
|
|
||||||
public qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
|
||||||
|
|
||||||
// MTA service
|
|
||||||
public mtaService: MtaService;
|
|
||||||
|
|
||||||
// services
|
|
||||||
public apiManager: ApiManager;
|
|
||||||
public ruleManager: RuleManager;
|
|
||||||
|
|
||||||
// configuration
|
|
||||||
private config: IEmailConstructorOptions;
|
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, options: IEmailConstructorOptions = {}) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
this.config = {
|
|
||||||
useMta: options.useMta ?? true,
|
|
||||||
mtaConfig: options.mtaConfig || {}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.useMta) {
|
|
||||||
// Initialize MTA service
|
|
||||||
this.mtaService = new MtaService(platformServiceRefArg, this.config.mtaConfig);
|
|
||||||
// Initialize MTA connector
|
|
||||||
this.mtaConnector = new MtaConnector(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize API manager and rule manager
|
|
||||||
this.apiManager = new ApiManager(this);
|
|
||||||
this.ruleManager = new RuleManager(this);
|
|
||||||
|
|
||||||
// Set up MTA SMTP server webhook if using MTA
|
|
||||||
if (this.config.useMta) {
|
|
||||||
// The MTA SMTP server will handle incoming emails directly
|
|
||||||
// through its SMTP protocol. No additional webhook needed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the email service
|
|
||||||
*/
|
|
||||||
public async start() {
|
|
||||||
// Initialize rule manager
|
|
||||||
await this.ruleManager.init();
|
|
||||||
|
|
||||||
// Start MTA service if enabled
|
|
||||||
if (this.config.useMta && this.mtaService) {
|
|
||||||
await this.mtaService.start();
|
|
||||||
logger.log('success', 'Started MTA service');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('success', `Started email service`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the email service
|
|
||||||
*/
|
|
||||||
public async stop() {
|
|
||||||
// Stop MTA service if it's running
|
|
||||||
if (this.config.useMta && this.mtaService) {
|
|
||||||
await this.mtaService.stop();
|
|
||||||
logger.log('info', 'Stopped MTA service');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('info', 'Stopped email service');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email using the configured provider (Mailgun or MTA)
|
|
||||||
* @param email The email to send
|
|
||||||
* @param to Recipient(s)
|
|
||||||
* @param options Additional options
|
|
||||||
*/
|
|
||||||
public async sendEmail(
|
|
||||||
email: plugins.smartmail.Smartmail<>,
|
|
||||||
to: string | string[],
|
|
||||||
options: any = {}
|
|
||||||
): Promise<string> {
|
|
||||||
// Determine which connector to use
|
|
||||||
if (this.config.useMta && this.mtaConnector) {
|
|
||||||
return this.mtaConnector.sendEmail(email, to, options);
|
|
||||||
} else {
|
|
||||||
throw new Error('No email provider configured');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get email service statistics
|
|
||||||
*/
|
|
||||||
public getStats() {
|
|
||||||
const stats: any = {
|
|
||||||
activeProviders: []
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.config.useMta) {
|
|
||||||
stats.activeProviders.push('mta');
|
|
||||||
stats.mta = this.mtaService.getStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { EmailService } from './email.classes.emailservice.js';
|
|
||||||
import { logger } from '../logger.js';
|
|
||||||
|
|
||||||
export class RuleManager {
|
|
||||||
public emailRef: EmailService;
|
|
||||||
public smartruleInstance = new plugins.smartrule.SmartRule<
|
|
||||||
plugins.smartmail.Smartmail<any>
|
|
||||||
>();
|
|
||||||
|
|
||||||
constructor(emailRefArg: EmailService) {
|
|
||||||
this.emailRef = emailRefArg;
|
|
||||||
|
|
||||||
// Register MTA handler for incoming emails if MTA is enabled
|
|
||||||
if (this.emailRef.mtaService) {
|
|
||||||
this.setupMtaIncomingHandler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up handler for incoming emails via MTA's SMTP server
|
|
||||||
*/
|
|
||||||
private setupMtaIncomingHandler() {
|
|
||||||
// The original MtaService doesn't have a direct callback for incoming emails,
|
|
||||||
// but we can modify this approach based on how you prefer to integrate.
|
|
||||||
// One option would be to extend the MtaService to add an event emitter.
|
|
||||||
|
|
||||||
// For now, we'll use a directory watcher as an example
|
|
||||||
// This would watch the directory where MTA saves incoming emails
|
|
||||||
const incomingDir = this.emailRef.mtaService['receivedEmailsDir'] || './received';
|
|
||||||
|
|
||||||
// Simple file watcher (in real implementation, use proper file watching)
|
|
||||||
// This is just conceptual - would need modification to work with your specific setup
|
|
||||||
this.watchIncomingEmails(incomingDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch directory for incoming emails (conceptual implementation)
|
|
||||||
*/
|
|
||||||
private watchIncomingEmails(directory: string) {
|
|
||||||
console.log(`Watching for incoming emails in: ${directory}`);
|
|
||||||
|
|
||||||
// Conceptual - in a real implementation, set up proper file watching
|
|
||||||
// or modify the MTA to emit events when emails are received
|
|
||||||
|
|
||||||
/*
|
|
||||||
// Example using a file watcher:
|
|
||||||
const watcher = plugins.fs.watch(directory, async (eventType, filename) => {
|
|
||||||
if (eventType === 'rename' && filename.endsWith('.eml')) {
|
|
||||||
const filePath = plugins.path.join(directory, filename);
|
|
||||||
await this.handleMtaIncomingEmail(filePath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming email received via MTA
|
|
||||||
*/
|
|
||||||
public async handleMtaIncomingEmail(emailPath: string) {
|
|
||||||
try {
|
|
||||||
// Process the email file
|
|
||||||
const fetchedSmartmail = await this.emailRef.mtaConnector.receiveEmail(emailPath);
|
|
||||||
|
|
||||||
console.log('=======================');
|
|
||||||
console.log('Received a mail via MTA:');
|
|
||||||
console.log(`From: ${fetchedSmartmail.options.creationObjectRef.From}`);
|
|
||||||
console.log(`To: ${fetchedSmartmail.options.creationObjectRef.To}`);
|
|
||||||
console.log(`Subject: ${fetchedSmartmail.options.creationObjectRef.Subject}`);
|
|
||||||
console.log('^^^^^^^^^^^^^^^^^^^^^^^');
|
|
||||||
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`email from ${fetchedSmartmail.options.creationObjectRef.From} to ${fetchedSmartmail.options.creationObjectRef.To} with subject '${fetchedSmartmail.options.creationObjectRef.Subject}'`,
|
|
||||||
{
|
|
||||||
eventType: 'receivedEmail',
|
|
||||||
provider: 'mta',
|
|
||||||
email: {
|
|
||||||
from: fetchedSmartmail.options.creationObjectRef.From,
|
|
||||||
to: fetchedSmartmail.options.creationObjectRef.To,
|
|
||||||
subject: fetchedSmartmail.options.creationObjectRef.Subject,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process with rules
|
|
||||||
this.smartruleInstance.makeDecision(fetchedSmartmail);
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to process incoming MTA email: ${error.message}`, {
|
|
||||||
eventType: 'emailError',
|
|
||||||
provider: 'mta',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
// Setup email rules
|
|
||||||
await this.createForwards();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* creates the default forwards
|
|
||||||
*/
|
|
||||||
public async createForwards() {
|
|
||||||
const forwards: { originalToAddress: string[]; forwardedToAddress: string[] }[] = [];
|
|
||||||
console.log(`${forwards.length} forward rules configured:`);
|
|
||||||
for (const forward of forwards) {
|
|
||||||
console.log(forward);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const forward of forwards) {
|
|
||||||
this.smartruleInstance.createRule(
|
|
||||||
10,
|
|
||||||
async (smartmailArg) => {
|
|
||||||
const matched = forward.originalToAddress.reduce<boolean>((prevValue, currentValue) => {
|
|
||||||
return smartmailArg.options.creationObjectRef.To.includes(currentValue) || prevValue;
|
|
||||||
}, false);
|
|
||||||
if (matched) {
|
|
||||||
console.log('Forward rule matched');
|
|
||||||
console.log(forward);
|
|
||||||
return 'apply-continue';
|
|
||||||
} else {
|
|
||||||
return 'continue';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async (smartmailArg: plugins.smartmail.Smartmail<any>) => {
|
|
||||||
forward.forwardedToAddress.map(async (toArg) => {
|
|
||||||
const forwardedSmartMail = new plugins.smartmail.Smartmail({
|
|
||||||
body:
|
|
||||||
`
|
|
||||||
<div style="background: #CCC; padding: 10px; border-radius: 3px;">
|
|
||||||
<div><b>Original Sender:</b></div>
|
|
||||||
<div>${smartmailArg.options.creationObjectRef.From}</div>
|
|
||||||
<div><b>Original Recipient:</b></div>
|
|
||||||
<div>${smartmailArg.options.creationObjectRef.To}</div>
|
|
||||||
<div><b>Forwarded to:</b></div>
|
|
||||||
<div>${forward.forwardedToAddress.reduce<string>((pVal, cVal) => {
|
|
||||||
return `${pVal ? pVal + ', ' : ''}${cVal}`;
|
|
||||||
}, null)}</div>
|
|
||||||
<div><b>Subject:</b></div>
|
|
||||||
<div>${smartmailArg.getSubject()}</div>
|
|
||||||
<div><b>The original body can be found below.</b></div>
|
|
||||||
</div>
|
|
||||||
` + smartmailArg.getBody(),
|
|
||||||
from: 'forwarder@mail.lossless.one',
|
|
||||||
subject: `Forwarded mail for '${smartmailArg.options.creationObjectRef.To}'`,
|
|
||||||
});
|
|
||||||
for (const attachment of smartmailArg.attachments) {
|
|
||||||
forwardedSmartMail.addAttachment(attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the EmailService's sendEmail method to send with the appropriate provider
|
|
||||||
await this.emailRef.sendEmail(forwardedSmartMail, toArg);
|
|
||||||
|
|
||||||
console.log(`forwarded mail to ${toArg}`);
|
|
||||||
logger.log(
|
|
||||||
'info',
|
|
||||||
`email from ${
|
|
||||||
smartmailArg.options.creationObjectRef.From
|
|
||||||
} to ${toArg} with subject '${smartmailArg.getSubject()}'`,
|
|
||||||
{
|
|
||||||
eventType: 'forwardedEmail',
|
|
||||||
email: {
|
|
||||||
from: smartmailArg.options.creationObjectRef.From,
|
|
||||||
to: smartmailArg.options.creationObjectRef.To,
|
|
||||||
forwardedTo: toArg,
|
|
||||||
subject: smartmailArg.options.creationObjectRef.Subject,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import * as plugins from './email.plugins.js';
|
|
||||||
|
|
||||||
export class TemplateManager {
|
|
||||||
public smartmailDefault = new plugins.smartmail.Smartmail({
|
|
||||||
body: `
|
|
||||||
|
|
||||||
`,
|
|
||||||
from: `noreply@mail.lossless.com`,
|
|
||||||
subject: `{{subject}}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
public createSmartmailFromData(tempalteTypeArg: plugins.lointEmail.TTemplates) {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { EmailService } from './email.classes.emailservice.js';
|
|
||||||
|
|
||||||
export { EmailService as Email };
|
|
||||||
525
ts/errors/base.errors.ts
Normal file
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Platform Service Error System
|
||||||
|
*
|
||||||
|
* This module provides a comprehensive error handling system for the Platform Service,
|
||||||
|
* with structured error types, error codes, and consistent patterns for logging and recovery.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Export error codes and types
|
||||||
|
export * from './error.codes.js';
|
||||||
|
|
||||||
|
// Export base error classes
|
||||||
|
export * from './base.errors.js';
|
||||||
|
|
||||||
|
// Export domain-specific error classes
|
||||||
|
export * from './reputation.errors.js';
|
||||||
|
|
||||||
|
// Export error handler
|
||||||
|
export * from './error-handler.js';
|
||||||
|
|
||||||
|
// Export utility function to create specific error types based on the error category
|
||||||
|
import { getErrorClassForCategory } from './base.errors.js';
|
||||||
|
export { getErrorClassForCategory };
|
||||||
|
|
||||||
|
// Import needed classes for utility functions
|
||||||
|
import { PlatformError } from './base.errors.js';
|
||||||
|
import { ErrorSeverity, ErrorCategory, ErrorRecoverability } from './error.codes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a typed error from a standard Error
|
||||||
|
* Useful for converting errors from external libraries or APIs
|
||||||
|
*
|
||||||
|
* @param error Standard error to convert
|
||||||
|
* @param code Error code to assign
|
||||||
|
* @param contextData Additional context data
|
||||||
|
* @returns Typed PlatformError
|
||||||
|
*/
|
||||||
|
export function fromError(
|
||||||
|
error: Error,
|
||||||
|
code: string,
|
||||||
|
contextData: Record<string, any> = {}
|
||||||
|
): PlatformError {
|
||||||
|
return new PlatformError(
|
||||||
|
error.message,
|
||||||
|
code,
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
ErrorCategory.OPERATION,
|
||||||
|
ErrorRecoverability.NON_RECOVERABLE,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...contextData,
|
||||||
|
originalError: {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an error is retryable
|
||||||
|
*
|
||||||
|
* @param error Error to check
|
||||||
|
* @returns Boolean indicating if the error should be retried
|
||||||
|
*/
|
||||||
|
export function isRetryable(error: any): boolean {
|
||||||
|
// If it's our platform error, use its recoverability property
|
||||||
|
if (error && typeof error === 'object' && 'recoverability' in error) {
|
||||||
|
return error.recoverability === ErrorRecoverability.RECOVERABLE ||
|
||||||
|
error.recoverability === ErrorRecoverability.MAYBE_RECOVERABLE ||
|
||||||
|
error.recoverability === ErrorRecoverability.TRANSIENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a network error (these are often transient)
|
||||||
|
if (error && typeof error === 'object' && error.code) {
|
||||||
|
const networkErrors = [
|
||||||
|
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH',
|
||||||
|
'ENETUNREACH', 'ENOTFOUND', 'EPROTO', 'ECONNABORTED'
|
||||||
|
];
|
||||||
|
|
||||||
|
return networkErrors.includes(error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default, we can't determine if the error is retryable
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a wrapped version of a function that catches errors
|
||||||
|
* and converts them to typed PlatformErrors
|
||||||
|
*
|
||||||
|
* @param fn Function to wrap
|
||||||
|
* @param errorCode Default error code to use
|
||||||
|
* @param contextData Additional context data
|
||||||
|
* @returns Wrapped function
|
||||||
|
*/
|
||||||
|
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
|
||||||
|
fn: T,
|
||||||
|
errorCode: string,
|
||||||
|
contextData: Record<string, any> = {}
|
||||||
|
): T {
|
||||||
|
return (async function(...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||||
|
try {
|
||||||
|
return await fn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'code' in error) {
|
||||||
|
// Already a typed error, rethrow
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw fromError(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
errorCode,
|
||||||
|
{
|
||||||
|
...contextData,
|
||||||
|
fnName: fn.name,
|
||||||
|
args: args.map(arg =>
|
||||||
|
typeof arg === 'object'
|
||||||
|
? '[Object]'
|
||||||
|
: String(arg).substring(0, 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a function with exponential backoff
|
||||||
|
*
|
||||||
|
* @param fn Function to retry
|
||||||
|
* @param options Retry options
|
||||||
|
* @returns Function result or throws after max retries
|
||||||
|
*/
|
||||||
|
export async function retry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: {
|
||||||
|
maxRetries?: number;
|
||||||
|
initialDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
retryableErrors?: Array<string | RegExp>;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
initialDelay = 1000,
|
||||||
|
maxDelay = 30000,
|
||||||
|
backoffFactor = 2,
|
||||||
|
retryableErrors = []
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error));
|
||||||
|
|
||||||
|
// Check if we should retry
|
||||||
|
const shouldRetry = attempt < maxRetries && (
|
||||||
|
isRetryable(error) ||
|
||||||
|
retryableErrors.some(pattern => {
|
||||||
|
if (typeof pattern === 'string') {
|
||||||
|
return lastError.message.includes(pattern);
|
||||||
|
}
|
||||||
|
return pattern.test(lastError.message);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shouldRetry) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay with exponential backoff
|
||||||
|
const delay = Math.min(initialDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||||
|
|
||||||
|
// Add jitter to prevent thundering herd problem (±20%)
|
||||||
|
const jitter = 0.8 + Math.random() * 0.4;
|
||||||
|
const actualDelay = Math.floor(delay * jitter);
|
||||||
|
|
||||||
|
// Wait before next retry
|
||||||
|
await new Promise(resolve => setTimeout(resolve, actualDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should never happen, but TypeScript needs it
|
||||||
|
throw lastError!;
|
||||||
|
}
|
||||||
422
ts/errors/reputation.errors.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
PlatformError,
|
||||||
|
OperationError,
|
||||||
|
ResourceError
|
||||||
|
} from './base.errors.js';
|
||||||
|
import type { IErrorContext } from './base.errors.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
REPUTATION_CHECK_ERROR,
|
||||||
|
REPUTATION_DATA_ERROR,
|
||||||
|
REPUTATION_BLOCKLIST_ERROR,
|
||||||
|
REPUTATION_UPDATE_ERROR,
|
||||||
|
WARMUP_ALLOCATION_ERROR,
|
||||||
|
WARMUP_LIMIT_EXCEEDED,
|
||||||
|
WARMUP_SCHEDULE_ERROR
|
||||||
|
} from './error.codes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for reputation-related errors
|
||||||
|
*/
|
||||||
|
export class ReputationError extends OperationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param code Error code
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, code, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for reputation check errors
|
||||||
|
*/
|
||||||
|
export class ReputationCheckError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation check error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_CHECK_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ReputationCheckError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for an IP reputation check error
|
||||||
|
*
|
||||||
|
* @param ip IP address
|
||||||
|
* @param provider Reputation provider
|
||||||
|
* @param originalError Original error
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static ipCheckFailed(
|
||||||
|
ip: string,
|
||||||
|
provider: string,
|
||||||
|
originalError?: Error,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): ReputationCheckError {
|
||||||
|
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||||
|
return new ReputationCheckError(
|
||||||
|
`Failed to check reputation for IP ${ip} with provider ${provider}${errorMsg}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
ip,
|
||||||
|
provider,
|
||||||
|
originalError: originalError ? {
|
||||||
|
message: originalError.message,
|
||||||
|
stack: originalError.stack
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for a domain reputation check error
|
||||||
|
*
|
||||||
|
* @param domain Domain
|
||||||
|
* @param provider Reputation provider
|
||||||
|
* @param originalError Original error
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static domainCheckFailed(
|
||||||
|
domain: string,
|
||||||
|
provider: string,
|
||||||
|
originalError?: Error,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): ReputationCheckError {
|
||||||
|
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||||
|
return new ReputationCheckError(
|
||||||
|
`Failed to check reputation for domain ${domain} with provider ${provider}${errorMsg}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
domain,
|
||||||
|
provider,
|
||||||
|
originalError: originalError ? {
|
||||||
|
message: originalError.message,
|
||||||
|
stack: originalError.stack
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for reputation data errors
|
||||||
|
*/
|
||||||
|
export class ReputationDataError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation data error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_DATA_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ReputationDataError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for a data access error
|
||||||
|
*
|
||||||
|
* @param entity Entity type (domain, ip)
|
||||||
|
* @param entityId Entity identifier
|
||||||
|
* @param operation Operation that failed (read, write, update)
|
||||||
|
* @param originalError Original error
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static dataAccessFailed(
|
||||||
|
entity: string,
|
||||||
|
entityId: string,
|
||||||
|
operation: string,
|
||||||
|
originalError?: Error,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): ReputationDataError {
|
||||||
|
const errorMsg = originalError ? `: ${originalError.message}` : '';
|
||||||
|
return new ReputationDataError(
|
||||||
|
`Failed to ${operation} reputation data for ${entity} ${entityId}${errorMsg}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
entity,
|
||||||
|
entityId,
|
||||||
|
operation,
|
||||||
|
originalError: originalError ? {
|
||||||
|
message: originalError.message,
|
||||||
|
stack: originalError.stack
|
||||||
|
} : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for blocklist-related errors
|
||||||
|
*/
|
||||||
|
export class BlocklistError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new blocklist error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_BLOCKLIST_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof BlocklistError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for an entity found on a blocklist
|
||||||
|
*
|
||||||
|
* @param entity Entity type (domain, ip)
|
||||||
|
* @param entityId Entity identifier
|
||||||
|
* @param blocklist Blocklist name
|
||||||
|
* @param reason Reason for listing (if available)
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static entityBlocked(
|
||||||
|
entity: string,
|
||||||
|
entityId: string,
|
||||||
|
blocklist: string,
|
||||||
|
reason?: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): BlocklistError {
|
||||||
|
const reasonText = reason ? ` (${reason})` : '';
|
||||||
|
return new BlocklistError(
|
||||||
|
`${entity.charAt(0).toUpperCase() + entity.slice(1)} ${entityId} is listed on blocklist ${blocklist}${reasonText}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
entity,
|
||||||
|
entityId,
|
||||||
|
blocklist,
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
userMessage: `The ${entity} ${entityId} is on a blocklist. This may affect email deliverability.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for reputation update errors
|
||||||
|
*/
|
||||||
|
export class ReputationUpdateError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new reputation update error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, REPUTATION_UPDATE_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof ReputationUpdateError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for IP warmup allocation errors
|
||||||
|
*/
|
||||||
|
export class WarmupAllocationError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new warmup allocation error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, WARMUP_ALLOCATION_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof WarmupAllocationError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for no available IPs
|
||||||
|
*
|
||||||
|
* @param domain Domain requesting an IP
|
||||||
|
* @param policy Allocation policy that was used
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static noAvailableIps(
|
||||||
|
domain: string,
|
||||||
|
policy: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): WarmupAllocationError {
|
||||||
|
return new WarmupAllocationError(
|
||||||
|
`No available IPs for domain ${domain} using ${policy} allocation policy`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
domain,
|
||||||
|
policy
|
||||||
|
},
|
||||||
|
userMessage: `No available sending IPs for ${domain}.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for IP warmup limit exceeded errors
|
||||||
|
*/
|
||||||
|
export class WarmupLimitError extends ResourceError {
|
||||||
|
/**
|
||||||
|
* Creates a new warmup limit error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, WARMUP_LIMIT_EXCEEDED, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof WarmupLimitError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance for daily sending limit exceeded
|
||||||
|
*
|
||||||
|
* @param ip IP address
|
||||||
|
* @param domain Domain
|
||||||
|
* @param limit Daily limit
|
||||||
|
* @param sent Number of emails sent
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
public static dailyLimitExceeded(
|
||||||
|
ip: string,
|
||||||
|
domain: string,
|
||||||
|
limit: number,
|
||||||
|
sent: number,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
): WarmupLimitError {
|
||||||
|
return new WarmupLimitError(
|
||||||
|
`Daily sending limit exceeded for IP ${ip} and domain ${domain}: ${sent}/${limit}`,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
data: {
|
||||||
|
...context.data,
|
||||||
|
ip,
|
||||||
|
domain,
|
||||||
|
limit,
|
||||||
|
sent
|
||||||
|
},
|
||||||
|
userMessage: `Daily sending limit reached for ${domain}.`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for IP warmup schedule errors
|
||||||
|
*/
|
||||||
|
export class WarmupScheduleError extends ReputationError {
|
||||||
|
/**
|
||||||
|
* Creates a new warmup schedule error
|
||||||
|
*
|
||||||
|
* @param message Error message
|
||||||
|
* @param context Additional context
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
context: IErrorContext = {}
|
||||||
|
) {
|
||||||
|
super(message, WARMUP_SCHEDULE_ERROR, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance with updated context
|
||||||
|
*/
|
||||||
|
protected createWithContext(context: IErrorContext): PlatformError {
|
||||||
|
return new (this.constructor as typeof WarmupScheduleError)(
|
||||||
|
this.message,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
ts/index.ts
@@ -1,4 +1,16 @@
|
|||||||
export * from './00_commitinfo_data.js';
|
export * from './00_commitinfo_data.js';
|
||||||
import { SzPlatformService } from './classes.platformservice.js';
|
|
||||||
|
|
||||||
export const runCli = async () => {}
|
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
|
||||||
|
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||||
|
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
// DcRouter
|
||||||
|
export * from './classes.dcrouter.js';
|
||||||
|
|
||||||
|
// RADIUS module
|
||||||
|
export * from './radius/index.js';
|
||||||
|
|
||||||
|
// Remote Ingress module
|
||||||
|
export * from './remoteingress/index.js';
|
||||||
|
|
||||||
|
export const runCli = async () => {};
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
|
||||||
import * as plugins from '../plugins.js';
|
|
||||||
|
|
||||||
export interface ILetterConstructorOptions {
|
|
||||||
letterxpressUser: string;
|
|
||||||
letterxpressToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LetterService {
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
public options: ILetterConstructorOptions;
|
|
||||||
public letterxpressAccount: plugins.letterxpress.LetterXpressAccount;
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, optionsArg: ILetterConstructorOptions) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
this.options = optionsArg;
|
|
||||||
this.platformServiceRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
|
|
||||||
this.typedrouter.addTypedHandler<
|
|
||||||
plugins.servezoneInterfaces.platformservice.letter.IRequest_SendLetter
|
|
||||||
>(new plugins.typedrequest.TypedHandler('sendLetter', async dataArg => {
|
|
||||||
if(dataArg.needsCover) {
|
|
||||||
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
processId: '',
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start() {
|
|
||||||
this.letterxpressAccount = new plugins.letterxpress.LetterXpressAccount({
|
|
||||||
username: this.options.letterxpressUser,
|
|
||||||
apiKey: this.options.letterxpressToken,
|
|
||||||
});
|
|
||||||
await this.letterxpressAccount.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stop() {}
|
|
||||||
}
|
|
||||||
93
ts/logger.ts
@@ -1,9 +1,98 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
|
||||||
|
|
||||||
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'
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory log buffer for the OpsServer UI
|
||||||
|
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
|
||||||
|
|
||||||
|
// Default Smartlog instance (exported so OpsServer can add push destinations)
|
||||||
|
export const baseLogger = new plugins.smartlog.Smartlog({
|
||||||
logContext: {
|
logContext: {
|
||||||
environment: 'production',
|
environment: envMap[nodeEnv] || 'production',
|
||||||
runtime: 'node',
|
runtime: 'node',
|
||||||
zone: 'serve.zone',
|
zone: 'serve.zone',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire the buffer destination so all logs are captured
|
||||||
|
baseLogger.addLogDestination(logBuffer);
|
||||||
|
|
||||||
|
// Extended logger compatible with the original enhanced logger API
|
||||||
|
class StandardLogger {
|
||||||
|
private defaultContext: Record<string, any> = {};
|
||||||
|
private correlationId: string | null = null;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
// Log methods
|
||||||
|
public log(level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context: Record<string, any> = {}) {
|
||||||
|
const combinedContext = {
|
||||||
|
...this.defaultContext,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.correlationId) {
|
||||||
|
combinedContext.correlation_id = this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
baseLogger.log(level, message, combinedContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public error(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('error', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public warn(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('warn', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public info(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('info', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public success(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('success', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public debug(message: string, context: Record<string, any> = {}) {
|
||||||
|
this.log('debug', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context management
|
||||||
|
public setContext(context: Record<string, any>, overwrite: boolean = false) {
|
||||||
|
if (overwrite) {
|
||||||
|
this.defaultContext = context;
|
||||||
|
} else {
|
||||||
|
this.defaultContext = {
|
||||||
|
...this.defaultContext,
|
||||||
|
...context
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correlation ID management
|
||||||
|
public setCorrelationId(id: string | null = null): string {
|
||||||
|
this.correlationId = id || randomUUID();
|
||||||
|
return this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCorrelationId(): string | null {
|
||||||
|
return this.correlationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCorrelationId(): void {
|
||||||
|
this.correlationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const logger = new StandardLogger();
|
||||||
|
|||||||
75
ts/monitoring/classes.metricscache.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
748
ts/monitoring/classes.metricsmanager.ts
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { DcRouter } from '../classes.dcrouter.js';
|
||||||
|
import { MetricsCache } from './classes.metricscache.js';
|
||||||
|
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
export class MetricsManager {
|
||||||
|
private metricsLogger: 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(),
|
||||||
|
// Per-second query count ring buffer (300 entries = 5 minutes)
|
||||||
|
queryRing: new Int32Array(300),
|
||||||
|
queryRingLastSecond: 0, // last epoch second that was written
|
||||||
|
responseTimes: [] as number[], // Track response times in ms
|
||||||
|
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-minute time-series buckets for charts
|
||||||
|
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
|
||||||
|
private dnsMinuteBuckets = new Map<number, { queries: number }>();
|
||||||
|
|
||||||
|
// 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 Smartlog instance for SmartMetrics (requires its own instance)
|
||||||
|
this.metricsLogger = new plugins.smartlog.Smartlog({
|
||||||
|
logContext: {
|
||||||
|
environment: 'production',
|
||||||
|
runtime: 'node',
|
||||||
|
zone: 'dcrouter-metrics',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, '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.queryRing.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = 0;
|
||||||
|
this.dnsMetrics.responseTimes = [];
|
||||||
|
this.dnsMetrics.recentQueries = [];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune old time-series buckets every minute (don't wait for lazy query)
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
}, 60000); // Check every minute
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Clear caches and time-series buckets on shutdown
|
||||||
|
this.metricsCache.clear();
|
||||||
|
this.emailMinuteBuckets.clear();
|
||||||
|
this.dnsMinuteBuckets.clear();
|
||||||
|
|
||||||
|
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;
|
||||||
|
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uptime: process.uptime(),
|
||||||
|
startTime: Date.now() - (process.uptime() * 1000),
|
||||||
|
memoryUsage: {
|
||||||
|
heapUsed,
|
||||||
|
heapTotal,
|
||||||
|
external,
|
||||||
|
rss,
|
||||||
|
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||||
|
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||||
|
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||||
|
},
|
||||||
|
cpuUsage: {
|
||||||
|
user: smartMetricsData.cpuPercentage,
|
||||||
|
system: 0,
|
||||||
|
},
|
||||||
|
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 ring buffer (sum last 60 seconds)
|
||||||
|
const queriesPerSecond = this.getQueryRingSum(60) / 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,
|
||||||
|
recentQueries: this.dnsMetrics.recentQueries.slice(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync security metrics from the SecurityLogger singleton (last 24h).
|
||||||
|
* Called before returning security stats so counters reflect real events.
|
||||||
|
*/
|
||||||
|
private syncFromSecurityLogger(): void {
|
||||||
|
try {
|
||||||
|
const securityLogger = SecurityLogger.getInstance();
|
||||||
|
const summary = securityLogger.getEventsSummary(86400000); // last 24h
|
||||||
|
|
||||||
|
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
|
||||||
|
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
|
||||||
|
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
|
||||||
|
this.securityMetrics.authFailures =
|
||||||
|
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
|
||||||
|
this.securityMetrics.blockedIPs =
|
||||||
|
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
|
||||||
|
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
|
||||||
|
} catch {
|
||||||
|
// SecurityLogger may not be initialized yet — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get security metrics
|
||||||
|
public async getSecurityStats() {
|
||||||
|
return this.metricsCache.get('securityStats', () => {
|
||||||
|
// Sync counters from the real SecurityLogger events
|
||||||
|
this.syncFromSecurityLogger();
|
||||||
|
|
||||||
|
// 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++;
|
||||||
|
this.incrementEmailBucket('sent');
|
||||||
|
|
||||||
|
if (recipient) {
|
||||||
|
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||||
|
this.emailMetrics.recipients.set(recipient, count + 1);
|
||||||
|
|
||||||
|
// Cap recipients map to prevent unbounded growth within a day
|
||||||
|
if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) {
|
||||||
|
const sorted = Array.from(this.emailMetrics.recipients.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8));
|
||||||
|
this.emailMetrics.recipients = new Map(sorted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.incrementEmailBucket('received');
|
||||||
|
|
||||||
|
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.incrementEmailBucket('failed');
|
||||||
|
|
||||||
|
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, answered?: boolean): void {
|
||||||
|
this.dnsMetrics.totalQueries++;
|
||||||
|
this.incrementDnsBucket();
|
||||||
|
|
||||||
|
// Store recent query entry
|
||||||
|
this.dnsMetrics.recentQueries.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
domain,
|
||||||
|
type: queryType,
|
||||||
|
answered: answered ?? true,
|
||||||
|
responseTimeMs: responseTimeMs ?? 0,
|
||||||
|
});
|
||||||
|
if (this.dnsMetrics.recentQueries.length > 100) {
|
||||||
|
this.dnsMetrics.recentQueries.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheHit) {
|
||||||
|
this.dnsMetrics.cacheHits++;
|
||||||
|
} else {
|
||||||
|
this.dnsMetrics.cacheMisses++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment per-second query counter in ring buffer
|
||||||
|
this.incrementQueryRing();
|
||||||
|
|
||||||
|
// Track response time if provided
|
||||||
|
if (responseTimeMs) {
|
||||||
|
this.dnsMetrics.responseTimes.push(responseTimeMs);
|
||||||
|
// Keep only last 1000 response times
|
||||||
|
if (this.dnsMetrics.responseTimes.length > 1000) {
|
||||||
|
this.dnsMetrics.responseTimes.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track query types
|
||||||
|
this.dnsMetrics.queryTypes[queryType] = (this.dnsMetrics.queryTypes[queryType] || 0) + 1;
|
||||||
|
|
||||||
|
// Track top domains with size limit
|
||||||
|
const currentCount = this.dnsMetrics.topDomains.get(domain) || 0;
|
||||||
|
this.dnsMetrics.topDomains.set(domain, currentCount + 1);
|
||||||
|
|
||||||
|
// If we've exceeded the limit, remove the least accessed domains
|
||||||
|
if (this.dnsMetrics.topDomains.size > this.MAX_TOP_DOMAINS) {
|
||||||
|
// Convert to array, sort by count, and keep only top domains
|
||||||
|
const sortedDomains = Array.from(this.dnsMetrics.topDomains.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8)); // Keep 80% to avoid frequent cleanup
|
||||||
|
|
||||||
|
// Clear and repopulate with top domains
|
||||||
|
this.dnsMetrics.topDomains.clear();
|
||||||
|
sortedDomains.forEach(([domain, count]) => {
|
||||||
|
this.dnsMetrics.topDomains.set(domain, count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security event tracking methods
|
||||||
|
public trackBlockedIP(ip?: string, reason?: string): void {
|
||||||
|
this.securityMetrics.blockedIPs++;
|
||||||
|
|
||||||
|
this.securityMetrics.incidents.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'ip_blocked',
|
||||||
|
severity: 'medium',
|
||||||
|
details: `IP ${ip || 'unknown'} blocked: ${reason || 'security policy'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 1000 incidents
|
||||||
|
if (this.securityMetrics.incidents.length > 1000) {
|
||||||
|
this.securityMetrics.incidents.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public trackAuthFailure(username?: string, ip?: string): void {
|
||||||
|
this.securityMetrics.authFailures++;
|
||||||
|
|
||||||
|
this.securityMetrics.incidents.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'auth_failure',
|
||||||
|
severity: 'low',
|
||||||
|
details: `Authentication failed for ${username || 'unknown'} from ${ip || 'unknown'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 1000 incidents
|
||||||
|
if (this.securityMetrics.incidents.length > 1000) {
|
||||||
|
this.securityMetrics.incidents.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public trackSpamDetected(sender?: string): void {
|
||||||
|
this.securityMetrics.spamDetected++;
|
||||||
|
|
||||||
|
this.securityMetrics.incidents.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'spam_detected',
|
||||||
|
severity: 'low',
|
||||||
|
details: `Spam detected from ${sender || 'unknown'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 1000 incidents
|
||||||
|
if (this.securityMetrics.incidents.length > 1000) {
|
||||||
|
this.securityMetrics.incidents.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public trackMalwareDetected(source?: string): void {
|
||||||
|
this.securityMetrics.malwareDetected++;
|
||||||
|
|
||||||
|
this.securityMetrics.incidents.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'malware_detected',
|
||||||
|
severity: 'high',
|
||||||
|
details: `Malware detected from ${source || 'unknown'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 1000 incidents
|
||||||
|
if (this.securityMetrics.incidents.length > 1000) {
|
||||||
|
this.securityMetrics.incidents.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public trackPhishingDetected(source?: string): void {
|
||||||
|
this.securityMetrics.phishingDetected++;
|
||||||
|
|
||||||
|
this.securityMetrics.incidents.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'phishing_detected',
|
||||||
|
severity: 'high',
|
||||||
|
details: `Phishing attempt from ${source || 'unknown'}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 1000 incidents
|
||||||
|
if (this.securityMetrics.incidents.length > 1000) {
|
||||||
|
this.securityMetrics.incidents.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get network metrics from SmartProxy
|
||||||
|
public async getNetworkStats() {
|
||||||
|
// Use shorter cache TTL for network stats to ensure real-time updates
|
||||||
|
return this.metricsCache.get('networkStats', () => {
|
||||||
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
|
|
||||||
|
if (!proxyMetrics) {
|
||||||
|
return {
|
||||||
|
connectionsByIP: new Map<string, number>(),
|
||||||
|
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
|
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||||
|
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||||
|
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||||
|
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||||
|
requestsPerSecond: 0,
|
||||||
|
requestsTotal: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metrics using the new API
|
||||||
|
const connectionsByIP = proxyMetrics.connections.byIP();
|
||||||
|
const instantThroughput = proxyMetrics.throughput.instant();
|
||||||
|
|
||||||
|
// Get throughput rate
|
||||||
|
const throughputRate = {
|
||||||
|
bytesInPerSecond: instantThroughput.in,
|
||||||
|
bytesOutPerSecond: instantThroughput.out
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get top IPs
|
||||||
|
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||||
|
|
||||||
|
// Get total data transferred
|
||||||
|
const totalDataTransferred = {
|
||||||
|
bytesIn: proxyMetrics.totals.bytesIn(),
|
||||||
|
bytesOut: proxyMetrics.totals.bytesOut()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get throughput history from Rust engine (up to 300 seconds)
|
||||||
|
const throughputHistory = proxyMetrics.throughput.history(300);
|
||||||
|
|
||||||
|
// Get per-IP throughput
|
||||||
|
const throughputByIP = proxyMetrics.throughput.byIP();
|
||||||
|
|
||||||
|
// Get HTTP request rates
|
||||||
|
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||||
|
const requestsTotal = proxyMetrics.requests.total();
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionsByIP,
|
||||||
|
throughputRate,
|
||||||
|
topIPs,
|
||||||
|
totalDataTransferred,
|
||||||
|
throughputHistory,
|
||||||
|
throughputByIP,
|
||||||
|
requestsPerSecond,
|
||||||
|
requestsTotal,
|
||||||
|
};
|
||||||
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Time-series helpers ---
|
||||||
|
|
||||||
|
private static minuteKey(ts: number = Date.now()): number {
|
||||||
|
return Math.floor(ts / 60000) * 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
|
||||||
|
const key = MetricsManager.minuteKey();
|
||||||
|
let bucket = this.emailMinuteBuckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { sent: 0, received: 0, failed: 0 };
|
||||||
|
this.emailMinuteBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket[field]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private incrementDnsBucket(): void {
|
||||||
|
const key = MetricsManager.minuteKey();
|
||||||
|
let bucket = this.dnsMinuteBuckets.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { queries: 0 };
|
||||||
|
this.dnsMinuteBuckets.set(key, bucket);
|
||||||
|
}
|
||||||
|
bucket.queries++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the per-second query counter in the ring buffer.
|
||||||
|
* Zeros any stale slots between the last write and the current second.
|
||||||
|
*/
|
||||||
|
private incrementQueryRing(): void {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
// First call — zero and anchor
|
||||||
|
ring.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length] = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) {
|
||||||
|
// Entire ring is stale — clear all
|
||||||
|
ring.fill(0);
|
||||||
|
} else if (gap > 0) {
|
||||||
|
// Zero slots from (last+1) to currentSecond (inclusive)
|
||||||
|
for (let s = last + 1; s <= currentSecond; s++) {
|
||||||
|
ring[s % ring.length] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dnsMetrics.queryRingLastSecond = currentSecond;
|
||||||
|
ring[currentSecond % ring.length]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum query counts from the ring buffer for the last N seconds.
|
||||||
|
*/
|
||||||
|
private getQueryRingSum(seconds: number): number {
|
||||||
|
const currentSecond = Math.floor(Date.now() / 1000);
|
||||||
|
const ring = this.dnsMetrics.queryRing;
|
||||||
|
const last = this.dnsMetrics.queryRingLastSecond;
|
||||||
|
|
||||||
|
if (last === 0) return 0;
|
||||||
|
|
||||||
|
// First, zero stale slots so reads are accurate even without writes
|
||||||
|
const gap = currentSecond - last;
|
||||||
|
if (gap >= ring.length) return 0; // all data is stale
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
const limit = Math.min(seconds, ring.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const sec = currentSecond - i;
|
||||||
|
if (sec < last - (ring.length - 1)) break; // slot is from older cycle
|
||||||
|
if (sec > last) continue; // no writes yet for this second
|
||||||
|
sum += ring[sec % ring.length];
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneOldBuckets(): void {
|
||||||
|
const cutoff = Date.now() - 86400000; // 24h
|
||||||
|
for (const key of this.emailMinuteBuckets.keys()) {
|
||||||
|
if (key < cutoff) this.emailMinuteBuckets.delete(key);
|
||||||
|
}
|
||||||
|
for (const key of this.dnsMinuteBuckets.keys()) {
|
||||||
|
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get email time-series data for the last N hours, aggregated per minute.
|
||||||
|
*/
|
||||||
|
public getEmailTimeSeries(hours: number = 24): {
|
||||||
|
sent: Array<{ timestamp: number; value: number }>;
|
||||||
|
received: Array<{ timestamp: number; value: number }>;
|
||||||
|
failed: Array<{ timestamp: number; value: number }>;
|
||||||
|
} {
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
const cutoff = Date.now() - hours * 3600000;
|
||||||
|
const sent: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
const received: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
const failed: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
|
||||||
|
.filter((k) => k >= cutoff)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const bucket = this.emailMinuteBuckets.get(key)!;
|
||||||
|
sent.push({ timestamp: key, value: bucket.sent });
|
||||||
|
received.push({ timestamp: key, value: bucket.received });
|
||||||
|
failed.push({ timestamp: key, value: bucket.failed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, received, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS time-series data for the last N hours, aggregated per minute.
|
||||||
|
*/
|
||||||
|
public getDnsTimeSeries(hours: number = 24): {
|
||||||
|
queries: Array<{ timestamp: number; value: number }>;
|
||||||
|
} {
|
||||||
|
this.pruneOldBuckets();
|
||||||
|
const cutoff = Date.now() - hours * 3600000;
|
||||||
|
const queries: Array<{ timestamp: number; value: number }> = [];
|
||||||
|
|
||||||
|
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
|
||||||
|
.filter((k) => k >= cutoff)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
const bucket = this.dnsMinuteBuckets.get(key)!;
|
||||||
|
queries.push({ timestamp: key, value: bucket.queries });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { queries };
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts/monitoring/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './classes.metricsmanager.js';
|
||||||
@@ -1,7 +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.email.js';
|
|
||||||
@@ -1,956 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { Email } from './mta.classes.email.js';
|
|
||||||
import type { IEmailOptions } from './mta.classes.email.js';
|
|
||||||
import { DeliveryStatus } from './mta.classes.emailsendjob.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
import type { IDnsRecord } from './mta.classes.dnsmanager.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication options for API requests
|
|
||||||
*/
|
|
||||||
interface AuthOptions {
|
|
||||||
/** Required API keys for different endpoints */
|
|
||||||
apiKeys: Map<string, string[]>;
|
|
||||||
/** JWT secret for token-based authentication */
|
|
||||||
jwtSecret?: string;
|
|
||||||
/** Whether to validate IP addresses */
|
|
||||||
validateIp?: boolean;
|
|
||||||
/** Allowed IP addresses */
|
|
||||||
allowedIps?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting options for API endpoints
|
|
||||||
*/
|
|
||||||
interface RateLimitOptions {
|
|
||||||
/** Maximum requests per window */
|
|
||||||
maxRequests: number;
|
|
||||||
/** Time window in milliseconds */
|
|
||||||
windowMs: number;
|
|
||||||
/** Whether to apply per endpoint */
|
|
||||||
perEndpoint?: boolean;
|
|
||||||
/** Whether to apply per IP */
|
|
||||||
perIp?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API route definition
|
|
||||||
*/
|
|
||||||
interface ApiRoute {
|
|
||||||
/** HTTP method */
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
||||||
/** Path pattern */
|
|
||||||
path: string;
|
|
||||||
/** Handler function */
|
|
||||||
handler: (req: any, res: any) => Promise<any>;
|
|
||||||
/** Required authentication level */
|
|
||||||
authLevel: 'none' | 'basic' | 'admin';
|
|
||||||
/** Rate limiting options */
|
|
||||||
rateLimit?: RateLimitOptions;
|
|
||||||
/** Route description */
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email send request
|
|
||||||
*/
|
|
||||||
interface SendEmailRequest {
|
|
||||||
/** Email details */
|
|
||||||
email: IEmailOptions;
|
|
||||||
/** Whether to validate domains before sending */
|
|
||||||
validateDomains?: boolean;
|
|
||||||
/** Priority level (1-5, 1 = highest) */
|
|
||||||
priority?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email status response
|
|
||||||
*/
|
|
||||||
interface EmailStatusResponse {
|
|
||||||
/** Email ID */
|
|
||||||
id: string;
|
|
||||||
/** Current status */
|
|
||||||
status: DeliveryStatus;
|
|
||||||
/** Send time */
|
|
||||||
sentAt?: Date;
|
|
||||||
/** Delivery time */
|
|
||||||
deliveredAt?: Date;
|
|
||||||
/** Error message if failed */
|
|
||||||
error?: string;
|
|
||||||
/** Recipient address */
|
|
||||||
recipient: string;
|
|
||||||
/** Number of delivery attempts */
|
|
||||||
attempts: number;
|
|
||||||
/** Next retry time */
|
|
||||||
nextRetry?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Domain verification response
|
|
||||||
*/
|
|
||||||
interface DomainVerificationResponse {
|
|
||||||
/** Domain name */
|
|
||||||
domain: string;
|
|
||||||
/** Whether the domain is verified */
|
|
||||||
verified: boolean;
|
|
||||||
/** Verification details */
|
|
||||||
details: {
|
|
||||||
/** SPF record status */
|
|
||||||
spf: {
|
|
||||||
valid: boolean;
|
|
||||||
record?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
/** DKIM record status */
|
|
||||||
dkim: {
|
|
||||||
valid: boolean;
|
|
||||||
record?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
/** DMARC record status */
|
|
||||||
dmarc: {
|
|
||||||
valid: boolean;
|
|
||||||
record?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
/** MX record status */
|
|
||||||
mx: {
|
|
||||||
valid: boolean;
|
|
||||||
records?: string[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API error response
|
|
||||||
*/
|
|
||||||
interface ApiError {
|
|
||||||
/** Error code */
|
|
||||||
code: string;
|
|
||||||
/** Error message */
|
|
||||||
message: string;
|
|
||||||
/** Detailed error information */
|
|
||||||
details?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple HTTP Response helper
|
|
||||||
*/
|
|
||||||
class HttpResponse {
|
|
||||||
private headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
public statusCode: number = 200;
|
|
||||||
|
|
||||||
constructor(private res: any) {}
|
|
||||||
|
|
||||||
header(name: string, value: string): HttpResponse {
|
|
||||||
this.headers[name] = value;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
status(code: number): HttpResponse {
|
|
||||||
this.statusCode = code;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
json(data: any): void {
|
|
||||||
this.res.writeHead(this.statusCode, this.headers);
|
|
||||||
this.res.end(JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
end(): void {
|
|
||||||
this.res.writeHead(this.statusCode, this.headers);
|
|
||||||
this.res.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Manager for MTA service
|
|
||||||
*/
|
|
||||||
export class ApiManager {
|
|
||||||
/** TypedRouter for API routing */
|
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
/** MTA service reference */
|
|
||||||
private mtaRef: MtaService;
|
|
||||||
/** HTTP server */
|
|
||||||
private server: any;
|
|
||||||
/** Authentication options */
|
|
||||||
private authOptions: AuthOptions;
|
|
||||||
/** API routes */
|
|
||||||
private routes: ApiRoute[] = [];
|
|
||||||
/** Rate limiters */
|
|
||||||
private rateLimiters: Map<string, {
|
|
||||||
count: number;
|
|
||||||
resetTime: number;
|
|
||||||
clients: Map<string, {
|
|
||||||
count: number;
|
|
||||||
resetTime: number;
|
|
||||||
}>;
|
|
||||||
}> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize API Manager
|
|
||||||
* @param mtaRef MTA service reference
|
|
||||||
*/
|
|
||||||
constructor(mtaRef?: MtaService) {
|
|
||||||
this.mtaRef = mtaRef;
|
|
||||||
|
|
||||||
// Default authentication options
|
|
||||||
this.authOptions = {
|
|
||||||
apiKeys: new Map(),
|
|
||||||
validateIp: false,
|
|
||||||
allowedIps: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register routes
|
|
||||||
this.registerRoutes();
|
|
||||||
|
|
||||||
// Create HTTP server with request handler
|
|
||||||
this.server = plugins.http.createServer(this.handleRequest.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set MTA service reference
|
|
||||||
* @param mtaRef MTA service reference
|
|
||||||
*/
|
|
||||||
public setMtaService(mtaRef: MtaService): void {
|
|
||||||
this.mtaRef = mtaRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure authentication options
|
|
||||||
* @param options Authentication options
|
|
||||||
*/
|
|
||||||
public configureAuth(options: Partial<AuthOptions>): void {
|
|
||||||
this.authOptions = {
|
|
||||||
...this.authOptions,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle HTTP request
|
|
||||||
*/
|
|
||||||
private async handleRequest(req: any, res: any): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
// Create a response helper
|
|
||||||
const response = new HttpResponse(res);
|
|
||||||
|
|
||||||
// Add CORS headers
|
|
||||||
response.header('Access-Control-Allow-Origin', '*');
|
|
||||||
response.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
response.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key');
|
|
||||||
|
|
||||||
// Handle preflight OPTIONS request
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
return response.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse URL to get path and query
|
|
||||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
||||||
const path = url.pathname;
|
|
||||||
|
|
||||||
// Collect request body if POST or PUT
|
|
||||||
let body = '';
|
|
||||||
if (req.method === 'POST' || req.method === 'PUT') {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
req.on('data', (chunk: Buffer) => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('end', () => {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (err: Error) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse body as JSON if Content-Type is application/json
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
|
||||||
if (contentType.includes('application/json')) {
|
|
||||||
try {
|
|
||||||
req.body = JSON.parse(body);
|
|
||||||
} catch (error) {
|
|
||||||
return response.status(400).json({
|
|
||||||
code: 'INVALID_JSON',
|
|
||||||
message: 'Invalid JSON in request body'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
req.body = body;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authentication level to request
|
|
||||||
req.authLevel = 'none';
|
|
||||||
|
|
||||||
// Check API key
|
|
||||||
const apiKey = req.headers['x-api-key'];
|
|
||||||
if (apiKey) {
|
|
||||||
for (const [level, keys] of this.authOptions.apiKeys.entries()) {
|
|
||||||
if (keys.includes(apiKey)) {
|
|
||||||
req.authLevel = level;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check JWT token (if configured)
|
|
||||||
if (this.authOptions.jwtSecret && req.headers.authorization) {
|
|
||||||
try {
|
|
||||||
const token = req.headers.authorization.split(' ')[1];
|
|
||||||
// Note: We would need to add JWT verification
|
|
||||||
// Using a simple placeholder for now
|
|
||||||
const decoded = { level: 'none' }; // Simplified - would use actual JWT library
|
|
||||||
|
|
||||||
if (decoded && decoded.level) {
|
|
||||||
req.authLevel = decoded.level;
|
|
||||||
req.user = decoded;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Invalid token, but don't fail the request yet
|
|
||||||
console.error('Invalid JWT token:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check IP address (if configured)
|
|
||||||
if (this.authOptions.validateIp) {
|
|
||||||
const clientIp = req.socket.remoteAddress;
|
|
||||||
if (!this.authOptions.allowedIps.includes(clientIp)) {
|
|
||||||
return response.status(403).json({
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
message: 'IP address not allowed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching route
|
|
||||||
const route = this.findRoute(req.method, path);
|
|
||||||
|
|
||||||
if (!route) {
|
|
||||||
return response.status(404).json({
|
|
||||||
code: 'NOT_FOUND',
|
|
||||||
message: 'Endpoint not found'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
if (route.authLevel !== 'none' && req.authLevel !== route.authLevel && req.authLevel !== 'admin') {
|
|
||||||
return response.status(403).json({
|
|
||||||
code: 'FORBIDDEN',
|
|
||||||
message: `This endpoint requires ${route.authLevel} access`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check rate limit
|
|
||||||
if (route.rateLimit) {
|
|
||||||
const exceeded = this.checkRateLimit(route, req);
|
|
||||||
if (exceeded) {
|
|
||||||
return response.status(429).json({
|
|
||||||
code: 'RATE_LIMIT_EXCEEDED',
|
|
||||||
message: 'Rate limit exceeded, please try again later'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract path parameters
|
|
||||||
const pathParams = this.extractPathParams(route.path, path);
|
|
||||||
req.params = pathParams;
|
|
||||||
|
|
||||||
// Extract query parameters
|
|
||||||
req.query = {};
|
|
||||||
for (const [key, value] of url.searchParams.entries()) {
|
|
||||||
req.query[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the request
|
|
||||||
await route.handler(req, response);
|
|
||||||
|
|
||||||
// Log request
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
console.log(`[API] ${req.method} ${path} ${response.statusCode} ${duration}ms`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error handling request:`, error);
|
|
||||||
|
|
||||||
// Send appropriate error response
|
|
||||||
const status = error.status || 500;
|
|
||||||
const apiError: ApiError = {
|
|
||||||
code: error.code || 'INTERNAL_ERROR',
|
|
||||||
message: error.message || 'Internal server error'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
apiError.details = error.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.status(status).json(apiError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a route matching the method and path
|
|
||||||
*/
|
|
||||||
private findRoute(method: string, path: string): ApiRoute | null {
|
|
||||||
for (const route of this.routes) {
|
|
||||||
if (route.method === method && this.pathMatches(route.path, path)) {
|
|
||||||
return route;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a path matches a route pattern
|
|
||||||
*/
|
|
||||||
private pathMatches(pattern: string, path: string): boolean {
|
|
||||||
// Convert route pattern to regex
|
|
||||||
const patternParts = pattern.split('/');
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
if (patternParts.length !== pathParts.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
|
||||||
if (patternParts[i].startsWith(':')) {
|
|
||||||
// Parameter - always matches
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patternParts[i] !== pathParts[i]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract path parameters from URL
|
|
||||||
*/
|
|
||||||
private extractPathParams(pattern: string, path: string): Record<string, string> {
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
const patternParts = pattern.split('/');
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
for (let i = 0; i < patternParts.length; i++) {
|
|
||||||
if (patternParts[i].startsWith(':')) {
|
|
||||||
const paramName = patternParts[i].substring(1);
|
|
||||||
params[paramName] = pathParts[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register API routes
|
|
||||||
*/
|
|
||||||
private registerRoutes(): void {
|
|
||||||
// Email routes
|
|
||||||
this.addRoute({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/email/send',
|
|
||||||
handler: this.handleSendEmail.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Send an email'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/email/status/:id',
|
|
||||||
handler: this.handleGetEmailStatus.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Get email delivery status'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Domain routes
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/domain/verify/:domain',
|
|
||||||
handler: this.handleVerifyDomain.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Verify domain DNS records'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/domain/records/:domain',
|
|
||||||
handler: this.handleGetDomainRecords.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Get recommended DNS records for domain'
|
|
||||||
});
|
|
||||||
|
|
||||||
// DKIM routes
|
|
||||||
this.addRoute({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/api/dkim/generate/:domain',
|
|
||||||
handler: this.handleGenerateDkim.bind(this),
|
|
||||||
authLevel: 'admin',
|
|
||||||
description: 'Generate DKIM keys for domain'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/dkim/public/:domain',
|
|
||||||
handler: this.handleGetDkimPublicKey.bind(this),
|
|
||||||
authLevel: 'basic',
|
|
||||||
description: 'Get DKIM public key for domain'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stats route
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api/stats',
|
|
||||||
handler: this.handleGetStats.bind(this),
|
|
||||||
authLevel: 'admin',
|
|
||||||
description: 'Get MTA statistics'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Documentation route
|
|
||||||
this.addRoute({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/api',
|
|
||||||
handler: this.handleGetApiDocs.bind(this),
|
|
||||||
authLevel: 'none',
|
|
||||||
description: 'API documentation'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an API route
|
|
||||||
* @param route Route definition
|
|
||||||
*/
|
|
||||||
private addRoute(route: ApiRoute): void {
|
|
||||||
this.routes.push(route);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check rate limit for a route
|
|
||||||
* @param route Route definition
|
|
||||||
* @param req Express request
|
|
||||||
* @returns Whether rate limit is exceeded
|
|
||||||
*/
|
|
||||||
private checkRateLimit(route: ApiRoute, req: any): boolean {
|
|
||||||
if (!route.rateLimit) return false;
|
|
||||||
|
|
||||||
const { maxRequests, windowMs, perEndpoint, perIp } = route.rateLimit;
|
|
||||||
|
|
||||||
// Determine rate limit key
|
|
||||||
let key = 'global';
|
|
||||||
if (perEndpoint) {
|
|
||||||
key = `${route.method}:${route.path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create limiter
|
|
||||||
if (!this.rateLimiters.has(key)) {
|
|
||||||
this.rateLimiters.set(key, {
|
|
||||||
count: 0,
|
|
||||||
resetTime: Date.now() + windowMs,
|
|
||||||
clients: new Map()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const limiter = this.rateLimiters.get(key);
|
|
||||||
|
|
||||||
// Reset if window has passed
|
|
||||||
if (Date.now() > limiter.resetTime) {
|
|
||||||
limiter.count = 0;
|
|
||||||
limiter.resetTime = Date.now() + windowMs;
|
|
||||||
limiter.clients.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check per-IP limit if enabled
|
|
||||||
if (perIp) {
|
|
||||||
const clientIp = req.socket.remoteAddress;
|
|
||||||
let clientLimiter = limiter.clients.get(clientIp);
|
|
||||||
|
|
||||||
if (!clientLimiter) {
|
|
||||||
clientLimiter = {
|
|
||||||
count: 0,
|
|
||||||
resetTime: Date.now() + windowMs
|
|
||||||
};
|
|
||||||
limiter.clients.set(clientIp, clientLimiter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset client limiter if needed
|
|
||||||
if (Date.now() > clientLimiter.resetTime) {
|
|
||||||
clientLimiter.count = 0;
|
|
||||||
clientLimiter.resetTime = Date.now() + windowMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check client limit
|
|
||||||
if (clientLimiter.count >= maxRequests) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment client count
|
|
||||||
clientLimiter.count++;
|
|
||||||
} else {
|
|
||||||
// Check global limit
|
|
||||||
if (limiter.count >= maxRequests) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment global count
|
|
||||||
limiter.count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an API error
|
|
||||||
* @param code Error code
|
|
||||||
* @param message Error message
|
|
||||||
* @param status HTTP status code
|
|
||||||
* @param details Additional details
|
|
||||||
* @returns API error
|
|
||||||
*/
|
|
||||||
private createError(code: string, message: string, status = 400, details?: any): Error & { code: string; status: number; details?: any } {
|
|
||||||
const error = new Error(message) as Error & { code: string; status: number; details?: any };
|
|
||||||
error.code = code;
|
|
||||||
error.status = status;
|
|
||||||
if (details) {
|
|
||||||
error.details = details;
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that MTA service is available
|
|
||||||
*/
|
|
||||||
private validateMtaService(): void {
|
|
||||||
if (!this.mtaRef) {
|
|
||||||
throw this.createError('SERVICE_UNAVAILABLE', 'MTA service is not available', 503);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle email send request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleSendEmail(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const data = req.body as SendEmailRequest;
|
|
||||||
|
|
||||||
if (!data || !data.email) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing email data');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create Email instance
|
|
||||||
const email = new Email(data.email);
|
|
||||||
|
|
||||||
// Validate domains if requested
|
|
||||||
if (data.validateDomains) {
|
|
||||||
const fromDomain = email.getFromDomain();
|
|
||||||
if (fromDomain) {
|
|
||||||
const verification = await this.mtaRef.dnsManager.verifyEmailAuthRecords(fromDomain);
|
|
||||||
|
|
||||||
// Check if SPF and DKIM are valid
|
|
||||||
if (!verification.spf.valid || !verification.dkim.valid) {
|
|
||||||
throw this.createError('DOMAIN_VERIFICATION_FAILED', 'Domain DNS verification failed', 400, {
|
|
||||||
verification
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email
|
|
||||||
const id = await this.mtaRef.send(email);
|
|
||||||
|
|
||||||
// Return success response
|
|
||||||
res.json({
|
|
||||||
id,
|
|
||||||
message: 'Email queued successfully',
|
|
||||||
status: 'pending'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Handle Email constructor errors
|
|
||||||
if (error.message.includes('Invalid') || error.message.includes('must have')) {
|
|
||||||
throw this.createError('INVALID_EMAIL', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle email status request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetEmailStatus(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const id = req.params.id;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing email ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get email status
|
|
||||||
const status = this.mtaRef.getEmailStatus(id);
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
throw this.createError('NOT_FOUND', `Email with ID ${id} not found`, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create response
|
|
||||||
const response: EmailStatusResponse = {
|
|
||||||
id: status.id,
|
|
||||||
status: status.status,
|
|
||||||
sentAt: status.addedAt,
|
|
||||||
recipient: status.email.to[0],
|
|
||||||
attempts: status.attempts
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add additional fields if available
|
|
||||||
if (status.lastAttempt) {
|
|
||||||
response.sentAt = status.lastAttempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.status === DeliveryStatus.DELIVERED) {
|
|
||||||
response.deliveredAt = status.lastAttempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.error) {
|
|
||||||
response.error = status.error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.nextAttempt) {
|
|
||||||
response.nextRetry = status.nextAttempt;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle domain verification request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleVerifyDomain(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify domain DNS records
|
|
||||||
const records = await this.mtaRef.dnsManager.verifyEmailAuthRecords(domain);
|
|
||||||
|
|
||||||
// Get MX records
|
|
||||||
let mxValid = false;
|
|
||||||
let mxRecords: string[] = [];
|
|
||||||
let mxError: string = undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mxResult = await this.mtaRef.dnsManager.lookupMx(domain);
|
|
||||||
mxValid = mxResult.length > 0;
|
|
||||||
mxRecords = mxResult.map(mx => mx.exchange);
|
|
||||||
} catch (error) {
|
|
||||||
mxError = error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create response
|
|
||||||
const response: DomainVerificationResponse = {
|
|
||||||
domain,
|
|
||||||
verified: records.spf.valid && records.dkim.valid && records.dmarc.valid && mxValid,
|
|
||||||
details: {
|
|
||||||
spf: {
|
|
||||||
valid: records.spf.valid,
|
|
||||||
record: records.spf.value,
|
|
||||||
error: records.spf.error
|
|
||||||
},
|
|
||||||
dkim: {
|
|
||||||
valid: records.dkim.valid,
|
|
||||||
record: records.dkim.value,
|
|
||||||
error: records.dkim.error
|
|
||||||
},
|
|
||||||
dmarc: {
|
|
||||||
valid: records.dmarc.valid,
|
|
||||||
record: records.dmarc.value,
|
|
||||||
error: records.dmarc.error
|
|
||||||
},
|
|
||||||
mx: {
|
|
||||||
valid: mxValid,
|
|
||||||
records: mxRecords,
|
|
||||||
error: mxError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('VERIFICATION_FAILED', `Domain verification failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get domain records request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetDomainRecords(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate recommended DNS records
|
|
||||||
const records = await this.mtaRef.dnsManager.generateAllRecommendedRecords(domain);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
domain,
|
|
||||||
records
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('GENERATION_FAILED', `DNS record generation failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle generate DKIM keys request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGenerateDkim(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate DKIM keys
|
|
||||||
await this.mtaRef.dkimCreator.handleDKIMKeysForDomain(domain);
|
|
||||||
|
|
||||||
// Get DNS record
|
|
||||||
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
domain,
|
|
||||||
dnsRecord,
|
|
||||||
message: 'DKIM keys generated successfully'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('GENERATION_FAILED', `DKIM generation failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get DKIM public key request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetDkimPublicKey(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
const domain = req.params.domain;
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
throw this.createError('INVALID_REQUEST', 'Missing domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get DKIM keys
|
|
||||||
const keys = await this.mtaRef.dkimCreator.readDKIMKeys(domain);
|
|
||||||
|
|
||||||
// Get DNS record
|
|
||||||
const dnsRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
domain,
|
|
||||||
publicKey: keys.publicKey,
|
|
||||||
dnsRecord
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw this.createError('NOT_FOUND', `DKIM keys not found for domain: ${domain}`, 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get stats request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetStats(req: any, res: any): Promise<void> {
|
|
||||||
this.validateMtaService();
|
|
||||||
|
|
||||||
// Get MTA stats
|
|
||||||
const stats = this.mtaRef.getStats();
|
|
||||||
|
|
||||||
res.json(stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle get API docs request
|
|
||||||
* @param req Express request
|
|
||||||
* @param res Express response
|
|
||||||
*/
|
|
||||||
private async handleGetApiDocs(req: any, res: any): Promise<void> {
|
|
||||||
// Generate API documentation
|
|
||||||
const docs = {
|
|
||||||
name: 'MTA API',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'API for interacting with the MTA service',
|
|
||||||
endpoints: this.routes.map(route => ({
|
|
||||||
method: route.method,
|
|
||||||
path: route.path,
|
|
||||||
description: route.description,
|
|
||||||
authLevel: route.authLevel
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(docs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the API server
|
|
||||||
* @param port Port to listen on
|
|
||||||
* @returns Promise that resolves when server is started
|
|
||||||
*/
|
|
||||||
public start(port: number = 3000): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
// Start HTTP server
|
|
||||||
this.server.listen(port, () => {
|
|
||||||
console.log(`API server listening on port ${port}`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start API server:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the API server
|
|
||||||
*/
|
|
||||||
public stop(): void {
|
|
||||||
if (this.server) {
|
|
||||||
this.server.close();
|
|
||||||
console.log('API server stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
|
|
||||||
import { Email } from './mta.classes.email.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
const readFile = plugins.util.promisify(plugins.fs.readFile);
|
|
||||||
const writeFile = plugins.util.promisify(plugins.fs.writeFile);
|
|
||||||
const generateKeyPair = plugins.util.promisify(plugins.crypto.generateKeyPair);
|
|
||||||
|
|
||||||
export interface IKeyPaths {
|
|
||||||
privateKeyPath: string;
|
|
||||||
publicKeyPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DKIMCreator {
|
|
||||||
private keysDir: string;
|
|
||||||
|
|
||||||
constructor(private metaRef: MtaService, keysDir = paths.keysDir) {
|
|
||||||
this.keysDir = keysDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getKeyPathsForDomain(domainArg: string): Promise<IKeyPaths> {
|
|
||||||
return {
|
|
||||||
privateKeyPath: plugins.path.join(this.keysDir, `${domainArg}-private.pem`),
|
|
||||||
publicKeyPath: plugins.path.join(this.keysDir, `${domainArg}-public.pem`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a DKIM key is present and creates one and stores it to disk otherwise
|
|
||||||
public async handleDKIMKeysForDomain(domainArg: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.readDKIMKeys(domainArg);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`No DKIM keys found for ${domainArg}. Generating...`);
|
|
||||||
await this.createAndStoreDKIMKeys(domainArg);
|
|
||||||
const dnsValue = await this.getDNSRecordForDomain(domainArg);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
|
||||||
plugins.smartfile.memory.toFsSync(JSON.stringify(dnsValue, null, 2), plugins.path.join(paths.dnsRecordsDir, `${domainArg}.dkimrecord.json`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleDKIMKeysForEmail(email: Email): Promise<void> {
|
|
||||||
const domain = email.from.split('@')[1];
|
|
||||||
await this.handleDKIMKeysForDomain(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read DKIM keys from disk
|
|
||||||
public async readDKIMKeys(domainArg: string): Promise<{ privateKey: string; publicKey: string }> {
|
|
||||||
const keyPaths = await this.getKeyPathsForDomain(domainArg);
|
|
||||||
const [privateKeyBuffer, publicKeyBuffer] = await Promise.all([
|
|
||||||
readFile(keyPaths.privateKeyPath),
|
|
||||||
readFile(keyPaths.publicKeyPath),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Convert the buffers to strings
|
|
||||||
const privateKey = privateKeyBuffer.toString();
|
|
||||||
const publicKey = publicKeyBuffer.toString();
|
|
||||||
|
|
||||||
return { privateKey, publicKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a DKIM key pair - changed to public for API access
|
|
||||||
public async createDKIMKeys(): Promise<{ privateKey: string; publicKey: string }> {
|
|
||||||
const { privateKey, publicKey } = await generateKeyPair('rsa', {
|
|
||||||
modulusLength: 2048,
|
|
||||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
||||||
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { privateKey, publicKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store a DKIM key pair to disk - changed to public for API access
|
|
||||||
public async storeDKIMKeys(
|
|
||||||
privateKey: string,
|
|
||||||
publicKey: string,
|
|
||||||
privateKeyPath: string,
|
|
||||||
publicKeyPath: string
|
|
||||||
): Promise<void> {
|
|
||||||
await Promise.all([writeFile(privateKeyPath, privateKey), writeFile(publicKeyPath, publicKey)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a DKIM key pair and store it to disk - changed to public for API access
|
|
||||||
public async createAndStoreDKIMKeys(domain: string): Promise<void> {
|
|
||||||
const { privateKey, publicKey } = await this.createDKIMKeys();
|
|
||||||
const keyPaths = await this.getKeyPathsForDomain(domain);
|
|
||||||
await this.storeDKIMKeys(
|
|
||||||
privateKey,
|
|
||||||
publicKey,
|
|
||||||
keyPaths.privateKeyPath,
|
|
||||||
keyPaths.publicKeyPath
|
|
||||||
);
|
|
||||||
console.log(`DKIM keys for ${domain} created and stored.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Changed to public for API access
|
|
||||||
public async getDNSRecordForDomain(domainArg: string): Promise<plugins.tsclass.network.IDnsRecord> {
|
|
||||||
await this.handleDKIMKeysForDomain(domainArg);
|
|
||||||
const keys = await this.readDKIMKeys(domainArg);
|
|
||||||
|
|
||||||
// Remove the PEM header and footer and newlines
|
|
||||||
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
|
||||||
const pemFooter = '-----END PUBLIC KEY-----';
|
|
||||||
const keyContents = keys.publicKey
|
|
||||||
.replace(pemHeader, '')
|
|
||||||
.replace(pemFooter, '')
|
|
||||||
.replace(/\n/g, '');
|
|
||||||
|
|
||||||
// Now generate the DKIM DNS TXT record
|
|
||||||
const dnsRecordValue = `v=DKIM1; h=sha256; k=rsa; p=${keyContents}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `mta._domainkey.${domainArg}`,
|
|
||||||
type: 'TXT',
|
|
||||||
dnsSecEnabled: null,
|
|
||||||
value: dnsRecordValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
class DKIMVerifier {
|
|
||||||
public mtaRef: MtaService;
|
|
||||||
|
|
||||||
constructor(mtaRefArg: MtaService) {
|
|
||||||
this.mtaRef = mtaRefArg;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verify(email: string): Promise<boolean> {
|
|
||||||
console.log('Trying to verify DKIM now...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verification = await plugins.mailauth.authenticate(email, {
|
|
||||||
/* resolver: (...args) => {
|
|
||||||
console.log(args);
|
|
||||||
} */
|
|
||||||
});
|
|
||||||
console.log(verification);
|
|
||||||
if (verification && verification.dkim.results[0].status.result === 'pass') {
|
|
||||||
console.log('DKIM Verification result: pass');
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.error('DKIM Verification failed:', verification?.error || 'Unknown error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('DKIM Verification failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DKIMVerifier };
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for DNS record information
|
|
||||||
*/
|
|
||||||
export interface IDnsRecord {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
ttl?: number;
|
|
||||||
dnsSecEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for DNS lookup options
|
|
||||||
*/
|
|
||||||
export interface IDnsLookupOptions {
|
|
||||||
/** Cache time to live in milliseconds, 0 to disable caching */
|
|
||||||
cacheTtl?: number;
|
|
||||||
/** Timeout for DNS queries in milliseconds */
|
|
||||||
timeout?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for DNS verification result
|
|
||||||
*/
|
|
||||||
export interface IDnsVerificationResult {
|
|
||||||
record: string;
|
|
||||||
found: boolean;
|
|
||||||
valid: boolean;
|
|
||||||
value?: string;
|
|
||||||
expectedValue?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manager for DNS-related operations, including record lookups, verification, and generation
|
|
||||||
*/
|
|
||||||
export class DNSManager {
|
|
||||||
public mtaRef: MtaService;
|
|
||||||
private cache: Map<string, { data: any; expires: number }> = new Map();
|
|
||||||
private defaultOptions: IDnsLookupOptions = {
|
|
||||||
cacheTtl: 300000, // 5 minutes
|
|
||||||
timeout: 5000 // 5 seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(mtaRefArg: MtaService, options?: IDnsLookupOptions) {
|
|
||||||
this.mtaRef = mtaRefArg;
|
|
||||||
|
|
||||||
if (options) {
|
|
||||||
this.defaultOptions = {
|
|
||||||
...this.defaultOptions,
|
|
||||||
...options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the DNS records directory exists
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lookup MX records for a domain
|
|
||||||
* @param domain Domain to look up
|
|
||||||
* @param options Lookup options
|
|
||||||
* @returns Array of MX records sorted by priority
|
|
||||||
*/
|
|
||||||
public async lookupMx(domain: string, options?: IDnsLookupOptions): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
|
||||||
const cacheKey = `mx:${domain}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.getFromCache<plugins.dns.MxRecord[]>(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await this.dnsResolveMx(domain, lookupOptions.timeout);
|
|
||||||
|
|
||||||
// Sort by priority
|
|
||||||
records.sort((a, b) => a.priority - b.priority);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error looking up MX records for ${domain}:`, error);
|
|
||||||
throw new Error(`Failed to lookup MX records for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lookup TXT records for a domain
|
|
||||||
* @param domain Domain to look up
|
|
||||||
* @param options Lookup options
|
|
||||||
* @returns Array of TXT records
|
|
||||||
*/
|
|
||||||
public async lookupTxt(domain: string, options?: IDnsLookupOptions): Promise<string[][]> {
|
|
||||||
const lookupOptions = { ...this.defaultOptions, ...options };
|
|
||||||
const cacheKey = `txt:${domain}`;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = this.getFromCache<string[][]>(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await this.dnsResolveTxt(domain, lookupOptions.timeout);
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.setInCache(cacheKey, records, lookupOptions.cacheTtl);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error looking up TXT records for ${domain}:`, error);
|
|
||||||
throw new Error(`Failed to lookup TXT records for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find specific TXT record by subdomain and prefix
|
|
||||||
* @param domain Base domain
|
|
||||||
* @param subdomain Subdomain prefix (e.g., "dkim._domainkey")
|
|
||||||
* @param prefix Record prefix to match (e.g., "v=DKIM1")
|
|
||||||
* @param options Lookup options
|
|
||||||
* @returns Matching TXT record or null if not found
|
|
||||||
*/
|
|
||||||
public async findTxtRecord(
|
|
||||||
domain: string,
|
|
||||||
subdomain: string = '',
|
|
||||||
prefix: string = '',
|
|
||||||
options?: IDnsLookupOptions
|
|
||||||
): Promise<string | null> {
|
|
||||||
const fullDomain = subdomain ? `${subdomain}.${domain}` : domain;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const records = await this.lookupTxt(fullDomain, options);
|
|
||||||
|
|
||||||
for (const recordArray of records) {
|
|
||||||
// TXT records can be split into chunks, join them
|
|
||||||
const record = recordArray.join('');
|
|
||||||
|
|
||||||
if (!prefix || record.startsWith(prefix)) {
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
// Domain might not exist or no TXT records
|
|
||||||
console.log(`No matching TXT record found for ${fullDomain} with prefix ${prefix}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a domain has a valid SPF record
|
|
||||||
* @param domain Domain to verify
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
|
||||||
public async verifySpfRecord(domain: string): Promise<IDnsVerificationResult> {
|
|
||||||
const result: IDnsVerificationResult = {
|
|
||||||
record: 'SPF',
|
|
||||||
found: false,
|
|
||||||
valid: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const spfRecord = await this.findTxtRecord(domain, '', 'v=spf1');
|
|
||||||
|
|
||||||
if (spfRecord) {
|
|
||||||
result.found = true;
|
|
||||||
result.value = spfRecord;
|
|
||||||
|
|
||||||
// Basic validation - check if it contains all, include, ip4, ip6, or mx mechanisms
|
|
||||||
const isValid = /v=spf1\s+([-~?+]?(all|include:|ip4:|ip6:|mx|a|exists:))/.test(spfRecord);
|
|
||||||
result.valid = isValid;
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
result.error = 'SPF record format is invalid';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = 'No SPF record found';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.error = `Error verifying SPF: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a domain has a valid DKIM record
|
|
||||||
* @param domain Domain to verify
|
|
||||||
* @param selector DKIM selector (usually "mta" in our case)
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
|
||||||
public async verifyDkimRecord(domain: string, selector: string = 'mta'): Promise<IDnsVerificationResult> {
|
|
||||||
const result: IDnsVerificationResult = {
|
|
||||||
record: 'DKIM',
|
|
||||||
found: false,
|
|
||||||
valid: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dkimSelector = `${selector}._domainkey`;
|
|
||||||
const dkimRecord = await this.findTxtRecord(domain, dkimSelector, 'v=DKIM1');
|
|
||||||
|
|
||||||
if (dkimRecord) {
|
|
||||||
result.found = true;
|
|
||||||
result.value = dkimRecord;
|
|
||||||
|
|
||||||
// Basic validation - check for required fields
|
|
||||||
const hasP = dkimRecord.includes('p=');
|
|
||||||
result.valid = dkimRecord.includes('v=DKIM1') && hasP;
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
result.error = 'DKIM record is missing required fields';
|
|
||||||
} else if (dkimRecord.includes('p=') && !dkimRecord.match(/p=[a-zA-Z0-9+/]+/)) {
|
|
||||||
result.valid = false;
|
|
||||||
result.error = 'DKIM record has invalid public key format';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = `No DKIM record found for selector ${selector}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.error = `Error verifying DKIM: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if a domain has a valid DMARC record
|
|
||||||
* @param domain Domain to verify
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
|
||||||
public async verifyDmarcRecord(domain: string): Promise<IDnsVerificationResult> {
|
|
||||||
const result: IDnsVerificationResult = {
|
|
||||||
record: 'DMARC',
|
|
||||||
found: false,
|
|
||||||
valid: false
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dmarcDomain = `_dmarc.${domain}`;
|
|
||||||
const dmarcRecord = await this.findTxtRecord(dmarcDomain, '', 'v=DMARC1');
|
|
||||||
|
|
||||||
if (dmarcRecord) {
|
|
||||||
result.found = true;
|
|
||||||
result.value = dmarcRecord;
|
|
||||||
|
|
||||||
// Basic validation - check for required fields
|
|
||||||
const hasPolicy = dmarcRecord.includes('p=');
|
|
||||||
result.valid = dmarcRecord.includes('v=DMARC1') && hasPolicy;
|
|
||||||
|
|
||||||
if (!result.valid) {
|
|
||||||
result.error = 'DMARC record is missing required fields';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.error = 'No DMARC record found';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
result.error = `Error verifying DMARC: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check all email authentication records (SPF, DKIM, DMARC) for a domain
|
|
||||||
* @param domain Domain to check
|
|
||||||
* @param dkimSelector DKIM selector
|
|
||||||
* @returns Object with verification results for each record type
|
|
||||||
*/
|
|
||||||
public async verifyEmailAuthRecords(domain: string, dkimSelector: string = 'mta'): Promise<{
|
|
||||||
spf: IDnsVerificationResult;
|
|
||||||
dkim: IDnsVerificationResult;
|
|
||||||
dmarc: IDnsVerificationResult;
|
|
||||||
}> {
|
|
||||||
const [spf, dkim, dmarc] = await Promise.all([
|
|
||||||
this.verifySpfRecord(domain),
|
|
||||||
this.verifyDkimRecord(domain, dkimSelector),
|
|
||||||
this.verifyDmarcRecord(domain)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { spf, dkim, dmarc };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a recommended SPF record for a domain
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param options Configuration options for the SPF record
|
|
||||||
* @returns Generated SPF record
|
|
||||||
*/
|
|
||||||
public generateSpfRecord(domain: string, options: {
|
|
||||||
includeMx?: boolean;
|
|
||||||
includeA?: boolean;
|
|
||||||
includeIps?: string[];
|
|
||||||
includeSpf?: string[];
|
|
||||||
policy?: 'none' | 'neutral' | 'softfail' | 'fail' | 'reject';
|
|
||||||
} = {}): IDnsRecord {
|
|
||||||
const {
|
|
||||||
includeMx = true,
|
|
||||||
includeA = true,
|
|
||||||
includeIps = [],
|
|
||||||
includeSpf = [],
|
|
||||||
policy = 'softfail'
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let value = 'v=spf1';
|
|
||||||
|
|
||||||
if (includeMx) {
|
|
||||||
value += ' mx';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeA) {
|
|
||||||
value += ' a';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add IP addresses
|
|
||||||
for (const ip of includeIps) {
|
|
||||||
if (ip.includes(':')) {
|
|
||||||
value += ` ip6:${ip}`;
|
|
||||||
} else {
|
|
||||||
value += ` ip4:${ip}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add includes
|
|
||||||
for (const include of includeSpf) {
|
|
||||||
value += ` include:${include}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add policy
|
|
||||||
const policyMap = {
|
|
||||||
'none': '?all',
|
|
||||||
'neutral': '~all',
|
|
||||||
'softfail': '~all',
|
|
||||||
'fail': '-all',
|
|
||||||
'reject': '-all'
|
|
||||||
};
|
|
||||||
|
|
||||||
value += ` ${policyMap[policy]}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: domain,
|
|
||||||
type: 'TXT',
|
|
||||||
value: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a recommended DMARC record for a domain
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param options Configuration options for the DMARC record
|
|
||||||
* @returns Generated DMARC record
|
|
||||||
*/
|
|
||||||
public generateDmarcRecord(domain: string, options: {
|
|
||||||
policy?: 'none' | 'quarantine' | 'reject';
|
|
||||||
subdomainPolicy?: 'none' | 'quarantine' | 'reject';
|
|
||||||
pct?: number;
|
|
||||||
rua?: string;
|
|
||||||
ruf?: string;
|
|
||||||
daysInterval?: number;
|
|
||||||
} = {}): IDnsRecord {
|
|
||||||
const {
|
|
||||||
policy = 'none',
|
|
||||||
subdomainPolicy,
|
|
||||||
pct = 100,
|
|
||||||
rua,
|
|
||||||
ruf,
|
|
||||||
daysInterval = 1
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let value = 'v=DMARC1; p=' + policy;
|
|
||||||
|
|
||||||
if (subdomainPolicy) {
|
|
||||||
value += `; sp=${subdomainPolicy}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pct !== 100) {
|
|
||||||
value += `; pct=${pct}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rua) {
|
|
||||||
value += `; rua=mailto:${rua}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ruf) {
|
|
||||||
value += `; ruf=mailto:${ruf}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (daysInterval !== 1) {
|
|
||||||
value += `; ri=${daysInterval * 86400}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add reporting format and ADKIM/ASPF alignment
|
|
||||||
value += '; fo=1; adkim=r; aspf=r';
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: `_dmarc.${domain}`,
|
|
||||||
type: 'TXT',
|
|
||||||
value: value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save DNS record recommendations to a file
|
|
||||||
* @param domain Domain name
|
|
||||||
* @param records DNS records to save
|
|
||||||
*/
|
|
||||||
public async saveDnsRecommendations(domain: string, records: IDnsRecord[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const filePath = plugins.path.join(paths.dnsRecordsDir, `${domain}.recommendations.json`);
|
|
||||||
plugins.smartfile.memory.toFsSync(JSON.stringify(records, null, 2), filePath);
|
|
||||||
console.log(`DNS recommendations for ${domain} saved to ${filePath}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error saving DNS recommendations for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache key value
|
|
||||||
* @param key Cache key
|
|
||||||
* @returns Cached value or undefined if not found or expired
|
|
||||||
*/
|
|
||||||
private getFromCache<T>(key: string): T | undefined {
|
|
||||||
const cached = this.cache.get(key);
|
|
||||||
|
|
||||||
if (cached && cached.expires > Date.now()) {
|
|
||||||
return cached.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove expired entry
|
|
||||||
if (cached) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set cache key value
|
|
||||||
* @param key Cache key
|
|
||||||
* @param data Data to cache
|
|
||||||
* @param ttl TTL in milliseconds
|
|
||||||
*/
|
|
||||||
private setInCache<T>(key: string, data: T, ttl: number = this.defaultOptions.cacheTtl): void {
|
|
||||||
if (ttl <= 0) return; // Don't cache if TTL is disabled
|
|
||||||
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
expires: Date.now() + ttl
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the DNS cache
|
|
||||||
* @param key Optional specific key to clear, or all cache if not provided
|
|
||||||
*/
|
|
||||||
public clearCache(key?: string): void {
|
|
||||||
if (key) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
} else {
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based wrapper for dns.resolveMx
|
|
||||||
* @param domain Domain to resolve
|
|
||||||
* @param timeout Timeout in milliseconds
|
|
||||||
* @returns Promise resolving to MX records
|
|
||||||
*/
|
|
||||||
private dnsResolveMx(domain: string, timeout: number = 5000): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(`DNS MX lookup timeout for ${domain}`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(addresses);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based wrapper for dns.resolveTxt
|
|
||||||
* @param domain Domain to resolve
|
|
||||||
* @param timeout Timeout in milliseconds
|
|
||||||
* @returns Promise resolving to TXT records
|
|
||||||
*/
|
|
||||||
private dnsResolveTxt(domain: string, timeout: number = 5000): Promise<string[][]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
reject(new Error(`DNS TXT lookup timeout for ${domain}`));
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
plugins.dns.resolveTxt(domain, (err, records) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(records);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate all recommended DNS records for proper email authentication
|
|
||||||
* @param domain Domain to generate records for
|
|
||||||
* @returns Array of recommended DNS records
|
|
||||||
*/
|
|
||||||
public async generateAllRecommendedRecords(domain: string): Promise<IDnsRecord[]> {
|
|
||||||
const records: IDnsRecord[] = [];
|
|
||||||
|
|
||||||
// Get DKIM record (already created by DKIMCreator)
|
|
||||||
try {
|
|
||||||
// Now using the public method
|
|
||||||
const dkimRecord = await this.mtaRef.dkimCreator.getDNSRecordForDomain(domain);
|
|
||||||
records.push(dkimRecord);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error getting DKIM record for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate SPF record
|
|
||||||
const spfRecord = this.generateSpfRecord(domain, {
|
|
||||||
includeMx: true,
|
|
||||||
includeA: true,
|
|
||||||
policy: 'softfail'
|
|
||||||
});
|
|
||||||
records.push(spfRecord);
|
|
||||||
|
|
||||||
// Generate DMARC record
|
|
||||||
const dmarcRecord = this.generateDmarcRecord(domain, {
|
|
||||||
policy: 'none', // Start with monitoring mode
|
|
||||||
rua: `dmarc@${domain}` // Replace with appropriate report address
|
|
||||||
});
|
|
||||||
records.push(dmarcRecord);
|
|
||||||
|
|
||||||
// Save recommendations
|
|
||||||
await this.saveDnsRecommendations(domain, records);
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
export interface IAttachment {
|
|
||||||
filename: string;
|
|
||||||
content: Buffer;
|
|
||||||
contentType: string;
|
|
||||||
contentId?: string; // Optional content ID for inline attachments
|
|
||||||
encoding?: string; // Optional encoding specification
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IEmailOptions {
|
|
||||||
from: string;
|
|
||||||
to: string | string[]; // Support multiple recipients
|
|
||||||
cc?: string | string[]; // Optional CC recipients
|
|
||||||
bcc?: string | string[]; // Optional BCC recipients
|
|
||||||
subject: string;
|
|
||||||
text: string;
|
|
||||||
html?: string; // Optional HTML version
|
|
||||||
attachments?: IAttachment[];
|
|
||||||
headers?: Record<string, string>; // Optional additional headers
|
|
||||||
mightBeSpam?: boolean;
|
|
||||||
priority?: 'high' | 'normal' | 'low'; // Optional email priority
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Email {
|
|
||||||
from: string;
|
|
||||||
to: string[];
|
|
||||||
cc: string[];
|
|
||||||
bcc: string[];
|
|
||||||
subject: string;
|
|
||||||
text: string;
|
|
||||||
html?: string;
|
|
||||||
attachments: IAttachment[];
|
|
||||||
headers: Record<string, string>;
|
|
||||||
mightBeSpam: boolean;
|
|
||||||
priority: 'high' | 'normal' | 'low';
|
|
||||||
|
|
||||||
constructor(options: IEmailOptions) {
|
|
||||||
// Validate and set the from address
|
|
||||||
if (!this.isValidEmail(options.from)) {
|
|
||||||
throw new Error(`Invalid sender email address: ${options.from}`);
|
|
||||||
}
|
|
||||||
this.from = options.from;
|
|
||||||
|
|
||||||
// Handle to addresses (single or multiple)
|
|
||||||
this.to = this.parseRecipients(options.to);
|
|
||||||
|
|
||||||
// Handle optional cc and bcc
|
|
||||||
this.cc = options.cc ? this.parseRecipients(options.cc) : [];
|
|
||||||
this.bcc = options.bcc ? this.parseRecipients(options.bcc) : [];
|
|
||||||
|
|
||||||
// Validate that we have at least one recipient
|
|
||||||
if (this.to.length === 0 && this.cc.length === 0 && this.bcc.length === 0) {
|
|
||||||
throw new Error('Email must have at least one recipient');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set subject with sanitization
|
|
||||||
this.subject = this.sanitizeString(options.subject || '');
|
|
||||||
|
|
||||||
// Set text content with sanitization
|
|
||||||
this.text = this.sanitizeString(options.text || '');
|
|
||||||
|
|
||||||
// Set optional HTML content
|
|
||||||
this.html = options.html ? this.sanitizeString(options.html) : undefined;
|
|
||||||
|
|
||||||
// Set attachments
|
|
||||||
this.attachments = Array.isArray(options.attachments) ? options.attachments : [];
|
|
||||||
|
|
||||||
// Set additional headers
|
|
||||||
this.headers = options.headers || {};
|
|
||||||
|
|
||||||
// Set spam flag
|
|
||||||
this.mightBeSpam = options.mightBeSpam || false;
|
|
||||||
|
|
||||||
// Set priority
|
|
||||||
this.priority = options.priority || 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an email address using a regex pattern
|
|
||||||
* @param email The email address to validate
|
|
||||||
* @returns boolean indicating if the email is valid
|
|
||||||
*/
|
|
||||||
private isValidEmail(email: string): boolean {
|
|
||||||
if (!email || typeof email !== 'string') return false;
|
|
||||||
|
|
||||||
// Basic but effective email regex
|
|
||||||
const emailRegex = /^[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,}$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses and validates recipient email addresses
|
|
||||||
* @param recipients A string or array of recipient emails
|
|
||||||
* @returns Array of validated email addresses
|
|
||||||
*/
|
|
||||||
private parseRecipients(recipients: string | string[]): string[] {
|
|
||||||
const result: string[] = [];
|
|
||||||
|
|
||||||
if (typeof recipients === 'string') {
|
|
||||||
// Handle single recipient
|
|
||||||
if (this.isValidEmail(recipients)) {
|
|
||||||
result.push(recipients);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid recipient email address: ${recipients}`);
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(recipients)) {
|
|
||||||
// Handle multiple recipients
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
if (this.isValidEmail(recipient)) {
|
|
||||||
result.push(recipient);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Invalid recipient email address: ${recipient}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Basic sanitization for strings to prevent header injection
|
|
||||||
* @param input The string to sanitize
|
|
||||||
* @returns Sanitized string
|
|
||||||
*/
|
|
||||||
private sanitizeString(input: string): string {
|
|
||||||
if (!input) return '';
|
|
||||||
|
|
||||||
// Remove CR and LF characters to prevent header injection
|
|
||||||
return input.replace(/\r|\n/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the domain part of the from email address
|
|
||||||
* @returns The domain part of the from email or null if invalid
|
|
||||||
*/
|
|
||||||
public getFromDomain(): string | null {
|
|
||||||
try {
|
|
||||||
const parts = this.from.split('@');
|
|
||||||
if (parts.length !== 2 || !parts[1]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return parts[1];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting domain from email:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all recipients (to, cc, bcc) as a unique array
|
|
||||||
* @returns Array of all unique recipient email addresses
|
|
||||||
*/
|
|
||||||
public getAllRecipients(): string[] {
|
|
||||||
// Combine all recipients and remove duplicates
|
|
||||||
return [...new Set([...this.to, ...this.cc, ...this.bcc])];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets primary recipient (first in the to field)
|
|
||||||
* @returns The primary recipient email or null if none exists
|
|
||||||
*/
|
|
||||||
public getPrimaryRecipient(): string | null {
|
|
||||||
return this.to.length > 0 ? this.to[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the email has attachments
|
|
||||||
* @returns Boolean indicating if the email has attachments
|
|
||||||
*/
|
|
||||||
public hasAttachments(): boolean {
|
|
||||||
return this.attachments.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the total size of all attachments in bytes
|
|
||||||
* @returns Total size of all attachments in bytes
|
|
||||||
*/
|
|
||||||
public getAttachmentsSize(): number {
|
|
||||||
return this.attachments.reduce((total, attachment) => {
|
|
||||||
return total + (attachment.content?.length || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an RFC822 compliant email string
|
|
||||||
* @returns The email formatted as an RFC822 compliant string
|
|
||||||
*/
|
|
||||||
public toRFC822String(): string {
|
|
||||||
// This is a simplified version - a complete implementation would be more complex
|
|
||||||
let result = '';
|
|
||||||
|
|
||||||
// Add headers
|
|
||||||
result += `From: ${this.from}\r\n`;
|
|
||||||
result += `To: ${this.to.join(', ')}\r\n`;
|
|
||||||
|
|
||||||
if (this.cc.length > 0) {
|
|
||||||
result += `Cc: ${this.cc.join(', ')}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
result += `Subject: ${this.subject}\r\n`;
|
|
||||||
result += `Date: ${new Date().toUTCString()}\r\n`;
|
|
||||||
|
|
||||||
// Add custom headers
|
|
||||||
for (const [key, value] of Object.entries(this.headers)) {
|
|
||||||
result += `${key}: ${value}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add priority if not normal
|
|
||||||
if (this.priority !== 'normal') {
|
|
||||||
const priorityValue = this.priority === 'high' ? '1' : '5';
|
|
||||||
result += `X-Priority: ${priorityValue}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add content type and body
|
|
||||||
result += `Content-Type: text/plain; charset=utf-8\r\n`;
|
|
||||||
result += `\r\n${this.text}\r\n`;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,623 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { Email } from './mta.classes.email.js';
|
|
||||||
import { EmailSignJob } from './mta.classes.emailsignjob.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
// Configuration options for email sending
|
|
||||||
export interface IEmailSendOptions {
|
|
||||||
maxRetries?: number;
|
|
||||||
retryDelay?: number; // in milliseconds
|
|
||||||
connectionTimeout?: number; // in milliseconds
|
|
||||||
tlsOptions?: plugins.tls.ConnectionOptions;
|
|
||||||
debugMode?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email delivery status
|
|
||||||
export enum DeliveryStatus {
|
|
||||||
PENDING = 'pending',
|
|
||||||
SENDING = 'sending',
|
|
||||||
DELIVERED = 'delivered',
|
|
||||||
FAILED = 'failed',
|
|
||||||
DEFERRED = 'deferred' // Temporary failure, will retry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detailed information about delivery attempts
|
|
||||||
export interface DeliveryInfo {
|
|
||||||
status: DeliveryStatus;
|
|
||||||
attempts: number;
|
|
||||||
error?: Error;
|
|
||||||
lastAttempt?: Date;
|
|
||||||
nextAttempt?: Date;
|
|
||||||
mxServer?: string;
|
|
||||||
deliveryTime?: Date;
|
|
||||||
logs: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailSendJob {
|
|
||||||
mtaRef: MtaService;
|
|
||||||
private email: Email;
|
|
||||||
private socket: plugins.net.Socket | plugins.tls.TLSSocket = null;
|
|
||||||
private mxServers: string[] = [];
|
|
||||||
private currentMxIndex = 0;
|
|
||||||
private options: IEmailSendOptions;
|
|
||||||
public deliveryInfo: DeliveryInfo;
|
|
||||||
|
|
||||||
constructor(mtaRef: MtaService, emailArg: Email, options: IEmailSendOptions = {}) {
|
|
||||||
this.email = emailArg;
|
|
||||||
this.mtaRef = mtaRef;
|
|
||||||
|
|
||||||
// Set default options
|
|
||||||
this.options = {
|
|
||||||
maxRetries: options.maxRetries || 3,
|
|
||||||
retryDelay: options.retryDelay || 300000, // 5 minutes
|
|
||||||
connectionTimeout: options.connectionTimeout || 30000, // 30 seconds
|
|
||||||
tlsOptions: options.tlsOptions || { rejectUnauthorized: true },
|
|
||||||
debugMode: options.debugMode || false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize delivery info
|
|
||||||
this.deliveryInfo = {
|
|
||||||
status: DeliveryStatus.PENDING,
|
|
||||||
attempts: 0,
|
|
||||||
logs: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send the email with retry logic
|
|
||||||
*/
|
|
||||||
async send(): Promise<DeliveryStatus> {
|
|
||||||
try {
|
|
||||||
// Check if the email is valid before attempting to send
|
|
||||||
this.validateEmail();
|
|
||||||
|
|
||||||
// Resolve MX records for the recipient domain
|
|
||||||
await this.resolveMxRecords();
|
|
||||||
|
|
||||||
// Try to send the email
|
|
||||||
return await this.attemptDelivery();
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Critical error in send process: ${error.message}`);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for potential future retry or analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the email before sending
|
|
||||||
*/
|
|
||||||
private validateEmail(): void {
|
|
||||||
if (!this.email.to || this.email.to.length === 0) {
|
|
||||||
throw new Error('No recipients specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.email.from) {
|
|
||||||
throw new Error('No sender specified');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
if (!fromDomain) {
|
|
||||||
throw new Error('Invalid sender domain');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve MX records for the recipient domain
|
|
||||||
*/
|
|
||||||
private async resolveMxRecords(): Promise<void> {
|
|
||||||
const domain = this.email.getPrimaryRecipient()?.split('@')[1];
|
|
||||||
if (!domain) {
|
|
||||||
throw new Error('Invalid recipient domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log(`Resolving MX records for domain: ${domain}`);
|
|
||||||
try {
|
|
||||||
const addresses = await this.resolveMx(domain);
|
|
||||||
|
|
||||||
// Sort by priority (lowest number = highest priority)
|
|
||||||
addresses.sort((a, b) => a.priority - b.priority);
|
|
||||||
|
|
||||||
this.mxServers = addresses.map(mx => mx.exchange);
|
|
||||||
this.log(`Found ${this.mxServers.length} MX servers: ${this.mxServers.join(', ')}`);
|
|
||||||
|
|
||||||
if (this.mxServers.length === 0) {
|
|
||||||
throw new Error(`No MX records found for domain: ${domain}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Failed to resolve MX records: ${error.message}`);
|
|
||||||
throw new Error(`MX lookup failed for ${domain}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to deliver the email with retries
|
|
||||||
*/
|
|
||||||
private async attemptDelivery(): Promise<DeliveryStatus> {
|
|
||||||
while (this.deliveryInfo.attempts < this.options.maxRetries) {
|
|
||||||
this.deliveryInfo.attempts++;
|
|
||||||
this.deliveryInfo.lastAttempt = new Date();
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.SENDING;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log(`Delivery attempt ${this.deliveryInfo.attempts} of ${this.options.maxRetries}`);
|
|
||||||
|
|
||||||
// Try each MX server in order of priority
|
|
||||||
while (this.currentMxIndex < this.mxServers.length) {
|
|
||||||
const currentMx = this.mxServers[this.currentMxIndex];
|
|
||||||
this.deliveryInfo.mxServer = currentMx;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log(`Attempting delivery to MX server: ${currentMx}`);
|
|
||||||
await this.connectAndSend(currentMx);
|
|
||||||
|
|
||||||
// If we get here, email was sent successfully
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.DELIVERED;
|
|
||||||
this.deliveryInfo.deliveryTime = new Date();
|
|
||||||
this.log(`Email delivered successfully to ${currentMx}`);
|
|
||||||
|
|
||||||
// Save successful email record
|
|
||||||
await this.saveSuccess();
|
|
||||||
return DeliveryStatus.DELIVERED;
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error with MX ${currentMx}: ${error.message}`);
|
|
||||||
|
|
||||||
// Clean up socket if it exists
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try the next MX server
|
|
||||||
this.currentMxIndex++;
|
|
||||||
|
|
||||||
// If this is a permanent failure, don't try other MX servers
|
|
||||||
if (this.isPermanentFailure(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we've tried all MX servers without success, throw an error
|
|
||||||
throw new Error('All MX servers failed');
|
|
||||||
} catch (error) {
|
|
||||||
// Check if this is a permanent failure
|
|
||||||
if (this.isPermanentFailure(error)) {
|
|
||||||
this.log(`Permanent failure: ${error.message}`);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a temporary failure, we can retry
|
|
||||||
this.log(`Temporary failure: ${error.message}`);
|
|
||||||
|
|
||||||
// If this is the last attempt, mark as failed
|
|
||||||
if (this.deliveryInfo.attempts >= this.options.maxRetries) {
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
this.deliveryInfo.error = error;
|
|
||||||
|
|
||||||
// Save failed email for analysis
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule the next retry
|
|
||||||
const nextRetryTime = new Date(Date.now() + this.options.retryDelay);
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.DEFERRED;
|
|
||||||
this.deliveryInfo.nextAttempt = nextRetryTime;
|
|
||||||
this.log(`Will retry at ${nextRetryTime.toISOString()}`);
|
|
||||||
|
|
||||||
// Wait before retrying
|
|
||||||
await this.delay(this.options.retryDelay);
|
|
||||||
|
|
||||||
// Reset MX server index for the next attempt
|
|
||||||
this.currentMxIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, all retries failed
|
|
||||||
this.deliveryInfo.status = DeliveryStatus.FAILED;
|
|
||||||
await this.saveFailed();
|
|
||||||
return DeliveryStatus.FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to a specific MX server and send the email
|
|
||||||
*/
|
|
||||||
private async connectAndSend(mxServer: string): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let commandTimeout: NodeJS.Timeout;
|
|
||||||
|
|
||||||
// Function to clear timeouts and remove listeners
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.removeAllListeners();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to set a timeout for each command
|
|
||||||
const setCommandTimeout = () => {
|
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
commandTimeout = setTimeout(() => {
|
|
||||||
this.log('Connection timed out');
|
|
||||||
cleanup();
|
|
||||||
if (this.socket) {
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
}
|
|
||||||
reject(new Error('Connection timed out'));
|
|
||||||
}, this.options.connectionTimeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect to the MX server
|
|
||||||
this.log(`Connecting to ${mxServer}:25`);
|
|
||||||
setCommandTimeout();
|
|
||||||
|
|
||||||
this.socket = plugins.net.connect(25, mxServer);
|
|
||||||
|
|
||||||
this.socket.on('error', (err) => {
|
|
||||||
this.log(`Socket error: ${err.message}`);
|
|
||||||
cleanup();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up the command sequence
|
|
||||||
this.socket.once('data', async (data) => {
|
|
||||||
try {
|
|
||||||
const greeting = data.toString();
|
|
||||||
this.log(`Server greeting: ${greeting.trim()}`);
|
|
||||||
|
|
||||||
if (!greeting.startsWith('220')) {
|
|
||||||
throw new Error(`Unexpected server greeting: ${greeting}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// EHLO command
|
|
||||||
const fromDomain = this.email.getFromDomain();
|
|
||||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
|
||||||
|
|
||||||
// Try STARTTLS if available
|
|
||||||
try {
|
|
||||||
await this.sendCommand('STARTTLS\r\n', '220');
|
|
||||||
this.upgradeToTLS(mxServer, fromDomain);
|
|
||||||
// The TLS handshake and subsequent commands will continue in the upgradeToTLS method
|
|
||||||
// resolve will be called from there if successful
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`STARTTLS failed or not supported: ${error.message}`);
|
|
||||||
this.log('Continuing with unencrypted connection');
|
|
||||||
|
|
||||||
// Continue with unencrypted connection
|
|
||||||
await this.sendEmailCommands();
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
cleanup();
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrade the connection to TLS
|
|
||||||
*/
|
|
||||||
private upgradeToTLS(mxServer: string, fromDomain: string): void {
|
|
||||||
this.log('Starting TLS handshake');
|
|
||||||
|
|
||||||
const tlsOptions = {
|
|
||||||
...this.options.tlsOptions,
|
|
||||||
socket: this.socket,
|
|
||||||
servername: mxServer
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create TLS socket
|
|
||||||
this.socket = plugins.tls.connect(tlsOptions);
|
|
||||||
|
|
||||||
// Handle TLS connection
|
|
||||||
this.socket.once('secureConnect', async () => {
|
|
||||||
try {
|
|
||||||
this.log('TLS connection established');
|
|
||||||
|
|
||||||
// Send EHLO again over TLS
|
|
||||||
await this.sendCommand(`EHLO ${fromDomain}\r\n`, '250');
|
|
||||||
|
|
||||||
// Send the email
|
|
||||||
await this.sendEmailCommands();
|
|
||||||
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`Error in TLS session: ${error.message}`);
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('error', (err) => {
|
|
||||||
this.log(`TLS error: ${err.message}`);
|
|
||||||
this.socket.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send SMTP commands to deliver the email
|
|
||||||
*/
|
|
||||||
private async sendEmailCommands(): Promise<void> {
|
|
||||||
// MAIL FROM command
|
|
||||||
await this.sendCommand(`MAIL FROM:<${this.email.from}>\r\n`, '250');
|
|
||||||
|
|
||||||
// RCPT TO command for each recipient
|
|
||||||
for (const recipient of this.email.getAllRecipients()) {
|
|
||||||
await this.sendCommand(`RCPT TO:<${recipient}>\r\n`, '250');
|
|
||||||
}
|
|
||||||
|
|
||||||
// DATA command
|
|
||||||
await this.sendCommand('DATA\r\n', '354');
|
|
||||||
|
|
||||||
// Create the email message with DKIM signature
|
|
||||||
const message = await this.createEmailMessage();
|
|
||||||
|
|
||||||
// Send the message content
|
|
||||||
await this.sendCommand(message);
|
|
||||||
await this.sendCommand('\r\n.\r\n', '250');
|
|
||||||
|
|
||||||
// QUIT command
|
|
||||||
await this.sendCommand('QUIT\r\n', '221');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the full email message with headers and DKIM signature
|
|
||||||
*/
|
|
||||||
private async createEmailMessage(): Promise<string> {
|
|
||||||
this.log('Preparing email message');
|
|
||||||
|
|
||||||
const messageId = `<${plugins.uuid.v4()}@${this.email.getFromDomain()}>`;
|
|
||||||
const boundary = '----=_NextPart_' + plugins.uuid.v4();
|
|
||||||
|
|
||||||
// Prepare headers
|
|
||||||
const headers = {
|
|
||||||
'Message-ID': messageId,
|
|
||||||
'From': this.email.from,
|
|
||||||
'To': this.email.to.join(', '),
|
|
||||||
'Subject': this.email.subject,
|
|
||||||
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
|
|
||||||
'Date': new Date().toUTCString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add CC header if present
|
|
||||||
if (this.email.cc && this.email.cc.length > 0) {
|
|
||||||
headers['Cc'] = this.email.cc.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom headers
|
|
||||||
for (const [key, value] of Object.entries(this.email.headers || {})) {
|
|
||||||
headers[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add priority header if not normal
|
|
||||||
if (this.email.priority && this.email.priority !== 'normal') {
|
|
||||||
const priorityValue = this.email.priority === 'high' ? '1' : '5';
|
|
||||||
headers['X-Priority'] = priorityValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create body
|
|
||||||
let body = '';
|
|
||||||
|
|
||||||
// Text part
|
|
||||||
body += `--${boundary}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${this.email.text}\r\n`;
|
|
||||||
|
|
||||||
// HTML part if present
|
|
||||||
if (this.email.html) {
|
|
||||||
body += `--${boundary}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${this.email.html}\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
for (const attachment of this.email.attachments) {
|
|
||||||
body += `--${boundary}\r\nContent-Type: ${attachment.contentType}; name="${attachment.filename}"\r\n`;
|
|
||||||
body += 'Content-Transfer-Encoding: base64\r\n';
|
|
||||||
body += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n`;
|
|
||||||
|
|
||||||
// Add Content-ID for inline attachments if present
|
|
||||||
if (attachment.contentId) {
|
|
||||||
body += `Content-ID: <${attachment.contentId}>\r\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
body += '\r\n';
|
|
||||||
body += attachment.content.toString('base64') + '\r\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
// End of message
|
|
||||||
body += `--${boundary}--\r\n`;
|
|
||||||
|
|
||||||
// Create DKIM signature
|
|
||||||
const dkimSigner = new EmailSignJob(this.mtaRef, {
|
|
||||||
domain: this.email.getFromDomain(),
|
|
||||||
selector: 'mta',
|
|
||||||
headers: headers,
|
|
||||||
body: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build the message with headers
|
|
||||||
let headerString = '';
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
headerString += `${key}: ${value}\r\n`;
|
|
||||||
}
|
|
||||||
let message = headerString + '\r\n' + body;
|
|
||||||
|
|
||||||
// Add DKIM signature header
|
|
||||||
let signatureHeader = await dkimSigner.getSignatureHeader(message);
|
|
||||||
message = `${signatureHeader}${message}`;
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a command to the SMTP server and wait for the expected response
|
|
||||||
*/
|
|
||||||
private sendCommand(command: string, expectedResponseCode?: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!this.socket) {
|
|
||||||
return reject(new Error('Socket not connected'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug log for commands (except DATA which can be large)
|
|
||||||
if (this.options.debugMode && !command.startsWith('--')) {
|
|
||||||
const logCommand = command.length > 100
|
|
||||||
? command.substring(0, 97) + '...'
|
|
||||||
: command;
|
|
||||||
this.log(`Sending: ${logCommand.replace(/\r\n/g, '<CRLF>')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socket.write(command, (error) => {
|
|
||||||
if (error) {
|
|
||||||
this.log(`Write error: ${error.message}`);
|
|
||||||
return reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no response is expected, resolve immediately
|
|
||||||
if (!expectedResponseCode) {
|
|
||||||
return resolve('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a timeout for the response
|
|
||||||
const responseTimeout = setTimeout(() => {
|
|
||||||
this.log('Response timeout');
|
|
||||||
reject(new Error('Response timeout'));
|
|
||||||
}, this.options.connectionTimeout);
|
|
||||||
|
|
||||||
// Wait for the response
|
|
||||||
this.socket.once('data', (data) => {
|
|
||||||
clearTimeout(responseTimeout);
|
|
||||||
const response = data.toString();
|
|
||||||
|
|
||||||
if (this.options.debugMode) {
|
|
||||||
this.log(`Received: ${response.trim()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.startsWith(expectedResponseCode)) {
|
|
||||||
resolve(response);
|
|
||||||
} else {
|
|
||||||
const error = new Error(`Unexpected server response: ${response.trim()}`);
|
|
||||||
this.log(error.message);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if an error represents a permanent failure
|
|
||||||
*/
|
|
||||||
private isPermanentFailure(error: Error): boolean {
|
|
||||||
if (!error || !error.message) return false;
|
|
||||||
|
|
||||||
const message = error.message.toLowerCase();
|
|
||||||
|
|
||||||
// Check for permanent SMTP error codes (5xx)
|
|
||||||
if (message.match(/^5\d\d/)) return true;
|
|
||||||
|
|
||||||
// Check for specific permanent failure messages
|
|
||||||
const permanentFailurePatterns = [
|
|
||||||
'no such user',
|
|
||||||
'user unknown',
|
|
||||||
'domain not found',
|
|
||||||
'invalid domain',
|
|
||||||
'rejected',
|
|
||||||
'denied',
|
|
||||||
'prohibited',
|
|
||||||
'authentication required',
|
|
||||||
'authentication failed',
|
|
||||||
'unauthorized'
|
|
||||||
];
|
|
||||||
|
|
||||||
return permanentFailurePatterns.some(pattern => message.includes(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve MX records for a domain
|
|
||||||
*/
|
|
||||||
private resolveMx(domain: string): Promise<plugins.dns.MxRecord[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
plugins.dns.resolveMx(domain, (err, addresses) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err);
|
|
||||||
} else {
|
|
||||||
resolve(addresses);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a log entry
|
|
||||||
*/
|
|
||||||
private log(message: string): void {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const logEntry = `[${timestamp}] ${message}`;
|
|
||||||
this.deliveryInfo.logs.push(logEntry);
|
|
||||||
|
|
||||||
if (this.options.debugMode) {
|
|
||||||
console.log(`EmailSendJob: ${logEntry}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a successful email for record keeping
|
|
||||||
*/
|
|
||||||
private async saveSuccess(): Promise<void> {
|
|
||||||
try {
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
|
||||||
const emailContent = await this.createEmailMessage();
|
|
||||||
const fileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.eml`;
|
|
||||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.sentEmailsDir, fileName));
|
|
||||||
|
|
||||||
// Save delivery info
|
|
||||||
const infoFileName = `${Date.now()}_success_${this.email.getPrimaryRecipient()}.json`;
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
JSON.stringify(this.deliveryInfo, null, 2),
|
|
||||||
plugins.path.join(paths.sentEmailsDir, infoFileName)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving successful email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a failed email for potential retry
|
|
||||||
*/
|
|
||||||
private async saveFailed(): Promise<void> {
|
|
||||||
try {
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
|
||||||
const emailContent = await this.createEmailMessage();
|
|
||||||
const fileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.eml`;
|
|
||||||
plugins.smartfile.memory.toFsSync(emailContent, plugins.path.join(paths.failedEmailsDir, fileName));
|
|
||||||
|
|
||||||
// Save delivery info
|
|
||||||
const infoFileName = `${Date.now()}_failed_${this.email.getPrimaryRecipient()}.json`;
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
JSON.stringify(this.deliveryInfo, null, 2),
|
|
||||||
plugins.path.join(paths.failedEmailsDir, infoFileName)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving failed email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple delay function
|
|
||||||
*/
|
|
||||||
private delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
interface Headers {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IEmailSignJobOptions {
|
|
||||||
domain: string;
|
|
||||||
selector: string;
|
|
||||||
headers: Headers;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EmailSignJob {
|
|
||||||
mtaRef: MtaService;
|
|
||||||
jobOptions: IEmailSignJobOptions;
|
|
||||||
|
|
||||||
constructor(mtaRefArg: MtaService, options: IEmailSignJobOptions) {
|
|
||||||
this.mtaRef = mtaRefArg;
|
|
||||||
this.jobOptions = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPrivateKey(): Promise<string> {
|
|
||||||
return plugins.fs.promises.readFile(
|
|
||||||
(await this.mtaRef.dkimCreator.getKeyPathsForDomain(this.jobOptions.domain)).privateKeyPath,
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
|
||||||
const signResult = await plugins.dkimSign(emailMessage, {
|
|
||||||
// Optional, default canonicalization, default is "relaxed/relaxed"
|
|
||||||
canonicalization: 'relaxed/relaxed', // c=
|
|
||||||
|
|
||||||
// Optional, default signing and hashing algorithm
|
|
||||||
// Mostly useful when you want to use rsa-sha1, otherwise no need to set
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
|
|
||||||
// Optional, default is current time
|
|
||||||
signTime: new Date(), // t=
|
|
||||||
|
|
||||||
// Keys for one or more signatures
|
|
||||||
// Different signatures can use different algorithms (mostly useful when
|
|
||||||
// you want to sign a message both with RSA and Ed25519)
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: this.jobOptions.domain, // d=
|
|
||||||
selector: this.jobOptions.selector, // s=
|
|
||||||
// supported key types: RSA, Ed25519
|
|
||||||
privateKey: await this.loadPrivateKey(), // k=
|
|
||||||
|
|
||||||
// Optional algorithm, default is derived from the key.
|
|
||||||
// Overrides whatever was set in parent object
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
|
|
||||||
// Optional signature specifc canonicalization, overrides whatever was set in parent object
|
|
||||||
canonicalization: 'relaxed/relaxed', // c=
|
|
||||||
|
|
||||||
// Maximum number of canonicalized body bytes to sign (eg. the "l=" tag).
|
|
||||||
// Do not use though. This is available only for compatibility testing.
|
|
||||||
// maxBodyLength: 12345
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const signature = signResult.signatures;
|
|
||||||
return signature;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,945 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
|
|
||||||
import { Email } from './mta.classes.email.js';
|
|
||||||
import { EmailSendJob, DeliveryStatus } from './mta.classes.emailsendjob.js';
|
|
||||||
import { DKIMCreator } from './mta.classes.dkimcreator.js';
|
|
||||||
import { DKIMVerifier } from './mta.classes.dkimverifier.js';
|
|
||||||
import { SMTPServer, type ISmtpServerOptions } from './mta.classes.smtpserver.js';
|
|
||||||
import { DNSManager } from './mta.classes.dnsmanager.js';
|
|
||||||
import { ApiManager } from './mta.classes.apimanager.js';
|
|
||||||
import type { SzPlatformService } from '../classes.platformservice.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for the MTA service
|
|
||||||
*/
|
|
||||||
export interface IMtaConfig {
|
|
||||||
/** SMTP server options */
|
|
||||||
smtp?: {
|
|
||||||
/** Whether to enable the SMTP server */
|
|
||||||
enabled?: boolean;
|
|
||||||
/** Port to listen on (default: 25) */
|
|
||||||
port?: number;
|
|
||||||
/** SMTP server hostname */
|
|
||||||
hostname?: string;
|
|
||||||
/** Maximum allowed email size in bytes */
|
|
||||||
maxSize?: number;
|
|
||||||
};
|
|
||||||
/** SSL/TLS configuration */
|
|
||||||
tls?: {
|
|
||||||
/** Domain for certificate */
|
|
||||||
domain?: string;
|
|
||||||
/** Whether to auto-renew certificates */
|
|
||||||
autoRenew?: boolean;
|
|
||||||
/** Custom key/cert paths (if not using auto-provision) */
|
|
||||||
keyPath?: string;
|
|
||||||
certPath?: string;
|
|
||||||
};
|
|
||||||
/** Outbound email sending configuration */
|
|
||||||
outbound?: {
|
|
||||||
/** Maximum concurrent sending jobs */
|
|
||||||
concurrency?: number;
|
|
||||||
/** Retry configuration */
|
|
||||||
retries?: {
|
|
||||||
/** Maximum number of retries per message */
|
|
||||||
max?: number;
|
|
||||||
/** Initial delay between retries (milliseconds) */
|
|
||||||
delay?: number;
|
|
||||||
/** Whether to use exponential backoff for retries */
|
|
||||||
useBackoff?: boolean;
|
|
||||||
};
|
|
||||||
/** Rate limiting configuration */
|
|
||||||
rateLimit?: {
|
|
||||||
/** Maximum emails per period */
|
|
||||||
maxPerPeriod?: number;
|
|
||||||
/** Time period in milliseconds */
|
|
||||||
periodMs?: number;
|
|
||||||
/** Whether to apply per domain (vs globally) */
|
|
||||||
perDomain?: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** Security settings */
|
|
||||||
security?: {
|
|
||||||
/** Whether to use DKIM signing */
|
|
||||||
useDkim?: boolean;
|
|
||||||
/** Whether to verify inbound DKIM signatures */
|
|
||||||
verifyDkim?: boolean;
|
|
||||||
/** Whether to verify SPF on inbound */
|
|
||||||
verifySpf?: boolean;
|
|
||||||
/** Whether to use TLS for outbound when available */
|
|
||||||
useTls?: boolean;
|
|
||||||
/** Whether to require valid certificates */
|
|
||||||
requireValidCerts?: boolean;
|
|
||||||
};
|
|
||||||
/** Domains configuration */
|
|
||||||
domains?: {
|
|
||||||
/** List of domains that this MTA will handle as local */
|
|
||||||
local?: string[];
|
|
||||||
/** Whether to auto-create DNS records */
|
|
||||||
autoCreateDnsRecords?: boolean;
|
|
||||||
/** DKIM selector to use (default: "mta") */
|
|
||||||
dkimSelector?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Email queue entry
|
|
||||||
*/
|
|
||||||
interface QueueEntry {
|
|
||||||
id: string;
|
|
||||||
email: Email;
|
|
||||||
addedAt: Date;
|
|
||||||
processing: boolean;
|
|
||||||
attempts: number;
|
|
||||||
lastAttempt?: Date;
|
|
||||||
nextAttempt?: Date;
|
|
||||||
error?: Error;
|
|
||||||
status: DeliveryStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Certificate information
|
|
||||||
*/
|
|
||||||
interface Certificate {
|
|
||||||
privateKey: string;
|
|
||||||
publicKey: string;
|
|
||||||
expiresAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stats for MTA monitoring
|
|
||||||
*/
|
|
||||||
interface MtaStats {
|
|
||||||
startTime: Date;
|
|
||||||
emailsReceived: number;
|
|
||||||
emailsSent: number;
|
|
||||||
emailsFailed: number;
|
|
||||||
activeConnections: number;
|
|
||||||
queueSize: number;
|
|
||||||
certificateInfo?: {
|
|
||||||
domain: string;
|
|
||||||
expiresAt: Date;
|
|
||||||
daysUntilExpiry: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main MTA Service class that coordinates all email functionality
|
|
||||||
*/
|
|
||||||
export class MtaService {
|
|
||||||
/** Reference to the platform service */
|
|
||||||
public platformServiceRef: SzPlatformService;
|
|
||||||
|
|
||||||
/** SMTP server instance */
|
|
||||||
public server: SMTPServer;
|
|
||||||
|
|
||||||
/** DKIM creator for signing outgoing emails */
|
|
||||||
public dkimCreator: DKIMCreator;
|
|
||||||
|
|
||||||
/** DKIM verifier for validating incoming emails */
|
|
||||||
public dkimVerifier: DKIMVerifier;
|
|
||||||
|
|
||||||
/** DNS manager for handling DNS records */
|
|
||||||
public dnsManager: DNSManager;
|
|
||||||
|
|
||||||
/** API manager for external integrations */
|
|
||||||
public apiManager: ApiManager;
|
|
||||||
|
|
||||||
/** Email queue for outbound emails */
|
|
||||||
private emailQueue: Map<string, QueueEntry> = new Map();
|
|
||||||
|
|
||||||
/** Email queue processing state */
|
|
||||||
private queueProcessing = false;
|
|
||||||
|
|
||||||
/** Rate limiters for outbound emails */
|
|
||||||
private rateLimiters: Map<string, {
|
|
||||||
tokens: number;
|
|
||||||
lastRefill: number;
|
|
||||||
}> = new Map();
|
|
||||||
|
|
||||||
/** Certificate cache */
|
|
||||||
private certificate: Certificate = null;
|
|
||||||
|
|
||||||
/** MTA configuration */
|
|
||||||
private config: IMtaConfig;
|
|
||||||
|
|
||||||
/** Stats for monitoring */
|
|
||||||
private stats: MtaStats;
|
|
||||||
|
|
||||||
/** Whether the service is currently running */
|
|
||||||
private running = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the MTA service
|
|
||||||
* @param platformServiceRefArg Reference to the platform service
|
|
||||||
* @param config Configuration options
|
|
||||||
*/
|
|
||||||
constructor(platformServiceRefArg: SzPlatformService, config: IMtaConfig = {}) {
|
|
||||||
this.platformServiceRef = platformServiceRefArg;
|
|
||||||
|
|
||||||
// Initialize with default configuration
|
|
||||||
this.config = this.getDefaultConfig();
|
|
||||||
|
|
||||||
// Merge with provided configuration
|
|
||||||
this.config = this.mergeConfig(this.config, config);
|
|
||||||
|
|
||||||
// Initialize components
|
|
||||||
this.dkimCreator = new DKIMCreator(this);
|
|
||||||
this.dkimVerifier = new DKIMVerifier(this);
|
|
||||||
this.dnsManager = new DNSManager(this);
|
|
||||||
this.apiManager = new ApiManager();
|
|
||||||
|
|
||||||
// Initialize stats
|
|
||||||
this.stats = {
|
|
||||||
startTime: new Date(),
|
|
||||||
emailsReceived: 0,
|
|
||||||
emailsSent: 0,
|
|
||||||
emailsFailed: 0,
|
|
||||||
activeConnections: 0,
|
|
||||||
queueSize: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure required directories exist
|
|
||||||
this.ensureDirectories();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default configuration
|
|
||||||
*/
|
|
||||||
private getDefaultConfig(): IMtaConfig {
|
|
||||||
return {
|
|
||||||
smtp: {
|
|
||||||
enabled: true,
|
|
||||||
port: 25,
|
|
||||||
hostname: 'mta.lossless.one',
|
|
||||||
maxSize: 10 * 1024 * 1024 // 10MB
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
domain: 'mta.lossless.one',
|
|
||||||
autoRenew: true
|
|
||||||
},
|
|
||||||
outbound: {
|
|
||||||
concurrency: 5,
|
|
||||||
retries: {
|
|
||||||
max: 3,
|
|
||||||
delay: 300000, // 5 minutes
|
|
||||||
useBackoff: true
|
|
||||||
},
|
|
||||||
rateLimit: {
|
|
||||||
maxPerPeriod: 100,
|
|
||||||
periodMs: 60000, // 1 minute
|
|
||||||
perDomain: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
useDkim: true,
|
|
||||||
verifyDkim: true,
|
|
||||||
verifySpf: true,
|
|
||||||
useTls: true,
|
|
||||||
requireValidCerts: false
|
|
||||||
},
|
|
||||||
domains: {
|
|
||||||
local: ['lossless.one'],
|
|
||||||
autoCreateDnsRecords: true,
|
|
||||||
dkimSelector: 'mta'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge configurations
|
|
||||||
*/
|
|
||||||
private mergeConfig(defaultConfig: IMtaConfig, customConfig: IMtaConfig): IMtaConfig {
|
|
||||||
// Deep merge of configurations
|
|
||||||
// (A more robust implementation would use a dedicated deep-merge library)
|
|
||||||
const merged = { ...defaultConfig };
|
|
||||||
|
|
||||||
// Merge first level
|
|
||||||
for (const [key, value] of Object.entries(customConfig)) {
|
|
||||||
if (value === null || value === undefined) continue;
|
|
||||||
|
|
||||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
merged[key] = { ...merged[key], ...value };
|
|
||||||
} else {
|
|
||||||
merged[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure required directories exist
|
|
||||||
*/
|
|
||||||
private ensureDirectories(): void {
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.keysDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.sentEmailsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.failedEmailsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.dnsRecordsDir);
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.logsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the MTA service
|
|
||||||
*/
|
|
||||||
public async start(): Promise<void> {
|
|
||||||
if (this.running) {
|
|
||||||
console.warn('MTA service is already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Starting MTA service...');
|
|
||||||
|
|
||||||
// Load or provision certificate
|
|
||||||
await this.loadOrProvisionCertificate();
|
|
||||||
|
|
||||||
// Start SMTP server if enabled
|
|
||||||
if (this.config.smtp.enabled) {
|
|
||||||
const smtpOptions: ISmtpServerOptions = {
|
|
||||||
port: this.config.smtp.port,
|
|
||||||
key: this.certificate.privateKey,
|
|
||||||
cert: this.certificate.publicKey,
|
|
||||||
hostname: this.config.smtp.hostname
|
|
||||||
};
|
|
||||||
|
|
||||||
this.server = new SMTPServer(this, smtpOptions);
|
|
||||||
this.server.start();
|
|
||||||
console.log(`SMTP server started on port ${smtpOptions.port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start queue processing
|
|
||||||
this.startQueueProcessing();
|
|
||||||
|
|
||||||
// Update DNS records for local domains if configured
|
|
||||||
if (this.config.domains.autoCreateDnsRecords) {
|
|
||||||
await this.updateDnsRecordsForLocalDomains();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.running = true;
|
|
||||||
console.log('MTA service started successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start MTA service:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the MTA service
|
|
||||||
*/
|
|
||||||
public async stop(): Promise<void> {
|
|
||||||
if (!this.running) {
|
|
||||||
console.warn('MTA service is not running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Stopping MTA service...');
|
|
||||||
|
|
||||||
// Stop SMTP server if running
|
|
||||||
if (this.server) {
|
|
||||||
await this.server.stop();
|
|
||||||
this.server = null;
|
|
||||||
console.log('SMTP server stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop queue processing
|
|
||||||
this.queueProcessing = false;
|
|
||||||
console.log('Email queue processing stopped');
|
|
||||||
|
|
||||||
this.running = false;
|
|
||||||
console.log('MTA service stopped successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping MTA service:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an email (add to queue)
|
|
||||||
*/
|
|
||||||
public async send(email: Email): Promise<string> {
|
|
||||||
if (!this.running) {
|
|
||||||
throw new Error('MTA service is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a unique ID for this email
|
|
||||||
const id = plugins.uuid.v4();
|
|
||||||
|
|
||||||
// Validate email
|
|
||||||
this.validateEmail(email);
|
|
||||||
|
|
||||||
// Create DKIM keys if needed
|
|
||||||
if (this.config.security.useDkim) {
|
|
||||||
await this.dkimCreator.handleDKIMKeysForEmail(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
this.emailQueue.set(id, {
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
addedAt: new Date(),
|
|
||||||
processing: false,
|
|
||||||
attempts: 0,
|
|
||||||
status: DeliveryStatus.PENDING
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.queueSize = this.emailQueue.size;
|
|
||||||
|
|
||||||
console.log(`Email added to queue: ${id}`);
|
|
||||||
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get status of an email in the queue
|
|
||||||
*/
|
|
||||||
public getEmailStatus(id: string): QueueEntry | null {
|
|
||||||
return this.emailQueue.get(id) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming email
|
|
||||||
*/
|
|
||||||
public async processIncomingEmail(email: Email): Promise<boolean> {
|
|
||||||
if (!this.running) {
|
|
||||||
throw new Error('MTA service is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Processing incoming email from ${email.from} to ${email.to}`);
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.emailsReceived++;
|
|
||||||
|
|
||||||
// Check if the recipient domain is local
|
|
||||||
const recipientDomain = email.to[0].split('@')[1];
|
|
||||||
const isLocalDomain = this.isLocalDomain(recipientDomain);
|
|
||||||
|
|
||||||
if (isLocalDomain) {
|
|
||||||
// Save to local mailbox
|
|
||||||
await this.saveToLocalMailbox(email);
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
// Forward to another server
|
|
||||||
const forwardId = await this.send(email);
|
|
||||||
console.log(`Forwarding email to ${email.to} with queue ID ${forwardId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing incoming email:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a domain is local
|
|
||||||
*/
|
|
||||||
private isLocalDomain(domain: string): boolean {
|
|
||||||
return this.config.domains.local.includes(domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save an email to a local mailbox
|
|
||||||
*/
|
|
||||||
private async saveToLocalMailbox(email: Email): Promise<void> {
|
|
||||||
// Simplified implementation - in a real system, this would store to a user's mailbox
|
|
||||||
const mailboxPath = plugins.path.join(paths.receivedEmailsDir, 'local');
|
|
||||||
plugins.smartfile.fs.ensureDirSync(mailboxPath);
|
|
||||||
|
|
||||||
const emailContent = email.toRFC822String();
|
|
||||||
const filename = `${Date.now()}_${email.to[0].replace('@', '_at_')}.eml`;
|
|
||||||
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
emailContent,
|
|
||||||
plugins.path.join(mailboxPath, filename)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Email saved to local mailbox: ${filename}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start processing the email queue
|
|
||||||
*/
|
|
||||||
private startQueueProcessing(): void {
|
|
||||||
if (this.queueProcessing) return;
|
|
||||||
|
|
||||||
this.queueProcessing = true;
|
|
||||||
this.processQueue();
|
|
||||||
console.log('Email queue processing started');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process emails in the queue
|
|
||||||
*/
|
|
||||||
private async processQueue(): Promise<void> {
|
|
||||||
if (!this.queueProcessing) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get pending emails ordered by next attempt time
|
|
||||||
const pendingEmails = Array.from(this.emailQueue.values())
|
|
||||||
.filter(entry =>
|
|
||||||
(entry.status === DeliveryStatus.PENDING || entry.status === DeliveryStatus.DEFERRED) &&
|
|
||||||
!entry.processing &&
|
|
||||||
(!entry.nextAttempt || entry.nextAttempt <= new Date())
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Sort by next attempt time, then by added time
|
|
||||||
if (a.nextAttempt && b.nextAttempt) {
|
|
||||||
return a.nextAttempt.getTime() - b.nextAttempt.getTime();
|
|
||||||
} else if (a.nextAttempt) {
|
|
||||||
return 1;
|
|
||||||
} else if (b.nextAttempt) {
|
|
||||||
return -1;
|
|
||||||
} else {
|
|
||||||
return a.addedAt.getTime() - b.addedAt.getTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine how many emails we can process concurrently
|
|
||||||
const availableSlots = Math.max(0, this.config.outbound.concurrency -
|
|
||||||
Array.from(this.emailQueue.values()).filter(e => e.processing).length);
|
|
||||||
|
|
||||||
// Process emails up to our concurrency limit
|
|
||||||
for (let i = 0; i < Math.min(availableSlots, pendingEmails.length); i++) {
|
|
||||||
const entry = pendingEmails[i];
|
|
||||||
|
|
||||||
// Check rate limits
|
|
||||||
if (!this.checkRateLimit(entry.email)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as processing
|
|
||||||
entry.processing = true;
|
|
||||||
|
|
||||||
// Process in background
|
|
||||||
this.processQueueEntry(entry).catch(error => {
|
|
||||||
console.error(`Error processing queue entry ${entry.id}:`, error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in queue processing:', error);
|
|
||||||
} finally {
|
|
||||||
// Schedule next processing cycle
|
|
||||||
setTimeout(() => this.processQueue(), 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a single queue entry
|
|
||||||
*/
|
|
||||||
private async processQueueEntry(entry: QueueEntry): Promise<void> {
|
|
||||||
try {
|
|
||||||
console.log(`Processing queue entry ${entry.id}`);
|
|
||||||
|
|
||||||
// Update attempt counters
|
|
||||||
entry.attempts++;
|
|
||||||
entry.lastAttempt = new Date();
|
|
||||||
|
|
||||||
// Create send job
|
|
||||||
const sendJob = new EmailSendJob(this, entry.email, {
|
|
||||||
maxRetries: 1, // We handle retries at the queue level
|
|
||||||
tlsOptions: {
|
|
||||||
rejectUnauthorized: this.config.security.requireValidCerts
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the email
|
|
||||||
const status = await sendJob.send();
|
|
||||||
entry.status = status;
|
|
||||||
|
|
||||||
if (status === DeliveryStatus.DELIVERED) {
|
|
||||||
// Success - remove from queue
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
this.stats.emailsSent++;
|
|
||||||
console.log(`Email ${entry.id} delivered successfully`);
|
|
||||||
} else if (status === DeliveryStatus.FAILED) {
|
|
||||||
// Permanent failure
|
|
||||||
entry.error = sendJob.deliveryInfo.error;
|
|
||||||
this.stats.emailsFailed++;
|
|
||||||
console.log(`Email ${entry.id} failed permanently: ${entry.error.message}`);
|
|
||||||
|
|
||||||
// Remove from queue
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
} else if (status === DeliveryStatus.DEFERRED) {
|
|
||||||
// Temporary failure - schedule retry if attempts remain
|
|
||||||
entry.error = sendJob.deliveryInfo.error;
|
|
||||||
|
|
||||||
if (entry.attempts >= this.config.outbound.retries.max) {
|
|
||||||
// Max retries reached - mark as failed
|
|
||||||
entry.status = DeliveryStatus.FAILED;
|
|
||||||
this.stats.emailsFailed++;
|
|
||||||
console.log(`Email ${entry.id} failed after ${entry.attempts} attempts: ${entry.error.message}`);
|
|
||||||
|
|
||||||
// Remove from queue
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
} else {
|
|
||||||
// Schedule retry
|
|
||||||
const delay = this.calculateRetryDelay(entry.attempts);
|
|
||||||
entry.nextAttempt = new Date(Date.now() + delay);
|
|
||||||
console.log(`Email ${entry.id} deferred, next attempt at ${entry.nextAttempt}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Unexpected error processing queue entry ${entry.id}:`, error);
|
|
||||||
|
|
||||||
// Handle unexpected errors similarly to deferred
|
|
||||||
entry.error = error;
|
|
||||||
|
|
||||||
if (entry.attempts >= this.config.outbound.retries.max) {
|
|
||||||
entry.status = DeliveryStatus.FAILED;
|
|
||||||
this.stats.emailsFailed++;
|
|
||||||
this.emailQueue.delete(entry.id);
|
|
||||||
} else {
|
|
||||||
entry.status = DeliveryStatus.DEFERRED;
|
|
||||||
const delay = this.calculateRetryDelay(entry.attempts);
|
|
||||||
entry.nextAttempt = new Date(Date.now() + delay);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Mark as no longer processing
|
|
||||||
entry.processing = false;
|
|
||||||
|
|
||||||
// Update stats
|
|
||||||
this.stats.queueSize = this.emailQueue.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate delay before retry based on attempt number
|
|
||||||
*/
|
|
||||||
private calculateRetryDelay(attemptNumber: number): number {
|
|
||||||
const baseDelay = this.config.outbound.retries.delay;
|
|
||||||
|
|
||||||
if (this.config.outbound.retries.useBackoff) {
|
|
||||||
// Exponential backoff: base_delay * (2^(attempt-1))
|
|
||||||
return baseDelay * Math.pow(2, attemptNumber - 1);
|
|
||||||
} else {
|
|
||||||
return baseDelay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an email can be sent under rate limits
|
|
||||||
*/
|
|
||||||
private checkRateLimit(email: Email): boolean {
|
|
||||||
const config = this.config.outbound.rateLimit;
|
|
||||||
if (!config || !config.maxPerPeriod) {
|
|
||||||
return true; // No rate limit configured
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which limiter to use
|
|
||||||
const key = config.perDomain ? email.getFromDomain() : 'global';
|
|
||||||
|
|
||||||
// Initialize limiter if needed
|
|
||||||
if (!this.rateLimiters.has(key)) {
|
|
||||||
this.rateLimiters.set(key, {
|
|
||||||
tokens: config.maxPerPeriod,
|
|
||||||
lastRefill: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const limiter = this.rateLimiters.get(key);
|
|
||||||
|
|
||||||
// Refill tokens based on time elapsed
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsedMs = now - limiter.lastRefill;
|
|
||||||
const tokensToAdd = Math.floor(elapsedMs / config.periodMs) * config.maxPerPeriod;
|
|
||||||
|
|
||||||
if (tokensToAdd > 0) {
|
|
||||||
limiter.tokens = Math.min(config.maxPerPeriod, limiter.tokens + tokensToAdd);
|
|
||||||
limiter.lastRefill = now - (elapsedMs % config.periodMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have tokens available
|
|
||||||
if (limiter.tokens > 0) {
|
|
||||||
limiter.tokens--;
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
console.log(`Rate limit exceeded for ${key}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load or provision a TLS certificate
|
|
||||||
*/
|
|
||||||
private async loadOrProvisionCertificate(): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Check if we have manual cert paths specified
|
|
||||||
if (this.config.tls.keyPath && this.config.tls.certPath) {
|
|
||||||
console.log('Using manually specified certificate files');
|
|
||||||
|
|
||||||
const [privateKey, publicKey] = await Promise.all([
|
|
||||||
plugins.fs.promises.readFile(this.config.tls.keyPath, 'utf-8'),
|
|
||||||
plugins.fs.promises.readFile(this.config.tls.certPath, 'utf-8')
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.certificate = {
|
|
||||||
privateKey,
|
|
||||||
publicKey,
|
|
||||||
expiresAt: this.getCertificateExpiry(publicKey)
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`Certificate loaded, expires: ${this.certificate.expiresAt}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, use auto-provisioning
|
|
||||||
console.log(`Provisioning certificate for ${this.config.tls.domain}`);
|
|
||||||
this.certificate = await this.provisionCertificate(this.config.tls.domain);
|
|
||||||
console.log(`Certificate provisioned, expires: ${this.certificate.expiresAt}`);
|
|
||||||
|
|
||||||
// Set up auto-renewal if configured
|
|
||||||
if (this.config.tls.autoRenew) {
|
|
||||||
this.setupCertificateRenewal();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading or provisioning certificate:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provision a certificate from the certificate service
|
|
||||||
*/
|
|
||||||
private async provisionCertificate(domain: string): Promise<Certificate> {
|
|
||||||
try {
|
|
||||||
// Setup proper authentication
|
|
||||||
const authToken = await this.getAuthToken();
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
throw new Error('Failed to obtain authentication token for certificate provisioning');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize client
|
|
||||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
const typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
|
|
||||||
typedrouter,
|
|
||||||
'https://cloudly.lossless.one:443'
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Request certificate
|
|
||||||
const typedCertificateRequest = typedsocketClient.createTypedRequest<any>('getSslCertificate');
|
|
||||||
const typedResponse = await typedCertificateRequest.fire({
|
|
||||||
authToken,
|
|
||||||
requiredCertName: domain,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!typedResponse || !typedResponse.certificate) {
|
|
||||||
throw new Error('Invalid response from certificate service');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract certificate information
|
|
||||||
const cert = typedResponse.certificate;
|
|
||||||
|
|
||||||
// Determine expiry date
|
|
||||||
const expiresAt = this.getCertificateExpiry(cert.publicKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
privateKey: cert.privateKey,
|
|
||||||
publicKey: cert.publicKey,
|
|
||||||
expiresAt
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
// Always close the client
|
|
||||||
await typedsocketClient.stop();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Certificate provisioning failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get authentication token for certificate service
|
|
||||||
*/
|
|
||||||
private async getAuthToken(): Promise<string> {
|
|
||||||
// Implementation would depend on authentication mechanism
|
|
||||||
// This is a simplified example assuming the platform service has an auth method
|
|
||||||
try {
|
|
||||||
// For now, return a placeholder token - in production this would
|
|
||||||
// authenticate properly with the certificate service
|
|
||||||
return 'mta-service-auth-token';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to obtain auth token:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract certificate expiry date from public key
|
|
||||||
*/
|
|
||||||
private getCertificateExpiry(publicKey: string): Date {
|
|
||||||
try {
|
|
||||||
// This is a simplified implementation
|
|
||||||
// In a real system, you would parse the certificate properly
|
|
||||||
// using a certificate parsing library
|
|
||||||
|
|
||||||
// For now, set expiry to 90 days from now
|
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
|
||||||
return expiresAt;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to extract certificate expiry:', error);
|
|
||||||
|
|
||||||
// Default to 30 days from now
|
|
||||||
const defaultExpiry = new Date();
|
|
||||||
defaultExpiry.setDate(defaultExpiry.getDate() + 30);
|
|
||||||
return defaultExpiry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up certificate auto-renewal
|
|
||||||
*/
|
|
||||||
private setupCertificateRenewal(): void {
|
|
||||||
if (!this.certificate || !this.certificate.expiresAt) {
|
|
||||||
console.warn('Cannot setup certificate renewal: no valid certificate');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate time until renewal (30 days before expiry)
|
|
||||||
const now = new Date();
|
|
||||||
const renewalDate = new Date(this.certificate.expiresAt);
|
|
||||||
renewalDate.setDate(renewalDate.getDate() - 30);
|
|
||||||
|
|
||||||
const timeUntilRenewal = Math.max(0, renewalDate.getTime() - now.getTime());
|
|
||||||
|
|
||||||
console.log(`Certificate renewal scheduled for ${renewalDate}`);
|
|
||||||
|
|
||||||
// Schedule renewal
|
|
||||||
setTimeout(() => {
|
|
||||||
this.renewCertificate().catch(error => {
|
|
||||||
console.error('Certificate renewal failed:', error);
|
|
||||||
});
|
|
||||||
}, timeUntilRenewal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renew the certificate
|
|
||||||
*/
|
|
||||||
private async renewCertificate(): Promise<void> {
|
|
||||||
try {
|
|
||||||
console.log('Renewing certificate...');
|
|
||||||
|
|
||||||
// Provision new certificate
|
|
||||||
const newCertificate = await this.provisionCertificate(this.config.tls.domain);
|
|
||||||
|
|
||||||
// Replace current certificate
|
|
||||||
this.certificate = newCertificate;
|
|
||||||
|
|
||||||
console.log(`Certificate renewed, new expiry: ${newCertificate.expiresAt}`);
|
|
||||||
|
|
||||||
// Update SMTP server with new certificate if running
|
|
||||||
if (this.server) {
|
|
||||||
// Restart server with new certificate
|
|
||||||
await this.server.stop();
|
|
||||||
|
|
||||||
const smtpOptions: ISmtpServerOptions = {
|
|
||||||
port: this.config.smtp.port,
|
|
||||||
key: this.certificate.privateKey,
|
|
||||||
cert: this.certificate.publicKey,
|
|
||||||
hostname: this.config.smtp.hostname
|
|
||||||
};
|
|
||||||
|
|
||||||
this.server = new SMTPServer(this, smtpOptions);
|
|
||||||
this.server.start();
|
|
||||||
|
|
||||||
console.log('SMTP server restarted with new certificate');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule next renewal
|
|
||||||
this.setupCertificateRenewal();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Certificate renewal failed:', error);
|
|
||||||
|
|
||||||
// Schedule retry after 24 hours
|
|
||||||
setTimeout(() => {
|
|
||||||
this.renewCertificate().catch(err => {
|
|
||||||
console.error('Certificate renewal retry failed:', err);
|
|
||||||
});
|
|
||||||
}, 24 * 60 * 60 * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update DNS records for all local domains
|
|
||||||
*/
|
|
||||||
private async updateDnsRecordsForLocalDomains(): Promise<void> {
|
|
||||||
if (!this.config.domains.local || this.config.domains.local.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Updating DNS records for local domains...');
|
|
||||||
|
|
||||||
for (const domain of this.config.domains.local) {
|
|
||||||
try {
|
|
||||||
console.log(`Updating DNS records for ${domain}`);
|
|
||||||
|
|
||||||
// Generate DKIM keys if needed
|
|
||||||
await this.dkimCreator.handleDKIMKeysForDomain(domain);
|
|
||||||
|
|
||||||
// Generate all recommended DNS records
|
|
||||||
const records = await this.dnsManager.generateAllRecommendedRecords(domain);
|
|
||||||
|
|
||||||
console.log(`Generated ${records.length} DNS records for ${domain}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error updating DNS records for ${domain}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an email before sending
|
|
||||||
*/
|
|
||||||
private validateEmail(email: Email): void {
|
|
||||||
// The Email class constructor already performs basic validation
|
|
||||||
// Here we can add additional MTA-specific validation
|
|
||||||
|
|
||||||
if (!email.from) {
|
|
||||||
throw new Error('Email must have a sender address');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email.to || email.to.length === 0) {
|
|
||||||
throw new Error('Email must have at least one recipient');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the sender domain is allowed
|
|
||||||
const senderDomain = email.getFromDomain();
|
|
||||||
if (!senderDomain) {
|
|
||||||
throw new Error('Invalid sender domain');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the sender domain is one of our local domains, ensure we have DKIM keys
|
|
||||||
if (this.isLocalDomain(senderDomain) && this.config.security.useDkim) {
|
|
||||||
// DKIM keys will be created if needed in the send method
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get MTA service statistics
|
|
||||||
*/
|
|
||||||
public getStats(): MtaStats {
|
|
||||||
// Update queue size
|
|
||||||
this.stats.queueSize = this.emailQueue.size;
|
|
||||||
|
|
||||||
// Update certificate info if available
|
|
||||||
if (this.certificate) {
|
|
||||||
const now = new Date();
|
|
||||||
const daysUntilExpiry = Math.floor(
|
|
||||||
(this.certificate.expiresAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.stats.certificateInfo = {
|
|
||||||
domain: this.config.tls.domain,
|
|
||||||
expiresAt: this.certificate.expiresAt,
|
|
||||||
daysUntilExpiry
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...this.stats };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
import * as plugins from '../plugins.js';
|
|
||||||
import * as paths from '../paths.js';
|
|
||||||
import { Email } from './mta.classes.email.js';
|
|
||||||
import type { MtaService } from './mta.classes.mta.js';
|
|
||||||
|
|
||||||
export interface ISmtpServerOptions {
|
|
||||||
port: number;
|
|
||||||
key: string;
|
|
||||||
cert: string;
|
|
||||||
hostname?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SMTP Session States
|
|
||||||
enum SmtpState {
|
|
||||||
GREETING,
|
|
||||||
AFTER_EHLO,
|
|
||||||
MAIL_FROM,
|
|
||||||
RCPT_TO,
|
|
||||||
DATA,
|
|
||||||
DATA_RECEIVING,
|
|
||||||
FINISHED
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structure to store session information
|
|
||||||
interface SmtpSession {
|
|
||||||
state: SmtpState;
|
|
||||||
clientHostname: string;
|
|
||||||
mailFrom: string;
|
|
||||||
rcptTo: string[];
|
|
||||||
emailData: string;
|
|
||||||
useTLS: boolean;
|
|
||||||
connectionEnded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SMTPServer {
|
|
||||||
public mtaRef: MtaService;
|
|
||||||
private smtpServerOptions: ISmtpServerOptions;
|
|
||||||
private server: plugins.net.Server;
|
|
||||||
private sessions: Map<plugins.net.Socket | plugins.tls.TLSSocket, SmtpSession>;
|
|
||||||
private hostname: string;
|
|
||||||
|
|
||||||
constructor(mtaRefArg: MtaService, optionsArg: ISmtpServerOptions) {
|
|
||||||
console.log('SMTPServer instance is being created...');
|
|
||||||
|
|
||||||
this.mtaRef = mtaRefArg;
|
|
||||||
this.smtpServerOptions = optionsArg;
|
|
||||||
this.sessions = new Map();
|
|
||||||
this.hostname = optionsArg.hostname || 'mta.lossless.one';
|
|
||||||
|
|
||||||
this.server = plugins.net.createServer((socket) => {
|
|
||||||
this.handleNewConnection(socket);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleNewConnection(socket: plugins.net.Socket): void {
|
|
||||||
console.log(`New connection from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
|
|
||||||
// Initialize a new session
|
|
||||||
this.sessions.set(socket, {
|
|
||||||
state: SmtpState.GREETING,
|
|
||||||
clientHostname: '',
|
|
||||||
mailFrom: '',
|
|
||||||
rcptTo: [],
|
|
||||||
emailData: '',
|
|
||||||
useTLS: false,
|
|
||||||
connectionEnded: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send greeting
|
|
||||||
this.sendResponse(socket, `220 ${this.hostname} ESMTP Service Ready`);
|
|
||||||
|
|
||||||
socket.on('data', (data) => {
|
|
||||||
this.processData(socket, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('end', () => {
|
|
||||||
console.log(`Connection ended from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (session) {
|
|
||||||
session.connectionEnded = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
console.error(`Socket error: ${err.message}`);
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
socket.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
console.log(`Connection closed from ${socket.remoteAddress}:${socket.remotePort}`);
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendResponse(socket: plugins.net.Socket | plugins.tls.TLSSocket, response: string): void {
|
|
||||||
try {
|
|
||||||
socket.write(`${response}\r\n`);
|
|
||||||
console.log(`→ ${response}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error sending response: ${error.message}`);
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: Buffer): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) {
|
|
||||||
console.error('No session found for socket. Closing connection.');
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're in DATA_RECEIVING state, handle differently
|
|
||||||
if (session.state === SmtpState.DATA_RECEIVING) {
|
|
||||||
return this.processEmailData(socket, data.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process normal SMTP commands
|
|
||||||
const lines = data.toString().split('\r\n').filter(line => line.length > 0);
|
|
||||||
for (const line of lines) {
|
|
||||||
console.log(`← ${line}`);
|
|
||||||
this.processCommand(socket, line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processCommand(socket: plugins.net.Socket | plugins.tls.TLSSocket, commandLine: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session || session.connectionEnded) return;
|
|
||||||
|
|
||||||
const [command, ...args] = commandLine.split(' ');
|
|
||||||
const upperCommand = command.toUpperCase();
|
|
||||||
|
|
||||||
switch (upperCommand) {
|
|
||||||
case 'EHLO':
|
|
||||||
case 'HELO':
|
|
||||||
this.handleEhlo(socket, args.join(' '));
|
|
||||||
break;
|
|
||||||
case 'STARTTLS':
|
|
||||||
this.handleStartTls(socket);
|
|
||||||
break;
|
|
||||||
case 'MAIL':
|
|
||||||
this.handleMailFrom(socket, args.join(' '));
|
|
||||||
break;
|
|
||||||
case 'RCPT':
|
|
||||||
this.handleRcptTo(socket, args.join(' '));
|
|
||||||
break;
|
|
||||||
case 'DATA':
|
|
||||||
this.handleData(socket);
|
|
||||||
break;
|
|
||||||
case 'RSET':
|
|
||||||
this.handleRset(socket);
|
|
||||||
break;
|
|
||||||
case 'QUIT':
|
|
||||||
this.handleQuit(socket);
|
|
||||||
break;
|
|
||||||
case 'NOOP':
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.sendResponse(socket, '502 Command not implemented');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleEhlo(socket: plugins.net.Socket | plugins.tls.TLSSocket, clientHostname: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (!clientHostname) {
|
|
||||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.clientHostname = clientHostname;
|
|
||||||
session.state = SmtpState.AFTER_EHLO;
|
|
||||||
|
|
||||||
// List available extensions
|
|
||||||
this.sendResponse(socket, `250-${this.hostname} Hello ${clientHostname}`);
|
|
||||||
this.sendResponse(socket, '250-SIZE 10485760'); // 10MB max
|
|
||||||
this.sendResponse(socket, '250-8BITMIME');
|
|
||||||
|
|
||||||
// Only offer STARTTLS if we haven't already established it
|
|
||||||
if (!session.useTLS) {
|
|
||||||
this.sendResponse(socket, '250-STARTTLS');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendResponse(socket, '250 HELP');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleStartTls(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.useTLS) {
|
|
||||||
this.sendResponse(socket, '503 TLS already active');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sendResponse(socket, '220 Ready to start TLS');
|
|
||||||
this.startTLS(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMailFrom(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.AFTER_EHLO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email from MAIL FROM:<user@example.com>
|
|
||||||
const emailMatch = args.match(/FROM:<([^>]*)>/i);
|
|
||||||
if (!emailMatch) {
|
|
||||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailMatch[1];
|
|
||||||
if (!this.isValidEmail(email)) {
|
|
||||||
this.sendResponse(socket, '501 Invalid email address');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.mailFrom = email;
|
|
||||||
session.state = SmtpState.MAIL_FROM;
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRcptTo(socket: plugins.net.Socket | plugins.tls.TLSSocket, args: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.MAIL_FROM && session.state !== SmtpState.RCPT_TO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract email from RCPT TO:<user@example.com>
|
|
||||||
const emailMatch = args.match(/TO:<([^>]*)>/i);
|
|
||||||
if (!emailMatch) {
|
|
||||||
this.sendResponse(socket, '501 Syntax error in parameters or arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = emailMatch[1];
|
|
||||||
if (!this.isValidEmail(email)) {
|
|
||||||
this.sendResponse(socket, '501 Invalid email address');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.rcptTo.push(email);
|
|
||||||
session.state = SmtpState.RCPT_TO;
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleData(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.state !== SmtpState.RCPT_TO) {
|
|
||||||
this.sendResponse(socket, '503 Bad sequence of commands');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.state = SmtpState.DATA_RECEIVING;
|
|
||||||
session.emailData = '';
|
|
||||||
this.sendResponse(socket, '354 End data with <CR><LF>.<CR><LF>');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRset(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
// Reset the session data but keep connection information
|
|
||||||
session.state = SmtpState.AFTER_EHLO;
|
|
||||||
session.mailFrom = '';
|
|
||||||
session.rcptTo = [];
|
|
||||||
session.emailData = '';
|
|
||||||
|
|
||||||
this.sendResponse(socket, '250 OK');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleQuit(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
this.sendResponse(socket, '221 Goodbye');
|
|
||||||
|
|
||||||
// If we have collected email data, try to parse it before closing
|
|
||||||
if (session.state === SmtpState.FINISHED && session.emailData.length > 0) {
|
|
||||||
this.parseEmail(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.end();
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
private processEmailData(socket: plugins.net.Socket | plugins.tls.TLSSocket, data: string): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
// Check for end of data marker
|
|
||||||
if (data.endsWith('\r\n.\r\n')) {
|
|
||||||
// Remove the end of data marker
|
|
||||||
const emailData = data.slice(0, -5);
|
|
||||||
session.emailData += emailData;
|
|
||||||
session.state = SmtpState.FINISHED;
|
|
||||||
|
|
||||||
// Save and process the email
|
|
||||||
this.saveEmail(socket);
|
|
||||||
this.sendResponse(socket, '250 OK: Message accepted for delivery');
|
|
||||||
} else {
|
|
||||||
// Accumulate the data
|
|
||||||
session.emailData += data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): void {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure the directory exists
|
|
||||||
plugins.smartfile.fs.ensureDirSync(paths.receivedEmailsDir);
|
|
||||||
|
|
||||||
// Write the email to disk
|
|
||||||
plugins.smartfile.memory.toFsSync(
|
|
||||||
session.emailData,
|
|
||||||
plugins.path.join(paths.receivedEmailsDir, `${Date.now()}.eml`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse the email
|
|
||||||
this.parseEmail(socket);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async parseEmail(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<void> {
|
|
||||||
const session = this.sessions.get(socket);
|
|
||||||
if (!session || !session.emailData) {
|
|
||||||
console.error('No email data found for session.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mightBeSpam = false;
|
|
||||||
|
|
||||||
// Verifying the email with DKIM
|
|
||||||
try {
|
|
||||||
const isVerified = await this.mtaRef.dkimVerifier.verify(session.emailData);
|
|
||||||
mightBeSpam = !isVerified;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to verify DKIM signature:', error);
|
|
||||||
mightBeSpam = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(session.emailData);
|
|
||||||
|
|
||||||
const email = new Email({
|
|
||||||
from: parsedEmail.from?.value[0].address || session.mailFrom,
|
|
||||||
to: session.rcptTo[0], // Use the first recipient
|
|
||||||
subject: parsedEmail.subject || '',
|
|
||||||
text: parsedEmail.html || parsedEmail.text || '',
|
|
||||||
attachments: parsedEmail.attachments?.map((attachment) => ({
|
|
||||||
filename: attachment.filename || '',
|
|
||||||
content: attachment.content,
|
|
||||||
contentType: attachment.contentType,
|
|
||||||
})) || [],
|
|
||||||
mightBeSpam: mightBeSpam,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Email received and parsed:', {
|
|
||||||
from: email.from,
|
|
||||||
to: email.to,
|
|
||||||
subject: email.subject,
|
|
||||||
attachments: email.attachments.length,
|
|
||||||
mightBeSpam: email.mightBeSpam
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process or forward the email as needed
|
|
||||||
// this.mtaRef.processIncomingEmail(email); // You could add this method to your MTA service
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing email:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startTLS(socket: plugins.net.Socket): void {
|
|
||||||
try {
|
|
||||||
const secureContext = plugins.tls.createSecureContext({
|
|
||||||
key: this.smtpServerOptions.key,
|
|
||||||
cert: this.smtpServerOptions.cert,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tlsSocket = new plugins.tls.TLSSocket(socket, {
|
|
||||||
secureContext: secureContext,
|
|
||||||
isServer: true,
|
|
||||||
server: this.server
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalSession = this.sessions.get(socket);
|
|
||||||
if (!originalSession) {
|
|
||||||
console.error('No session found when upgrading to TLS');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transfer the session data to the new TLS socket
|
|
||||||
this.sessions.set(tlsSocket, {
|
|
||||||
...originalSession,
|
|
||||||
useTLS: true,
|
|
||||||
state: SmtpState.GREETING // Reset state to require a new EHLO
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sessions.delete(socket);
|
|
||||||
|
|
||||||
tlsSocket.on('secure', () => {
|
|
||||||
console.log('TLS negotiation successful');
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('data', (data: Buffer) => {
|
|
||||||
this.processData(tlsSocket, data);
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('end', () => {
|
|
||||||
console.log('TLS socket ended');
|
|
||||||
const session = this.sessions.get(tlsSocket);
|
|
||||||
if (session) {
|
|
||||||
session.connectionEnded = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('error', (err) => {
|
|
||||||
console.error('TLS socket error:', err);
|
|
||||||
this.sessions.delete(tlsSocket);
|
|
||||||
tlsSocket.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
tlsSocket.on('close', () => {
|
|
||||||
console.log('TLS socket closed');
|
|
||||||
this.sessions.delete(tlsSocket);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error upgrading connection to TLS:', error);
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidEmail(email: string): boolean {
|
|
||||||
// Basic email validation - more comprehensive validation could be implemented
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
return emailRegex.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
public start(): void {
|
|
||||||
this.server.listen(this.smtpServerOptions.port, () => {
|
|
||||||
console.log(`SMTP Server is now running on port ${this.smtpServerOptions.port}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public stop(): void {
|
|
||||||
this.server.getConnections((err, count) => {
|
|
||||||
if (err) throw err;
|
|
||||||
console.log('Number of active connections: ', count);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.close(() => {
|
|
||||||
console.log('SMTP Server is now stopped');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
102
ts/opsserver/classes.opsserver.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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';
|
||||||
|
import * as interfaces from '../../ts_interfaces/index.js';
|
||||||
|
import { requireValidIdentity, requireAdminIdentity } from './helpers/guards.js';
|
||||||
|
|
||||||
|
export class OpsServer {
|
||||||
|
public dcRouterRef: DcRouter;
|
||||||
|
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||||
|
|
||||||
|
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// Auth-enforced routers — middleware validates identity before any handler runs
|
||||||
|
public viewRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
public adminRouter = new plugins.typedrequest.TypedRouter<{ request: { identity: interfaces.data.IIdentity } }>();
|
||||||
|
|
||||||
|
// Handler instances
|
||||||
|
public adminHandler: handlers.AdminHandler;
|
||||||
|
private configHandler: handlers.ConfigHandler;
|
||||||
|
private logsHandler: handlers.LogsHandler;
|
||||||
|
private securityHandler: handlers.SecurityHandler;
|
||||||
|
private statsHandler: handlers.StatsHandler;
|
||||||
|
private radiusHandler: handlers.RadiusHandler;
|
||||||
|
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||||
|
private certificateHandler: handlers.CertificateHandler;
|
||||||
|
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
||||||
|
private routeManagementHandler: handlers.RouteManagementHandler;
|
||||||
|
private apiTokenHandler: handlers.ApiTokenHandler;
|
||||||
|
|
||||||
|
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> {
|
||||||
|
// AdminHandler must be initialized first (JWT setup needed for guards)
|
||||||
|
this.adminHandler = new handlers.AdminHandler(this);
|
||||||
|
await this.adminHandler.initialize();
|
||||||
|
|
||||||
|
// viewRouter middleware: requires valid identity (any logged-in user)
|
||||||
|
this.viewRouter.addMiddleware(async (typedRequest) => {
|
||||||
|
await requireValidIdentity(this.adminHandler, typedRequest.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// adminRouter middleware: requires admin identity
|
||||||
|
this.adminRouter.addMiddleware(async (typedRequest) => {
|
||||||
|
await requireAdminIdentity(this.adminHandler, typedRequest.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect auth routers to the main typedrouter
|
||||||
|
this.typedrouter.addTypedRouter(this.viewRouter);
|
||||||
|
this.typedrouter.addTypedRouter(this.adminRouter);
|
||||||
|
|
||||||
|
// Instantiate all handlers — they self-register with the appropriate router
|
||||||
|
this.configHandler = new handlers.ConfigHandler(this);
|
||||||
|
this.logsHandler = new handlers.LogsHandler(this);
|
||||||
|
this.securityHandler = new handlers.SecurityHandler(this);
|
||||||
|
this.statsHandler = new handlers.StatsHandler(this);
|
||||||
|
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||||
|
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||||
|
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||||
|
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||||
|
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||||
|
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||||
|
|
||||||
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
// Clean up log handler streams and push destination before stopping the server
|
||||||
|
if (this.logsHandler) {
|
||||||
|
this.logsHandler.cleanup();
|
||||||
|
}
|
||||||
|
if (this.server) {
|
||||||
|
await this.server.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
240
ts/opsserver/handlers/admin.handler.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export interface IJwtData {
|
||||||
|
userId: string;
|
||||||
|
status: 'loggedIn' | 'loggedOut';
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
// JWT instance
|
||||||
|
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||||
|
|
||||||
|
// Simple in-memory user storage (in production, use proper database)
|
||||||
|
private users = new Map<string, {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
// Add this handler's router to the parent
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
await this.initializeJwt();
|
||||||
|
this.initializeDefaultUsers();
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeJwt(): Promise<void> {
|
||||||
|
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
||||||
|
await this.smartjwtInstance.init();
|
||||||
|
|
||||||
|
// For development, create new keypair each time
|
||||||
|
// In production, load from storage like cloudly does
|
||||||
|
await this.smartjwtInstance.createNewKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeDefaultUsers(): void {
|
||||||
|
// Add default admin user
|
||||||
|
const adminId = plugins.uuid.v4();
|
||||||
|
this.users.set(adminId, {
|
||||||
|
id: adminId,
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
role: 'admin',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Admin Login Handler
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
'adminLoginWithUsernameAndPassword',
|
||||||
|
async (dataArg) => {
|
||||||
|
try {
|
||||||
|
// Find user by username and password
|
||||||
|
let user: { id: string; username: string; password: string; role: string } | null = null;
|
||||||
|
for (const [_, userData] of this.users) {
|
||||||
|
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
||||||
|
user = userData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||||
|
|
||||||
|
const jwt = await this.smartjwtInstance.createJWT({
|
||||||
|
userId: user.id,
|
||||||
|
status: 'loggedIn',
|
||||||
|
expiresAt: expiresAtTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
identity: {
|
||||||
|
jwt,
|
||||||
|
userId: user.id,
|
||||||
|
name: user.username,
|
||||||
|
expiresAt: expiresAtTimestamp,
|
||||||
|
role: user.role,
|
||||||
|
type: 'user',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin Logout Handler
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
|
||||||
|
'adminLogout',
|
||||||
|
async (dataArg) => {
|
||||||
|
// In a real implementation, you might want to blacklist the JWT
|
||||||
|
// For now, just return success
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify Identity Handler
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
'verifyIdentity',
|
||||||
|
async (dataArg) => {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (jwtData.expiresAt < Date.now()) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if logged in
|
||||||
|
if (jwtData.status !== 'loggedIn') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = this.users.get(jwtData.userId);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
identity: {
|
||||||
|
jwt: dataArg.identity.jwt,
|
||||||
|
userId: user.id,
|
||||||
|
name: user.username,
|
||||||
|
expiresAt: jwtData.expiresAt,
|
||||||
|
role: user.role,
|
||||||
|
type: 'user',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a guard for valid identity (matching cloudly pattern)
|
||||||
|
*/
|
||||||
|
public validIdentityGuard = new plugins.smartguard.Guard<{
|
||||||
|
identity: interfaces.data.IIdentity;
|
||||||
|
}>(
|
||||||
|
async (dataArg) => {
|
||||||
|
if (!dataArg.identity?.jwt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(dataArg.identity.jwt);
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (jwtData.expiresAt < Date.now()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if (jwtData.status !== 'loggedIn') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify data hasn't been tampered with
|
||||||
|
if (dataArg.identity.expiresAt !== jwtData.expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataArg.identity.userId !== jwtData.userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
failedHint: 'identity is not valid',
|
||||||
|
name: 'validIdentityGuard',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a guard for admin identity (matching cloudly pattern)
|
||||||
|
*/
|
||||||
|
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
||||||
|
identity: interfaces.data.IIdentity;
|
||||||
|
}>(
|
||||||
|
async (dataArg) => {
|
||||||
|
// First check if identity is valid
|
||||||
|
const isValid = await this.validIdentityGuard.exec(dataArg);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin role
|
||||||
|
return dataArg.identity.role === 'admin';
|
||||||
|
},
|
||||||
|
{
|
||||||
|
failedHint: 'user is not admin',
|
||||||
|
name: 'adminIdentityGuard',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
97
ts/opsserver/handlers/api-token.handler.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class ApiTokenHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// All token management endpoints register directly on adminRouter
|
||||||
|
// (middleware enforces admin JWT check, so no per-handler requireAdmin needed)
|
||||||
|
const router = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// Create API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
||||||
|
'createApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const result = await manager.createToken(
|
||||||
|
dataArg.name,
|
||||||
|
dataArg.scopes,
|
||||||
|
dataArg.expiresInDays ?? null,
|
||||||
|
dataArg.identity.userId,
|
||||||
|
);
|
||||||
|
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// List API tokens
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
||||||
|
'listApiTokens',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { tokens: [] };
|
||||||
|
}
|
||||||
|
return { tokens: manager.listTokens() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
||||||
|
'revokeApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.revokeToken(dataArg.id);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Roll API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RollApiToken>(
|
||||||
|
'rollApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const result = await manager.rollToken(dataArg.id);
|
||||||
|
if (!result) {
|
||||||
|
return { success: false, message: 'Token not found' };
|
||||||
|
}
|
||||||
|
return { success: true, tokenValue: result.rawToken };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle API token
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
||||||
|
'toggleApiToken',
|
||||||
|
async (dataArg) => {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
}
|
||||||
|
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
|
||||||
|
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
511
ts/opsserver/handlers/certificate.handler.ts
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class CertificateHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get Certificate Overview
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||||
|
'getCertificateOverview',
|
||||||
|
async (dataArg) => {
|
||||||
|
const certificates = await this.buildCertificateOverview();
|
||||||
|
const summary = this.buildSummary(certificates);
|
||||||
|
return { certificates, summary };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
|
// Legacy route-based reprovision (backward compat)
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||||
|
'reprovisionCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.reprovisionCertificateByRoute(dataArg.routeName);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Domain-based reprovision (preferred)
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
|
'reprovisionCertificateDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.reprovisionCertificateDomain(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete certificate
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||||
|
'deleteCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.deleteCertificate(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export certificate
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||||
|
'exportCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.exportCertificate(dataArg.domain);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import certificate
|
||||||
|
adminRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||||
|
'importCertificate',
|
||||||
|
async (dataArg) => {
|
||||||
|
return this.importCertificate(dataArg.cert);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build domain-centric certificate overview.
|
||||||
|
* Instead of one row per route, we produce one row per unique domain.
|
||||||
|
*/
|
||||||
|
private async buildCertificateOverview(): Promise<interfaces.requests.ICertificateInfo[]> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
if (!smartProxy) return [];
|
||||||
|
|
||||||
|
const routes = smartProxy.routeManager.getRoutes();
|
||||||
|
|
||||||
|
// Phase 1: Collect unique domains with their associated route info
|
||||||
|
const domainMap = new Map<string, {
|
||||||
|
routeNames: string[];
|
||||||
|
source: interfaces.requests.TCertificateSource;
|
||||||
|
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||||
|
canReprovision: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
if (!route.name) continue;
|
||||||
|
|
||||||
|
const tls = route.action?.tls;
|
||||||
|
if (!tls) continue;
|
||||||
|
|
||||||
|
// Skip passthrough routes - they don't manage certificates
|
||||||
|
if (tls.mode === 'passthrough') continue;
|
||||||
|
|
||||||
|
const routeDomains = route.match.domains
|
||||||
|
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Determine source
|
||||||
|
let source: interfaces.requests.TCertificateSource = 'none';
|
||||||
|
if (tls.certificate === 'auto') {
|
||||||
|
if ((smartProxy.settings as any).certProvisionFunction) {
|
||||||
|
source = 'provision-function';
|
||||||
|
} else {
|
||||||
|
source = 'acme';
|
||||||
|
}
|
||||||
|
} else if (tls.certificate && typeof tls.certificate === 'object') {
|
||||||
|
source = 'static';
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReprovision = source === 'acme' || source === 'provision-function';
|
||||||
|
const tlsMode = tls.mode as 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
||||||
|
|
||||||
|
for (const domain of routeDomains) {
|
||||||
|
const existing = domainMap.get(domain);
|
||||||
|
if (existing) {
|
||||||
|
// Add this route name to the existing domain entry
|
||||||
|
if (!existing.routeNames.includes(route.name)) {
|
||||||
|
existing.routeNames.push(route.name);
|
||||||
|
}
|
||||||
|
// Upgrade source if more specific
|
||||||
|
if (existing.source === 'none' && source !== 'none') {
|
||||||
|
existing.source = source;
|
||||||
|
existing.canReprovision = canReprovision;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
domainMap.set(domain, {
|
||||||
|
routeNames: [route.name],
|
||||||
|
source,
|
||||||
|
tlsMode,
|
||||||
|
canReprovision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Resolve status for each unique domain
|
||||||
|
const certificates: interfaces.requests.ICertificateInfo[] = [];
|
||||||
|
|
||||||
|
for (const [domain, info] of domainMap) {
|
||||||
|
let status: interfaces.requests.TCertificateStatus = 'unknown';
|
||||||
|
let expiryDate: string | undefined;
|
||||||
|
let issuedAt: string | undefined;
|
||||||
|
let issuer: string | undefined;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
|
// Check event-based status from certificateStatusMap (now keyed by domain)
|
||||||
|
const eventStatus = dcRouter.certificateStatusMap.get(domain);
|
||||||
|
if (eventStatus) {
|
||||||
|
status = eventStatus.status;
|
||||||
|
expiryDate = eventStatus.expiryDate;
|
||||||
|
issuedAt = eventStatus.issuedAt;
|
||||||
|
error = eventStatus.error;
|
||||||
|
if (eventStatus.source) {
|
||||||
|
issuer = eventStatus.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SmartProxy certificate status if no event data
|
||||||
|
if (status === 'unknown' && info.routeNames.length > 0) {
|
||||||
|
try {
|
||||||
|
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||||
|
if (rustStatus) {
|
||||||
|
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||||
|
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||||
|
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||||
|
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||||
|
status = rustStatus.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Rust bridge may not support this command yet — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check persisted cert data from StorageManager
|
||||||
|
if (status === 'unknown') {
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||||
|
if (!certData) {
|
||||||
|
// Also check certStore path (proxy-certs)
|
||||||
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||||
|
}
|
||||||
|
if (certData?.validUntil) {
|
||||||
|
expiryDate = new Date(certData.validUntil).toISOString();
|
||||||
|
if (certData.created) {
|
||||||
|
issuedAt = new Date(certData.created).toISOString();
|
||||||
|
}
|
||||||
|
issuer = 'smartacme-dns-01';
|
||||||
|
} else if (certData?.publicKey) {
|
||||||
|
// certStore has the cert — parse PEM for expiry
|
||||||
|
try {
|
||||||
|
const x509 = new plugins.crypto.X509Certificate(certData.publicKey);
|
||||||
|
expiryDate = new Date(x509.validTo).toISOString();
|
||||||
|
issuedAt = new Date(x509.validFrom).toISOString();
|
||||||
|
} catch { /* PEM parsing failed */ }
|
||||||
|
status = 'valid';
|
||||||
|
issuer = 'cert-store';
|
||||||
|
} else if (certData) {
|
||||||
|
status = 'valid';
|
||||||
|
issuer = 'cert-store';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute status from expiry date
|
||||||
|
if (expiryDate && (status === 'valid' || status === 'unknown')) {
|
||||||
|
const expiry = new Date(expiryDate);
|
||||||
|
const now = new Date();
|
||||||
|
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (daysUntilExpiry < 0) {
|
||||||
|
status = 'expired';
|
||||||
|
} else if (daysUntilExpiry < 30) {
|
||||||
|
status = 'expiring';
|
||||||
|
} else {
|
||||||
|
status = 'valid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static certs with no other info default to 'valid'
|
||||||
|
if (info.source === 'static' && status === 'unknown') {
|
||||||
|
status = 'valid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACME/provision-function routes with no cert data are still provisioning
|
||||||
|
if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
|
||||||
|
status = 'provisioning';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Attach backoff info
|
||||||
|
let backoffInfo: interfaces.requests.ICertificateInfo['backoffInfo'];
|
||||||
|
if (dcRouter.certProvisionScheduler) {
|
||||||
|
const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
|
||||||
|
if (bi) {
|
||||||
|
backoffInfo = bi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
certificates.push({
|
||||||
|
domain,
|
||||||
|
routeNames: info.routeNames,
|
||||||
|
status,
|
||||||
|
source: info.source,
|
||||||
|
tlsMode: info.tlsMode,
|
||||||
|
expiryDate,
|
||||||
|
issuer,
|
||||||
|
issuedAt,
|
||||||
|
error,
|
||||||
|
canReprovision: info.canReprovision,
|
||||||
|
backoffInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSummary(certificates: interfaces.requests.ICertificateInfo[]): {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
expiring: number;
|
||||||
|
expired: number;
|
||||||
|
failed: number;
|
||||||
|
unknown: number;
|
||||||
|
} {
|
||||||
|
const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 };
|
||||||
|
summary.total = certificates.length;
|
||||||
|
for (const cert of certificates) {
|
||||||
|
switch (cert.status) {
|
||||||
|
case 'valid': summary.valid++; break;
|
||||||
|
case 'expiring': summary.expiring++; break;
|
||||||
|
case 'expired': summary.expired++; break;
|
||||||
|
case 'failed': summary.failed++; break;
|
||||||
|
case 'provisioning': // count as unknown
|
||||||
|
case 'unknown': summary.unknown++; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy route-based reprovisioning
|
||||||
|
*/
|
||||||
|
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
|
||||||
|
if (!smartProxy) {
|
||||||
|
return { success: false, message: 'SmartProxy is not running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await smartProxy.provisionCertificate(routeName);
|
||||||
|
// Clear event-based status for domains in this route
|
||||||
|
for (const [domain, entry] of dcRouter.certificateStatusMap) {
|
||||||
|
if (entry.routeNames.includes(routeName)) {
|
||||||
|
dcRouter.certificateStatusMap.delete(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err.message || 'Failed to reprovision certificate' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain-based reprovisioning — clears backoff first, then triggers provision
|
||||||
|
*/
|
||||||
|
private async reprovisionCertificateDomain(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
|
||||||
|
if (!smartProxy) {
|
||||||
|
return { success: false, message: 'SmartProxy is not running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear backoff for this domain (user override)
|
||||||
|
if (dcRouter.certProvisionScheduler) {
|
||||||
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear status map entry so it gets refreshed
|
||||||
|
dcRouter.certificateStatusMap.delete(domain);
|
||||||
|
|
||||||
|
// Try to provision via SmartAcme directly
|
||||||
|
if (dcRouter.smartAcme) {
|
||||||
|
try {
|
||||||
|
await dcRouter.smartAcme.getCertificateForDomain(domain);
|
||||||
|
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try provisioning via the first matching route
|
||||||
|
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||||
|
if (routeNames.length > 0) {
|
||||||
|
try {
|
||||||
|
await smartProxy.provisionCertificate(routeNames[0]);
|
||||||
|
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete certificate data for a domain from storage
|
||||||
|
*/
|
||||||
|
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Delete from all known storage paths
|
||||||
|
const paths = [
|
||||||
|
`/proxy-certs/${domain}`,
|
||||||
|
`/proxy-certs/${cleanDomain}`,
|
||||||
|
`/certs/${cleanDomain}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
try {
|
||||||
|
await dcRouter.storageManager.delete(path);
|
||||||
|
} catch {
|
||||||
|
// Path may not exist — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear from in-memory status map
|
||||||
|
dcRouter.certificateStatusMap.delete(domain);
|
||||||
|
|
||||||
|
// Clear backoff info
|
||||||
|
if (dcRouter.certProvisionScheduler) {
|
||||||
|
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: `Certificate data deleted for '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export certificate data for a domain as ICert-shaped JSON
|
||||||
|
*/
|
||||||
|
private async exportCertificate(domain: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
cert?: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Try SmartAcme /certs/ path first (has full ICert fields)
|
||||||
|
let certData = await dcRouter.storageManager.getJSON(`/certs/${cleanDomain}`);
|
||||||
|
if (certData && certData.publicKey && certData.privateKey) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: certData.id || plugins.crypto.randomUUID(),
|
||||||
|
domainName: certData.domainName || domain,
|
||||||
|
created: certData.created || Date.now(),
|
||||||
|
validUntil: certData.validUntil || 0,
|
||||||
|
privateKey: certData.privateKey,
|
||||||
|
publicKey: certData.publicKey,
|
||||||
|
csr: certData.csr || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try /proxy-certs/ with original domain
|
||||||
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${domain}`);
|
||||||
|
if (!certData || !certData.publicKey) {
|
||||||
|
// Try with clean domain
|
||||||
|
certData = await dcRouter.storageManager.getJSON(`/proxy-certs/${cleanDomain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certData && certData.publicKey && certData.privateKey) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cert: {
|
||||||
|
id: plugins.crypto.randomUUID(),
|
||||||
|
domainName: domain,
|
||||||
|
created: certData.validFrom || Date.now(),
|
||||||
|
validUntil: certData.validUntil || 0,
|
||||||
|
privateKey: certData.privateKey,
|
||||||
|
publicKey: certData.publicKey,
|
||||||
|
csr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: `No certificate data found for '${domain}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a certificate from ICert-shaped JSON
|
||||||
|
*/
|
||||||
|
private async importCertificate(cert: {
|
||||||
|
id: string;
|
||||||
|
domainName: string;
|
||||||
|
created: number;
|
||||||
|
validUntil: number;
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
csr: string;
|
||||||
|
}): Promise<{ success: boolean; message?: string }> {
|
||||||
|
// Validate PEM content
|
||||||
|
if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
|
||||||
|
return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
|
||||||
|
}
|
||||||
|
if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
|
||||||
|
return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
|
||||||
|
|
||||||
|
// Save to /certs/ (SmartAcme-compatible path)
|
||||||
|
await dcRouter.storageManager.setJSON(`/certs/${cleanDomain}`, {
|
||||||
|
id: cert.id,
|
||||||
|
domainName: cert.domainName,
|
||||||
|
created: cert.created,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
csr: cert.csr || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also save to /proxy-certs/ (proxy-cert format)
|
||||||
|
await dcRouter.storageManager.setJSON(`/proxy-certs/${cert.domainName}`, {
|
||||||
|
domain: cert.domainName,
|
||||||
|
publicKey: cert.publicKey,
|
||||||
|
privateKey: cert.privateKey,
|
||||||
|
ca: undefined,
|
||||||
|
validUntil: cert.validUntil,
|
||||||
|
validFrom: cert.created,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update in-memory status map
|
||||||
|
dcRouter.certificateStatusMap.set(cert.domainName, {
|
||||||
|
status: 'valid',
|
||||||
|
source: 'static',
|
||||||
|
expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
|
||||||
|
issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
|
||||||
|
routeNames: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, message: `Certificate imported for '${cert.domainName}'` };
|
||||||
|
}
|
||||||
|
}
|
||||||
214
ts/opsserver/handlers/config.handler.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import * as paths from '../../paths.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class ConfigHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
|
// Get Configuration Handler (read-only)
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||||
|
'getConfiguration',
|
||||||
|
async (dataArg, toolsArg) => {
|
||||||
|
const config = await this.getConfiguration();
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
section: dataArg.section,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
|
||||||
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const opts = dcRouter.options;
|
||||||
|
const resolvedPaths = dcRouter.resolvedPaths;
|
||||||
|
|
||||||
|
// --- System ---
|
||||||
|
const storageBackend: 'filesystem' | 'custom' | 'memory' = opts.storage?.readFunction
|
||||||
|
? 'custom'
|
||||||
|
: opts.storage?.fsPath
|
||||||
|
? 'filesystem'
|
||||||
|
: 'memory';
|
||||||
|
|
||||||
|
// Resolve proxy IPs: fall back to SmartProxy's runtime proxyIPs if not in opts
|
||||||
|
let proxyIps = opts.proxyIps || [];
|
||||||
|
if (proxyIps.length === 0 && dcRouter.smartProxy) {
|
||||||
|
const spSettings = (dcRouter.smartProxy as any).settings;
|
||||||
|
if (spSettings?.proxyIPs?.length > 0) {
|
||||||
|
proxyIps = spSettings.proxyIPs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const system: interfaces.requests.IConfigData['system'] = {
|
||||||
|
baseDir: resolvedPaths.dcrouterHomeDir,
|
||||||
|
dataDir: resolvedPaths.dataDir,
|
||||||
|
publicIp: opts.publicIp || dcRouter.detectedPublicIp || null,
|
||||||
|
proxyIps,
|
||||||
|
uptime: Math.floor(process.uptime()),
|
||||||
|
storageBackend,
|
||||||
|
storagePath: opts.storage?.fsPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SmartProxy ---
|
||||||
|
let acmeInfo: interfaces.requests.IConfigData['smartProxy']['acme'] = null;
|
||||||
|
if (opts.smartProxyConfig?.acme) {
|
||||||
|
const acme = opts.smartProxyConfig.acme;
|
||||||
|
acmeInfo = {
|
||||||
|
enabled: acme.enabled !== false,
|
||||||
|
accountEmail: acme.accountEmail || '',
|
||||||
|
useProduction: acme.useProduction !== false,
|
||||||
|
autoRenew: acme.autoRenew !== false,
|
||||||
|
renewThresholdDays: acme.renewThresholdDays || 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let routeCount = 0;
|
||||||
|
if (dcRouter.routeConfigManager) {
|
||||||
|
try {
|
||||||
|
const merged = await dcRouter.routeConfigManager.getMergedRoutes();
|
||||||
|
routeCount = merged.routes.length;
|
||||||
|
} catch {
|
||||||
|
routeCount = opts.smartProxyConfig?.routes?.length || 0;
|
||||||
|
}
|
||||||
|
} else if (opts.smartProxyConfig?.routes) {
|
||||||
|
routeCount = opts.smartProxyConfig.routes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smartProxy: interfaces.requests.IConfigData['smartProxy'] = {
|
||||||
|
enabled: !!dcRouter.smartProxy,
|
||||||
|
routeCount,
|
||||||
|
acme: acmeInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Email ---
|
||||||
|
let emailDomains: string[] = [];
|
||||||
|
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
|
||||||
|
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
|
||||||
|
} else if (opts.emailConfig?.domains) {
|
||||||
|
emailDomains = opts.emailConfig.domains.map((d: any) =>
|
||||||
|
typeof d === 'string' ? d : d.domain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let portMapping: Record<string, number> | null = null;
|
||||||
|
if (opts.emailPortConfig?.portMapping) {
|
||||||
|
portMapping = {};
|
||||||
|
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
|
||||||
|
portMapping[String(ext)] = int as number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const email: interfaces.requests.IConfigData['email'] = {
|
||||||
|
enabled: !!dcRouter.emailServer,
|
||||||
|
ports: opts.emailConfig?.ports || [],
|
||||||
|
portMapping,
|
||||||
|
hostname: opts.emailConfig?.hostname || null,
|
||||||
|
domains: emailDomains,
|
||||||
|
emailRouteCount: opts.emailConfig?.routes?.length || 0,
|
||||||
|
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DNS ---
|
||||||
|
const dnsRecords = (opts.dnsRecords || []).map(r => ({
|
||||||
|
name: r.name,
|
||||||
|
type: r.type,
|
||||||
|
value: r.value,
|
||||||
|
ttl: r.ttl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||||
|
enabled: !!dcRouter.dnsServer,
|
||||||
|
port: 53,
|
||||||
|
nsDomains: opts.dnsNsDomains || [],
|
||||||
|
scopes: opts.dnsScopes || [],
|
||||||
|
recordCount: dnsRecords.length,
|
||||||
|
records: dnsRecords,
|
||||||
|
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- TLS ---
|
||||||
|
let tlsSource: 'acme' | 'static' | 'none' = 'none';
|
||||||
|
if (opts.tls?.certPath && opts.tls?.keyPath) {
|
||||||
|
tlsSource = 'static';
|
||||||
|
} else if (opts.smartProxyConfig?.acme?.enabled !== false && opts.smartProxyConfig?.acme) {
|
||||||
|
tlsSource = 'acme';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tls: interfaces.requests.IConfigData['tls'] = {
|
||||||
|
contactEmail: opts.tls?.contactEmail || opts.smartProxyConfig?.acme?.accountEmail || null,
|
||||||
|
domain: opts.tls?.domain || null,
|
||||||
|
source: tlsSource,
|
||||||
|
certPath: opts.tls?.certPath || null,
|
||||||
|
keyPath: opts.tls?.keyPath || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Cache ---
|
||||||
|
const cacheConfig = opts.cacheConfig;
|
||||||
|
const cache: interfaces.requests.IConfigData['cache'] = {
|
||||||
|
enabled: cacheConfig?.enabled !== false,
|
||||||
|
storagePath: cacheConfig?.storagePath || resolvedPaths.defaultTsmDbPath,
|
||||||
|
dbName: cacheConfig?.dbName || 'dcrouter',
|
||||||
|
defaultTTLDays: cacheConfig?.defaultTTLDays || 30,
|
||||||
|
cleanupIntervalHours: cacheConfig?.cleanupIntervalHours || 1,
|
||||||
|
ttlConfig: cacheConfig?.ttlConfig ? { ...cacheConfig.ttlConfig } as Record<string, number> : {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- RADIUS ---
|
||||||
|
const radiusCfg = opts.radiusConfig;
|
||||||
|
const radius: interfaces.requests.IConfigData['radius'] = {
|
||||||
|
enabled: !!dcRouter.radiusServer,
|
||||||
|
authPort: radiusCfg?.authPort || null,
|
||||||
|
acctPort: radiusCfg?.acctPort || null,
|
||||||
|
bindAddress: radiusCfg?.bindAddress || null,
|
||||||
|
clientCount: radiusCfg?.clients?.length || 0,
|
||||||
|
vlanDefaultVlan: radiusCfg?.vlanAssignment?.defaultVlan ?? null,
|
||||||
|
vlanAllowUnknownMacs: radiusCfg?.vlanAssignment?.allowUnknownMacs ?? null,
|
||||||
|
vlanMappingCount: radiusCfg?.vlanAssignment?.mappings?.length || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Remote Ingress ---
|
||||||
|
const riCfg = opts.remoteIngressConfig;
|
||||||
|
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
|
||||||
|
|
||||||
|
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
|
||||||
|
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
|
||||||
|
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
|
||||||
|
tlsMode = 'custom';
|
||||||
|
} else if (riCfg?.hubDomain) {
|
||||||
|
try {
|
||||||
|
const stored = await dcRouter.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
||||||
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
|
tlsMode = 'acme';
|
||||||
|
}
|
||||||
|
} catch { /* no stored cert */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
|
||||||
|
enabled: !!dcRouter.remoteIngressManager,
|
||||||
|
tunnelPort: riCfg?.tunnelPort || null,
|
||||||
|
hubDomain: riCfg?.hubDomain || null,
|
||||||
|
tlsMode,
|
||||||
|
connectedEdgeIps,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
system,
|
||||||
|
smartProxy,
|
||||||
|
email,
|
||||||
|
dns,
|
||||||
|
tls,
|
||||||
|
cache,
|
||||||
|
radius,
|
||||||
|
remoteIngress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
273
ts/opsserver/handlers/email-ops.handler.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class EmailOpsHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
|
// Get All Emails Handler
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
||||||
|
'getAllEmails',
|
||||||
|
async (dataArg) => {
|
||||||
|
const emails = this.getAllQueueEmails();
|
||||||
|
return { emails };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Email Detail Handler
|
||||||
|
viewRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||||
|
'getEmailDetail',
|
||||||
|
async (dataArg) => {
|
||||||
|
const email = this.getEmailDetail(dataArg.emailId);
|
||||||
|
return { email };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter) ----
|
||||||
|
|
||||||
|
// Resend Failed Email Handler
|
||||||
|
adminRouter.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 {
|
||||||
|
const newQueueId = await queue.enqueue(
|
||||||
|
item.processingResult,
|
||||||
|
item.processingMode,
|
||||||
|
item.route
|
||||||
|
);
|
||||||
|
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 all queue items mapped to catalog IEmail format
|
||||||
|
*/
|
||||||
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||||
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer?.deliveryQueue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = emailServer.deliveryQueue;
|
||||||
|
const queueMap = (queue as any).queue as Map<string, any>;
|
||||||
|
|
||||||
|
if (!queueMap) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emails: interfaces.requests.IEmail[] = [];
|
||||||
|
|
||||||
|
for (const [id, item] of queueMap.entries()) {
|
||||||
|
emails.push(this.mapQueueItemToEmail(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending (newest first)
|
||||||
|
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
|
||||||
|
return emails;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single email detail by ID
|
||||||
|
*/
|
||||||
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||||
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer?.deliveryQueue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = emailServer.deliveryQueue;
|
||||||
|
const item = queue.getItem(emailId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapQueueItemToEmailDetail(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a queue item to catalog IEmail format
|
||||||
|
*/
|
||||||
|
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
|
||||||
|
const processingResult = item.processingResult;
|
||||||
|
let from = '';
|
||||||
|
let to = '';
|
||||||
|
let subject = '';
|
||||||
|
let messageId = '';
|
||||||
|
let size = '0 B';
|
||||||
|
|
||||||
|
if (processingResult) {
|
||||||
|
if (processingResult.email) {
|
||||||
|
from = processingResult.email.from || '';
|
||||||
|
to = (processingResult.email.to || [])[0] || '';
|
||||||
|
subject = processingResult.email.subject || '';
|
||||||
|
} else if (processingResult.from) {
|
||||||
|
from = processingResult.from;
|
||||||
|
to = (processingResult.to || [])[0] || '';
|
||||||
|
subject = processingResult.subject || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get messageId
|
||||||
|
if (typeof processingResult.getMessageId === 'function') {
|
||||||
|
try {
|
||||||
|
messageId = processingResult.getMessageId() || '';
|
||||||
|
} catch {
|
||||||
|
messageId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute approximate size
|
||||||
|
const textLen = processingResult.text?.length || 0;
|
||||||
|
const htmlLen = processingResult.html?.length || 0;
|
||||||
|
let attachSize = 0;
|
||||||
|
if (typeof processingResult.getAttachmentsSize === 'function') {
|
||||||
|
try {
|
||||||
|
attachSize = processingResult.getAttachmentsSize() || 0;
|
||||||
|
} catch {
|
||||||
|
attachSize = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size = this.formatSize(textLen + htmlLen + attachSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map queue status to catalog TEmailStatus
|
||||||
|
const status = this.mapStatus(item.status);
|
||||||
|
|
||||||
|
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
direction: 'outbound' as interfaces.requests.TEmailDirection,
|
||||||
|
status,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
timestamp: new Date(createdAt).toISOString(),
|
||||||
|
messageId,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a queue item to catalog IEmailDetail format
|
||||||
|
*/
|
||||||
|
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
|
||||||
|
const base = this.mapQueueItemToEmail(item);
|
||||||
|
const processingResult = item.processingResult;
|
||||||
|
|
||||||
|
let toList: string[] = [];
|
||||||
|
let cc: string[] = [];
|
||||||
|
let headers: Record<string, string> = {};
|
||||||
|
let body = '';
|
||||||
|
|
||||||
|
if (processingResult) {
|
||||||
|
if (processingResult.email) {
|
||||||
|
toList = processingResult.email.to || [];
|
||||||
|
cc = processingResult.email.cc || [];
|
||||||
|
} else {
|
||||||
|
toList = processingResult.to || [];
|
||||||
|
cc = processingResult.cc || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = processingResult.headers || {};
|
||||||
|
body = processingResult.html || processingResult.text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toList,
|
||||||
|
cc,
|
||||||
|
smtpLog: [],
|
||||||
|
connectionInfo: {
|
||||||
|
sourceIp: '',
|
||||||
|
sourceHostname: '',
|
||||||
|
destinationIp: '',
|
||||||
|
destinationPort: 0,
|
||||||
|
tlsVersion: '',
|
||||||
|
tlsCipher: '',
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: '',
|
||||||
|
authUser: '',
|
||||||
|
},
|
||||||
|
authenticationResults: {
|
||||||
|
spf: 'none',
|
||||||
|
spfDomain: '',
|
||||||
|
dkim: 'none',
|
||||||
|
dkimDomain: '',
|
||||||
|
dmarc: 'none',
|
||||||
|
dmarcPolicy: '',
|
||||||
|
},
|
||||||
|
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
|
||||||
|
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map queue status to catalog TEmailStatus
|
||||||
|
*/
|
||||||
|
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
|
||||||
|
switch (queueStatus) {
|
||||||
|
case 'pending':
|
||||||
|
case 'processing':
|
||||||
|
return 'pending';
|
||||||
|
case 'delivered':
|
||||||
|
return 'delivered';
|
||||||
|
case 'failed':
|
||||||
|
return 'bounced';
|
||||||
|
case 'deferred':
|
||||||
|
return 'deferred';
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format byte size to human-readable string
|
||||||
|
*/
|
||||||
|
private formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
ts/opsserver/handlers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export * from './admin.handler.js';
|
||||||
|
export * from './config.handler.js';
|
||||||
|
export * from './logs.handler.js';
|
||||||
|
export * from './security.handler.js';
|
||||||
|
export * from './stats.handler.js';
|
||||||
|
export * from './radius.handler.js';
|
||||||
|
export * from './email-ops.handler.js';
|
||||||
|
export * from './certificate.handler.js';
|
||||||
|
export * from './remoteingress.handler.js';
|
||||||
|
export * from './route-management.handler.js';
|
||||||
|
export * from './api-token.handler.js';
|
||||||
340
ts/opsserver/handlers/logs.handler.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
import { logBuffer, baseLogger } from '../../logger.js';
|
||||||
|
|
||||||
|
// Module-level singleton: the log push destination is added once and reuses
|
||||||
|
// the current OpsServer reference so it survives OpsServer restarts without
|
||||||
|
// accumulating duplicate destinations.
|
||||||
|
let logPushDestinationInstalled = false;
|
||||||
|
let currentOpsServerRef: OpsServer | null = null;
|
||||||
|
|
||||||
|
export class LogsHandler {
|
||||||
|
private activeStreamStops: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
this.setupLogPushDestination();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all active log streams and deactivate the push destination.
|
||||||
|
* Called when OpsServer stops.
|
||||||
|
*/
|
||||||
|
public cleanup(): void {
|
||||||
|
// Stop all active follow-mode log streams
|
||||||
|
for (const stop of this.activeStreamStops) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
this.activeStreamStops.clear();
|
||||||
|
// Deactivate the push destination (it stays registered but becomes a no-op)
|
||||||
|
currentOpsServerRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// All log endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
|
// Get Recent Logs Handler
|
||||||
|
router.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,
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get Log Stream Handler
|
||||||
|
router.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();
|
||||||
|
|
||||||
|
// Track the stop function so we can clean up on shutdown
|
||||||
|
this.activeStreamStops.add(streamLogs.stop);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logStream: virtualStream as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
||||||
|
switch (smartlogLevel) {
|
||||||
|
case 'silly':
|
||||||
|
case 'debug':
|
||||||
|
return 'debug';
|
||||||
|
case 'warn':
|
||||||
|
return 'warn';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static deriveCategory(
|
||||||
|
zone?: string,
|
||||||
|
message?: string
|
||||||
|
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
|
||||||
|
const msg = (message || '').toLowerCase();
|
||||||
|
if (msg.includes('[security:') || msg.includes('security')) return 'security';
|
||||||
|
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
|
||||||
|
if (zone === 'dns' || msg.includes('dns')) return 'dns';
|
||||||
|
if (msg.includes('smtp')) return 'smtp';
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}>> {
|
||||||
|
// Compute a timestamp cutoff from timeRange
|
||||||
|
let since: number | undefined;
|
||||||
|
if (timeRange) {
|
||||||
|
const rangeMs: Record<string, number> = {
|
||||||
|
'1h': 3600000,
|
||||||
|
'6h': 21600000,
|
||||||
|
'24h': 86400000,
|
||||||
|
'7d': 604800000,
|
||||||
|
'30d': 2592000000,
|
||||||
|
};
|
||||||
|
since = Date.now() - (rangeMs[timeRange] || 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the UI level to smartlog levels for filtering
|
||||||
|
const smartlogLevels: string[] | undefined = level
|
||||||
|
? level === 'debug'
|
||||||
|
? ['debug', 'silly']
|
||||||
|
: level === 'info'
|
||||||
|
? ['info', 'ok', 'success', 'note', 'lifecycle']
|
||||||
|
: [level]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Fetch a larger batch from buffer, then apply category filter client-side
|
||||||
|
const rawEntries = logBuffer.getEntries({
|
||||||
|
level: smartlogLevels as any,
|
||||||
|
search,
|
||||||
|
since,
|
||||||
|
limit: limit * 3, // over-fetch to compensate for category filtering
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map ILogPackage → UI log format and apply category filter
|
||||||
|
const mapped: Array<{
|
||||||
|
timestamp: number;
|
||||||
|
level: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
|
message: string;
|
||||||
|
metadata?: any;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const pkg of rawEntries) {
|
||||||
|
const uiLevel = LogsHandler.mapLogLevel(pkg.level);
|
||||||
|
const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
|
||||||
|
|
||||||
|
if (category && uiCategory !== category) continue;
|
||||||
|
|
||||||
|
mapped.push({
|
||||||
|
timestamp: pkg.timestamp,
|
||||||
|
level: uiLevel,
|
||||||
|
category: uiCategory,
|
||||||
|
message: pkg.message,
|
||||||
|
metadata: pkg.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mapped.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a log destination to the base logger that pushes entries
|
||||||
|
* to all connected ops_dashboard TypedSocket clients.
|
||||||
|
*
|
||||||
|
* Uses a module-level singleton so the destination is added only once,
|
||||||
|
* even across OpsServer restart cycles. The destination reads
|
||||||
|
* `currentOpsServerRef` dynamically so it always uses the active server.
|
||||||
|
*/
|
||||||
|
private setupLogPushDestination(): void {
|
||||||
|
// Update the module-level reference so the existing destination uses the new server
|
||||||
|
currentOpsServerRef = this.opsServerRef;
|
||||||
|
|
||||||
|
if (logPushDestinationInstalled) {
|
||||||
|
return; // destination already registered — just updated the ref
|
||||||
|
}
|
||||||
|
logPushDestinationInstalled = true;
|
||||||
|
|
||||||
|
baseLogger.addLogDestination({
|
||||||
|
async handleLog(logPackage: any) {
|
||||||
|
const opsServer = currentOpsServerRef;
|
||||||
|
if (!opsServer) return;
|
||||||
|
|
||||||
|
const typedsocket = opsServer.server?.typedserver?.typedsocket;
|
||||||
|
if (!typedsocket) return;
|
||||||
|
|
||||||
|
let connections: any[];
|
||||||
|
try {
|
||||||
|
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connections.length === 0) return;
|
||||||
|
|
||||||
|
const entry: interfaces.data.ILogEntry = {
|
||||||
|
timestamp: logPackage.timestamp || Date.now(),
|
||||||
|
level: LogsHandler.mapLogLevel(logPackage.level),
|
||||||
|
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
|
||||||
|
message: logPackage.message,
|
||||||
|
metadata: logPackage.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const conn of connections) {
|
||||||
|
try {
|
||||||
|
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
|
||||||
|
'pushLogEntry',
|
||||||
|
conn,
|
||||||
|
);
|
||||||
|
push.fire({ entry }).catch(() => {}); // fire-and-forget
|
||||||
|
} catch {
|
||||||
|
// connection may have closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 stopped = false;
|
||||||
|
let logIndex = 0;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
this.activeStreamStops.delete(stop);
|
||||||
|
};
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For follow mode, simulate real-time log streaming
|
||||||
|
intervalId = setInterval(async () => {
|
||||||
|
if (stopped) {
|
||||||
|
// Guard: clear interval if stop() was called between ticks
|
||||||
|
clearInterval(intervalId!);
|
||||||
|
intervalId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
try {
|
||||||
|
// Use a timeout to detect hung streams (sendData can hang if the
|
||||||
|
// VirtualStream's keepAlive loop has ended)
|
||||||
|
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||||
|
await Promise.race([
|
||||||
|
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Stream closed, errored, or timed out — clean up
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { start, stop };
|
||||||
|
}
|
||||||
|
}
|
||||||
403
ts/opsserver/handlers/radius.handler.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
export class RadiusHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
// ========================================================================
|
||||||
|
// RADIUS Client Management
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// Get all RADIUS clients (read)
|
||||||
|
viewRouter.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 (write)
|
||||||
|
adminRouter.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 (write)
|
||||||
|
adminRouter.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 (read)
|
||||||
|
viewRouter.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 (write)
|
||||||
|
adminRouter.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 (write)
|
||||||
|
adminRouter.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 (write)
|
||||||
|
adminRouter.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 (read)
|
||||||
|
viewRouter.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 (read)
|
||||||
|
viewRouter.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 (write)
|
||||||
|
adminRouter.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 (read)
|
||||||
|
viewRouter.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 (read)
|
||||||
|
viewRouter.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||