Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de65641f6f | |||
| ffddc1a5f5 | |||
| 26152e0520 | |||
| f79ad07a57 | |||
| 76d5b9bf7c | |||
| 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 |
31
.playwright-mcp/console-2026-02-23T12-47-06-007Z.log
Normal file
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
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
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
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
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
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
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
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
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
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-03-02T19-32-32-890Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-02T19-32-32-890Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
.playwright-mcp/page-2026-03-02T19-33-32-637Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-02T19-33-32-637Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
.playwright-mcp/page-2026-03-03T21-48-01-361Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-03T21-48-01-361Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
.playwright-mcp/page-2026-03-03T22-07-21-001Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-03T22-07-21-001Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
240
changelog.md
240
changelog.md
@@ -1,5 +1,245 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-05 - 11.0.7 - fix(deps)
|
||||||
|
bump @git.zone/tsbuild to ^4.1.3 and @push.rocks/lik to ^6.3.1
|
||||||
|
|
||||||
|
- Updated devDependency @git.zone/tsbuild from ^4.1.2 to ^4.1.3 in package.json
|
||||||
|
- Updated dependency @push.rocks/lik from ^6.2.2 to ^6.3.1 in package.json
|
||||||
|
- Changes are non-breaking dependency bumps; no source code changes
|
||||||
|
|
||||||
|
## 2026-03-04 - 11.0.5 - fix(none)
|
||||||
|
no changes detected; nothing to release
|
||||||
|
|
||||||
|
- Diff contained no changes
|
||||||
|
- No files modified; no version bump required
|
||||||
|
|
||||||
|
## 2026-03-04 - 11.0.4 - fix()
|
||||||
|
no changes
|
||||||
|
|
||||||
|
- No files changed in the provided diff; no release or version bump required.
|
||||||
|
|
||||||
|
## 2026-03-04 - 11.0.3 - fix()
|
||||||
|
no changes detected
|
||||||
|
|
||||||
|
- Diff shows no file changes; no code changes to release.
|
||||||
|
|
||||||
|
## 2026-03-04 - 11.0.2 - fix(dcrouter)
|
||||||
|
no changes detected; no files were modified
|
||||||
|
|
||||||
|
- diff was empty
|
||||||
|
- no source or package changes detected
|
||||||
|
|
||||||
|
## 2026-03-04 - 11.0.1 - fix(auth)
|
||||||
|
treat expired JWTs as no identity, improve logout and token verification flow, and bump deps
|
||||||
|
|
||||||
|
- App: getActionContext now treats expired JWTs as null to avoid using stale identities for requests.
|
||||||
|
- Logout action always clears local login state; server-side adminLogout is attempted only when a valid identity exists.
|
||||||
|
- Dashboard: verify persisted JWT with server (verifyIdentity) on startup; if verification fails, clear state and show login.
|
||||||
|
- Auto-refresh: on combined refresh failure, detect auth-related errors (invalid/unauthorized/401), dispatch logout and reload to force re-login.
|
||||||
|
- Deps: bumped devDependencies @git.zone/tstest (^3.2.0) and @git.zone/tswatch (^3.2.5); added runtime dependency @push.rocks/lik (^6.2.2).
|
||||||
|
- Tests/artifacts: added Playwright console logs and page screenshots (test artifacts) to the commit.
|
||||||
|
|
||||||
|
## 2026-03-03 - 11.0.0 - BREAKING CHANGE(opsserver)
|
||||||
|
Require authentication for OpsServer endpoints, split handlers into authenticated view/admin routers, and make identity required on many TypedRequest interfaces
|
||||||
|
|
||||||
|
- Added viewRouter and adminRouter to OpsServer and wired middleware to enforce identity/admin checks (requireValidIdentity, requireAdminIdentity).
|
||||||
|
- Moved handlers to appropriate routers (viewRouter for read endpoints, adminRouter for write/admin endpoints) instead of registering on the unauthenticated main typedrouter.
|
||||||
|
- Made identity a required field on numerous ts_interfaces request types (breaking change to request typings).
|
||||||
|
- Refactored ApiTokenHandler to register directly on adminRouter and use dataArg.identity.userId (no per-handler admin checks needed thanks to middleware).
|
||||||
|
- Updated tests: added admin login to obtain identity, adjusted protected endpoint tests to expect rejection when unauthenticated, and adapted other tests to pass identity where required.
|
||||||
|
- Added IReq_GetNetworkStats request/response typings to ts_interfaces/requests/stats.ts.
|
||||||
|
- Bumped dependencies: @api.global/typedrequest ^3.3.0 and @api.global/typedserver ^8.4.2.
|
||||||
|
|
||||||
|
## 2026-03-03 - 10.1.9 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.9.1
|
||||||
|
|
||||||
|
- Updated package.json dependency @push.rocks/smartproxy from ^25.9.0 to ^25.9.1
|
||||||
|
- No other code changes; current package version is 10.1.8, recommend a patch release
|
||||||
|
|
||||||
|
## 2026-03-03 - 10.1.8 - fix(deps)
|
||||||
|
bump dependencies: @push.rocks/smartmetrics to ^3.0.2, @push.rocks/smartproxy to ^25.9.0, @serve.zone/remoteingress to ^4.4.0
|
||||||
|
|
||||||
|
- @push.rocks/smartmetrics: 3.0.1 -> 3.0.2 (patch)
|
||||||
|
- @push.rocks/smartproxy: 25.8.5 -> 25.9.0 (minor)
|
||||||
|
- @serve.zone/remoteingress: 4.3.0 -> 4.4.0 (minor)
|
||||||
|
|
||||||
|
## 2026-03-03 - 10.1.7 - fix(ops-view-apitokens)
|
||||||
|
use correct lucide icon name for roll/rotate actions in API tokens view
|
||||||
|
|
||||||
|
- Updated iconName from 'lucide:rotate-cw' to 'lucide:rotateCw' in ts_web/elements/ops-view-apitokens.ts (two occurrences) to match lucide icon naming and ensure icons render correctly
|
||||||
|
- Non-functional UI fix; no API or behavior changes
|
||||||
|
|
||||||
|
## 2026-03-02 - 10.1.6 - fix(ts_web)
|
||||||
|
use actionContext for dispatches in web state actions and bump @push.rocks/smartstate to ^2.2.0
|
||||||
|
|
||||||
|
- Action handlers in ts_web/appstate.ts now accept an actionContext parameter and call await actionContext.dispatch(...) instead of using statePartArg.dispatchAction(...).
|
||||||
|
- Handlers return the awaited dispatch result (ensuring callers receive refreshed state) instead of returning the previous statePartArg.getState().
|
||||||
|
- Dependency bumped in package.json: @push.rocks/smartstate from ^2.1.1 to ^2.2.0.
|
||||||
|
- Playwright artifacts (logs and page screenshots) were added under .playwright-mcp.
|
||||||
|
|
||||||
|
## 2026-03-02 - 10.1.5 - fix(monitoring)
|
||||||
|
use a per-second ring buffer for DNS query metrics, improve DNS logging rate limiting and security event aggregation, and bump smartmta dependency
|
||||||
|
|
||||||
|
- Replace unbounded query timestamp array with a fixed-size per-second Int32Array ring buffer (300s) to calculate queries-per-second with O(1) updates and bounded memory
|
||||||
|
- Add incrementQueryRing and getQueryRingSum helpers to correctly zero stale slots and sum recent seconds
|
||||||
|
- Change metrics cache interval from 200ms to 1000ms to better match dashboard polling and reduce update frequency
|
||||||
|
- Refactor DNS adaptive logging to use per-second counters (dnsLogWindowSecond / dnsLogWindowCount) instead of timestamp arrays to avoid per-query array filtering and improve rate limiting accuracy; reset counters on flush
|
||||||
|
- Security logger: avoid mutating source when sorting/filtering, and implement single-pass aggregation with optional time-window filtering for byLevel/byType/top lists
|
||||||
|
- Bump dependency @push.rocks/smartmta from ^5.3.0 to ^5.3.1
|
||||||
|
|
||||||
|
## 2026-03-02 - 10.1.4 - fix(no-changes)
|
||||||
|
no changes detected; no version bump required
|
||||||
|
|
||||||
|
- package version is 10.1.3
|
||||||
|
- git diff contains no changes
|
||||||
|
|
||||||
|
## 2026-03-02 - 10.1.3 - fix(deps)
|
||||||
|
bump @api.global/typedrequest to ^3.2.7
|
||||||
|
|
||||||
|
- Updated @api.global/typedrequest from ^3.2.6 to ^3.2.7 in package.json
|
||||||
|
- Dependency patch bump only — no source code changes detected
|
||||||
|
- Current package version 10.1.2 -> recommended next version 10.1.3 (patch)
|
||||||
|
|
||||||
|
## 2026-03-01 - 10.1.2 - fix(core)
|
||||||
|
improve shutdown cleanup, socket/stream robustness, and memory/cache handling
|
||||||
|
|
||||||
|
- Reset security singletons and CacheDb on shutdown to allow GC (SecurityLogger, ContentScanner, IPReputationChecker, CacheDb).
|
||||||
|
- Add DNS socket 'error' handler and only destroy socket when not already destroyed to avoid uncaught exceptions.
|
||||||
|
- Move pruning of dnsMetrics.queryTimestamps to a periodic interval to avoid O(n) work on every query.
|
||||||
|
- Debounce IPReputationChecker cache saves (save timer + reset on instance reset) to reduce IO and prevent duplicate saves.
|
||||||
|
- Fix virtualStream send timeout handling by keeping/clearing a timeout handle to avoid leaks and hung promises.
|
||||||
|
- Add memory store eviction in StorageManager to cap entries (MAX_MEMORY_ENTRIES) and evict oldest entries when exceeded.
|
||||||
|
- Add terminal-ready timeout in ops-view-logs to avoid blocking UI initialization if xterm CDN fails to initialize.
|
||||||
|
- Bump dev dependency @types/node and push.rocks/smartstate versions.
|
||||||
|
|
||||||
|
## 2026-02-27 - 10.1.1 - fix(ops-view-apitokens)
|
||||||
|
replace lucide:refresh-cw with lucide:rotate-cw for Roll action icon
|
||||||
|
|
||||||
|
- Updated ts_web/elements/ops-view-apitokens.ts: changed iconName in two locations to 'lucide:rotate-cw' for the Roll/Roll Token actions.
|
||||||
|
- UI-only change — no functional or API behavior modified.
|
||||||
|
- Current package version is 10.1.0; recommended patch bump to 10.1.1.
|
||||||
|
|
||||||
|
## 2026-02-27 - 10.1.0 - feat(api-tokens)
|
||||||
|
add ability to roll (regenerate) API token secrets and UI to display the newly generated token once
|
||||||
|
|
||||||
|
- Server: added ApiTokenManager.rollToken(id) to regenerate a token secret, update its hash, persist it and log the action.
|
||||||
|
- Server: added opsserver handler 'rollApiToken' which requires admin identity and returns the new raw token value (shown once) or error messages.
|
||||||
|
- API: added typed request interface IReq_RollApiToken for the rollApiToken RPC.
|
||||||
|
- Web: added appstate.rollApiToken wrapper to call the new typed request.
|
||||||
|
- UI: ops-view-apitokens updated with a 'Roll' action and a modal flow to confirm rolling, call the API, refresh token list, and present the new token value to copy (token value is shown only once).
|
||||||
|
- Security: operation is admin-only and the raw token is returned only once after rolling.
|
||||||
|
|
||||||
|
## 2026-02-27 - 10.0.0 - BREAKING CHANGE(remote-ingress)
|
||||||
|
replace tlsConfigured boolean with tlsMode ('custom' | 'acme' | 'self-signed') and compute TLS mode server-side
|
||||||
|
|
||||||
|
- Server: compute remoteIngress.tlsMode = 'custom' when custom certPath/keyPath provided; else attempt to detect ACME by checking stored certs for hubDomain; default to 'self-signed' as fallback.
|
||||||
|
- API: replaced remoteIngress.tlsConfigured:boolean with tlsMode:'custom'|'acme'|'self-signed' — this is a breaking change for consumers of the config API.
|
||||||
|
- UI: ops view updated to display TLS Mode as a badge instead of a boolean "TLS Configured" field.
|
||||||
|
- Action required: update clients and integrations to read remoteIngress.tlsMode instead of tlsConfigured.
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.3.0 - feat(remoteingress)
|
||||||
|
add TLS certificate resolution and passthrough for RemoteIngress tunnel
|
||||||
|
|
||||||
|
- Resolve TLS certs for the RemoteIngress tunnel with priority: explicit certPath/keyPath files → stored ACME cert for hubDomain → fallback to self-signed
|
||||||
|
- Expose tls option on ITunnelManagerConfig and forward certPem/keyPem into hub.start so the hub can use the provided TLS materials
|
||||||
|
- Add logging for cert selection and file read failures
|
||||||
|
- Bump dependency @serve.zone/remoteingress from ^4.2.0 to ^4.3.0
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.2.0 - feat(remoteingress)
|
||||||
|
expose connected edge IPs and detected public IP; resolve proxy IPs from SmartProxy and improve ops UI
|
||||||
|
|
||||||
|
- Add detectedPublicIp to DC Router and populate it when a configured or auto-discovered public IP is chosen
|
||||||
|
- Use dcRouter.detectedPublicIp as a fallback for system.publicIp in the config handler
|
||||||
|
- Resolve proxy IPs from SmartProxy runtime settings when opts.proxyIps is not provided
|
||||||
|
- TunnelManager: capture peerAddr on edgeConnected and from Rust heartbeats, store per-edge publicIp, and add getConnectedEdgeIps()
|
||||||
|
- Expose connectedEdgeIps in the config API and return it in remoteIngress config
|
||||||
|
- Ops UI: show Connected Edge IPs, annotate 127.0.0.1 proxy IP as 'Remote Ingress' when applicable, and refresh remote ingress data during combined refresh when viewing remoteingress
|
||||||
|
- Bump dependency @serve.zone/remoteingress to ^4.2.0
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.1.10 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.8.5
|
||||||
|
|
||||||
|
- package.json: @push.rocks/smartproxy version updated from ^25.8.4 to ^25.8.5
|
||||||
|
- No other files changed
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.1.9 - fix(deps(smartmta))
|
||||||
|
bump @push.rocks/smartmta to ^5.3.0
|
||||||
|
|
||||||
|
- Updated @push.rocks/smartmta from ^5.2.6 to ^5.3.0 in package.json
|
||||||
|
- Patch release recommended (no source code changes)
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.1.8 - fix(deps)
|
||||||
|
bump @serve.zone/remoteingress to ^4.1.0
|
||||||
|
|
||||||
|
- Updated dependency @serve.zone/remoteingress from ^4.0.1 to ^4.1.0 in package.json
|
||||||
|
- Non-breaking dependency update; recommend patch version bump
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.1.7 - fix(dcrouter)
|
||||||
|
bump @push.rocks/smartproxy to ^25.8.4 and remove custom smartProxy timeout/connection lifetime settings from dcrouter
|
||||||
|
|
||||||
|
- Bumped dependency @push.rocks/smartproxy from ^25.8.3 to ^25.8.4 in package.json
|
||||||
|
- Removed explicit smartProxy options: socketTimeout, inactivityTimeout, keepAliveInactivityMultiplier, extendedKeepAliveLifetime, and maxConnectionLifetime from ts/classes.dcrouter.ts
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.1.6 - fix(cleanup)
|
||||||
|
prevent event listener and log stream leaks, tighten smartProxy connection timeouts, and improve graceful shutdown behavior
|
||||||
|
|
||||||
|
- Tightened smartProxy connection timeouts and lifetimes (5m socketTimeout, 10m inactivityTimeout, keep-alive multiplier, 1h extendedKeepAliveLifetime, 4h maxConnectionLifetime).
|
||||||
|
- Remove event listeners before stopping services to avoid leaks (smartProxy, emailServer, dnsServer, remote ingress hub).
|
||||||
|
- OpsServer.stop now invokes logsHandler.cleanup to tear down active log streams and avoid duplicate push destinations.
|
||||||
|
- LogsHandler rewritten to use a module-level singleton push destination, track active stream stop callbacks, add cleanup(), guard against hung VirtualStream.sendData with a 10s timeout, and ensure intervals are cleared on stop.
|
||||||
|
- updateSmartProxyConfig removes listeners on the old instance before stopping it.
|
||||||
|
- Dependency bumps: @api.global/typedsocket ^4.1.2, @push.rocks/smartdata ^7.1.0, @push.rocks/smartmta ^5.2.6, @push.rocks/smartproxy ^25.8.3.
|
||||||
|
|
||||||
|
## 2026-02-26 - 9.1.5 - fix(remoteingress)
|
||||||
|
Reconcile tunnel manager edge statuses with authoritative Rust hub periodically; update active tunnel counts and heartbeats, add missed edges, remove stale entries, and clear reconcile interval on stop
|
||||||
|
|
||||||
|
- Add reconcile() to sync TS-side edgeStatuses with hub.getStatus and overwrite activeTunnels with the authoritative activeStreams.
|
||||||
|
- Start a periodic reconcile (setInterval every 15s) and store the interval handle on the tunnel manager.
|
||||||
|
- Clear the reconcile interval in stop() to avoid background timers; remove edgeStatuses entries that are no longer connected in Rust.
|
||||||
|
- Bump dependency @serve.zone/remoteingress from ^4.0.0 to ^4.0.1.
|
||||||
|
|
||||||
|
## 2026-02-25 - 9.1.4 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^25.8.1
|
||||||
|
|
||||||
|
- Updated package.json dependency @push.rocks/smartproxy from ^25.8.0 to ^25.8.1
|
||||||
|
|
||||||
|
## 2026-02-24 - 9.1.3 - fix(deps)
|
||||||
|
bump @api.global/typedserver to ^8.4.0 and @push.rocks/smartproxy to ^25.8.0
|
||||||
|
|
||||||
|
- Updated @api.global/typedserver from ^8.3.1 to ^8.4.0
|
||||||
|
- Updated @push.rocks/smartproxy from ^25.7.9 to ^25.8.0
|
||||||
|
|
||||||
|
## 2026-02-24 - 9.1.2 - fix(deps)
|
||||||
|
bump dependency versions for build and runtime packages
|
||||||
|
|
||||||
|
- @git.zone/tsbundle: ^2.8.3 -> ^2.9.0
|
||||||
|
- @git.zone/tswatch: ^3.1.0 -> ^3.2.0
|
||||||
|
- @api.global/typedserver: ^8.3.0 -> ^8.3.1
|
||||||
|
- @design.estate/dees-catalog: ^3.43.2 -> ^3.43.3
|
||||||
|
|
||||||
|
## 2026-02-23 - 9.1.1 - fix(dcrouter)
|
||||||
|
no changes detected — no files modified, no release necessary
|
||||||
|
|
||||||
|
- Git diff contained no changes
|
||||||
|
- No files added, modified, or deleted
|
||||||
|
|
||||||
|
## 2026-02-23 - 9.1.0 - feat(ops-dashboard)
|
||||||
|
add lucide icons to Ops dashboard view tabs
|
||||||
|
|
||||||
|
- Added iconName property to 10 view tabs in ts_web/elements/ops-dashboard.ts to enable icons in the UI
|
||||||
|
- Icon mappings: Overview -> lucide:layoutDashboard, Configuration -> lucide:settings, Network -> lucide:network, Emails -> lucide:mail, Logs -> lucide:scrollText, Routes -> lucide:route, ApiTokens -> lucide:key, Security -> lucide:shield, Certificates -> lucide:badgeCheck, RemoteIngress -> lucide:globe
|
||||||
|
- Improves visual clarity of dashboard navigation
|
||||||
|
|
||||||
|
## 2026-02-23 - 9.0.0 - BREAKING CHANGE(opsserver)
|
||||||
|
Return structured configuration (IConfigData) from opsserver and update UI to render detailed config sections
|
||||||
|
|
||||||
|
- Introduce IConfigData interface with typed sections: system, smartProxy, email, dns, tls, cache, radius, remoteIngress.
|
||||||
|
- Replace ConfigHandler.getConfiguration implementation to assemble and return IConfigData (changes API response shape for getConfiguration).
|
||||||
|
- Refactor frontend: update appstate types and ops-view-config to render the new config sections, use @serve.zone/catalog IConfigField/IConfigSectionAction, add uptime formatting and remote ingress UI.
|
||||||
|
- Fix ops-view-apitokens form handling to correctly read dees-input-tags values.
|
||||||
|
- Update tests to expect new configuration fields.
|
||||||
|
- Bump dependency @serve.zone/catalog to ^2.5.0.
|
||||||
|
|
||||||
## 2026-02-23 - 8.1.0 - feat(route-management)
|
## 2026-02-23 - 8.1.0 - feat(route-management)
|
||||||
add programmatic route management API with API tokens and admin UI
|
add programmatic route management API with API tokens and admin UI
|
||||||
|
|
||||||
|
|||||||
35
package.json
35
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "8.1.0",
|
"version": "11.0.7",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -19,45 +19,46 @@
|
|||||||
"watch": "tswatch"
|
"watch": "tswatch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.1.2",
|
"@git.zone/tsbuild": "^4.1.3",
|
||||||
"@git.zone/tsbundle": "^2.8.3",
|
"@git.zone/tsbundle": "^2.9.0",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.1",
|
||||||
"@git.zone/tstest": "^3.1.8",
|
"@git.zone/tstest": "^3.2.0",
|
||||||
"@git.zone/tswatch": "^3.1.0",
|
"@git.zone/tswatch": "^3.2.5",
|
||||||
"@types/node": "^25.3.0"
|
"@types/node": "^25.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.2.6",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||||
"@api.global/typedserver": "^8.3.0",
|
"@api.global/typedserver": "^8.4.2",
|
||||||
"@api.global/typedsocket": "^4.1.0",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.43.2",
|
"@design.estate/dees-catalog": "^3.43.3",
|
||||||
"@design.estate/dees-element": "^2.1.6",
|
"@design.estate/dees-element": "^2.1.6",
|
||||||
|
"@push.rocks/lik": "^6.3.1",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartacme": "^9.1.3",
|
"@push.rocks/smartacme": "^9.1.3",
|
||||||
"@push.rocks/smartdata": "^7.0.15",
|
"@push.rocks/smartdata": "^7.1.0",
|
||||||
"@push.rocks/smartdns": "^7.9.0",
|
"@push.rocks/smartdns": "^7.9.0",
|
||||||
"@push.rocks/smartfile": "^13.1.2",
|
"@push.rocks/smartfile": "^13.1.2",
|
||||||
"@push.rocks/smartguard": "^3.1.0",
|
"@push.rocks/smartguard": "^3.1.0",
|
||||||
"@push.rocks/smartjwt": "^2.2.1",
|
"@push.rocks/smartjwt": "^2.2.1",
|
||||||
"@push.rocks/smartlog": "^3.2.1",
|
"@push.rocks/smartlog": "^3.2.1",
|
||||||
"@push.rocks/smartmetrics": "^3.0.1",
|
"@push.rocks/smartmetrics": "^3.0.2",
|
||||||
"@push.rocks/smartmongo": "^5.1.0",
|
"@push.rocks/smartmongo": "^5.1.0",
|
||||||
"@push.rocks/smartmta": "^5.2.2",
|
"@push.rocks/smartmta": "^5.3.1",
|
||||||
"@push.rocks/smartnetwork": "^4.4.0",
|
"@push.rocks/smartnetwork": "^4.4.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^25.7.9",
|
"@push.rocks/smartproxy": "^25.9.1",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
"@push.rocks/smartstate": "^2.0.30",
|
"@push.rocks/smartstate": "^2.2.0",
|
||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@serve.zone/catalog": "^2.3.0",
|
"@serve.zone/catalog": "^2.5.0",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.0.0",
|
"@serve.zone/remoteingress": "^4.4.0",
|
||||||
"@tsclass/tsclass": "^9.3.0",
|
"@tsclass/tsclass": "^9.3.0",
|
||||||
"lru-cache": "^11.2.6",
|
"lru-cache": "^11.2.6",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
|
|||||||
2888
pnpm-lock.yaml
generated
2888
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,27 +4,44 @@ import { TypedRequest } from '@api.global/typedrequest';
|
|||||||
import * as interfaces from '../ts_interfaces/index.js';
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
let testDcRouter: DcRouter;
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
|
||||||
tap.test('should start DCRouter with OpsServer', async () => {
|
tap.test('should start DCRouter with OpsServer', async () => {
|
||||||
testDcRouter = new DcRouter({
|
testDcRouter = new DcRouter({
|
||||||
// Minimal config for testing
|
// Minimal config for testing
|
||||||
cacheConfig: { enabled: false },
|
cacheConfig: { enabled: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await testDcRouter.start();
|
await testDcRouter.start();
|
||||||
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
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 () => {
|
tap.test('should respond to health status request', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await healthRequest.fire({
|
const response = await healthRequest.fire({
|
||||||
detailed: false
|
identity: adminIdentity,
|
||||||
|
detailed: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('health');
|
expect(response).toHaveProperty('health');
|
||||||
expect(response.health.healthy).toBeTrue();
|
expect(response.health.healthy).toBeTrue();
|
||||||
expect(response.health.services).toHaveProperty('OpsServer');
|
expect(response.health.services).toHaveProperty('OpsServer');
|
||||||
@@ -35,11 +52,12 @@ tap.test('should respond to server statistics request', async () => {
|
|||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'getServerStatistics'
|
'getServerStatistics'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await statsRequest.fire({
|
const response = await statsRequest.fire({
|
||||||
includeHistory: false
|
identity: adminIdentity,
|
||||||
|
includeHistory: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('stats');
|
expect(response).toHaveProperty('stats');
|
||||||
expect(response.stats).toHaveProperty('uptime');
|
expect(response.stats).toHaveProperty('uptime');
|
||||||
expect(response.stats).toHaveProperty('cpuUsage');
|
expect(response.stats).toHaveProperty('cpuUsage');
|
||||||
@@ -51,14 +69,20 @@ tap.test('should respond to configuration request', async () => {
|
|||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await configRequest.fire({});
|
const response = await configRequest.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
expect(response.config).toHaveProperty('email');
|
expect(response.config).toHaveProperty('email');
|
||||||
expect(response.config).toHaveProperty('dns');
|
expect(response.config).toHaveProperty('dns');
|
||||||
expect(response.config).toHaveProperty('proxy');
|
expect(response.config).toHaveProperty('tls');
|
||||||
expect(response.config).toHaveProperty('security');
|
expect(response.config).toHaveProperty('cache');
|
||||||
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should handle log retrieval request', async () => {
|
tap.test('should handle log retrieval request', async () => {
|
||||||
@@ -66,19 +90,34 @@ tap.test('should handle log retrieval request', async () => {
|
|||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'getRecentLogs'
|
'getRecentLogs'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await logsRequest.fire({
|
const response = await logsRequest.fire({
|
||||||
limit: 10
|
identity: adminIdentity,
|
||||||
|
limit: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('logs');
|
expect(response).toHaveProperty('logs');
|
||||||
expect(response).toHaveProperty('total');
|
expect(response).toHaveProperty('total');
|
||||||
expect(response).toHaveProperty('hasMore');
|
expect(response).toHaveProperty('hasMore');
|
||||||
expect(response.logs).toBeArray();
|
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 () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
await testDcRouter.stop();
|
await testDcRouter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -82,35 +82,42 @@ tap.test('should reject verify identity with invalid JWT', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow access to public endpoints without auth', async () => {
|
tap.test('should reject protected endpoints without auth', async () => {
|
||||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'getHealthStatus'
|
'getHealthStatus'
|
||||||
);
|
);
|
||||||
|
|
||||||
// No identity provided
|
try {
|
||||||
const response = await healthRequest.fire({});
|
// No identity provided — should be rejected
|
||||||
|
await healthRequest.fire({} as any);
|
||||||
expect(response).toHaveProperty('health');
|
expect(true).toBeFalse(); // Should not reach here
|
||||||
expect(response.health.healthy).toBeTrue();
|
} catch (error) {
|
||||||
console.log('Public endpoint accessible without auth');
|
expect(error).toBeTruthy();
|
||||||
|
console.log('Protected endpoint correctly rejects unauthenticated request');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should allow read-only config access', async () => {
|
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'http://localhost:3000/typedrequest',
|
'http://localhost:3000/typedrequest',
|
||||||
'getConfiguration'
|
'getConfiguration'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Config is read-only and doesn't require auth
|
const response = await configRequest.fire({
|
||||||
const response = await configRequest.fire({});
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
expect(response).toHaveProperty('config');
|
expect(response).toHaveProperty('config');
|
||||||
|
expect(response.config).toHaveProperty('system');
|
||||||
|
expect(response.config).toHaveProperty('smartProxy');
|
||||||
expect(response.config).toHaveProperty('email');
|
expect(response.config).toHaveProperty('email');
|
||||||
expect(response.config).toHaveProperty('dns');
|
expect(response.config).toHaveProperty('dns');
|
||||||
expect(response.config).toHaveProperty('proxy');
|
expect(response.config).toHaveProperty('tls');
|
||||||
expect(response.config).toHaveProperty('security');
|
expect(response.config).toHaveProperty('cache');
|
||||||
console.log('Configuration read successfully');
|
expect(response.config).toHaveProperty('radius');
|
||||||
|
expect(response.config).toHaveProperty('remoteIngress');
|
||||||
|
console.log('Authenticated access to config successful');
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should stop DCRouter', async () => {
|
tap.test('should stop DCRouter', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '8.1.0',
|
version: '11.0.7',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { MetricsManager } from './monitoring/index.js';
|
|||||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||||
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
||||||
|
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||||
@@ -217,8 +218,12 @@ export class DcRouter {
|
|||||||
public routeConfigManager?: RouteConfigManager;
|
public routeConfigManager?: RouteConfigManager;
|
||||||
public apiTokenManager?: ApiTokenManager;
|
public apiTokenManager?: ApiTokenManager;
|
||||||
|
|
||||||
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
|
public detectedPublicIp: string | null = null;
|
||||||
|
|
||||||
// DNS query logging rate limiter state
|
// DNS query logging rate limiter state
|
||||||
private dnsLogWindow: number[] = [];
|
private dnsLogWindowSecond: number = 0; // epoch second of current window
|
||||||
|
private dnsLogWindowCount: number = 0; // queries logged this second
|
||||||
private dnsBatchCount: number = 0;
|
private dnsBatchCount: number = 0;
|
||||||
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -897,12 +902,27 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
this.dnsBatchTimer = null;
|
this.dnsBatchTimer = null;
|
||||||
this.dnsBatchCount = 0;
|
this.dnsBatchCount = 0;
|
||||||
this.dnsLogWindow = [];
|
this.dnsLogWindowSecond = 0;
|
||||||
|
this.dnsLogWindowCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.opsServer.stop();
|
await this.opsServer.stop();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Remove event listeners before stopping services to prevent leaks
|
||||||
|
if (this.smartProxy) {
|
||||||
|
this.smartProxy.removeAllListeners();
|
||||||
|
}
|
||||||
|
if (this.emailServer) {
|
||||||
|
if ((this.emailServer as any).deliverySystem) {
|
||||||
|
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||||
|
}
|
||||||
|
this.emailServer.removeAllListeners();
|
||||||
|
}
|
||||||
|
if (this.dnsServer) {
|
||||||
|
this.dnsServer.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Stop all services in parallel for faster shutdown
|
// Stop all services in parallel for faster shutdown
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// Stop cache cleaner if running
|
// Stop cache cleaner if running
|
||||||
@@ -939,6 +959,7 @@ export class DcRouter {
|
|||||||
// Stop cache database after other services (they may need it during shutdown)
|
// Stop cache database after other services (they may need it during shutdown)
|
||||||
if (this.cacheDb) {
|
if (this.cacheDb) {
|
||||||
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
|
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
|
||||||
|
CacheDb.resetInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear backoff cache in cert scheduler
|
// Clear backoff cache in cert scheduler
|
||||||
@@ -962,6 +983,11 @@ export class DcRouter {
|
|||||||
this.apiTokenManager = undefined;
|
this.apiTokenManager = undefined;
|
||||||
this.certificateStatusMap.clear();
|
this.certificateStatusMap.clear();
|
||||||
|
|
||||||
|
// Reset security singletons to allow GC
|
||||||
|
SecurityLogger.resetInstance();
|
||||||
|
ContentScanner.resetInstance();
|
||||||
|
IPReputationChecker.resetInstance();
|
||||||
|
|
||||||
logger.log('info', 'All DcRouter services stopped');
|
logger.log('info', 'All DcRouter services stopped');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
|
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
|
||||||
@@ -976,10 +1002,11 @@ export class DcRouter {
|
|||||||
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
|
public async updateSmartProxyConfig(config: plugins.smartproxy.ISmartProxyOptions): Promise<void> {
|
||||||
// Stop existing SmartProxy if running
|
// Stop existing SmartProxy if running
|
||||||
if (this.smartProxy) {
|
if (this.smartProxy) {
|
||||||
|
this.smartProxy.removeAllListeners();
|
||||||
await this.smartProxy.stop();
|
await this.smartProxy.stop();
|
||||||
this.smartProxy = undefined;
|
this.smartProxy = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
this.options.smartProxyConfig = config;
|
this.options.smartProxyConfig = config;
|
||||||
|
|
||||||
@@ -1103,6 +1130,11 @@ export class DcRouter {
|
|||||||
try {
|
try {
|
||||||
// Stop the unified email server which contains all components
|
// Stop the unified email server which contains all components
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
|
// Remove listeners before stopping to prevent leaks on config update cycles
|
||||||
|
if ((this.emailServer as any).deliverySystem) {
|
||||||
|
(this.emailServer as any).deliverySystem.removeAllListeners();
|
||||||
|
}
|
||||||
|
this.emailServer.removeAllListeners();
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
logger.log('info', 'Unified email server stopped');
|
logger.log('info', 'Unified email server stopped');
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
@@ -1282,11 +1314,14 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive logging: individual logs up to 2/sec, then batch
|
// Adaptive logging: individual logs up to 2/sec, then batch
|
||||||
const now = Date.now();
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000);
|
if (nowSec !== this.dnsLogWindowSecond) {
|
||||||
|
this.dnsLogWindowSecond = nowSec;
|
||||||
|
this.dnsLogWindowCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.dnsLogWindow.length < 2) {
|
if (this.dnsLogWindowCount < 2) {
|
||||||
this.dnsLogWindow.push(now);
|
this.dnsLogWindowCount++;
|
||||||
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
|
const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
|
||||||
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
|
logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
|
||||||
} else {
|
} else {
|
||||||
@@ -1340,15 +1375,25 @@ export class DcRouter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent uncaught exception from socket 'error' events
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
logger.log('error', `DNS socket error: ${err.message}`);
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
|
logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the built-in socket handler from smartdns
|
// Use the built-in socket handler from smartdns
|
||||||
// This handles HTTP/2, DoH protocol, etc.
|
// This handles HTTP/2, DoH protocol, etc.
|
||||||
await (this.dnsServer as any).handleHttpsSocket(socket);
|
await (this.dnsServer as any).handleHttpsSocket(socket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', `DNS socket handler error: ${error.message}`);
|
logger.log('error', `DNS socket handler error: ${error.message}`);
|
||||||
socket.destroy();
|
if (!socket.destroyed) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1554,6 +1599,7 @@ export class DcRouter {
|
|||||||
} else if (this.options.publicIp) {
|
} else if (this.options.publicIp) {
|
||||||
// Use explicitly configured public IP
|
// Use explicitly configured public IP
|
||||||
publicIp = this.options.publicIp;
|
publicIp = this.options.publicIp;
|
||||||
|
this.detectedPublicIp = publicIp;
|
||||||
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
|
logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
|
||||||
} else {
|
} else {
|
||||||
// Auto-discover public IP using smartnetwork
|
// Auto-discover public IP using smartnetwork
|
||||||
@@ -1564,6 +1610,7 @@ export class DcRouter {
|
|||||||
|
|
||||||
if (publicIps.v4) {
|
if (publicIps.v4) {
|
||||||
publicIp = publicIps.v4;
|
publicIp = publicIps.v4;
|
||||||
|
this.detectedPublicIp = publicIp;
|
||||||
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
|
logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
|
||||||
} else {
|
} else {
|
||||||
logger.log('warn', 'Could not auto-discover public IPv4 address');
|
logger.log('warn', 'Could not auto-discover public IPv4 address');
|
||||||
@@ -1689,10 +1736,42 @@ export class DcRouter {
|
|||||||
const currentRoutes = this.options.smartProxyConfig?.routes || [];
|
const currentRoutes = this.options.smartProxyConfig?.routes || [];
|
||||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||||
|
|
||||||
|
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
||||||
|
const riCfg = this.options.remoteIngressConfig;
|
||||||
|
let tlsConfig: { certPem: string; keyPem: string } | undefined;
|
||||||
|
|
||||||
|
// Priority 1: Explicit cert/key file paths
|
||||||
|
if (riCfg.tls?.certPath && riCfg.tls?.keyPath) {
|
||||||
|
try {
|
||||||
|
const certPem = plugins.fs.readFileSync(riCfg.tls.certPath, 'utf8');
|
||||||
|
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
|
||||||
|
tlsConfig = { certPem, keyPem };
|
||||||
|
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
|
||||||
|
} catch (err) {
|
||||||
|
logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
|
||||||
|
if (!tlsConfig && riCfg.hubDomain) {
|
||||||
|
try {
|
||||||
|
const stored = await this.storageManager.getJSON(`/proxy-certs/${riCfg.hubDomain}`);
|
||||||
|
if (stored?.publicKey && stored?.privateKey) {
|
||||||
|
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
|
||||||
|
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
|
||||||
|
}
|
||||||
|
} catch { /* no stored cert, fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tlsConfig) {
|
||||||
|
logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
|
||||||
|
}
|
||||||
|
|
||||||
// Create and start the tunnel manager
|
// Create and start the tunnel manager
|
||||||
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
|
||||||
tunnelPort: this.options.remoteIngressConfig.tunnelPort ?? 8443,
|
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||||
targetHost: '127.0.0.1',
|
targetHost: '127.0.0.1',
|
||||||
|
tls: tlsConfig,
|
||||||
});
|
});
|
||||||
await this.tunnelManager.start();
|
await this.tunnelManager.start();
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,24 @@ export class ApiTokenManager {
|
|||||||
return true;
|
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.
|
* Enable or disable a token.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ export class MetricsManager {
|
|||||||
queryTypes: {} as Record<string, number>,
|
queryTypes: {} as Record<string, number>,
|
||||||
topDomains: new Map<string, number>(),
|
topDomains: new Map<string, number>(),
|
||||||
lastResetDate: new Date().toDateString(),
|
lastResetDate: new Date().toDateString(),
|
||||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
// 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
|
responseTimes: [] as number[], // Track response times in ms
|
||||||
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||||
};
|
};
|
||||||
@@ -95,12 +97,13 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses = 0;
|
this.dnsMetrics.cacheMisses = 0;
|
||||||
this.dnsMetrics.queryTypes = {};
|
this.dnsMetrics.queryTypes = {};
|
||||||
this.dnsMetrics.topDomains.clear();
|
this.dnsMetrics.topDomains.clear();
|
||||||
this.dnsMetrics.queryTimestamps = [];
|
this.dnsMetrics.queryRing.fill(0);
|
||||||
|
this.dnsMetrics.queryRingLastSecond = 0;
|
||||||
this.dnsMetrics.responseTimes = [];
|
this.dnsMetrics.responseTimes = [];
|
||||||
this.dnsMetrics.recentQueries = [];
|
this.dnsMetrics.recentQueries = [];
|
||||||
this.dnsMetrics.lastResetDate = currentDate;
|
this.dnsMetrics.lastResetDate = currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentDate !== this.securityMetrics.lastResetDate) {
|
if (currentDate !== this.securityMetrics.lastResetDate) {
|
||||||
this.securityMetrics.blockedIPs = 0;
|
this.securityMetrics.blockedIPs = 0;
|
||||||
this.securityMetrics.authFailures = 0;
|
this.securityMetrics.authFailures = 0;
|
||||||
@@ -141,16 +144,16 @@ export class MetricsManager {
|
|||||||
const smartMetricsData = await this.smartMetrics.getMetrics();
|
const smartMetricsData = await this.smartMetrics.getMetrics();
|
||||||
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
|
||||||
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
|
||||||
|
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
startTime: Date.now() - (process.uptime() * 1000),
|
startTime: Date.now() - (process.uptime() * 1000),
|
||||||
memoryUsage: {
|
memoryUsage: {
|
||||||
heapUsed: process.memoryUsage().heapUsed,
|
heapUsed,
|
||||||
heapTotal: process.memoryUsage().heapTotal,
|
heapTotal,
|
||||||
external: process.memoryUsage().external,
|
external,
|
||||||
rss: process.memoryUsage().rss,
|
rss,
|
||||||
// Add SmartMetrics memory data
|
|
||||||
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
maxMemoryMB: this.smartMetrics.maxMemoryMB,
|
||||||
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
actualUsageBytes: smartMetricsData.memoryUsageBytes,
|
||||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||||
@@ -219,11 +222,8 @@ export class MetricsManager {
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(([domain, count]) => ({ domain, count }));
|
.map(([domain, count]) => ({ domain, count }));
|
||||||
|
|
||||||
// Calculate queries per second from recent timestamps
|
// Calculate queries per second from ring buffer (sum last 60 seconds)
|
||||||
const now = Date.now();
|
const queriesPerSecond = this.getQueryRingSum(60) / 60;
|
||||||
const oneMinuteAgo = now - 60000;
|
|
||||||
const recentQueries = this.dnsMetrics.queryTimestamps.filter(ts => ts >= oneMinuteAgo);
|
|
||||||
const queriesPerSecond = recentQueries.length / 60;
|
|
||||||
|
|
||||||
// Calculate average response time
|
// Calculate average response time
|
||||||
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
const avgResponseTime = this.dnsMetrics.responseTimes.length > 0
|
||||||
@@ -427,12 +427,8 @@ export class MetricsManager {
|
|||||||
this.dnsMetrics.cacheMisses++;
|
this.dnsMetrics.cacheMisses++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track query timestamp
|
// Increment per-second query counter in ring buffer
|
||||||
this.dnsMetrics.queryTimestamps.push(Date.now());
|
this.incrementQueryRing();
|
||||||
|
|
||||||
// Keep only timestamps from last 5 minutes
|
|
||||||
const fiveMinutesAgo = Date.now() - 300000;
|
|
||||||
this.dnsMetrics.queryTimestamps = this.dnsMetrics.queryTimestamps.filter(ts => ts >= fiveMinutesAgo);
|
|
||||||
|
|
||||||
// Track response time if provided
|
// Track response time if provided
|
||||||
if (responseTimeMs) {
|
if (responseTimeMs) {
|
||||||
@@ -604,7 +600,7 @@ export class MetricsManager {
|
|||||||
requestsPerSecond,
|
requestsPerSecond,
|
||||||
requestsTotal,
|
requestsTotal,
|
||||||
};
|
};
|
||||||
}, 200); // Use 200ms cache for more frequent updates
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Time-series helpers ---
|
// --- Time-series helpers ---
|
||||||
@@ -633,6 +629,63 @@ export class MetricsManager {
|
|||||||
bucket.queries++;
|
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 {
|
private pruneOldBuckets(): void {
|
||||||
const cutoff = Date.now() - 86400000; // 24h
|
const cutoff = Date.now() - 86400000; // 24h
|
||||||
for (const key of this.emailMinuteBuckets.keys()) {
|
for (const key of this.emailMinuteBuckets.keys()) {
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ import type DcRouter from '../classes.dcrouter.js';
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as paths from '../paths.js';
|
import * as paths from '../paths.js';
|
||||||
import * as handlers from './handlers/index.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 {
|
export class OpsServer {
|
||||||
public dcRouterRef: DcRouter;
|
public dcRouterRef: DcRouter;
|
||||||
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
public server: plugins.typedserver.utilityservers.UtilityWebsiteServer;
|
||||||
|
|
||||||
// TypedRouter for OpsServer-specific handlers
|
// Main TypedRouter — unauthenticated endpoints (login/logout/verify) and own-auth handlers
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
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
|
// Handler instances
|
||||||
public adminHandler: handlers.AdminHandler;
|
public adminHandler: handlers.AdminHandler;
|
||||||
private configHandler: handlers.ConfigHandler;
|
private configHandler: handlers.ConfigHandler;
|
||||||
@@ -25,7 +31,7 @@ export class OpsServer {
|
|||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
|
|
||||||
// Add our typedrouter to the dcRouter's main typedrouter
|
// Add our typedrouter to the dcRouter's main typedrouter
|
||||||
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.dcRouterRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
}
|
}
|
||||||
@@ -51,10 +57,25 @@ export class OpsServer {
|
|||||||
* Set up all TypedRequest handlers
|
* Set up all TypedRequest handlers
|
||||||
*/
|
*/
|
||||||
private async setupHandlers(): Promise<void> {
|
private async setupHandlers(): Promise<void> {
|
||||||
// Instantiate all handlers - they self-register with the typedrouter
|
// AdminHandler must be initialized first (JWT setup needed for guards)
|
||||||
this.adminHandler = new handlers.AdminHandler(this);
|
this.adminHandler = new handlers.AdminHandler(this);
|
||||||
await this.adminHandler.initialize(); // JWT needs async initialization
|
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.configHandler = new handlers.ConfigHandler(this);
|
||||||
this.logsHandler = new handlers.LogsHandler(this);
|
this.logsHandler = new handlers.LogsHandler(this);
|
||||||
this.securityHandler = new handlers.SecurityHandler(this);
|
this.securityHandler = new handlers.SecurityHandler(this);
|
||||||
@@ -70,6 +91,10 @@ export class OpsServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
|
// Clean up log handler streams and push destination before stopping the server
|
||||||
|
if (this.logsHandler) {
|
||||||
|
this.logsHandler.cleanup();
|
||||||
|
}
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await this.server.stop();
|
await this.server.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,34 +3,20 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class ApiTokenHandler {
|
export class ApiTokenHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Token management requires admin JWT only (tokens cannot manage tokens).
|
|
||||||
*/
|
|
||||||
private async requireAdmin(identity?: interfaces.data.IIdentity): Promise<string> {
|
|
||||||
if (!identity?.jwt) {
|
|
||||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
|
||||||
}
|
|
||||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity });
|
|
||||||
if (!isAdmin) {
|
|
||||||
throw new plugins.typedrequest.TypedResponseError('admin access required');
|
|
||||||
}
|
|
||||||
return identity.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private registerHandlers(): void {
|
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
|
// Create API token
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
||||||
'createApiToken',
|
'createApiToken',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const userId = await this.requireAdmin(dataArg.identity);
|
|
||||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Token management not initialized' };
|
return { success: false, message: 'Token management not initialized' };
|
||||||
@@ -39,7 +25,7 @@ export class ApiTokenHandler {
|
|||||||
dataArg.name,
|
dataArg.name,
|
||||||
dataArg.scopes,
|
dataArg.scopes,
|
||||||
dataArg.expiresInDays ?? null,
|
dataArg.expiresInDays ?? null,
|
||||||
userId,
|
dataArg.identity.userId,
|
||||||
);
|
);
|
||||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||||
},
|
},
|
||||||
@@ -47,11 +33,10 @@ export class ApiTokenHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// List API tokens
|
// List API tokens
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
||||||
'listApiTokens',
|
'listApiTokens',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await this.requireAdmin(dataArg.identity);
|
|
||||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { tokens: [] };
|
return { tokens: [] };
|
||||||
@@ -62,11 +47,10 @@ export class ApiTokenHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Revoke API token
|
// Revoke API token
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
||||||
'revokeApiToken',
|
'revokeApiToken',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await this.requireAdmin(dataArg.identity);
|
|
||||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Token management not initialized' };
|
return { success: false, message: 'Token management not initialized' };
|
||||||
@@ -77,12 +61,29 @@ export class ApiTokenHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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
|
// Toggle API token
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
||||||
'toggleApiToken',
|
'toggleApiToken',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await this.requireAdmin(dataArg.identity);
|
|
||||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Token management not initialized' };
|
return { success: false, message: 'Token management not initialized' };
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class CertificateHandler {
|
export class CertificateHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
// Get Certificate Overview
|
// Get Certificate Overview
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCertificateOverview>(
|
||||||
'getCertificateOverview',
|
'getCertificateOverview',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -23,8 +25,10 @@ export class CertificateHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter — admin identity required via middleware) ----
|
||||||
|
|
||||||
// Legacy route-based reprovision (backward compat)
|
// Legacy route-based reprovision (backward compat)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificate>(
|
||||||
'reprovisionCertificate',
|
'reprovisionCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -34,7 +38,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Domain-based reprovision (preferred)
|
// Domain-based reprovision (preferred)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
'reprovisionCertificateDomain',
|
'reprovisionCertificateDomain',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -44,7 +48,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Delete certificate
|
// Delete certificate
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteCertificate>(
|
||||||
'deleteCertificate',
|
'deleteCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -54,7 +58,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Export certificate
|
// Export certificate
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ExportCertificate>(
|
||||||
'exportCertificate',
|
'exportCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -64,7 +68,7 @@ export class CertificateHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Import certificate
|
// Import certificate
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportCertificate>(
|
||||||
'importCertificate',
|
'importCertificate',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
|
import * as paths from '../../paths.js';
|
||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class ConfigHandler {
|
export class ConfigHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// Config endpoint registers directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Get Configuration Handler (read-only)
|
// Get Configuration Handler (read-only)
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetConfiguration>(
|
||||||
'getConfiguration',
|
'getConfiguration',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
const config = await this.getConfiguration(dataArg.section);
|
const config = await this.getConfiguration();
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
section: dataArg.section,
|
section: dataArg.section,
|
||||||
@@ -26,83 +26,189 @@ export class ConfigHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getConfiguration(section?: string): Promise<{
|
private async getConfiguration(): Promise<interfaces.requests.IConfigData> {
|
||||||
email: {
|
|
||||||
enabled: boolean;
|
|
||||||
ports: number[];
|
|
||||||
maxMessageSize: number;
|
|
||||||
rateLimits: {
|
|
||||||
perMinute: number;
|
|
||||||
perHour: number;
|
|
||||||
perDay: number;
|
|
||||||
};
|
|
||||||
domains?: string[];
|
|
||||||
};
|
|
||||||
dns: {
|
|
||||||
enabled: boolean;
|
|
||||||
port: number;
|
|
||||||
nameservers: string[];
|
|
||||||
caching: boolean;
|
|
||||||
ttl: number;
|
|
||||||
};
|
|
||||||
proxy: {
|
|
||||||
enabled: boolean;
|
|
||||||
httpPort: number;
|
|
||||||
httpsPort: number;
|
|
||||||
maxConnections: number;
|
|
||||||
};
|
|
||||||
security: {
|
|
||||||
blockList: string[];
|
|
||||||
rateLimit: boolean;
|
|
||||||
spamDetection: boolean;
|
|
||||||
tlsRequired: boolean;
|
|
||||||
};
|
|
||||||
}> {
|
|
||||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||||
|
const opts = dcRouter.options;
|
||||||
// Get email domains if email server is configured
|
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[] = [];
|
let emailDomains: string[] = [];
|
||||||
if (dcRouter.emailServer && dcRouter.emailServer.domainRegistry) {
|
if (dcRouter.emailServer && (dcRouter.emailServer as any).domainRegistry) {
|
||||||
emailDomains = dcRouter.emailServer.domainRegistry.getAllDomains();
|
emailDomains = (dcRouter.emailServer as any).domainRegistry.getAllDomains();
|
||||||
} else if (dcRouter.options.emailConfig?.domains) {
|
} else if (opts.emailConfig?.domains) {
|
||||||
// Fallback: get domains from email config options
|
emailDomains = opts.emailConfig.domains.map((d: any) =>
|
||||||
emailDomains = dcRouter.options.emailConfig.domains.map(d =>
|
|
||||||
typeof d === 'string' ? d : d.domain
|
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 {
|
return {
|
||||||
email: {
|
system,
|
||||||
enabled: !!dcRouter.emailServer,
|
smartProxy,
|
||||||
ports: dcRouter.emailServer ? [25, 465, 587, 2525] : [],
|
email,
|
||||||
maxMessageSize: 10 * 1024 * 1024, // 10MB default
|
dns,
|
||||||
rateLimits: {
|
tls,
|
||||||
perMinute: 10,
|
cache,
|
||||||
perHour: 100,
|
radius,
|
||||||
perDay: 1000,
|
remoteIngress,
|
||||||
},
|
|
||||||
domains: emailDomains,
|
|
||||||
},
|
|
||||||
dns: {
|
|
||||||
enabled: !!dcRouter.dnsServer,
|
|
||||||
port: 53,
|
|
||||||
nameservers: dcRouter.options.dnsNsDomains || [],
|
|
||||||
caching: true,
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
enabled: !!dcRouter.smartProxy,
|
|
||||||
httpPort: 80,
|
|
||||||
httpsPort: 443,
|
|
||||||
maxConnections: 1000,
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
blockList: [],
|
|
||||||
rateLimit: true,
|
|
||||||
spamDetection: true,
|
|
||||||
tlsRequired: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class EmailOpsHandler {
|
export class EmailOpsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
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
|
// Get All Emails Handler
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
||||||
'getAllEmails',
|
'getAllEmails',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -24,7 +25,7 @@ export class EmailOpsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get Email Detail Handler
|
// Get Email Detail Handler
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||||
'getEmailDetail',
|
'getEmailDetail',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
@@ -34,8 +35,10 @@ export class EmailOpsHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter) ----
|
||||||
|
|
||||||
// Resend Failed Email Handler
|
// Resend Failed Email Handler
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ResendEmail>(
|
||||||
'resendEmail',
|
'resendEmail',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
|
|||||||
@@ -3,19 +3,40 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { logBuffer, baseLogger } from '../../logger.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 {
|
export class LogsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
private activeStreamStops: Set<() => void> = new Set();
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
this.setupLogPushDestination();
|
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 {
|
private registerHandlers(): void {
|
||||||
|
// All log endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Get Recent Logs Handler
|
// Get Recent Logs Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRecentLogs>(
|
||||||
'getRecentLogs',
|
'getRecentLogs',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -27,24 +48,24 @@ export class LogsHandler {
|
|||||||
dataArg.search,
|
dataArg.search,
|
||||||
dataArg.timeRange
|
dataArg.timeRange
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logs,
|
logs,
|
||||||
total: logs.length, // TODO: Implement proper total count
|
total: logs.length,
|
||||||
hasMore: false, // TODO: Implement proper pagination
|
hasMore: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get Log Stream Handler
|
// Get Log Stream Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetLogStream>(
|
||||||
'getLogStream',
|
'getLogStream',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
// Create a virtual stream for log streaming
|
// Create a virtual stream for log streaming
|
||||||
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
const virtualStream = new plugins.typedrequest.VirtualStream<Uint8Array>();
|
||||||
|
|
||||||
// Set up log streaming
|
// Set up log streaming
|
||||||
const streamLogs = this.setupLogStream(
|
const streamLogs = this.setupLogStream(
|
||||||
virtualStream,
|
virtualStream,
|
||||||
@@ -52,20 +73,21 @@ export class LogsHandler {
|
|||||||
dataArg.filters?.category,
|
dataArg.filters?.category,
|
||||||
dataArg.follow
|
dataArg.follow
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start streaming
|
// Start streaming
|
||||||
streamLogs.start();
|
streamLogs.start();
|
||||||
|
|
||||||
// VirtualStream handles cleanup automatically
|
// Track the stop function so we can clean up on shutdown
|
||||||
|
this.activeStreamStops.add(streamLogs.stop);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logStream: virtualStream as any, // Cast to IVirtualStream interface
|
logStream: virtualStream as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
||||||
switch (smartlogLevel) {
|
switch (smartlogLevel) {
|
||||||
case 'silly':
|
case 'silly':
|
||||||
@@ -165,18 +187,30 @@ export class LogsHandler {
|
|||||||
|
|
||||||
return mapped;
|
return mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a log destination to the base logger that pushes entries
|
* Add a log destination to the base logger that pushes entries
|
||||||
* to all connected ops_dashboard TypedSocket clients.
|
* 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 {
|
private setupLogPushDestination(): void {
|
||||||
const opsServerRef = this.opsServerRef;
|
// 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({
|
baseLogger.addLogDestination({
|
||||||
async handleLog(logPackage: any) {
|
async handleLog(logPackage: any) {
|
||||||
// Access the TypedSocket server instance from OpsServer
|
const opsServer = currentOpsServerRef;
|
||||||
const typedsocket = opsServerRef.server?.typedserver?.typedsocket;
|
if (!opsServer) return;
|
||||||
|
|
||||||
|
const typedsocket = opsServer.server?.typedserver?.typedsocket;
|
||||||
if (!typedsocket) return;
|
if (!typedsocket) return;
|
||||||
|
|
||||||
let connections: any[];
|
let connections: any[];
|
||||||
@@ -220,8 +254,18 @@ export class LogsHandler {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
} {
|
} {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
|
let stopped = false;
|
||||||
let logIndex = 0;
|
let logIndex = 0;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
stopped = true;
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
this.activeStreamStops.delete(stop);
|
||||||
|
};
|
||||||
|
|
||||||
const start = () => {
|
const start = () => {
|
||||||
if (!follow) {
|
if (!follow) {
|
||||||
// Send existing logs and close
|
// Send existing logs and close
|
||||||
@@ -236,13 +280,19 @@ export class LogsHandler {
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
virtualStream.sendData(encoder.encode(logData));
|
virtualStream.sendData(encoder.encode(logData));
|
||||||
});
|
});
|
||||||
// VirtualStream doesn't have end() method - it closes automatically
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, simulate real-time log streaming
|
// For follow mode, simulate real-time log streaming
|
||||||
intervalId = setInterval(async () => {
|
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 categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||||
|
|
||||||
@@ -266,30 +316,25 @@ export class LogsHandler {
|
|||||||
const logData = JSON.stringify(logEntry);
|
const logData = JSON.stringify(logEntry);
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
try {
|
try {
|
||||||
await virtualStream.sendData(encoder.encode(logData));
|
// 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 {
|
} catch {
|
||||||
// Stream closed or errored — clean up to prevent interval leak
|
// Stream closed, errored, or timed out — clean up
|
||||||
clearInterval(intervalId!);
|
stop();
|
||||||
intervalId = null;
|
|
||||||
}
|
}
|
||||||
}, 2000); // Send a log every 2 seconds
|
}, 2000);
|
||||||
|
|
||||||
// TODO: Hook into actual logger events
|
|
||||||
// logger.on('log', (logEntry) => {
|
|
||||||
// if (matchesCriteria(logEntry, level, service)) {
|
|
||||||
// virtualStream.sendData(formatLogEntry(logEntry));
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = null;
|
|
||||||
}
|
|
||||||
// TODO: Unhook from logger events
|
|
||||||
};
|
|
||||||
|
|
||||||
return { start, stop };
|
return { start, stop };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class RadiusHandler {
|
export class RadiusHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// RADIUS Client Management
|
// RADIUS Client Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get all RADIUS clients
|
// Get all RADIUS clients (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
|
||||||
'getRadiusClients',
|
'getRadiusClients',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -40,8 +38,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add or update a RADIUS client
|
// Add or update a RADIUS client (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
|
||||||
'setRadiusClient',
|
'setRadiusClient',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -61,8 +59,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a RADIUS client
|
// Remove a RADIUS client (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
|
||||||
'removeRadiusClient',
|
'removeRadiusClient',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -85,8 +83,8 @@ export class RadiusHandler {
|
|||||||
// VLAN Mapping Management
|
// VLAN Mapping Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get all VLAN mappings
|
// Get all VLAN mappings (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
|
||||||
'getVlanMappings',
|
'getVlanMappings',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -121,8 +119,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add or update a VLAN mapping
|
// Add or update a VLAN mapping (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
|
||||||
'setVlanMapping',
|
'setVlanMapping',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -153,8 +151,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove a VLAN mapping
|
// Remove a VLAN mapping (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
|
||||||
'removeVlanMapping',
|
'removeVlanMapping',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -174,8 +172,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update VLAN configuration
|
// Update VLAN configuration (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
|
||||||
'updateVlanConfig',
|
'updateVlanConfig',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -206,8 +204,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Test VLAN assignment
|
// Test VLAN assignment (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
|
||||||
'testVlanAssignment',
|
'testVlanAssignment',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -240,8 +238,8 @@ export class RadiusHandler {
|
|||||||
// Accounting / Session Management
|
// Accounting / Session Management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get active sessions
|
// Get active sessions (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
|
||||||
'getRadiusSessions',
|
'getRadiusSessions',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -289,8 +287,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Disconnect a session
|
// Disconnect a session (write)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
|
||||||
'disconnectRadiusSession',
|
'disconnectRadiusSession',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -314,8 +312,8 @@ export class RadiusHandler {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get accounting summary
|
// Get accounting summary (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
|
||||||
'getRadiusAccountingSummary',
|
'getRadiusAccountingSummary',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -351,8 +349,8 @@ export class RadiusHandler {
|
|||||||
// Statistics
|
// Statistics
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// Get RADIUS statistics
|
// Get RADIUS statistics (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
|
||||||
'getRadiusStatistics',
|
'getRadiusStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
export class RemoteIngressHandler {
|
export class RemoteIngressHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
const viewRouter = this.opsServerRef.viewRouter;
|
||||||
|
const adminRouter = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// ---- Read endpoints (viewRouter — valid identity required via middleware) ----
|
||||||
|
|
||||||
// Get all remote ingress edges
|
// Get all remote ingress edges
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngresses>(
|
||||||
'getRemoteIngresses',
|
'getRemoteIngresses',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -36,8 +38,10 @@ export class RemoteIngressHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Write endpoints (adminRouter) ----
|
||||||
|
|
||||||
// Create a new remote ingress edge
|
// Create a new remote ingress edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRemoteIngress>(
|
||||||
'createRemoteIngress',
|
'createRemoteIngress',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -69,7 +73,7 @@ export class RemoteIngressHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Delete a remote ingress edge
|
// Delete a remote ingress edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRemoteIngress>(
|
||||||
'deleteRemoteIngress',
|
'deleteRemoteIngress',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -94,7 +98,7 @@ export class RemoteIngressHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update a remote ingress edge
|
// Update a remote ingress edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngress>(
|
||||||
'updateRemoteIngress',
|
'updateRemoteIngress',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -138,7 +142,7 @@ export class RemoteIngressHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Regenerate secret for an edge
|
// Regenerate secret for an edge
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RegenerateRemoteIngressSecret>(
|
||||||
'regenerateRemoteIngressSecret',
|
'regenerateRemoteIngressSecret',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -164,8 +168,8 @@ export class RemoteIngressHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get runtime status of all edges
|
// Get runtime status of all edges (read)
|
||||||
this.typedrouter.addTypedHandler(
|
viewRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressStatus>(
|
||||||
'getRemoteIngressStatus',
|
'getRemoteIngressStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -178,8 +182,8 @@ export class RemoteIngressHandler {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get a connection token for an edge
|
// Get a connection token for an edge (write — exposes secret)
|
||||||
this.typedrouter.addTypedHandler(
|
adminRouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
|
||||||
'getRemoteIngressConnectionToken',
|
'getRemoteIngressConnectionToken',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import * as interfaces from '../../../ts_interfaces/index.js';
|
|||||||
import { MetricsManager } from '../../monitoring/index.js';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
|
|
||||||
export class SecurityHandler {
|
export class SecurityHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All security endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Security Metrics Handler
|
// Security Metrics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityMetrics>(
|
||||||
'getSecurityMetrics',
|
'getSecurityMetrics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -40,7 +39,7 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Active Connections Handler
|
// Active Connections Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetActiveConnections>(
|
||||||
'getActiveConnections',
|
'getActiveConnections',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -77,8 +76,8 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Network Stats Handler - provides comprehensive network metrics
|
// Network Stats Handler - provides comprehensive network metrics
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetNetworkStats>(
|
||||||
'getNetworkStats',
|
'getNetworkStats',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
// Get network stats from MetricsManager if available
|
// Get network stats from MetricsManager if available
|
||||||
@@ -121,7 +120,7 @@ export class SecurityHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Rate Limit Status Handler
|
// Rate Limit Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRateLimitStatus>(
|
||||||
'getRateLimitStatus',
|
'getRateLimitStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -5,17 +5,16 @@ import { MetricsManager } from '../../monitoring/index.js';
|
|||||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||||
|
|
||||||
export class StatsHandler {
|
export class StatsHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
||||||
|
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
// Add this handler's router to the parent
|
|
||||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
||||||
this.registerHandlers();
|
this.registerHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerHandlers(): void {
|
private registerHandlers(): void {
|
||||||
|
// All stats endpoints register directly on viewRouter (valid identity required via middleware)
|
||||||
|
const router = this.opsServerRef.viewRouter;
|
||||||
|
|
||||||
// Server Statistics Handler
|
// Server Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetServerStatistics>(
|
||||||
'getServerStatistics',
|
'getServerStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -38,7 +37,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Email Statistics Handler
|
// Email Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailStatistics>(
|
||||||
'getEmailStatistics',
|
'getEmailStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -77,7 +76,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// DNS Statistics Handler
|
// DNS Statistics Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsStatistics>(
|
||||||
'getDnsStatistics',
|
'getDnsStatistics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -114,7 +113,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Queue Status Handler
|
// Queue Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueueStatus>(
|
||||||
'getQueueStatus',
|
'getQueueStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -142,7 +141,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Health Status Handler
|
// Health Status Handler
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHealthStatus>(
|
||||||
'getHealthStatus',
|
'getHealthStatus',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
@@ -167,7 +166,7 @@ export class StatsHandler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Combined Metrics Handler - More efficient for frontend polling
|
// Combined Metrics Handler - More efficient for frontend polling
|
||||||
this.typedrouter.addTypedHandler(
|
router.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetCombinedMetrics>(
|
||||||
'getCombinedMetrics',
|
'getCombinedMetrics',
|
||||||
async (dataArg, toolsArg) => {
|
async (dataArg, toolsArg) => {
|
||||||
|
|||||||
@@ -22,16 +22,17 @@ export async function passGuards<T extends { identity?: any }>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check admin identity in handlers
|
* Helper to check admin identity in handlers and middleware.
|
||||||
|
* Accepts both optional and required identity for flexibility.
|
||||||
*/
|
*/
|
||||||
export async function requireAdminIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
export async function requireAdminIdentity(
|
||||||
adminHandler: AdminHandler,
|
adminHandler: AdminHandler,
|
||||||
dataArg: T
|
dataArg: { identity?: interfaces.data.IIdentity }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!dataArg.identity) {
|
if (!dataArg.identity) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
const passed = await adminHandler.adminIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
||||||
@@ -39,16 +40,17 @@ export async function requireAdminIdentity<T extends { identity?: interfaces.dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check valid identity in handlers
|
* Helper to check valid identity in handlers and middleware.
|
||||||
|
* Accepts both optional and required identity for flexibility.
|
||||||
*/
|
*/
|
||||||
export async function requireValidIdentity<T extends { identity?: interfaces.data.IIdentity }>(
|
export async function requireValidIdentity(
|
||||||
adminHandler: AdminHandler,
|
adminHandler: AdminHandler,
|
||||||
dataArg: T
|
dataArg: { identity?: interfaces.data.IIdentity }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!dataArg.identity) {
|
if (!dataArg.identity) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
throw new plugins.typedrequest.TypedResponseError('No identity provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
const passed = await adminHandler.validIdentityGuard.exec({ identity: dataArg.identity });
|
||||||
if (!passed) {
|
if (!passed) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
throw new plugins.typedrequest.TypedResponseError('Valid identity required');
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
|||||||
export interface ITunnelManagerConfig {
|
export interface ITunnelManagerConfig {
|
||||||
tunnelPort?: number;
|
tunnelPort?: number;
|
||||||
targetHost?: string;
|
targetHost?: string;
|
||||||
|
tls?: {
|
||||||
|
certPem?: string;
|
||||||
|
keyPem?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +19,7 @@ export class TunnelManager {
|
|||||||
private manager: RemoteIngressManager;
|
private manager: RemoteIngressManager;
|
||||||
private config: ITunnelManagerConfig;
|
private config: ITunnelManagerConfig;
|
||||||
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
|
||||||
|
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
@@ -22,12 +27,11 @@ export class TunnelManager {
|
|||||||
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
this.hub = new plugins.remoteingress.RemoteIngressHub();
|
||||||
|
|
||||||
// Listen for edge connect/disconnect events
|
// Listen for edge connect/disconnect events
|
||||||
this.hub.on('edgeConnected', (data: { edgeId: string }) => {
|
this.hub.on('edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
|
||||||
const existing = this.edgeStatuses.get(data.edgeId);
|
|
||||||
this.edgeStatuses.set(data.edgeId, {
|
this.edgeStatuses.set(data.edgeId, {
|
||||||
edgeId: data.edgeId,
|
edgeId: data.edgeId,
|
||||||
connected: true,
|
connected: true,
|
||||||
publicIp: existing?.publicIp ?? null,
|
publicIp: data.peerAddr || null,
|
||||||
activeTunnels: 0,
|
activeTunnels: 0,
|
||||||
lastHeartbeat: Date.now(),
|
lastHeartbeat: Date.now(),
|
||||||
connectedAt: Date.now(),
|
connectedAt: Date.now(),
|
||||||
@@ -61,20 +65,73 @@ export class TunnelManager {
|
|||||||
await this.hub.start({
|
await this.hub.start({
|
||||||
tunnelPort: this.config.tunnelPort ?? 8443,
|
tunnelPort: this.config.tunnelPort ?? 8443,
|
||||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||||
|
tls: this.config.tls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send allowed edges to the hub
|
// Send allowed edges to the hub
|
||||||
await this.syncAllowedEdges();
|
await this.syncAllowedEdges();
|
||||||
|
|
||||||
|
// Periodically reconcile with authoritative Rust hub status
|
||||||
|
this.reconcileInterval = setInterval(() => {
|
||||||
|
this.reconcile().catch(() => {});
|
||||||
|
}, 15_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the tunnel hub.
|
* Stop the tunnel hub.
|
||||||
*/
|
*/
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
|
if (this.reconcileInterval) {
|
||||||
|
clearInterval(this.reconcileInterval);
|
||||||
|
this.reconcileInterval = null;
|
||||||
|
}
|
||||||
|
// Remove event listeners before stopping to prevent leaks
|
||||||
|
this.hub.removeAllListeners();
|
||||||
await this.hub.stop();
|
await this.hub.stop();
|
||||||
this.edgeStatuses.clear();
|
this.edgeStatuses.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile TS-side edge statuses with the authoritative Rust hub status.
|
||||||
|
* Overwrites event-derived activeTunnels with the real activeStreams count.
|
||||||
|
*/
|
||||||
|
private async reconcile(): Promise<void> {
|
||||||
|
const hubStatus = await this.hub.getStatus();
|
||||||
|
if (!hubStatus || !hubStatus.connectedEdges) return;
|
||||||
|
|
||||||
|
const rustEdgeIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const rustEdge of hubStatus.connectedEdges) {
|
||||||
|
rustEdgeIds.add(rustEdge.edgeId);
|
||||||
|
const existing = this.edgeStatuses.get(rustEdge.edgeId);
|
||||||
|
if (existing) {
|
||||||
|
existing.activeTunnels = rustEdge.activeStreams;
|
||||||
|
existing.lastHeartbeat = Date.now();
|
||||||
|
// Update peer address if available from Rust hub
|
||||||
|
if (rustEdge.peerAddr) {
|
||||||
|
existing.publicIp = rustEdge.peerAddr;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Missed edgeConnected event — add entry
|
||||||
|
this.edgeStatuses.set(rustEdge.edgeId, {
|
||||||
|
edgeId: rustEdge.edgeId,
|
||||||
|
connected: true,
|
||||||
|
publicIp: rustEdge.peerAddr || null,
|
||||||
|
activeTunnels: rustEdge.activeStreams,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
connectedAt: rustEdge.connectedAt * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove entries for edges no longer connected in Rust (missed edgeDisconnected)
|
||||||
|
for (const edgeId of this.edgeStatuses.keys()) {
|
||||||
|
if (!rustEdgeIds.has(edgeId)) {
|
||||||
|
this.edgeStatuses.delete(edgeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync allowed edges from the manager to the hub.
|
* Sync allowed edges from the manager to the hub.
|
||||||
* Call this after creating/deleting/updating edges.
|
* Call this after creating/deleting/updating edges.
|
||||||
@@ -109,6 +166,19 @@ export class TunnelManager {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public IPs of all connected edges.
|
||||||
|
*/
|
||||||
|
public getConnectedEdgeIps(): string[] {
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const status of this.edgeStatuses.values()) {
|
||||||
|
if (status.connected && status.publicIp) {
|
||||||
|
ips.push(status.publicIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total number of active tunnels across all edges.
|
* Get the total number of active tunnels across all edges.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -182,7 +182,14 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
return ContentScanner.instance;
|
return ContentScanner.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
ContentScanner.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan an email for malicious content
|
* Scan an email for malicious content
|
||||||
* @param email The email to scan
|
* @param email The email to scan
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export class IPReputationChecker {
|
|||||||
private reputationCache: LRUCache<string, IReputationResult>;
|
private reputationCache: LRUCache<string, IReputationResult>;
|
||||||
private options: Required<IIPReputationOptions>;
|
private options: Required<IIPReputationOptions>;
|
||||||
private storageManager?: any; // StorageManager instance
|
private storageManager?: any; // StorageManager instance
|
||||||
|
private saveCacheTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private static readonly SAVE_CACHE_DEBOUNCE_MS = 30_000;
|
||||||
|
|
||||||
// Default DNSBL servers
|
// Default DNSBL servers
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||||||
@@ -143,7 +145,20 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
return IPReputationChecker.instance;
|
return IPReputationChecker.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
if (IPReputationChecker.instance) {
|
||||||
|
if (IPReputationChecker.instance.saveCacheTimer) {
|
||||||
|
clearTimeout(IPReputationChecker.instance.saveCacheTimer);
|
||||||
|
IPReputationChecker.instance.saveCacheTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IPReputationChecker.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an IP address's reputation
|
* Check an IP address's reputation
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
@@ -213,12 +228,9 @@ export class IPReputationChecker {
|
|||||||
// Update cache with result
|
// Update cache with result
|
||||||
this.reputationCache.set(ip, result);
|
this.reputationCache.set(ip, result);
|
||||||
|
|
||||||
// Save cache if enabled
|
// Schedule debounced cache save if enabled
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
// Fire and forget the save operation
|
this.debouncedSaveCache();
|
||||||
this.saveCache().catch(error => {
|
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the reputation check
|
// Log the reputation check
|
||||||
@@ -447,6 +459,21 @@ export class IPReputationChecker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a debounced cache save (at most once per SAVE_CACHE_DEBOUNCE_MS)
|
||||||
|
*/
|
||||||
|
private debouncedSaveCache(): void {
|
||||||
|
if (this.saveCacheTimer) {
|
||||||
|
return; // already scheduled
|
||||||
|
}
|
||||||
|
this.saveCacheTimer = setTimeout(() => {
|
||||||
|
this.saveCacheTimer = null;
|
||||||
|
this.saveCache().catch(error => {
|
||||||
|
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||||
|
});
|
||||||
|
}, IPReputationChecker.SAVE_CACHE_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save cache to disk or storage manager
|
* Save cache to disk or storage manager
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -83,7 +83,14 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
return SecurityLogger.instance;
|
return SecurityLogger.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the singleton instance (for shutdown/testing)
|
||||||
|
*/
|
||||||
|
public static resetInstance(): void {
|
||||||
|
SecurityLogger.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a security event
|
* Log a security event
|
||||||
* @param event The security event to log
|
* @param event The security event to log
|
||||||
@@ -155,8 +162,9 @@ export class SecurityLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return most recent events up to limit
|
// Return most recent events up to limit (slice first to avoid mutating source)
|
||||||
return filteredEvents
|
return filteredEvents
|
||||||
|
.slice()
|
||||||
.sort((a, b) => b.timestamp - a.timestamp)
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
@@ -242,58 +250,46 @@ export class SecurityLogger {
|
|||||||
topIPs: Array<{ ip: string; count: number }>;
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
topDomains: Array<{ domain: string; count: number }>;
|
topDomains: Array<{ domain: string; count: number }>;
|
||||||
} {
|
} {
|
||||||
// Filter by time window if provided
|
const cutoff = timeWindow ? Date.now() - timeWindow : 0;
|
||||||
let events = this.securityEvents;
|
|
||||||
if (timeWindow) {
|
// Initialize counters
|
||||||
const cutoff = Date.now() - timeWindow;
|
const byLevel = {} as Record<SecurityLogLevel, number>;
|
||||||
events = events.filter(e => e.timestamp >= cutoff);
|
for (const level of Object.values(SecurityLogLevel)) {
|
||||||
|
byLevel[level] = 0;
|
||||||
|
}
|
||||||
|
const byType = {} as Record<SecurityEventType, number>;
|
||||||
|
for (const type of Object.values(SecurityEventType)) {
|
||||||
|
byType[type] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count by level
|
|
||||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
|
||||||
acc[level] = events.filter(e => e.level === level).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityLogLevel, number>);
|
|
||||||
|
|
||||||
// Count by type
|
|
||||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
|
||||||
acc[type] = events.filter(e => e.type === type).length;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<SecurityEventType, number>);
|
|
||||||
|
|
||||||
// Count by IP
|
|
||||||
const ipCounts = new Map<string, number>();
|
const ipCounts = new Map<string, number>();
|
||||||
events.forEach(e => {
|
const domainCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
// Single pass over all events
|
||||||
|
let total = 0;
|
||||||
|
for (const e of this.securityEvents) {
|
||||||
|
if (cutoff && e.timestamp < cutoff) continue;
|
||||||
|
total++;
|
||||||
|
byLevel[e.level]++;
|
||||||
|
byType[e.type]++;
|
||||||
if (e.ipAddress) {
|
if (e.ipAddress) {
|
||||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Count by domain
|
|
||||||
const domainCounts = new Map<string, number>();
|
|
||||||
events.forEach(e => {
|
|
||||||
if (e.domain) {
|
if (e.domain) {
|
||||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Sort and limit top entries
|
// Sort and limit top entries
|
||||||
const topIPs = Array.from(ipCounts.entries())
|
const topIPs = Array.from(ipCounts.entries())
|
||||||
.map(([ip, count]) => ({ ip, count }))
|
.map(([ip, count]) => ({ ip, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
const topDomains = Array.from(domainCounts.entries())
|
const topDomains = Array.from(domainCounts.entries())
|
||||||
.map(([domain, count]) => ({ domain, count }))
|
.map(([domain, count]) => ({ domain, count }))
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
return {
|
return { total, byLevel, byType, topIPs, topDomains };
|
||||||
total: events.length,
|
|
||||||
byLevel,
|
|
||||||
byType,
|
|
||||||
topIPs,
|
|
||||||
topDomains
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ export type StorageBackend = 'filesystem' | 'custom' | 'memory';
|
|||||||
* Provides unified key-value storage with multiple backend support
|
* Provides unified key-value storage with multiple backend support
|
||||||
*/
|
*/
|
||||||
export class StorageManager {
|
export class StorageManager {
|
||||||
|
private static readonly MAX_MEMORY_ENTRIES = 10_000;
|
||||||
private backend: StorageBackend;
|
private backend: StorageBackend;
|
||||||
private memoryStore: Map<string, string> = new Map();
|
private memoryStore: Map<string, string> = new Map();
|
||||||
private config: IStorageConfig;
|
private config: IStorageConfig;
|
||||||
@@ -227,6 +228,11 @@ export class StorageManager {
|
|||||||
|
|
||||||
case 'memory': {
|
case 'memory': {
|
||||||
this.memoryStore.set(key, value);
|
this.memoryStore.set(key, value);
|
||||||
|
// Evict oldest entries if memory store exceeds limit
|
||||||
|
while (this.memoryStore.size > StorageManager.MAX_MEMORY_ENTRIES) {
|
||||||
|
const firstKey = this.memoryStore.keys().next().value;
|
||||||
|
this.memoryStore.delete(firstKey);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'createApiToken';
|
method: 'createApiToken';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
scopes: TApiTokenScope[];
|
scopes: TApiTokenScope[];
|
||||||
expiresInDays?: number | null;
|
expiresInDays?: number | null;
|
||||||
@@ -38,7 +38,7 @@ export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.imple
|
|||||||
> {
|
> {
|
||||||
method: 'listApiTokens';
|
method: 'listApiTokens';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
tokens: IApiTokenInfo[];
|
tokens: IApiTokenInfo[];
|
||||||
@@ -54,7 +54,7 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'revokeApiToken';
|
method: 'revokeApiToken';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -63,6 +63,26 @@ export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.impl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll (regenerate) an API token's secret. Returns the new raw token value once.
|
||||||
|
* Admin JWT only.
|
||||||
|
*/
|
||||||
|
export interface IReq_RollApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RollApiToken
|
||||||
|
> {
|
||||||
|
method: 'rollApiToken';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
tokenValue?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable an API token.
|
* Enable or disable an API token.
|
||||||
*/
|
*/
|
||||||
@@ -72,7 +92,7 @@ export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'toggleApiToken';
|
method: 'toggleApiToken';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface IReq_GetCertificateOverview extends plugins.typedrequestInterfa
|
|||||||
> {
|
> {
|
||||||
method: 'getCertificateOverview';
|
method: 'getCertificateOverview';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
certificates: ICertificateInfo[];
|
certificates: ICertificateInfo[];
|
||||||
@@ -50,7 +50,7 @@ export interface IReq_ReprovisionCertificate extends plugins.typedrequestInterfa
|
|||||||
> {
|
> {
|
||||||
method: 'reprovisionCertificate';
|
method: 'reprovisionCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
routeName: string;
|
routeName: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -66,7 +66,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
|||||||
> {
|
> {
|
||||||
method: 'reprovisionCertificateDomain';
|
method: 'reprovisionCertificateDomain';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -82,7 +82,7 @@ export interface IReq_DeleteCertificate extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'deleteCertificate';
|
method: 'deleteCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -98,7 +98,7 @@ export interface IReq_ExportCertificate extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'exportCertificate';
|
method: 'exportCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -123,7 +123,7 @@ export interface IReq_ImportCertificate extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'importCertificate';
|
method: 'importCertificate';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
cert: {
|
cert: {
|
||||||
id: string;
|
id: string;
|
||||||
domainName: string;
|
domainName: string;
|
||||||
|
|||||||
@@ -1,6 +1,79 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as authInterfaces from '../data/auth.js';
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
|
export interface IConfigData {
|
||||||
|
system: {
|
||||||
|
baseDir: string;
|
||||||
|
dataDir: string;
|
||||||
|
publicIp: string | null;
|
||||||
|
proxyIps: string[];
|
||||||
|
uptime: number;
|
||||||
|
storageBackend: 'filesystem' | 'custom' | 'memory';
|
||||||
|
storagePath: string | null;
|
||||||
|
};
|
||||||
|
smartProxy: {
|
||||||
|
enabled: boolean;
|
||||||
|
routeCount: number;
|
||||||
|
acme: {
|
||||||
|
enabled: boolean;
|
||||||
|
accountEmail: string;
|
||||||
|
useProduction: boolean;
|
||||||
|
autoRenew: boolean;
|
||||||
|
renewThresholdDays: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
email: {
|
||||||
|
enabled: boolean;
|
||||||
|
ports: number[];
|
||||||
|
portMapping: Record<string, number> | null;
|
||||||
|
hostname: string | null;
|
||||||
|
domains: string[];
|
||||||
|
emailRouteCount: number;
|
||||||
|
receivedEmailsPath: string | null;
|
||||||
|
};
|
||||||
|
dns: {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
nsDomains: string[];
|
||||||
|
scopes: string[];
|
||||||
|
recordCount: number;
|
||||||
|
records: Array<{ name: string; type: string; value: string; ttl?: number }>;
|
||||||
|
dnsChallenge: boolean;
|
||||||
|
};
|
||||||
|
tls: {
|
||||||
|
contactEmail: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
source: 'acme' | 'static' | 'none';
|
||||||
|
certPath: string | null;
|
||||||
|
keyPath: string | null;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
enabled: boolean;
|
||||||
|
storagePath: string | null;
|
||||||
|
dbName: string | null;
|
||||||
|
defaultTTLDays: number;
|
||||||
|
cleanupIntervalHours: number;
|
||||||
|
ttlConfig: Record<string, number>;
|
||||||
|
};
|
||||||
|
radius: {
|
||||||
|
enabled: boolean;
|
||||||
|
authPort: number | null;
|
||||||
|
acctPort: number | null;
|
||||||
|
bindAddress: string | null;
|
||||||
|
clientCount: number;
|
||||||
|
vlanDefaultVlan: number | null;
|
||||||
|
vlanAllowUnknownMacs: boolean | null;
|
||||||
|
vlanMappingCount: number;
|
||||||
|
};
|
||||||
|
remoteIngress: {
|
||||||
|
enabled: boolean;
|
||||||
|
tunnelPort: number | null;
|
||||||
|
hubDomain: string | null;
|
||||||
|
tlsMode: 'custom' | 'acme' | 'self-signed';
|
||||||
|
connectedEdgeIps: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Get Configuration (read-only)
|
// Get Configuration (read-only)
|
||||||
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -8,11 +81,11 @@ export interface IReq_GetConfiguration extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'getConfiguration';
|
method: 'getConfiguration';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
section?: string;
|
section?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
config: any;
|
config: IConfigData;
|
||||||
section?: string;
|
section?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implem
|
|||||||
> {
|
> {
|
||||||
method: 'getAllEmails';
|
method: 'getAllEmails';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
emails: IEmail[];
|
emails: IEmail[];
|
||||||
@@ -84,7 +84,7 @@ export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'getEmailDetail';
|
method: 'getEmailDetail';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
emailId: string;
|
emailId: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -101,7 +101,7 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
|||||||
> {
|
> {
|
||||||
method: 'resendEmail';
|
method: 'resendEmail';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
emailId: string;
|
emailId: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface IReq_GetRecentLogs extends plugins.typedrequestInterfaces.imple
|
|||||||
> {
|
> {
|
||||||
method: 'getRecentLogs';
|
method: 'getRecentLogs';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -31,7 +31,7 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
|||||||
> {
|
> {
|
||||||
method: 'getLogStream';
|
method: 'getLogStream';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
follow?: boolean;
|
follow?: boolean;
|
||||||
filters?: {
|
filters?: {
|
||||||
level?: string[];
|
level?: string[];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface IReq_GetRadiusClients extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusClients';
|
method: 'getRadiusClients';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
clients: Array<{
|
clients: Array<{
|
||||||
@@ -35,7 +35,7 @@ export interface IReq_SetRadiusClient extends plugins.typedrequestInterfaces.imp
|
|||||||
> {
|
> {
|
||||||
method: 'setRadiusClient';
|
method: 'setRadiusClient';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
client: {
|
client: {
|
||||||
name: string;
|
name: string;
|
||||||
ipRange: string;
|
ipRange: string;
|
||||||
@@ -59,7 +59,7 @@ export interface IReq_RemoveRadiusClient extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'removeRadiusClient';
|
method: 'removeRadiusClient';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -81,7 +81,7 @@ export interface IReq_GetVlanMappings extends plugins.typedrequestInterfaces.imp
|
|||||||
> {
|
> {
|
||||||
method: 'getVlanMappings';
|
method: 'getVlanMappings';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
mappings: Array<{
|
mappings: Array<{
|
||||||
@@ -108,7 +108,7 @@ export interface IReq_SetVlanMapping extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'setVlanMapping';
|
method: 'setVlanMapping';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
mapping: {
|
mapping: {
|
||||||
mac: string;
|
mac: string;
|
||||||
vlan: number;
|
vlan: number;
|
||||||
@@ -139,7 +139,7 @@ export interface IReq_RemoveVlanMapping extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'removeVlanMapping';
|
method: 'removeVlanMapping';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
mac: string;
|
mac: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -157,7 +157,7 @@ export interface IReq_UpdateVlanConfig extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'updateVlanConfig';
|
method: 'updateVlanConfig';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
defaultVlan?: number;
|
defaultVlan?: number;
|
||||||
allowUnknownMacs?: boolean;
|
allowUnknownMacs?: boolean;
|
||||||
};
|
};
|
||||||
@@ -179,7 +179,7 @@ export interface IReq_TestVlanAssignment extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'testVlanAssignment';
|
method: 'testVlanAssignment';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
mac: string;
|
mac: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -207,7 +207,7 @@ export interface IReq_GetRadiusSessions extends plugins.typedrequestInterfaces.i
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusSessions';
|
method: 'getRadiusSessions';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
filter?: {
|
filter?: {
|
||||||
username?: string;
|
username?: string;
|
||||||
nasIpAddress?: string;
|
nasIpAddress?: string;
|
||||||
@@ -243,7 +243,7 @@ export interface IReq_DisconnectRadiusSession extends plugins.typedrequestInterf
|
|||||||
> {
|
> {
|
||||||
method: 'disconnectRadiusSession';
|
method: 'disconnectRadiusSession';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
@@ -262,7 +262,7 @@ export interface IReq_GetRadiusAccountingSummary extends plugins.typedrequestInt
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusAccountingSummary';
|
method: 'getRadiusAccountingSummary';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
};
|
};
|
||||||
@@ -296,7 +296,7 @@ export interface IReq_GetRadiusStatistics extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'getRadiusStatistics';
|
method: 'getRadiusStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
stats: {
|
stats: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'createRemoteIngress';
|
method: 'createRemoteIngress';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
autoDerivePorts?: boolean;
|
autoDerivePorts?: boolean;
|
||||||
@@ -36,7 +36,7 @@ export interface IReq_DeleteRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'deleteRemoteIngress';
|
method: 'deleteRemoteIngress';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -54,7 +54,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'updateRemoteIngress';
|
method: 'updateRemoteIngress';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
@@ -77,7 +77,7 @@ export interface IReq_RegenerateRemoteIngressSecret extends plugins.typedrequest
|
|||||||
> {
|
> {
|
||||||
method: 'regenerateRemoteIngressSecret';
|
method: 'regenerateRemoteIngressSecret';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -95,7 +95,7 @@ export interface IReq_GetRemoteIngresses extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getRemoteIngresses';
|
method: 'getRemoteIngresses';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
edges: IRemoteIngress[];
|
edges: IRemoteIngress[];
|
||||||
@@ -111,7 +111,7 @@ export interface IReq_GetRemoteIngressStatus extends plugins.typedrequestInterfa
|
|||||||
> {
|
> {
|
||||||
method: 'getRemoteIngressStatus';
|
method: 'getRemoteIngressStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
statuses: IRemoteIngressStatus[];
|
statuses: IRemoteIngressStatus[];
|
||||||
@@ -128,7 +128,7 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
|
|||||||
> {
|
> {
|
||||||
method: 'getRemoteIngressConnectionToken';
|
method: 'getRemoteIngressConnectionToken';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
edgeId: string;
|
edgeId: string;
|
||||||
hubHost?: string;
|
hubHost?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface IReq_GetServerStatistics extends plugins.typedrequestInterfaces
|
|||||||
> {
|
> {
|
||||||
method: 'getServerStatistics';
|
method: 'getServerStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
includeHistory?: boolean;
|
includeHistory?: boolean;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
};
|
};
|
||||||
@@ -29,7 +29,7 @@ export interface IReq_GetEmailStatistics extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getEmailStatistics';
|
method: 'getEmailStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
includeDetails?: boolean;
|
includeDetails?: boolean;
|
||||||
@@ -49,7 +49,7 @@ export interface IReq_GetDnsStatistics extends plugins.typedrequestInterfaces.im
|
|||||||
> {
|
> {
|
||||||
method: 'getDnsStatistics';
|
method: 'getDnsStatistics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
includeQueryTypes?: boolean;
|
includeQueryTypes?: boolean;
|
||||||
@@ -69,7 +69,7 @@ export interface IReq_GetRateLimitStatus extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getRateLimitStatus';
|
method: 'getRateLimitStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
includeBlocked?: boolean;
|
includeBlocked?: boolean;
|
||||||
@@ -91,7 +91,7 @@ export interface IReq_GetSecurityMetrics extends plugins.typedrequestInterfaces.
|
|||||||
> {
|
> {
|
||||||
method: 'getSecurityMetrics';
|
method: 'getSecurityMetrics';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
timeRange?: '1h' | '6h' | '24h' | '7d' | '30d';
|
||||||
includeDetails?: boolean;
|
includeDetails?: boolean;
|
||||||
};
|
};
|
||||||
@@ -112,7 +112,7 @@ export interface IReq_GetActiveConnections extends plugins.typedrequestInterface
|
|||||||
> {
|
> {
|
||||||
method: 'getActiveConnections';
|
method: 'getActiveConnections';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
protocol?: 'smtp' | 'smtps' | 'http' | 'https';
|
||||||
state?: string;
|
state?: string;
|
||||||
};
|
};
|
||||||
@@ -137,7 +137,7 @@ export interface IReq_GetQueueStatus extends plugins.typedrequestInterfaces.impl
|
|||||||
> {
|
> {
|
||||||
method: 'getQueueStatus';
|
method: 'getQueueStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
queueName?: string;
|
queueName?: string;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -153,10 +153,31 @@ export interface IReq_GetHealthStatus extends plugins.typedrequestInterfaces.imp
|
|||||||
> {
|
> {
|
||||||
method: 'getHealthStatus';
|
method: 'getHealthStatus';
|
||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
detailed?: boolean;
|
detailed?: boolean;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
health: statsInterfaces.IHealthStatus;
|
health: statsInterfaces.IHealthStatus;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network Stats (raw SmartProxy network data)
|
||||||
|
export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetNetworkStats
|
||||||
|
> {
|
||||||
|
method: 'getNetworkStats';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
connectionsByIP: Array<{ ip: string; count: number }>;
|
||||||
|
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
|
||||||
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
|
totalDataTransferred: { bytesIn: number; bytesOut: number };
|
||||||
|
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
|
||||||
|
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||||
|
requestsPerSecond: number;
|
||||||
|
requestsTotal: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '8.1.0',
|
version: '11.0.7',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface IStatsState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IConfigState {
|
export interface IConfigState {
|
||||||
config: any | null;
|
config: interfaces.requests.IConfigData | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -238,9 +238,12 @@ interface IActionContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActionContext = (): IActionContext => {
|
const getActionContext = (): IActionContext => {
|
||||||
return {
|
const identity = loginStatePart.getState().identity;
|
||||||
identity: loginStatePart.getState().identity,
|
// Treat expired JWTs as no identity — prevents stale persisted sessions from firing requests
|
||||||
};
|
if (identity && identity.expiresAt && identity.expiresAt < Date.now()) {
|
||||||
|
return { identity: null };
|
||||||
|
}
|
||||||
|
return { identity };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Login Action
|
// Login Action
|
||||||
@@ -271,24 +274,23 @@ export const loginAction = loginStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout Action
|
// Logout Action — always clears state, even if identity is expired/missing
|
||||||
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
if (!context.identity) return statePartArg.getState();
|
|
||||||
|
|
||||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
// Try to notify server, but don't block logout if identity is missing/expired
|
||||||
interfaces.requests.IReq_AdminLogout
|
if (context.identity) {
|
||||||
>('/typedrequest', 'adminLogout');
|
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_AdminLogout
|
||||||
try {
|
>('/typedrequest', 'adminLogout');
|
||||||
await typedRequest.fire({
|
try {
|
||||||
identity: context.identity,
|
await typedRequest.fire({ identity: context.identity });
|
||||||
});
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Logout error:', error);
|
||||||
console.error('Logout error:', error);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear login state regardless
|
// Always clear login state
|
||||||
return {
|
return {
|
||||||
identity: null,
|
identity: null,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
@@ -298,8 +300,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
|
|||||||
// Fetch All Stats Action - Using combined endpoint for efficiency
|
// Fetch All Stats Action - Using combined endpoint for efficiency
|
||||||
export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => {
|
export const fetchAllStatsAction = statsStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use combined metrics endpoint - single request instead of 4
|
// Use combined metrics endpoint - single request instead of 4
|
||||||
@@ -340,8 +342,8 @@ export const fetchAllStatsAction = statsStatePart.createAction(async (statePartA
|
|||||||
// Fetch Configuration Action (read-only)
|
// Fetch Configuration Action (read-only)
|
||||||
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
export const fetchConfigurationAction = configStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const configRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -373,6 +375,7 @@ export const fetchRecentLogsAction = logStatePart.createAction<{
|
|||||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
if (!context.identity) return statePartArg.getState();
|
||||||
|
|
||||||
const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const logsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_GetRecentLogs
|
interfaces.requests.IReq_GetRecentLogs
|
||||||
@@ -448,8 +451,8 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
|||||||
// Fetch Network Stats Action
|
// Fetch Network Stats Action
|
||||||
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => {
|
export const fetchNetworkStatsAction = networkStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch active connections using the existing endpoint
|
// Fetch active connections using the existing endpoint
|
||||||
@@ -522,6 +525,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -554,6 +558,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (stateP
|
|||||||
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => {
|
export const fetchCertificateOverviewAction = certificateStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -581,7 +586,7 @@ export const fetchCertificateOverviewAction = certificateStatePart.createAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
export const reprovisionCertificateAction = certificateStatePart.createAction<string>(
|
||||||
async (statePartArg, domain) => {
|
async (statePartArg, domain, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -596,8 +601,7 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after reprovisioning
|
// Re-fetch overview after reprovisioning
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
return await actionContext.dispatch(fetchCertificateOverviewAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -608,7 +612,7 @@ export const reprovisionCertificateAction = certificateStatePart.createAction<st
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const deleteCertificateAction = certificateStatePart.createAction<string>(
|
export const deleteCertificateAction = certificateStatePart.createAction<string>(
|
||||||
async (statePartArg, domain) => {
|
async (statePartArg, domain, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -623,8 +627,7 @@ export const deleteCertificateAction = certificateStatePart.createAction<string>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after deletion
|
// Re-fetch overview after deletion
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
return await actionContext.dispatch(fetchCertificateOverviewAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -643,7 +646,7 @@ export const importCertificateAction = certificateStatePart.createAction<{
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
csr: string;
|
csr: string;
|
||||||
}>(
|
}>(
|
||||||
async (statePartArg, cert) => {
|
async (statePartArg, cert, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -658,8 +661,7 @@ export const importCertificateAction = certificateStatePart.createAction<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch overview after import
|
// Re-fetch overview after import
|
||||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
return await actionContext.dispatch(fetchCertificateOverviewAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -700,6 +702,7 @@ export async function fetchConnectionToken(edgeId: string) {
|
|||||||
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
|
export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const edgesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -737,7 +740,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
autoDerivePorts?: boolean;
|
autoDerivePorts?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -756,7 +759,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// Refresh the list
|
// Refresh the list
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...statePartArg.getState(),
|
...statePartArg.getState(),
|
||||||
@@ -774,7 +777,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
|
export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<string>(
|
||||||
async (statePartArg, edgeId) => {
|
async (statePartArg, edgeId, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -788,8 +791,7 @@ export const deleteRemoteIngressAction = remoteIngressStatePart.createAction<str
|
|||||||
id: edgeId,
|
id: edgeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
return await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -805,7 +807,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
listenPorts?: number[];
|
listenPorts?: number[];
|
||||||
autoDerivePorts?: boolean;
|
autoDerivePorts?: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -823,8 +825,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
tags: dataArg.tags,
|
tags: dataArg.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
return await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -877,7 +878,7 @@ export const clearNewEdgeIdAction = remoteIngressStatePart.createAction(
|
|||||||
export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -892,8 +893,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
return await actionContext.dispatch(fetchRemoteIngressAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -909,6 +909,7 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
|||||||
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -939,7 +940,7 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
|
|||||||
export const createRouteAction = routeManagementStatePart.createAction<{
|
export const createRouteAction = routeManagementStatePart.createAction<{
|
||||||
route: any;
|
route: any;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -954,8 +955,7 @@ export const createRouteAction = routeManagementStatePart.createAction<{
|
|||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -965,7 +965,7 @@ export const createRouteAction = routeManagementStatePart.createAction<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
||||||
async (statePartArg, routeId) => {
|
async (statePartArg, routeId, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -979,8 +979,7 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
|||||||
id: routeId,
|
id: routeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -993,7 +992,7 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
|||||||
export const toggleRouteAction = routeManagementStatePart.createAction<{
|
export const toggleRouteAction = routeManagementStatePart.createAction<{
|
||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -1008,8 +1007,7 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
|
|||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1021,7 +1019,7 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
|
|||||||
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
||||||
routeName: string;
|
routeName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -1036,8 +1034,7 @@ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
|||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1047,7 +1044,7 @@ export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
|
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
|
||||||
async (statePartArg, routeName) => {
|
async (statePartArg, routeName, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -1061,8 +1058,7 @@ export const removeRouteOverrideAction = routeManagementStatePart.createAction<s
|
|||||||
routeName,
|
routeName,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
return await actionContext.dispatch(fetchMergedRoutesAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1079,6 +1075,7 @@ export const removeRouteOverrideAction = routeManagementStatePart.createAction<s
|
|||||||
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -1115,8 +1112,20 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rollApiToken(id: string) {
|
||||||
|
const context = getActionContext();
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_RollApiToken
|
||||||
|
>('/typedrequest', 'rollApiToken');
|
||||||
|
|
||||||
|
return request.fire({
|
||||||
|
identity: context.identity,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
|
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
|
||||||
async (statePartArg, tokenId) => {
|
async (statePartArg, tokenId, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -1130,8 +1139,7 @@ export const revokeApiTokenAction = routeManagementStatePart.createAction<string
|
|||||||
id: tokenId,
|
id: tokenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
return await actionContext.dispatch(fetchApiTokensAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1144,7 +1152,7 @@ export const revokeApiTokenAction = routeManagementStatePart.createAction<string
|
|||||||
export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||||
id: string;
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}>(async (statePartArg, dataArg) => {
|
}>(async (statePartArg, dataArg, actionContext) => {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const currentState = statePartArg.getState();
|
const currentState = statePartArg.getState();
|
||||||
|
|
||||||
@@ -1159,8 +1167,7 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
|||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
return await actionContext.dispatch(fetchApiTokensAction, null);
|
||||||
return statePartArg.getState();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -1221,8 +1228,9 @@ async function disconnectSocket() {
|
|||||||
// Combined refresh action for efficient polling
|
// Combined refresh action for efficient polling
|
||||||
async function dispatchCombinedRefreshAction() {
|
async function dispatchCombinedRefreshAction() {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
|
if (!context.identity) return;
|
||||||
const currentView = uiStatePart.getState().activeView;
|
const currentView = uiStatePart.getState().activeView;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Always fetch basic stats for dashboard widgets
|
// Always fetch basic stats for dashboard widgets
|
||||||
const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const combinedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
@@ -1321,8 +1329,23 @@ async function dispatchCombinedRefreshAction() {
|
|||||||
console.error('Certificate refresh failed:', error);
|
console.error('Certificate refresh failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh remote ingress data if on remoteingress view
|
||||||
|
if (currentView === 'remoteingress') {
|
||||||
|
try {
|
||||||
|
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Remote ingress refresh failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Combined refresh failed:', error);
|
console.error('Combined refresh failed:', error);
|
||||||
|
// If the error looks like an auth failure (invalid JWT), force re-login
|
||||||
|
const errMsg = String(error);
|
||||||
|
if (errMsg.includes('invalid') || errMsg.includes('unauthorized') || errMsg.includes('401')) {
|
||||||
|
await loginStatePart.dispatchAction(logoutAction, null);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
|
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||||
import { appRouter } from '../router.js';
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -43,42 +44,52 @@ export class OpsDashboard extends DeesElement {
|
|||||||
private viewTabs = [
|
private viewTabs = [
|
||||||
{
|
{
|
||||||
name: 'Overview',
|
name: 'Overview',
|
||||||
|
iconName: 'lucide:layoutDashboard',
|
||||||
element: OpsViewOverview,
|
element: OpsViewOverview,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration',
|
||||||
|
iconName: 'lucide:settings',
|
||||||
|
element: OpsViewConfig,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Network',
|
name: 'Network',
|
||||||
|
iconName: 'lucide:network',
|
||||||
element: OpsViewNetwork,
|
element: OpsViewNetwork,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Emails',
|
name: 'Emails',
|
||||||
|
iconName: 'lucide:mail',
|
||||||
element: OpsViewEmails,
|
element: OpsViewEmails,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Logs',
|
name: 'Logs',
|
||||||
|
iconName: 'lucide:scrollText',
|
||||||
element: OpsViewLogs,
|
element: OpsViewLogs,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Routes',
|
name: 'Routes',
|
||||||
|
iconName: 'lucide:route',
|
||||||
element: OpsViewRoutes,
|
element: OpsViewRoutes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ApiTokens',
|
name: 'ApiTokens',
|
||||||
|
iconName: 'lucide:key',
|
||||||
element: OpsViewApiTokens,
|
element: OpsViewApiTokens,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Configuration',
|
|
||||||
element: OpsViewConfig,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Security',
|
name: 'Security',
|
||||||
|
iconName: 'lucide:shield',
|
||||||
element: OpsViewSecurity,
|
element: OpsViewSecurity,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Certificates',
|
name: 'Certificates',
|
||||||
|
iconName: 'lucide:badgeCheck',
|
||||||
element: OpsViewCertificates,
|
element: OpsViewCertificates,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'RemoteIngress',
|
name: 'RemoteIngress',
|
||||||
|
iconName: 'lucide:globe',
|
||||||
element: OpsViewRemoteIngress,
|
element: OpsViewRemoteIngress,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -208,13 +219,27 @@ export class OpsDashboard extends DeesElement {
|
|||||||
// Handle initial state - check if we have a stored session that's still valid
|
// Handle initial state - check if we have a stored session that's still valid
|
||||||
const loginState = appstate.loginStatePart.getState();
|
const loginState = appstate.loginStatePart.getState();
|
||||||
if (loginState.identity?.jwt) {
|
if (loginState.identity?.jwt) {
|
||||||
// Verify JWT hasn't expired
|
|
||||||
if (loginState.identity.expiresAt > Date.now()) {
|
if (loginState.identity.expiresAt > Date.now()) {
|
||||||
// JWT still valid, restore logged-in state
|
// Client-side expiry looks valid — verify with server (keypair may have changed)
|
||||||
this.loginState = loginState;
|
try {
|
||||||
await simpleLogin.switchToSlottedContent();
|
const verifyRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
interfaces.requests.IReq_VerifyIdentity
|
||||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
>('/typedrequest', 'verifyIdentity');
|
||||||
|
const response = await verifyRequest.fire({ identity: loginState.identity });
|
||||||
|
if (response.valid) {
|
||||||
|
// JWT confirmed valid by server
|
||||||
|
this.loginState = loginState;
|
||||||
|
await simpleLogin.switchToSlottedContent();
|
||||||
|
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||||
|
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||||
|
} else {
|
||||||
|
// Server rejected the JWT — clear state, show login
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server unreachable or error — clear state, show login
|
||||||
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// JWT expired, clear the stored state
|
// JWT expired, clear the stored state
|
||||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||||
|
|||||||
@@ -152,6 +152,15 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Roll',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||||
|
await this.showRollTokenDialog(token);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Revoke',
|
name: 'Revoke',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
@@ -225,13 +234,17 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
name: 'Create',
|
name: 'Create',
|
||||||
iconName: 'lucide:key',
|
iconName: 'lucide:key',
|
||||||
action: async (modalArg: any) => {
|
action: async (modalArg: any) => {
|
||||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
const contentEl = modalArg.shadowRoot?.querySelector('.content');
|
||||||
|
const form = contentEl?.querySelector('dees-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const formData = await form.collectFormData();
|
const formData = await form.collectFormData();
|
||||||
if (!formData.name) return;
|
if (!formData.name) return;
|
||||||
|
|
||||||
// dees-input-tags returns string[] directly
|
// dees-input-tags is not in dees-form's FORM_INPUT_TYPES, so collectFormData() won't
|
||||||
const scopes = (formData.scopes || [])
|
// include it. Query the tags input directly and call getValue().
|
||||||
|
const tagsInput = form.querySelector('dees-input-tags') as any;
|
||||||
|
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
|
||||||
|
const scopes = rawScopes
|
||||||
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
||||||
|
|
||||||
const expiresInDays = formData.expiresInDays
|
const expiresInDays = formData.expiresInDays
|
||||||
@@ -275,6 +288,60 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Roll Token Secret',
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>This will regenerate the secret for <strong>${token.name}</strong>. The old token value will stop working immediately.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Roll Token',
|
||||||
|
iconName: 'lucide:rotateCw',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
try {
|
||||||
|
const response = await appstate.rollApiToken(token.id);
|
||||||
|
if (response.success && response.tokenValue) {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: 'Token Rolled',
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Copy this token now. It will not be shown again.</p>
|
||||||
|
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||||||
|
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Done',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (m: any) => await m.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to roll token:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as shared from './shared/index.js';
|
import * as shared from './shared/index.js';
|
||||||
import * as appstate from '../appstate.js';
|
import * as appstate from '../appstate.js';
|
||||||
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
type TemplateResult,
|
type TemplateResult,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import type { IConfigField, IConfigSectionAction } from '@serve.zone/catalog';
|
||||||
|
|
||||||
@customElement('ops-view-config')
|
@customElement('ops-view-config')
|
||||||
export class OpsViewConfig extends DeesElement {
|
export class OpsViewConfig extends DeesElement {
|
||||||
@state()
|
@state()
|
||||||
@@ -35,165 +38,19 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
shared.viewHostCss,
|
shared.viewHostCss,
|
||||||
css`
|
css`
|
||||||
.configSection {
|
|
||||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeader {
|
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle dees-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
color: ${cssManager.bdTheme('#666', '#888')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionContent {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configField {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configField:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldLabel {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: block;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldValue {
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
|
||||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldValue.empty {
|
|
||||||
color: ${cssManager.bdTheme('#999', '#666')};
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nestedFields {
|
|
||||||
margin-left: 16px;
|
|
||||||
padding-left: 16px;
|
|
||||||
border-left: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badge styles */
|
|
||||||
.statusBadge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge.enabled {
|
|
||||||
background: ${cssManager.bdTheme('#d4edda', '#1a3d1a')};
|
|
||||||
color: ${cssManager.bdTheme('#155724', '#66cc66')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge.disabled {
|
|
||||||
background: ${cssManager.bdTheme('#f8d7da', '#3d1a1a')};
|
|
||||||
color: ${cssManager.bdTheme('#721c24', '#cc6666')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge dees-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Array/list display */
|
|
||||||
.arrayItems {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrayItem {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
|
||||||
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrayCount {
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${cssManager.bdTheme('#999', '#666')};
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Numeric value formatting */
|
|
||||||
.numericValue {
|
|
||||||
font-weight: 600;
|
|
||||||
color: ${cssManager.bdTheme('#0066cc', '#66aaff')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorMessage {
|
|
||||||
background: ${cssManager.bdTheme('#fee', '#4a1f1f')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#fcc', '#6a2f2f')};
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 16px;
|
|
||||||
color: ${cssManager.bdTheme('#c00', '#ff6666')};
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingMessage {
|
.loadingMessage {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
color: ${cssManager.bdTheme('#666', '#999')};
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
||||||
}
|
}
|
||||||
|
|
||||||
.infoNote {
|
.errorMessage {
|
||||||
background: ${cssManager.bdTheme('#e7f3ff', '#1a2a3d')};
|
background: ${cssManager.bdTheme('#fee2e2', 'rgba(239,68,68,0.1)')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#b3d7ff', '#2a4a6d')};
|
border: 1px solid ${cssManager.bdTheme('#fecaca', 'rgba(239,68,68,0.3)')};
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 24px;
|
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||||
color: ${cssManager.bdTheme('#004085', '#88ccff')};
|
margin: 16px 0;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.infoNote dees-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -202,185 +59,276 @@ export class OpsViewConfig extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<ops-sectionheading>Configuration</ops-sectionheading>
|
<ops-sectionheading>Configuration</ops-sectionheading>
|
||||||
|
|
||||||
${this.configState.isLoading ? html`
|
${this.configState.isLoading
|
||||||
<div class="loadingMessage">
|
? html`
|
||||||
<dees-spinner></dees-spinner>
|
<div class="loadingMessage">
|
||||||
<p>Loading configuration...</p>
|
<dees-spinner></dees-spinner>
|
||||||
</div>
|
<p>Loading configuration...</p>
|
||||||
` : this.configState.error ? html`
|
|
||||||
<div class="errorMessage">
|
|
||||||
Error loading configuration: ${this.configState.error}
|
|
||||||
</div>
|
|
||||||
` : this.configState.config ? html`
|
|
||||||
<div class="infoNote">
|
|
||||||
<dees-icon icon="lucide:info"></dees-icon>
|
|
||||||
<span>This view displays the current running configuration. DcRouter is configured through code or remote management.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.renderConfigSection('email', 'Email', 'lucide:mail', this.configState.config?.email)}
|
|
||||||
${this.renderConfigSection('dns', 'DNS', 'lucide:globe', this.configState.config?.dns)}
|
|
||||||
${this.renderConfigSection('proxy', 'Proxy', 'lucide:network', this.configState.config?.proxy)}
|
|
||||||
${this.renderConfigSection('security', 'Security', 'lucide:shield', this.configState.config?.security)}
|
|
||||||
` : html`
|
|
||||||
<div class="errorMessage">No configuration loaded</div>
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderConfigSection(key: string, title: string, icon: string, config: any) {
|
|
||||||
const isEnabled = config?.enabled ?? false;
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="configSection">
|
|
||||||
<div class="sectionHeader">
|
|
||||||
<h3 class="sectionTitle">
|
|
||||||
<dees-icon icon="${icon}"></dees-icon>
|
|
||||||
${title}
|
|
||||||
</h3>
|
|
||||||
${this.renderStatusBadge(isEnabled)}
|
|
||||||
</div>
|
|
||||||
<div class="sectionContent">
|
|
||||||
${config ? this.renderConfigFields(config) : html`
|
|
||||||
<div class="fieldValue empty">Not configured</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderStatusBadge(enabled: boolean): TemplateResult {
|
|
||||||
return enabled
|
|
||||||
? html`<span class="statusBadge enabled"><dees-icon icon="lucide:check"></dees-icon>Enabled</span>`
|
|
||||||
: html`<span class="statusBadge disabled"><dees-icon icon="lucide:x"></dees-icon>Disabled</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderConfigFields(config: any, prefix = ''): TemplateResult | TemplateResult[] {
|
|
||||||
if (!config || typeof config !== 'object') {
|
|
||||||
return html`<div class="fieldValue">${this.formatValue(config)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(config).map(([key, value]) => {
|
|
||||||
const fieldName = prefix ? `${prefix}.${key}` : key;
|
|
||||||
const displayName = this.formatFieldName(key);
|
|
||||||
|
|
||||||
// Handle boolean values with badges
|
|
||||||
if (typeof value === 'boolean') {
|
|
||||||
return html`
|
|
||||||
<div class="configField">
|
|
||||||
<label class="fieldLabel">${displayName}</label>
|
|
||||||
${this.renderStatusBadge(value)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle arrays
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return html`
|
|
||||||
<div class="configField">
|
|
||||||
<label class="fieldLabel">${displayName}</label>
|
|
||||||
${this.renderArrayValue(value, key)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle nested objects
|
|
||||||
if (typeof value === 'object' && value !== null) {
|
|
||||||
return html`
|
|
||||||
<div class="configField">
|
|
||||||
<label class="fieldLabel">${displayName}</label>
|
|
||||||
<div class="nestedFields">
|
|
||||||
${this.renderConfigFields(value, fieldName)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`
|
||||||
`;
|
: this.configState.error
|
||||||
}
|
? html`
|
||||||
|
<div class="errorMessage">
|
||||||
// Handle primitive values
|
Error loading configuration: ${this.configState.error}
|
||||||
return html`
|
</div>
|
||||||
<div class="configField">
|
`
|
||||||
<label class="fieldLabel">${displayName}</label>
|
: this.configState.config
|
||||||
<div class="fieldValue">${this.formatValue(value, key)}</div>
|
? this.renderConfig()
|
||||||
</div>
|
: html`<div class="errorMessage">No configuration loaded</div>`}
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderArrayValue(arr: any[], fieldKey: string): TemplateResult {
|
|
||||||
if (arr.length === 0) {
|
|
||||||
return html`<div class="fieldValue empty">None configured</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should show as pills/tags
|
|
||||||
const showAsPills = arr.every(item => typeof item === 'string' || typeof item === 'number');
|
|
||||||
|
|
||||||
if (showAsPills) {
|
|
||||||
const itemLabel = this.getArrayItemLabel(fieldKey, arr.length);
|
|
||||||
return html`
|
|
||||||
<div class="arrayCount">${arr.length} ${itemLabel}</div>
|
|
||||||
<div class="arrayItems">
|
|
||||||
${arr.map(item => html`<span class="arrayItem">${item}</span>`)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For complex arrays, show as JSON
|
|
||||||
return html`
|
|
||||||
<div class="fieldValue">
|
|
||||||
${arr.length} items configured
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getArrayItemLabel(fieldKey: string, count: number): string {
|
private renderConfig(): TemplateResult {
|
||||||
const labels: Record<string, [string, string]> = {
|
const cfg = this.configState.config!;
|
||||||
ports: ['port', 'ports'],
|
|
||||||
domains: ['domain', 'domains'],
|
|
||||||
nameservers: ['nameserver', 'nameservers'],
|
|
||||||
blockList: ['IP', 'IPs'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const label = labels[fieldKey] || ['item', 'items'];
|
return html`
|
||||||
return count === 1 ? label[0] : label[1];
|
<sz-config-overview
|
||||||
|
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||||
|
@navigate=${(e: CustomEvent) => {
|
||||||
|
if (e.detail?.view) {
|
||||||
|
appRouter.navigateToView(e.detail.view);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${this.renderSystemSection(cfg.system)}
|
||||||
|
${this.renderSmartProxySection(cfg.smartProxy)}
|
||||||
|
${this.renderEmailSection(cfg.email)}
|
||||||
|
${this.renderDnsSection(cfg.dns)}
|
||||||
|
${this.renderTlsSection(cfg.tls)}
|
||||||
|
${this.renderCacheSection(cfg.cache)}
|
||||||
|
${this.renderRadiusSection(cfg.radius)}
|
||||||
|
${this.renderRemoteIngressSection(cfg.remoteIngress)}
|
||||||
|
</sz-config-overview>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatFieldName(key: string): string {
|
private renderSystemSection(sys: appstate.IConfigState['config']['system']): TemplateResult {
|
||||||
// Convert camelCase to readable format
|
// Annotate proxy IPs with source hint when Remote Ingress is active
|
||||||
return key
|
const ri = this.configState.config?.remoteIngress;
|
||||||
.replace(/([A-Z])/g, ' $1')
|
let proxyIpValues: string[] | null = sys.proxyIps.length > 0 ? [...sys.proxyIps] : null;
|
||||||
.replace(/^./, str => str.toUpperCase())
|
if (proxyIpValues && ri?.enabled && proxyIpValues.includes('127.0.0.1')) {
|
||||||
.trim();
|
proxyIpValues = proxyIpValues.map(ip =>
|
||||||
}
|
ip === '127.0.0.1' ? '127.0.0.1 (Remote Ingress)' : ip
|
||||||
|
);
|
||||||
private formatValue(value: any, fieldKey?: string): string | TemplateResult {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return html`<span class="empty">Not set</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'number') {
|
const fields: IConfigField[] = [
|
||||||
// Format bytes
|
{ key: 'Base Directory', value: sys.baseDir },
|
||||||
if (fieldKey?.toLowerCase().includes('size') || fieldKey?.toLowerCase().includes('bytes')) {
|
{ key: 'Data Directory', value: sys.dataDir },
|
||||||
return html`<span class="numericValue">${this.formatBytes(value)}</span>`;
|
{ key: 'Public IP', value: sys.publicIp },
|
||||||
}
|
{ key: 'Proxy IPs', value: proxyIpValues, type: 'pills' },
|
||||||
// Format time values
|
{ key: 'Uptime', value: this.formatUptime(sys.uptime) },
|
||||||
if (fieldKey?.toLowerCase().includes('ttl') || fieldKey?.toLowerCase().includes('timeout')) {
|
{ key: 'Storage Backend', value: sys.storageBackend, type: 'badge' },
|
||||||
return html`<span class="numericValue">${value} seconds</span>`;
|
{ key: 'Storage Path', value: sys.storagePath },
|
||||||
}
|
];
|
||||||
// Format port numbers
|
|
||||||
if (fieldKey?.toLowerCase().includes('port')) {
|
|
||||||
return html`<span class="numericValue">${value}</span>`;
|
|
||||||
}
|
|
||||||
// Format counts with separators
|
|
||||||
return html`<span class="numericValue">${value.toLocaleString()}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="System"
|
||||||
|
subtitle="Base paths and infrastructure"
|
||||||
|
icon="lucide:server"
|
||||||
|
status="enabled"
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatBytes(bytes: number): string {
|
private renderSmartProxySection(proxy: appstate.IConfigState['config']['smartProxy']): TemplateResult {
|
||||||
if (bytes === 0) return '0 B';
|
const fields: IConfigField[] = [
|
||||||
const k = 1024;
|
{ key: 'Route Count', value: proxy.routeCount },
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
if (proxy.acme) {
|
||||||
|
fields.push(
|
||||||
|
{ key: 'ACME Enabled', value: proxy.acme.enabled, type: 'boolean' },
|
||||||
|
{ key: 'Account Email', value: proxy.acme.accountEmail || null },
|
||||||
|
{ key: 'Use Production', value: proxy.acme.useProduction, type: 'boolean' },
|
||||||
|
{ key: 'Auto Renew', value: proxy.acme.autoRenew, type: 'boolean' },
|
||||||
|
{ key: 'Renew Threshold', value: `${proxy.acme.renewThresholdDays} days` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="SmartProxy"
|
||||||
|
subtitle="HTTP/HTTPS and TCP/SNI reverse proxy"
|
||||||
|
icon="lucide:network"
|
||||||
|
.status=${proxy.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEmailSection(email: appstate.IConfigState['config']['email']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Ports', value: email.ports.length > 0 ? email.ports.map(String) : null, type: 'pills' },
|
||||||
|
{ key: 'Hostname', value: email.hostname },
|
||||||
|
{ key: 'Domains', value: email.domains.length > 0 ? email.domains : null, type: 'pills' },
|
||||||
|
{ key: 'Email Routes', value: email.emailRouteCount },
|
||||||
|
{ key: 'Received Emails Path', value: email.receivedEmailsPath },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (email.portMapping) {
|
||||||
|
const mappingStr = Object.entries(email.portMapping)
|
||||||
|
.map(([ext, int]) => `${ext} → ${int}`)
|
||||||
|
.join(', ');
|
||||||
|
fields.splice(1, 0, { key: 'Port Mapping', value: mappingStr, type: 'code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="Email Server"
|
||||||
|
subtitle="SMTP email handling with smartmta"
|
||||||
|
icon="lucide:mail"
|
||||||
|
.status=${email.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDnsSection(dns: appstate.IConfigState['config']['dns']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Port', value: dns.port },
|
||||||
|
{ key: 'NS Domains', value: dns.nsDomains.length > 0 ? dns.nsDomains : null, type: 'pills' },
|
||||||
|
{ key: 'Scopes', value: dns.scopes.length > 0 ? dns.scopes : null, type: 'pills' },
|
||||||
|
{ key: 'Record Count', value: dns.recordCount },
|
||||||
|
{ key: 'DNS Challenge', value: dns.dnsChallenge, type: 'boolean' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="DNS Server"
|
||||||
|
subtitle="Authoritative DNS with smartdns"
|
||||||
|
icon="lucide:globe"
|
||||||
|
.status=${dns.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTlsSection(tls: appstate.IConfigState['config']['tls']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Contact Email', value: tls.contactEmail },
|
||||||
|
{ key: 'Domain', value: tls.domain },
|
||||||
|
{ key: 'Source', value: tls.source, type: 'badge' },
|
||||||
|
{ key: 'Certificate Path', value: tls.certPath },
|
||||||
|
{ key: 'Key Path', value: tls.keyPath },
|
||||||
|
];
|
||||||
|
|
||||||
|
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="TLS / Certificates"
|
||||||
|
subtitle="Certificate management and ACME"
|
||||||
|
icon="lucide:shield-check"
|
||||||
|
.status=${status as any}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCacheSection(cache: appstate.IConfigState['config']['cache']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Storage Path', value: cache.storagePath },
|
||||||
|
{ key: 'DB Name', value: cache.dbName },
|
||||||
|
{ key: 'Default TTL', value: `${cache.defaultTTLDays} days` },
|
||||||
|
{ key: 'Cleanup Interval', value: `${cache.cleanupIntervalHours} hours` },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cache.ttlConfig && Object.keys(cache.ttlConfig).length > 0) {
|
||||||
|
for (const [key, val] of Object.entries(cache.ttlConfig)) {
|
||||||
|
fields.push({ key: `TTL: ${key}`, value: `${val} days` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="Cache Database"
|
||||||
|
subtitle="Persistent caching with smartdata"
|
||||||
|
icon="lucide:database"
|
||||||
|
.status=${cache.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRadiusSection(radius: appstate.IConfigState['config']['radius']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Auth Port', value: radius.authPort },
|
||||||
|
{ key: 'Accounting Port', value: radius.acctPort },
|
||||||
|
{ key: 'Bind Address', value: radius.bindAddress },
|
||||||
|
{ key: 'Client Count', value: radius.clientCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (radius.vlanDefaultVlan !== null) {
|
||||||
|
fields.push(
|
||||||
|
{ key: 'Default VLAN', value: radius.vlanDefaultVlan },
|
||||||
|
{ key: 'Allow Unknown MACs', value: radius.vlanAllowUnknownMacs, type: 'boolean' },
|
||||||
|
{ key: 'VLAN Mappings', value: radius.vlanMappingCount },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = radius.enabled ? 'enabled' : 'not-configured';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="RADIUS Server"
|
||||||
|
subtitle="Network authentication and VLAN assignment"
|
||||||
|
icon="lucide:wifi"
|
||||||
|
.status=${status as any}
|
||||||
|
.fields=${fields}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRemoteIngressSection(ri: appstate.IConfigState['config']['remoteIngress']): TemplateResult {
|
||||||
|
const fields: IConfigField[] = [
|
||||||
|
{ key: 'Tunnel Port', value: ri.tunnelPort },
|
||||||
|
{ key: 'Hub Domain', value: ri.hubDomain },
|
||||||
|
{ key: 'TLS Mode', value: ri.tlsMode, type: 'badge' },
|
||||||
|
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actions: IConfigSectionAction[] = [
|
||||||
|
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<sz-config-section
|
||||||
|
title="Remote Ingress"
|
||||||
|
subtitle="Edge tunnel nodes"
|
||||||
|
icon="lucide:cloud"
|
||||||
|
.status=${ri.enabled ? 'enabled' : 'disabled'}
|
||||||
|
.fields=${fields}
|
||||||
|
.actions=${actions}
|
||||||
|
></sz-config-section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days > 0) parts.push(`${days}d`);
|
||||||
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
|
parts.push(`${mins}m`);
|
||||||
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,15 @@ export class OpsViewLogs extends DeesElement {
|
|||||||
// Wait for xterm terminal to finish initializing (CDN load)
|
// Wait for xterm terminal to finish initializing (CDN load)
|
||||||
if (!chartLog.terminalReady) {
|
if (!chartLog.terminalReady) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 200; // 200 * 50ms = 10 seconds
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (chartLog.terminalReady) { resolve(); return; }
|
if (chartLog.terminalReady) { resolve(); return; }
|
||||||
|
if (++attempts >= maxAttempts) {
|
||||||
|
console.warn('ops-view-logs: terminal ready timeout after 10s');
|
||||||
|
resolve(); // resolve gracefully to avoid blocking
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTimeout(check, 50);
|
setTimeout(check, 50);
|
||||||
};
|
};
|
||||||
check();
|
check();
|
||||||
|
|||||||
Reference in New Issue
Block a user