Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ebe0de92d | |||
| f5028ffb60 | |||
| 90016d1217 | |||
| 48d3d1218f | |||
| 4759c4f011 | |||
| 0fbd8d1cdd | |||
| 447cf44d68 | |||
| 82ce17a941 | |||
| 15da996e70 | |||
| 582e19e6a6 | |||
| 79765d6729 | |||
| ffc93eb9d3 | |||
| 1337a4905a | |||
| c7418d9e1a | |||
| 2a94ffd4c9 | |||
| b2fe6caf33 | |||
| 822bbc1957 | |||
| eacddc7ce1 | |||
| dc6ce341bd | |||
| 1aadc93f92 | |||
| 8fdcd479d6 | |||
| d24dde8eff | |||
| 40a34073e9 | |||
| 9ac297c197 | |||
| ddd0662fb8 | |||
| 11bc0dde6c | |||
| 610d691244 | |||
| c88410ea53 | |||
| 9cbdd24281 | |||
| dce1de8c4b |
7
.playwright-mcp/console-2026-02-23T10-44-24-024Z.log
Normal file
7
.playwright-mcp/console-2026-02-23T10-44-24-024Z.log
Normal file
@@ -0,0 +1,7 @@
|
||||
[ 74ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||
[ 587ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||
[ 697ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||
12
.playwright-mcp/console-2026-02-23T11-19-21-255Z.log
Normal file
12
.playwright-mcp/console-2026-02-23T11-19-21-255Z.log
Normal file
@@ -0,0 +1,12 @@
|
||||
[ 669ms] [WARNING] Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information. @ http://localhost:3000/chunk-3L5NJTXF.js:13541
|
||||
[ 729ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||
[ 27973ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||
[ 27973ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||
[ 29975ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||
[ 29975ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||
[ 33977ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||
[ 33978ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||
[ 41980ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||
[ 41980ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||
[ 51983ms] [ERROR] WebSocket connection to 'ws://localhost:3000/ws/reload' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/main.js:115
|
||||
[ 51983ms] [ERROR] [ReloadService] WebSocket error: Event @ http://localhost:3000/main.js:141
|
||||
6
.playwright-mcp/console-2026-02-23T11-20-31-682Z.log
Normal file
6
.playwright-mcp/console-2026-02-23T11-20-31-682Z.log
Normal file
@@ -0,0 +1,6 @@
|
||||
[ 55ms] TypeError: Cannot read properties of null (reading 'appendChild')
|
||||
at TypedserverStatusPill.show (http://localhost:3000/typedserver/devtools:17607:21)
|
||||
at TypedserverStatusPill.updateStatus (http://localhost:3000/typedserver/devtools:17567:10)
|
||||
at ReloadChecker.checkReload (http://localhost:3000/typedserver/devtools:18137:23)
|
||||
at async ReloadChecker.start (http://localhost:3000/typedserver/devtools:18224:9)
|
||||
[ 791ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||
50
.playwright-mcp/console-2026-02-23T11-21-09-382Z.log
Normal file
50
.playwright-mcp/console-2026-02-23T11-21-09-382Z.log
Normal file
@@ -0,0 +1,50 @@
|
||||
[ 272ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||
[ 272ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 274ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Pause-circle
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||
[ 274ms] [WARNING] Lucide icon 'Pause-circle' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 275ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||
[ 275ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078)
|
||||
at async N._$EP (http://localhost:3000/bundle.js:1:9024) @ http://localhost:3000/bundle.js:1203
|
||||
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 276ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for Refresh-cw
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||
[ 276ms] [WARNING] Lucide icon 'Refresh-cw' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 297ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||
[ 377ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||
[ 78064ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||
[ 78237ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||
[ 127969ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||
[ 127969ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||
[ 129695ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||
[ 129695ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||
[ 133309ms] [ERROR] WebSocket connection to 'ws://localhost:3000/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3000/typedserver/devtools:16227
|
||||
[ 133309ms] [ERROR] TypedSocket WebSocket error: Event @ http://localhost:3000/typedserver/devtools:16251
|
||||
[ 141762ms] [ERROR] method: >>getMergedRoutes<< got an ERROR: "unauthorized" with data undefined @ http://localhost:3000/bundle.js:13
|
||||
[ 141910ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/routes:0
|
||||
23
.playwright-mcp/console-2026-02-23T11-23-44-606Z.log
Normal file
23
.playwright-mcp/console-2026-02-23T11-23-44-606Z.log
Normal file
@@ -0,0 +1,23 @@
|
||||
[ 437ms] [ERROR] Error while trying to use the following icon from the Manifest: http://localhost:3000/assetbroker/manifest/icon-144x144.png (Download error or resource isn't a valid image) @ http://localhost:3000/overview:0
|
||||
[ 38948ms] [WARNING] FontAwesome icon not found: circle-check @ http://localhost:3000/bundle.js:1203
|
||||
[ 52895ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||
[ 52896ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 52896ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||
[ 52897ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
[ 99401ms] [ERROR] Error rendering Lucide icon: Error: Could not create element for MagnifyingGlass
|
||||
at N.updated (http://localhost:3000/bundle.js:1204:736)
|
||||
at N._$AE (http://localhost:3000/bundle.js:1:9837)
|
||||
at N.performUpdate (http://localhost:3000/bundle.js:1:9701)
|
||||
at N.scheduleUpdate (http://localhost:3000/bundle.js:1:9170)
|
||||
at N._$EP (http://localhost:3000/bundle.js:1:9078) @ http://localhost:3000/bundle.js:1203
|
||||
[ 99401ms] [WARNING] Lucide icon 'MagnifyingGlass' not found in lucideIcons object @ http://localhost:3000/bundle.js:1174
|
||||
BIN
.playwright-mcp/page-2026-02-23T11-25-39-255Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-23T11-25-39-255Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
.playwright-mcp/page-2026-02-23T11-26-10-952Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-23T11-26-10-952Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
.playwright-mcp/page-2026-02-23T11-26-15-885Z.png
Normal file
BIN
.playwright-mcp/page-2026-02-23T11-26-15-885Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
138
changelog.md
138
changelog.md
@@ -1,5 +1,143 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-23 - 8.1.0 - feat(route-management)
|
||||
add programmatic route management API with API tokens and admin UI
|
||||
|
||||
- Introduce RouteConfigManager to persist and manage programmatic routes and hardcoded-route overrides
|
||||
- Add ApiTokenManager to create, validate, list, toggle and revoke API tokens (stored hashed)
|
||||
- New OpsServer TypedRequest handlers: RouteManagementHandler (getMergedRoutes, create/update/delete/toggle routes, set/remove overrides) and ApiTokenHandler (create/list/revoke/toggle tokens)
|
||||
- DcRouter integration: initialize routeConfigManager and apiTokenManager, expose getConstructorRoutes and re-apply programmatic routes after SmartProxy restarts
|
||||
- Front-end additions: new 'Routes' and 'ApiTokens' views and UI components (ops-view-routes, ops-view-apitokens), router and appstate actions to fetch/manage routes and tokens
|
||||
- New TS interfaces and request types for route-management and API tokens, plus storage schemas for persisted routes, overrides and tokens
|
||||
- Bump dependency @serve.zone/catalog to ^2.3.0
|
||||
|
||||
## 2026-02-22 - 8.0.0 - BREAKING CHANGE(email-ops)
|
||||
migrate email operations to catalog-compatible email model and simplify UI/router
|
||||
|
||||
- Add @serve.zone/catalog dependency and import (szCatalog) in web plugins
|
||||
- Replace queue-based typedrequest methods with catalog APIs: getQueuedEmails / getSentEmails / getFailedEmails => getAllEmails and getEmailDetail (request/response shapes changed)
|
||||
- Update TypeScript interfaces: IEmailQueueItem/IBounceRecord/ISecurityIncident etc. replaced by IEmail, IEmailDetail, ISmtpLogEntry, IConnectionInfo, IAuthenticationResults (breaking type changes)
|
||||
- Frontend state and actions consolidated: emailOps state now holds emails array; multiple fetch actions removed and replaced by fetchAllEmailsAction and getEmailDetail usage
|
||||
- UI components updated: ops-view-emails switched to list/detail view and now requests email detail via new API; router no longer exposes email folder routes and email-folder navigation removed
|
||||
- Ops server handler refactored to return catalog-style emails and email detail; added status mapping and size formatting helpers
|
||||
|
||||
## 2026-02-21 - 7.4.3 - fix(logging)
|
||||
add adaptive rate-limited DNS query logging, flush pending DNS logs on shutdown, and enhance email delivery logging
|
||||
|
||||
- Introduce adaptive DNS logging: allow up to 2 individual DNS query logs per second, then aggregate further queries and emit a batched summary (dnsLogWindow, dnsBatchCount, dnsBatchTimer) with a 5s flush.
|
||||
- Flush pending DNS batch on stop() and log final DNS batch count during shutdown.
|
||||
- Enhance email observability by logging deliveryStart, deliverySuccess, deliveryFailed and bounceProcessed events alongside existing MetricsManager tracking.
|
||||
- Dependency bump: @design.estate/dees-catalog updated from ^3.43.1 to ^3.43.2.
|
||||
- Non-breaking change; intended as a patch release.
|
||||
|
||||
## 2026-02-21 - 7.4.2 - fix(monitoring,remoteingress,web)
|
||||
Prune old metrics buckets periodically, clear metrics caches on shutdown, simplify edge disconnect handling, and optimize network view data updates
|
||||
|
||||
- Call pruneOldBuckets() each minute to proactively remove stale time-series buckets in MetricsManager
|
||||
- Clear metricsCache, emailMinuteBuckets and dnsMinuteBuckets when MetricsManager stops to avoid stale state on shutdown
|
||||
- On edgeDisconnected remove the edgeStatuses entry instead of mutating an existing record (more explicit cleanup)
|
||||
- Remove unused traffic-timer variables and move requestsPerSec history updates from render() into updateNetworkData() to avoid unnecessary re-renders
|
||||
- Optimize traffic data array updates by shifting in-place then reassigning arrays to preserve Lit reactivity and reduce intermediate allocations
|
||||
|
||||
## 2026-02-21 - 7.4.1 - fix(dcrouter)
|
||||
replace console logging with structured logger, improve metrics logging, add terminal-ready wait in ops UI, bump dees-catalog patch
|
||||
|
||||
- Replace console.log/console.error calls in classes.dcrouter.ts with structured logger.log (info/debug/error) including contextual data and stringified errors
|
||||
- MetricsManager: create a dedicated Smartlog instance (metricsLogger) for SmartMetrics and use shared logger for lifecycle events (start/stop)
|
||||
- SmartProxy/ACME: convert startup/stop/cert events and error logging to structured logs; include generated route and cert metadata where relevant
|
||||
- Shutdown/startup flows: unify service start/stop/error messages through logger to provide consistent, structured output
|
||||
- UI change: ops-view-logs now waits for xterm terminalReady before pushing initial logs to avoid race conditions
|
||||
- Bump dependency @design.estate/dees-catalog from 3.43.0 to 3.43.1
|
||||
|
||||
## 2026-02-21 - 7.4.0 - feat(opsserver)
|
||||
add real-time log push to ops dashboard and recent DNS query tracking
|
||||
|
||||
- Export baseLogger and add a log destination that pushes log entries to connected ops_dashboard TypedSocket clients (ts/opsserver/handlers/logs.handler.ts, ts/logger.ts).
|
||||
- Introduce a new TypedRequest (pushLogEntry) interface for server→client log pushes (ts_interfaces/requests/logs.ts) and wire client handling in the web UI (ts_web/appstate.ts, ts_web/plugins.ts).
|
||||
- Add TypedSocket client connection lifecycle to the web app, stream pushed log entries into app state and update log views incrementally (ts_web/appstate.ts, ts_web/elements/ops-view-logs.ts).
|
||||
- MetricsManager now records recent DNS queries (timestamp, domain, type, answered, responseTimeMs) and exposes them via stats endpoints for display in the UI (ts/monitoring/classes.metricsmanager.ts, ts/opsserver/handlers/stats.handler.ts, ts_interfaces/data/stats.ts).
|
||||
- UI overview now displays DNS query entries and uses answered flag to set log level (ts_web/elements/ops-view-overview.ts).
|
||||
- Add import/export for typedsocket in web plugins to enable real-time push (ts_web/plugins.ts).
|
||||
- Bump dependency @push.rocks/smartproxy patch version ^25.7.8 → ^25.7.9 (package.json).
|
||||
|
||||
## 2026-02-20 - 7.3.0 - feat(dcrouter)
|
||||
Wire DNS server 'query' events to MetricsManager for time-series tracking and bump @push.rocks/smartdns to ^7.9.0
|
||||
|
||||
- Add dnsServer 'query' event listener that iterates event.questions and calls metricsManager.trackDnsQuery(question.type, question.name, false, event.responseTimeMs).
|
||||
- Listener is guarded by a metricsManager existence check to avoid runtime errors when metrics are not configured.
|
||||
- Bump dependency @push.rocks/smartdns from ^7.8.1 to ^7.9.0 in package.json.
|
||||
|
||||
## 2026-02-20 - 7.2.0 - feat(logs)
|
||||
replace custom logs list with dees-chart-log component and push logs to chart, add log mapping and lifecycle sync, and bump smartlog dependency
|
||||
|
||||
- Replaced the legacy in-component log list and styling with a dees-chart-log element to render application logs.
|
||||
- Added updated() lifecycle handler to push new logs to the chart and new helper methods pushLogsToChart() and getMappedLogEntries() to map log entries to the chart's expected format.
|
||||
- Removed the streaming toggle, getActiveFilters(), legacy CSS for the log list, and the old per-entry rendering markup.
|
||||
- Added explicit typing for dropdown @selectedOption handlers (e: any).
|
||||
- Bumped dependency @push.rocks/smartlog from ^3.2.0 to ^3.2.1 in package.json.
|
||||
|
||||
## 2026-02-19 - 7.1.0 - feat(ops/monitoring)
|
||||
add in-memory log buffer, metrics time-series and ops UI integration
|
||||
|
||||
- bump @push.rocks/smartlog to ^3.2.0
|
||||
- introduce SmartlogDestinationBuffer (logBuffer) and wire it into the base logger to provide an in-memory log store for the Ops UI
|
||||
- implement minute-resolution time-series buckets in MetricsManager with increment/prune helpers and new APIs getEmailTimeSeries and getDnsTimeSeries
|
||||
- sync security counters from SecurityLogger and expose recent security events via StatsHandler
|
||||
- wire email delivery lifecycle events and bounce processing to MetricsManager for tracking sent/received/failed metrics
|
||||
- LogsHandler now queries the in-memory log buffer, maps smartlog levels/categories, supports search/level/time-range filtering and pagination
|
||||
- UI updates: ops-view-overview, ops-view-logs and ops-view-security consume time-series and recent events to render charts, tables and filters
|
||||
|
||||
## 2026-02-19 - 7.0.1 - fix(monitoring)
|
||||
Use smartMetrics cpuPercentage for cpuUsage.user and update smartmetrics and smartproxy dependencies
|
||||
|
||||
- Switch cpuUsage.user from parseFloat(smartMetricsData.cpuUsageText) to smartMetricsData.cpuPercentage to align with smartmetrics v3 API
|
||||
- Bump @push.rocks/smartmetrics from ^2.0.10 to ^3.0.1
|
||||
- Bump @push.rocks/smartproxy from ^25.7.6 to ^25.7.8
|
||||
|
||||
## 2026-02-19 - 7.0.0 - BREAKING CHANGE(deps)
|
||||
bump dependencies: @serve.zone/remoteingress to ^4.0.0 (breaking), @push.rocks/smartproxy to ^25.7.6, @types/node to ^25.3.0
|
||||
|
||||
- Updated @serve.zone/remoteingress from ^3.3.0 to ^4.0.0 — major breaking change; may require code changes to adapt to new API.
|
||||
- Updated @push.rocks/smartproxy from ^25.7.3 to ^25.7.6 — patch update (non-breaking).
|
||||
- Updated @types/node from ^25.2.3 to ^25.3.0 — patch update (non-breaking).
|
||||
- Current package version is 6.13.2; recommend bumping to 7.0.0 due to the breaking dependency upgrade.
|
||||
|
||||
## 2026-02-19 - 6.13.2 - fix(runtime)
|
||||
prevent memory leaks and improve shutdown/stream handling across services
|
||||
|
||||
- Add CertProvisionScheduler.clear() to reset in-memory backoff cache and call it during DcRouter shutdown
|
||||
- Stop any existing SmartAcme instance before creating a new one (await stop and log errors) to avoid duplicate running instances
|
||||
- Null out many DcRouter service references and clear certificateStatusMap on shutdown to allow GC of stopped services
|
||||
- Cap emailMetrics.recipients map size and trim to ~80% of MAX_TOP_DOMAINS to prevent unbounded growth
|
||||
- Await virtualStream.sendData in logs follow handler and clear the interval if the stream errors/closes to avoid interval leaks
|
||||
- Limit normalizedMacCache size and evict oldest entries when it exceeds 10000 to prevent unbounded cache growth
|
||||
|
||||
## 2026-02-18 - 6.13.1 - fix(dcrouter)
|
||||
enable PROXY protocol v1 handling for SmartProxy when remoteIngress is enabled to preserve client IPs
|
||||
|
||||
- Set smartProxyConfig.acceptProxyProtocol = true when options.remoteIngressConfig.enabled
|
||||
- Whitelist loopback address by setting smartProxyConfig.proxyIPs = ['127.0.0.1']
|
||||
- Only applies when remoteIngress is enabled; used to accept tunneled connections forwarded by the hub to preserve original client IPs
|
||||
|
||||
## 2026-02-18 - 6.13.0 - feat(remoteingress)
|
||||
include listenPorts for allowed edges sent to the Rust hub and always resync allowed edges when edge properties change
|
||||
|
||||
- getAllowedEdges now returns listenPorts for each allowed edge (uses getEffectiveListenPorts)
|
||||
- remoteingress handler now calls tunnelManager.syncAllowedEdges() whenever tunnelManager exists so ports/tags/enabled changes are propagated
|
||||
- Improves Rust hub routing by providing per-edge listening ports and ensuring allowed-edge list is kept up-to-date
|
||||
|
||||
## 2026-02-18 - 6.12.0 - feat(remote-ingress)
|
||||
add Remote Ingress hub integration, OpsServer UI, APIs, and docs
|
||||
|
||||
- Integrates RemoteIngress (hub/tunnel) into DcRouter: runtime manager, tunnel manager and Rust data plane references added
|
||||
- Bumps dependency @serve.zone/remoteingress to ^3.3.0
|
||||
- Adds configuration defaults and IDcRouterOptions.remoteIngressConfig with tunnelPort/hubDomain/tls fields
|
||||
- Introduces OpsServer API endpoints and TypedRequest methods for remote ingress: getRemoteIngresses, createRemoteIngress, updateRemoteIngress, deleteRemoteIngress, regenerateRemoteIngressSecret, getRemoteIngressStatus, getRemoteIngressConnectionToken
|
||||
- UI updates: new Remote Ingress dashboard view, connection token generation & copy (clipboard API + fallback), auto-derived ports display, and toast notifications
|
||||
- State/API rename: newEdgeSecret -> newEdgeId and clearNewEdgeIdAction; appstate fetchConnectionToken usage
|
||||
- Documentation: README, ts/ and ts_web readmes, and ts_interfaces updated with interfaces and examples for Remote Ingress
|
||||
- Minor UI icon updates (search -> fa:magnifyingGlass, clipboard icon casing) and other doc/README improvements
|
||||
|
||||
## 2026-02-18 - 6.11.0 - feat(remoteingress)
|
||||
add ability to generate remote ingress connection tokens and UI copy action; add hubDomain config option; update remoteingress dependency to ^3.1.1
|
||||
|
||||
|
||||
17
package.json
17
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "6.11.0",
|
||||
"version": "8.1.0",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@types/node": "^25.2.3"
|
||||
"@types/node": "^25.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.2.6",
|
||||
@@ -32,31 +32,32 @@
|
||||
"@api.global/typedserver": "^8.3.0",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.43.0",
|
||||
"@design.estate/dees-catalog": "^3.43.2",
|
||||
"@design.estate/dees-element": "^2.1.6",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.1.3",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/smartdns": "^7.8.1",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartguard": "^3.1.0",
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.1.11",
|
||||
"@push.rocks/smartmetrics": "^2.0.10",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartmetrics": "^3.0.1",
|
||||
"@push.rocks/smartmongo": "^5.1.0",
|
||||
"@push.rocks/smartmta": "^5.2.2",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^25.7.3",
|
||||
"@push.rocks/smartproxy": "^25.7.9",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smartstate": "^2.0.30",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@serve.zone/catalog": "^2.3.0",
|
||||
"@serve.zone/interfaces": "^5.3.0",
|
||||
"@serve.zone/remoteingress": "^3.1.1",
|
||||
"@serve.zone/remoteingress": "^4.0.0",
|
||||
"@tsclass/tsclass": "^9.3.0",
|
||||
"lru-cache": "^11.2.6",
|
||||
"uuid": "^13.0.0"
|
||||
|
||||
267
pnpm-lock.yaml
generated
267
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.43.0
|
||||
version: 3.43.0(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.43.2
|
||||
version: 3.43.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.1.6
|
||||
version: 2.1.6
|
||||
@@ -42,8 +42,8 @@ importers:
|
||||
specifier: ^7.0.15
|
||||
version: 7.0.15(socks@2.8.7)
|
||||
'@push.rocks/smartdns':
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.1
|
||||
specifier: ^7.9.0
|
||||
version: 7.9.0
|
||||
'@push.rocks/smartfile':
|
||||
specifier: ^13.1.2
|
||||
version: 13.1.2
|
||||
@@ -54,11 +54,11 @@ importers:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
'@push.rocks/smartlog':
|
||||
specifier: ^3.1.11
|
||||
version: 3.1.11
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
'@push.rocks/smartmetrics':
|
||||
specifier: ^2.0.10
|
||||
version: 2.0.10
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
'@push.rocks/smartmongo':
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(socks@2.8.7)
|
||||
@@ -75,8 +75,8 @@ importers:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^25.7.3
|
||||
version: 25.7.3
|
||||
specifier: ^25.7.9
|
||||
version: 25.7.9
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -92,12 +92,15 @@ importers:
|
||||
'@push.rocks/smartunique':
|
||||
specifier: ^3.0.9
|
||||
version: 3.0.9
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@serve.zone/remoteingress':
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0
|
||||
@@ -124,8 +127,8 @@ importers:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@tiptap/pm@2.27.2)
|
||||
'@types/node':
|
||||
specifier: ^25.2.3
|
||||
version: 25.2.3
|
||||
specifier: ^25.3.0
|
||||
version: 25.3.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -351,8 +354,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.43.0':
|
||||
resolution: {integrity: sha512-UFW8oThP9Mc4L0wVVgmuGux868Ct/TwZ1WP8hZCe4e/+5gmxDc+4EArnt5hePHENboe1Soobh9mmrMN6kQZ3xQ==}
|
||||
'@design.estate/dees-catalog@3.43.2':
|
||||
resolution: {integrity: sha512-7pU+K+B70SxqR4DrBkpc/xvQGLDkxAV2jH7Qyh0TYgkCoxxjsxCuTMKM9JguA38wm6bEgBJVTvyg5S3wCwxm4Q==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -757,10 +760,6 @@ packages:
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||
|
||||
'@opentelemetry/api@1.9.0':
|
||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@oxc-project/types@0.99.0':
|
||||
resolution: {integrity: sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==}
|
||||
|
||||
@@ -898,8 +897,8 @@ packages:
|
||||
'@push.rocks/smartdelay@3.0.5':
|
||||
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
||||
|
||||
'@push.rocks/smartdns@7.8.1':
|
||||
resolution: {integrity: sha512-qEizM9dFzhq4XGICDC8Im7JLjwdokHdDZ6wLufBInaEOupq+8XOa9bC6EGlBQVsCXFUyrKzsFk6eBa9BSZMKPw==}
|
||||
'@push.rocks/smartdns@7.9.0':
|
||||
resolution: {integrity: sha512-1nDUfyXQo6j9HTUfcjE+BLeAv9QZ7WtAsM1V28zIoFdUpjNg/5g382L024H73PHsxh6lSYNhYYmFvWqzFQhXKg==}
|
||||
|
||||
'@push.rocks/smartenv@5.0.13':
|
||||
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
|
||||
@@ -964,8 +963,8 @@ packages:
|
||||
'@push.rocks/smartlog-interfaces@3.0.2':
|
||||
resolution: {integrity: sha512-8hGRTJehbsFSJxLhCQkA018mZtXVPxPTblbg9VaE/EqISRzUw+eosJ2EJV7M4Qu0eiTJZjnWnNLn8CkD77ziWw==}
|
||||
|
||||
'@push.rocks/smartlog@3.1.11':
|
||||
resolution: {integrity: sha512-zyLH8pQD2UD7l76wJBESEWXU1FSTBLOuRI0/DN139EYyMkwMq1+pdQKptTkJhhVL/OIj56oMg9SpJb4bJB7uKg==}
|
||||
'@push.rocks/smartlog@3.2.1':
|
||||
resolution: {integrity: sha512-x9/P59pfzY6HOGYmYrhqmoRl/pliTVx44g2Vbb8dIr/0zA39cAJHlPze1+UGncn37XKGmutK2iLSsJLEsexD0A==}
|
||||
|
||||
'@push.rocks/smartmail@2.2.0':
|
||||
resolution: {integrity: sha512-28K4HAcda7ODUUpFCgbS/uA+eqwVRcmLJERIdM9AvLHXaHAPLHH97HmwPPcAu9Sp3z05Um0inmDF51X6yVVkcw==}
|
||||
@@ -979,8 +978,8 @@ packages:
|
||||
'@push.rocks/smartmatch@2.0.0':
|
||||
resolution: {integrity: sha512-MBzP++1yNIBeox71X6VxpIgZ8m4bXnJpZJ4nWVH6IWpmO38MXTu4X0QF8tQnyT4LFcwvc9iiWaD15cstHa7Mmw==}
|
||||
|
||||
'@push.rocks/smartmetrics@2.0.10':
|
||||
resolution: {integrity: sha512-Fr4ZzJWFqTR67ThmPsj+uUfPoWZ+p87n7wItpXVjlQ2mf4pboWnsfmrrC1gqIXca1Dwa2i7L+GPoJ3hOZ+Qhfw==}
|
||||
'@push.rocks/smartmetrics@3.0.1':
|
||||
resolution: {integrity: sha512-wdc9v8F0dQXwraAVCZjGvt2lnGpbVYHWzER9OFR+u+ultMq7aLjUevxSpkS8BkhBMLTuNMCCWIqyAzaCqHzFgQ==}
|
||||
|
||||
'@push.rocks/smartmime@1.0.6':
|
||||
resolution: {integrity: sha512-PHd+I4UcsnOATNg8wjDsSAmmJ4CwQFrQCNzd0HSJMs4ZpiK3Ya91almd6GLpDPU370U4HFh4FaPF4eEAI6vkJQ==}
|
||||
@@ -1034,8 +1033,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@25.7.3':
|
||||
resolution: {integrity: sha512-9b5dwsLAhuDqnJptGBum4qBHlZwZPqPG3CJKxAwE3uFKjCmcE8qGDwodI0CjrQ7KW2PJ1BMq/Lk4ghs3Da6PWw==}
|
||||
'@push.rocks/smartproxy@25.7.9':
|
||||
resolution: {integrity: sha512-5esFvD72TEyveaEQbDYRgD7C5hDfWMSBvurNx3KPi02CBKG1gnhx/WWT7RHDS3KRF5fEQh9YxvI9aMkOwjc7sQ==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
@@ -1131,9 +1130,6 @@ packages:
|
||||
'@push.rocks/webrequest@3.0.37':
|
||||
resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==}
|
||||
|
||||
'@push.rocks/webrequest@4.0.1':
|
||||
resolution: {integrity: sha512-I60XZZLVf8W5I7YdmUVVu4G92teE3rg3/aKaV00BRg8vJ3VXx3wc59Qj4em7zxQ5o0HvL8m1Aezw3RFMDPyVgA==}
|
||||
|
||||
'@push.rocks/webrequest@4.0.2':
|
||||
resolution: {integrity: sha512-rowzty+Q2papFBcnNYPcy+8CQJukSn/FGfQG8ap0bUgQUsx882u8kEyLM0Q+GlGHS5OiZ+Z0z5TZqLKlk3XHxA==}
|
||||
|
||||
@@ -1337,11 +1333,14 @@ packages:
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@serve.zone/catalog@2.3.0':
|
||||
resolution: {integrity: sha512-KCIQZXBO5A93VIsRkI/UzApNImEHzuA7P3Wx33+mDVUZ8/I5hafuCLgPzNu1q/TgQUte+q6I6e5Erezc9Hn74Q==}
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||
|
||||
'@serve.zone/remoteingress@3.1.1':
|
||||
resolution: {integrity: sha512-tTN3hkLmfL8KeIEu7a685xNlESEZ548aFzKn+44yeUwABuQV5w3P39Pk2U3KAGB0K2prnpuzBGuoMaEIicE9Ew==}
|
||||
'@serve.zone/remoteingress@4.0.0':
|
||||
resolution: {integrity: sha512-kcC9pnQdS5Mw0ypmwT3GIYwrjVQJOjWw5TJ+j46t6Y98S4Mb7wEAAPsU+YRXozpkqUxWUDBeDK1fQHLvoF9PmQ==}
|
||||
|
||||
'@sindresorhus/is@5.6.0':
|
||||
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
||||
@@ -1834,11 +1833,8 @@ packages:
|
||||
'@types/node@22.19.11':
|
||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||
|
||||
'@types/node@25.2.3':
|
||||
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
||||
|
||||
'@types/pidusage@2.0.5':
|
||||
resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==}
|
||||
'@types/node@25.3.0':
|
||||
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
|
||||
|
||||
'@types/ping@0.4.4':
|
||||
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
|
||||
@@ -2059,9 +2055,6 @@ packages:
|
||||
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
bintrees@1.0.2:
|
||||
resolution: {integrity: sha1-SfiW1uhYpKSZ34XDj7OZua/4QPg=}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3234,8 +3227,8 @@ packages:
|
||||
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimatch@10.2.0:
|
||||
resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==}
|
||||
minimatch@10.2.1:
|
||||
resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
minimatch@3.1.2:
|
||||
@@ -3548,15 +3541,6 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pidtree@0.6.0:
|
||||
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
pidusage@4.0.1:
|
||||
resolution: {integrity: sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ping@0.4.4:
|
||||
resolution: {integrity: sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
@@ -3576,10 +3560,6 @@ packages:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
prom-client@15.1.3:
|
||||
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
|
||||
engines: {node: ^16 || ^18 || >=20}
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
@@ -3978,9 +3958,6 @@ packages:
|
||||
tar-stream@3.1.7:
|
||||
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
|
||||
|
||||
tdigest@0.1.2:
|
||||
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
||||
|
||||
text-decoder@1.2.3:
|
||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
||||
|
||||
@@ -4089,8 +4066,8 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
@@ -4247,13 +4224,11 @@ packages:
|
||||
|
||||
xterm-addon-fit@0.8.0:
|
||||
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==}
|
||||
deprecated: This package is now deprecated. Move to @xterm/addon-fit instead.
|
||||
peerDependencies:
|
||||
xterm: ^5.0.0
|
||||
|
||||
xterm@5.3.0:
|
||||
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==}
|
||||
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
@@ -4325,7 +4300,7 @@ snapshots:
|
||||
'@push.rocks/smartfeed': 1.4.0
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartlog-destination-devtools': 1.0.12
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@push.rocks/smartmanifest': 2.0.2
|
||||
@@ -4365,7 +4340,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
|
||||
'@cloudflare/workers-types': 4.20260210.0
|
||||
'@design.estate/dees-catalog': 3.43.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.43.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -4374,7 +4349,7 @@ snapshots:
|
||||
'@push.rocks/smartfile': 13.1.2
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartlog-destination-devtools': 1.0.12
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@push.rocks/smartmanifest': 2.0.2
|
||||
@@ -4441,7 +4416,7 @@ snapshots:
|
||||
'@apiclient.xyz/cloudflare@7.1.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrequest': 5.0.1
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
@@ -4963,7 +4938,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.43.0(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.43.2(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
@@ -5175,7 +5150,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfile': 13.1.2
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
typescript: 5.9.3
|
||||
@@ -5196,7 +5171,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartinteract': 2.0.16
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartlog-destination-local': 9.0.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
@@ -5222,7 +5197,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfile': 13.1.2
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartnpm': 2.0.6
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrequest': 5.0.1
|
||||
@@ -5257,7 +5232,7 @@ snapshots:
|
||||
'@push.rocks/smartexpect': 2.5.0
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
|
||||
'@push.rocks/smartnetwork': 4.4.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -5303,7 +5278,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartinteract': 2.0.16
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartlog-destination-local': 9.0.2
|
||||
'@push.rocks/smartshell': 3.3.0
|
||||
'@push.rocks/smartwatch': 6.3.0
|
||||
@@ -5534,8 +5509,6 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@opentelemetry/api@1.9.0': {}
|
||||
|
||||
'@oxc-project/types@0.99.0': {}
|
||||
|
||||
'@pdf-lib/standard-fonts@1.0.0':
|
||||
@@ -5738,7 +5711,7 @@ snapshots:
|
||||
'@push.rocks/qenv': 6.1.3
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
@@ -5762,7 +5735,7 @@ snapshots:
|
||||
'@api.global/typedrequest': 3.2.6
|
||||
'@configvault.io/interfaces': 1.0.17
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
'@push.rocks/smartacme@9.1.3(socks@2.8.7)':
|
||||
@@ -5772,8 +5745,8 @@ snapshots:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdata': 7.0.15(socks@2.8.7)
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartdns': 7.8.1
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartnetwork': 4.4.0
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
@@ -5883,7 +5856,7 @@ snapshots:
|
||||
'@push.rocks/smartcli@4.0.20':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartobject': 1.0.12
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
@@ -5908,7 +5881,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
@@ -5937,7 +5910,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
@@ -5966,7 +5939,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
|
||||
'@push.rocks/smartdns@7.8.1':
|
||||
'@push.rocks/smartdns@7.9.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartenv': 6.0.0
|
||||
@@ -5974,7 +5947,7 @@ snapshots:
|
||||
'@push.rocks/smartrust': 1.2.1
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
acme-client: 5.4.0
|
||||
minimatch: 10.2.0
|
||||
minimatch: 10.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6129,7 +6102,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 2.0.2
|
||||
'@tsclass/tsclass': 4.4.4
|
||||
|
||||
'@push.rocks/smartlog@3.1.11':
|
||||
'@push.rocks/smartlog@3.2.1':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/consolecolor': 2.0.3
|
||||
@@ -6138,13 +6111,13 @@ snapshots:
|
||||
'@push.rocks/smartfile': 11.2.7
|
||||
'@push.rocks/smarthash': 3.2.6
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
'@push.rocks/webrequest': 4.0.1
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
'@push.rocks/webrequest': 4.0.2
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
|
||||
'@push.rocks/smartmail@2.2.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.8.1
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@push.rocks/smartfile': 13.1.2
|
||||
'@push.rocks/smartmustache': 3.0.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
@@ -6173,14 +6146,10 @@ snapshots:
|
||||
dependencies:
|
||||
matcher: 5.0.0
|
||||
|
||||
'@push.rocks/smartmetrics@2.0.10':
|
||||
'@push.rocks/smartmetrics@3.0.1':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@types/pidusage': 2.0.5
|
||||
pidtree: 0.6.0
|
||||
pidusage: 4.0.1
|
||||
prom-client: 15.1.3
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
|
||||
'@push.rocks/smartmime@1.0.6':
|
||||
dependencies:
|
||||
@@ -6249,7 +6218,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@push.rocks/smartfile': 13.1.2
|
||||
'@push.rocks/smartfs': 1.3.1
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartmail': 2.2.0
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrust': 1.2.1
|
||||
@@ -6266,7 +6235,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartnetwork@4.4.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.8.1
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@push.rocks/smartping': 1.0.8
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartstring': 4.1.0
|
||||
@@ -6352,13 +6321,13 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@25.7.3':
|
||||
'@push.rocks/smartproxy@25.7.9':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartrust': 1.2.1
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
minimatch: 10.2.0
|
||||
minimatch: 10.2.1
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5(typescript@5.9.3)':
|
||||
dependencies:
|
||||
@@ -6436,7 +6405,7 @@ snapshots:
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartenv': 6.0.0
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
ws: 8.19.0
|
||||
transitivePeerDependencies:
|
||||
@@ -6471,7 +6440,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartenv': 5.0.13
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
@@ -6586,7 +6555,7 @@ snapshots:
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
@@ -6602,7 +6571,7 @@ snapshots:
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
@@ -6618,7 +6587,7 @@ snapshots:
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.11
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
@@ -6637,14 +6606,6 @@ snapshots:
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/webstore': 2.0.20
|
||||
|
||||
'@push.rocks/webrequest@4.0.1':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartenv': 5.0.13
|
||||
'@push.rocks/smartjson': 5.2.0
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/webstore': 2.0.20
|
||||
|
||||
'@push.rocks/webrequest@4.0.2':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -6824,13 +6785,26 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@serve.zone/catalog@2.3.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.43.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.3.8
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@tiptap/pm'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.3.0
|
||||
|
||||
'@serve.zone/remoteingress@3.1.1':
|
||||
'@serve.zone/remoteingress@4.0.0':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.3
|
||||
'@push.rocks/smartrust': 1.2.1
|
||||
@@ -7362,22 +7336,22 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@@ -7385,7 +7359,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -7398,17 +7372,17 @@ snapshots:
|
||||
|
||||
'@types/from2@2.3.6':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/glob@8.1.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -7430,12 +7404,12 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7458,16 +7432,16 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/node@18.19.130':
|
||||
dependencies:
|
||||
@@ -7477,11 +7451,9 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.2.3':
|
||||
'@types/node@25.3.0':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/pidusage@2.0.5': {}
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/ping@0.4.4': {}
|
||||
|
||||
@@ -7497,22 +7469,22 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/symbol-tree@3.2.5': {}
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7542,11 +7514,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@@ -7692,8 +7664,6 @@ snapshots:
|
||||
|
||||
basic-ftp@5.1.0: {}
|
||||
|
||||
bintrees@1.0.2: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -8019,7 +7989,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 25.3.0
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
@@ -9163,7 +9133,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@isaacs/brace-expansion': 5.0.1
|
||||
|
||||
minimatch@10.2.0:
|
||||
minimatch@10.2.1:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
@@ -9439,12 +9409,6 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pidusage@4.0.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
ping@0.4.4: {}
|
||||
|
||||
pkg-dir@4.2.0:
|
||||
@@ -9459,11 +9423,6 @@ snapshots:
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
prom-client@15.1.3:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
tdigest: 0.1.2
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
prosemirror-changeset@2.4.0:
|
||||
@@ -10050,10 +10009,6 @@ snapshots:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
tdigest@0.1.2:
|
||||
dependencies:
|
||||
bintrees: 1.0.2
|
||||
|
||||
text-decoder@1.2.3:
|
||||
dependencies:
|
||||
b4a: 1.7.3
|
||||
@@ -10152,7 +10107,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
|
||||
173
readme.md
173
readme.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**dcrouter: The all-in-one gateway for your datacenter.** 🚀
|
||||
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, and RADIUS protocols. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, and enterprise-grade email infrastructure — all from a single process.
|
||||
A comprehensive traffic routing solution that provides unified gateway capabilities for HTTP/HTTPS, TCP/SNI, email (SMTP), DNS, RADIUS, and remote edge ingress — all from a single process. Designed for enterprises requiring robust traffic management, automatic TLS certificate provisioning, distributed edge networking, and enterprise-grade email infrastructure.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -21,6 +21,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- [Email System](#email-system)
|
||||
- [DNS Server](#dns-server)
|
||||
- [RADIUS Server](#radius-server)
|
||||
- [Remote Ingress](#remote-ingress)
|
||||
- [Certificate Management](#certificate-management)
|
||||
- [Storage & Caching](#storage--caching)
|
||||
- [Security Features](#security-features)
|
||||
@@ -60,6 +61,14 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **RADIUS accounting** for session tracking, traffic metering, and billing
|
||||
- **Real-time management** via OpsServer API
|
||||
|
||||
### 🌍 Remote Ingress (powered by [remoteingress](https://code.foss.global/serve.zone/remoteingress))
|
||||
- **Distributed edge networking** — accept traffic at remote edge nodes and tunnel it to the hub
|
||||
- **Edge registration CRUD** with secret-based authentication
|
||||
- **Auto-derived ports** — edges automatically pick up ports from routes tagged with `remoteIngress.enabled`
|
||||
- **Connection tokens** — generate a single opaque base64url token containing hubHost, hubPort, edgeId, and secret for easy edge provisioning
|
||||
- **Real-time status monitoring** — connected/disconnected state, public IP, active tunnels, heartbeat tracking
|
||||
- **OpsServer dashboard** with enable/disable, edit, secret regeneration, token copy, and delete actions
|
||||
|
||||
### ⚡ High Performance
|
||||
- **Rust-powered proxy engine** via SmartProxy for maximum throughput
|
||||
- **Rust-powered MTA engine** via smartmta (TypeScript + Rust hybrid) for reliable email delivery
|
||||
@@ -76,8 +85,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
### 🖥️ OpsServer Dashboard
|
||||
- **Web-based management interface** with real-time monitoring
|
||||
- **JWT authentication** with session persistence
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, and security events
|
||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, and security events
|
||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||
- **Remote ingress management** with connection token generation and one-click copy
|
||||
- **Read-only configuration display** — DcRouter is configured through code
|
||||
|
||||
## Installation
|
||||
@@ -219,6 +229,13 @@ const router = new DcRouter({
|
||||
accounting: { enabled: true, retentionDays: 30 }
|
||||
},
|
||||
|
||||
// Remote Ingress — edge nodes tunnel traffic to this hub
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'hub.example.com',
|
||||
},
|
||||
|
||||
// Persistent storage
|
||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
||||
|
||||
@@ -246,6 +263,7 @@ graph TB
|
||||
TCP[TCP Clients]
|
||||
DNS[DNS Queries]
|
||||
RAD[RADIUS Clients]
|
||||
EDGE[Edge Nodes]
|
||||
end
|
||||
|
||||
subgraph "DcRouter Core"
|
||||
@@ -254,6 +272,7 @@ graph TB
|
||||
ES[smartmta Email Server<br/><i>TypeScript + Rust</i>]
|
||||
DS[SmartDNS Server<br/><i>Rust-powered</i>]
|
||||
RS[SmartRadius Server]
|
||||
RI[RemoteIngress Hub<br/><i>Rust data plane</i>]
|
||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||
OS[OpsServer Dashboard]
|
||||
MM[Metrics Manager]
|
||||
@@ -273,11 +292,13 @@ graph TB
|
||||
SMTP --> ES
|
||||
DNS --> DS
|
||||
RAD --> RS
|
||||
EDGE --> RI
|
||||
|
||||
DC --> SP
|
||||
DC --> ES
|
||||
DC --> DS
|
||||
DC --> RS
|
||||
DC --> RI
|
||||
DC --> CM
|
||||
DC --> OS
|
||||
DC --> MM
|
||||
@@ -288,6 +309,7 @@ graph TB
|
||||
SP --> API
|
||||
ES --> MAIL
|
||||
ES --> DB
|
||||
RI --> SP
|
||||
|
||||
CM -.-> SP
|
||||
CM -.-> ES
|
||||
@@ -303,6 +325,7 @@ graph TB
|
||||
| **DNS Server** | `@push.rocks/smartdns` | Authoritative DNS with dynamic records and DKIM TXT auto-generation (Rust engine) |
|
||||
| **SmartAcme** | `@push.rocks/smartacme` | ACME certificate management with per-domain dedup, concurrency control, and rate limiting |
|
||||
| **RADIUS Server** | `@push.rocks/smartradius` | Network authentication with MAB, VLAN assignment, and accounting |
|
||||
| **RemoteIngress** | `@serve.zone/remoteingress` | Distributed edge tunneling with Rust data plane and TS management |
|
||||
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
||||
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
||||
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
|
||||
@@ -312,19 +335,20 @@ graph TB
|
||||
|
||||
DcRouter acts purely as an **orchestrator** — it doesn't implement protocols itself. Instead, it wires together best-in-class packages for each protocol:
|
||||
|
||||
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, and SmartRadius based on which configs are provided.
|
||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||
1. **On `start()`**: DcRouter initializes OpsServer (port 3000), then spins up SmartProxy, smartmta, SmartDNS, SmartRadius, and RemoteIngress based on which configs are provided.
|
||||
2. **During operation**: Each service handles its own protocol independently. SmartProxy uses a Rust-powered engine for maximum throughput. smartmta uses a hybrid TypeScript + Rust architecture for reliable email delivery. RemoteIngress runs a Rust data plane for edge tunnel networking. SmartAcme v9 handles all certificate operations with built-in concurrency control and rate limiting.
|
||||
3. **On `stop()`**: All services are gracefully shut down in parallel, including cleanup of HTTP agents and DNS clients.
|
||||
|
||||
### Rust-Powered Architecture
|
||||
|
||||
DcRouter itself is a pure TypeScript orchestrator, but three of its core sub-components ship with **compiled Rust binaries** for performance-critical paths. At runtime each package detects the platform, unpacks the correct binary, and communicates with TypeScript over IPC/FFI — so you get the ergonomics of TypeScript with the throughput of native code.
|
||||
DcRouter itself is a pure TypeScript orchestrator, but several of its core sub-components ship with **compiled Rust binaries** for performance-critical paths. At runtime each package detects the platform, unpacks the correct binary, and communicates with TypeScript over IPC/FFI — so you get the ergonomics of TypeScript with the throughput of native code.
|
||||
|
||||
| Component | Rust Binary | What It Handles |
|
||||
|-----------|-------------|-----------------|
|
||||
| **SmartProxy** | `smartproxy-bin` | All TCP/TLS/HTTP proxy networking, NFTables integration, connection metrics |
|
||||
| **smartmta** | `mailer-bin` | SMTP server + client, DKIM/SPF/DMARC, content scanning, IP reputation |
|
||||
| **SmartDNS** | `smartdns-bin` | DNS server (UDP + DNS-over-HTTPS), DNSSEC, DNS client resolution |
|
||||
| **RemoteIngress** | `remoteingress-bin` | Edge tunnel data plane, multiplexed streams, heartbeat management |
|
||||
| **SmartRadius** | — | Pure TypeScript (no Rust component) |
|
||||
|
||||
## Configuration Reference
|
||||
@@ -333,6 +357,10 @@ DcRouter itself is a pure TypeScript orchestrator, but three of its core sub-com
|
||||
|
||||
```typescript
|
||||
interface IDcRouterOptions {
|
||||
// ── Base ───────────────────────────────────────────────────────
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
baseDir?: string;
|
||||
|
||||
// ── Traffic Routing ────────────────────────────────────────────
|
||||
/** SmartProxy config for HTTP/HTTPS and TCP/SNI routing */
|
||||
smartProxyConfig?: ISmartProxyOptions;
|
||||
@@ -376,6 +404,18 @@ interface IDcRouterOptions {
|
||||
accounting?: { enabled: boolean; retentionDays?: number };
|
||||
};
|
||||
|
||||
// ── Remote Ingress ─────────────────────────────────────────────
|
||||
/** Remote Ingress hub for edge tunnel connections */
|
||||
remoteIngressConfig?: {
|
||||
enabled?: boolean; // default: false
|
||||
tunnelPort?: number; // default: 8443
|
||||
hubDomain?: string; // External hostname for connection tokens
|
||||
tls?: {
|
||||
certPath?: string;
|
||||
keyPath?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── TLS & Certificates ────────────────────────────────────────
|
||||
tls?: {
|
||||
contactEmail: string;
|
||||
@@ -701,6 +741,107 @@ RADIUS is fully manageable at runtime via the OpsServer API:
|
||||
- Session monitoring and forced disconnects
|
||||
- Accounting summaries and statistics
|
||||
|
||||
## Remote Ingress
|
||||
|
||||
DcRouter can act as a **hub** for distributed edge nodes using [`@serve.zone/remoteingress`](https://code.foss.global/serve.zone/remoteingress). Edge nodes accept incoming traffic at remote locations and tunnel it back to the hub over a single multiplexed connection. This is ideal for scenarios where you need to accept traffic at multiple geographic locations but process it centrally.
|
||||
|
||||
### Enabling Remote Ingress
|
||||
|
||||
```typescript
|
||||
const router = new DcRouter({
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'hub.example.com', // Embedded in connection tokens
|
||||
},
|
||||
// Routes tagged with remoteIngress are auto-derived to edge listen ports
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-via-edge',
|
||||
match: { domains: ['app.example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
},
|
||||
remoteIngress: { enabled: true } // Edges will listen on port 443
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await router.start();
|
||||
```
|
||||
|
||||
### Edge Registration
|
||||
|
||||
Edges are registered via the OpsServer API (or dashboard UI). Each edge gets a unique ID and secret:
|
||||
|
||||
```typescript
|
||||
// Via TypedRequest API
|
||||
const createReq = new TypedRequest<IReq_CreateRemoteIngress>(
|
||||
'https://hub:3000/typedrequest', 'createRemoteIngress'
|
||||
);
|
||||
const { edge } = await createReq.fire({
|
||||
identity,
|
||||
name: 'edge-nyc-01',
|
||||
autoDerivePorts: true,
|
||||
tags: ['us-east'],
|
||||
});
|
||||
// edge.secret is returned only on creation — save it!
|
||||
```
|
||||
|
||||
### Connection Tokens 🔑
|
||||
|
||||
Instead of configuring edges with four separate values (hubHost, hubPort, edgeId, secret), DcRouter can generate a single **connection token** — an opaque base64url string that encodes everything:
|
||||
|
||||
```typescript
|
||||
// Via TypedRequest API
|
||||
const tokenReq = new TypedRequest<IReq_GetRemoteIngressConnectionToken>(
|
||||
'https://hub:3000/typedrequest', 'getRemoteIngressConnectionToken'
|
||||
);
|
||||
const { token } = await tokenReq.fire({ identity, edgeId: 'edge-uuid' });
|
||||
// token = "eyJoIjoiaHViLmV4YW1wbGUuY29tIiwicCI6ODQ0MywiZSI6I..."
|
||||
|
||||
// On the edge side, just pass the token:
|
||||
const edge = new RemoteIngressEdge({ token });
|
||||
await edge.start();
|
||||
```
|
||||
|
||||
The token is generated using `remoteingress.encodeConnectionToken()` and contains `{ hubHost, hubPort, edgeId, secret }`. The `hubHost` comes from `remoteIngressConfig.hubDomain` (or can be overridden per-request).
|
||||
|
||||
In the OpsServer dashboard, click **"Copy Token"** on any edge row to copy the connection token to your clipboard.
|
||||
|
||||
### Auto-Derived Ports
|
||||
|
||||
When routes have `remoteIngress: { enabled: true }`, edges with `autoDerivePorts: true` (default) automatically pick up those routes' ports. You can also use `edgeFilter` to restrict which edges get which ports:
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: 'web-route',
|
||||
match: { ports: [443] },
|
||||
action: { /* ... */ },
|
||||
remoteIngress: {
|
||||
enabled: true,
|
||||
edgeFilter: ['us-east', 'edge-uuid-123'] // Only edges with matching id or tags
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard Actions
|
||||
|
||||
The OpsServer Remote Ingress view provides:
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| **Create Edge Node** | Register a new edge with name, ports, tags |
|
||||
| **Enable / Disable** | Toggle an edge on or off |
|
||||
| **Edit** | Modify name, manual ports, auto-derive setting, tags |
|
||||
| **Regenerate Secret** | Issue a new secret (invalidates the old one) |
|
||||
| **Copy Token** | Generate and copy a base64url connection token to clipboard |
|
||||
| **Delete** | Remove the edge registration |
|
||||
|
||||
## Certificate Management
|
||||
|
||||
DcRouter uses [`@push.rocks/smartacme`](https://code.foss.global/push.rocks/smartacme) v9 for ACME certificate provisioning. smartacme v9 brings significant improvements over previous versions:
|
||||
@@ -767,6 +908,7 @@ The OpsServer includes a **Certificates** view showing:
|
||||
- Expiry dates and issuer information
|
||||
- Backoff status for failed domains
|
||||
- One-click reprovisioning per domain
|
||||
- Certificate import and export
|
||||
|
||||
## Storage & Caching
|
||||
|
||||
@@ -788,7 +930,7 @@ storage: {
|
||||
// Simply omit the storage config
|
||||
```
|
||||
|
||||
Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs, cert backoff state.
|
||||
Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs, cert backoff state, remote ingress edge registrations.
|
||||
|
||||
### Cache Database
|
||||
|
||||
@@ -874,7 +1016,8 @@ The OpsServer provides a web-based management interface served on port 3000. It'
|
||||
| 📊 **Overview** | Real-time server stats, CPU/memory, connection counts, email throughput |
|
||||
| 🌐 **Network** | Active connections, top IPs, throughput rates, SmartProxy metrics |
|
||||
| 📧 **Email** | Queue monitoring (queued/sent/failed), bounce records, security incidents |
|
||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning |
|
||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
||||
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||
| 🛡️ **Security** | IP reputation, rate limit status, blocked connections |
|
||||
@@ -906,6 +1049,18 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
||||
'getCertificateOverview' // Domain-centric certificate status
|
||||
'reprovisionCertificate' // Reprovision by route name (legacy)
|
||||
'reprovisionCertificateDomain' // Reprovision by domain (preferred)
|
||||
'importCertificate' // Import a certificate
|
||||
'exportCertificate' // Export a certificate
|
||||
'deleteCertificate' // Delete a certificate
|
||||
|
||||
// Remote Ingress
|
||||
'getRemoteIngresses' // List all edge registrations
|
||||
'createRemoteIngress' // Register a new edge
|
||||
'updateRemoteIngress' // Update edge settings
|
||||
'deleteRemoteIngress' // Remove an edge
|
||||
'regenerateRemoteIngressSecret' // Issue a new secret
|
||||
'getRemoteIngressStatus' // Runtime status of all edges
|
||||
'getRemoteIngressConnectionToken' // Generate a connection token for an edge
|
||||
|
||||
// Configuration (read-only)
|
||||
'getConfiguration' // Current system config
|
||||
@@ -957,6 +1112,8 @@ const router = new DcRouter(options: IDcRouterOptions);
|
||||
| `emailServer` | `UnifiedEmailServer` | Email server instance (from smartmta) |
|
||||
| `dnsServer` | `DnsServer` | DNS server instance |
|
||||
| `radiusServer` | `RadiusServer` | RADIUS server instance |
|
||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||
| `storageManager` | `StorageManager` | Storage backend |
|
||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||
@@ -1000,7 +1157,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
DcRouter includes a comprehensive test suite covering all system components:
|
||||
|
||||
```bash
|
||||
# Run all tests (10 files, 73 tests)
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run a specific test file
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
|
||||
const devRouter = new DcRouter({
|
||||
// Configure services as needed for development
|
||||
// OpsServer always starts on port 3000
|
||||
|
||||
// Example: Add SmartProxy routes
|
||||
// smartProxyConfig: {
|
||||
// routes: [...]
|
||||
// },
|
||||
|
||||
// Example: Add email configuration
|
||||
// emailConfig: {
|
||||
// ports: [2525],
|
||||
// hostname: 'localhost',
|
||||
// domains: [],
|
||||
// routes: []
|
||||
// },
|
||||
// SmartProxy routes for development/demo
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-traffic',
|
||||
match: { ports: [18080], domains: ['example.com', '*.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
|
||||
},
|
||||
{
|
||||
name: 'api-gateway',
|
||||
match: { ports: [18080], domains: ['api.example.com'], path: '/v1/*' },
|
||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 4000 }] },
|
||||
},
|
||||
{
|
||||
name: 'tls-passthrough',
|
||||
match: { ports: [18443], domains: ['secure.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: 'localhost', port: 4443 }],
|
||||
tls: { mode: 'passthrough' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Disable cache/mongo for dev
|
||||
cacheConfig: { enabled: false },
|
||||
});
|
||||
|
||||
console.log('Starting DcRouter in development mode...');
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '6.11.0',
|
||||
version: '8.1.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -106,6 +106,13 @@ export class CertProvisionScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all in-memory backoff cache entries
|
||||
*/
|
||||
public clear(): void {
|
||||
this.backoffCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backoff info for UI display
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ import { OpsServer } from './opsserver/index.js';
|
||||
import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager } from './config/index.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -212,6 +213,15 @@ export class DcRouter {
|
||||
public remoteIngressManager?: RemoteIngressManager;
|
||||
public tunnelManager?: TunnelManager;
|
||||
|
||||
// Programmatic config API
|
||||
public routeConfigManager?: RouteConfigManager;
|
||||
public apiTokenManager?: ApiTokenManager;
|
||||
|
||||
// DNS query logging rate limiter state
|
||||
private dnsLogWindow: number[] = [];
|
||||
private dnsBatchCount: number = 0;
|
||||
private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Certificate status tracking from SmartProxy events (keyed by domain)
|
||||
public certificateStatusMap = new Map<string, {
|
||||
status: 'valid' | 'failed';
|
||||
@@ -228,6 +238,9 @@ export class DcRouter {
|
||||
// TypedRouter for API endpoints
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
||||
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Environment access
|
||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
|
||||
@@ -252,9 +265,7 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
public async start() {
|
||||
console.log('╔═══════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Starting DcRouter Services ║');
|
||||
console.log('╚═══════════════════════════════════════════════════════════════════╝');
|
||||
logger.log('info', 'Starting DcRouter Services');
|
||||
|
||||
|
||||
this.opsServer = new OpsServer(this);
|
||||
@@ -272,7 +283,17 @@ export class DcRouter {
|
||||
|
||||
// Set up SmartProxy for HTTP/HTTPS and all traffic including email routes
|
||||
await this.setupSmartProxy();
|
||||
|
||||
|
||||
// Initialize programmatic config API managers
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
this.storageManager,
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager(this.storageManager);
|
||||
await this.apiTokenManager.initialize();
|
||||
await this.routeConfigManager.initialize();
|
||||
|
||||
// Set up unified email handling if configured
|
||||
if (this.options.emailConfig) {
|
||||
await this.setupUnifiedEmailHandling();
|
||||
@@ -296,7 +317,7 @@ export class DcRouter {
|
||||
|
||||
this.logStartupSummary();
|
||||
} catch (error) {
|
||||
console.error('❌ Error starting DcRouter:', error);
|
||||
logger.log('error', 'Error starting DcRouter', { error: String(error) });
|
||||
// Try to clean up any services that may have started
|
||||
await this.stop();
|
||||
throw error;
|
||||
@@ -307,104 +328,60 @@ export class DcRouter {
|
||||
* Log comprehensive startup summary
|
||||
*/
|
||||
private logStartupSummary(): void {
|
||||
console.log('\n╔═══════════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ DcRouter Started Successfully ║');
|
||||
console.log('╚═══════════════════════════════════════════════════════════════════╝\n');
|
||||
|
||||
logger.log('info', 'DcRouter Started Successfully');
|
||||
|
||||
// Metrics summary
|
||||
if (this.metricsManager) {
|
||||
console.log('📊 Metrics Service:');
|
||||
console.log(' ├─ SmartMetrics: Active');
|
||||
console.log(' ├─ SmartProxy Stats: Active');
|
||||
console.log(' └─ Real-time tracking: Enabled');
|
||||
logger.log('info', 'Metrics Service: SmartMetrics active, SmartProxy stats active, real-time tracking enabled');
|
||||
}
|
||||
|
||||
|
||||
// SmartProxy summary
|
||||
if (this.smartProxy) {
|
||||
console.log('🌐 SmartProxy Service:');
|
||||
const routeCount = this.options.smartProxyConfig?.routes?.length || 0;
|
||||
console.log(` ├─ Routes configured: ${routeCount}`);
|
||||
console.log(` ├─ ACME enabled: ${this.options.smartProxyConfig?.acme?.enabled || false}`);
|
||||
if (this.options.smartProxyConfig?.acme?.enabled) {
|
||||
console.log(` ├─ ACME email: ${this.options.smartProxyConfig.acme.email || 'not set'}`);
|
||||
console.log(` └─ ACME mode: ${this.options.smartProxyConfig.acme.useProduction ? 'production' : 'staging'}`);
|
||||
} else {
|
||||
console.log(' └─ ACME: disabled');
|
||||
}
|
||||
const acmeEnabled = this.options.smartProxyConfig?.acme?.enabled || false;
|
||||
const acmeMode = acmeEnabled
|
||||
? `email=${this.options.smartProxyConfig!.acme!.email || 'not set'}, mode=${this.options.smartProxyConfig!.acme!.useProduction ? 'production' : 'staging'}`
|
||||
: 'disabled';
|
||||
logger.log('info', `SmartProxy Service: ${routeCount} routes, ACME: ${acmeMode}`);
|
||||
}
|
||||
|
||||
|
||||
// Email service summary
|
||||
if (this.emailServer && this.options.emailConfig) {
|
||||
console.log('\n📧 Email Service:');
|
||||
const ports = this.options.emailConfig.ports || [];
|
||||
console.log(` ├─ Ports: ${ports.join(', ')}`);
|
||||
console.log(` ├─ Hostname: ${this.options.emailConfig.hostname || 'localhost'}`);
|
||||
console.log(` ├─ Domains configured: ${this.options.emailConfig.domains?.length || 0}`);
|
||||
if (this.options.emailConfig.domains && this.options.emailConfig.domains.length > 0) {
|
||||
this.options.emailConfig.domains.forEach((domain, index) => {
|
||||
const isLast = index === this.options.emailConfig!.domains!.length - 1;
|
||||
console.log(` ${isLast ? '└─' : '├─'} ${domain.domain} (${domain.dnsMode || 'default'})`);
|
||||
});
|
||||
}
|
||||
console.log(` └─ DKIM: Initialized for all domains`);
|
||||
const domainCount = this.options.emailConfig.domains?.length || 0;
|
||||
const domainNames = this.options.emailConfig.domains?.map(d => `${d.domain} (${d.dnsMode || 'default'})`).join(', ') || 'none';
|
||||
logger.log('info', `Email Service: ports=[${ports.join(', ')}], hostname=${this.options.emailConfig.hostname || 'localhost'}, domains=${domainCount} [${domainNames}], DKIM initialized`);
|
||||
}
|
||||
|
||||
|
||||
// DNS service summary
|
||||
if (this.dnsServer && this.options.dnsNsDomains && this.options.dnsScopes) {
|
||||
console.log('\n🌍 DNS Service:');
|
||||
console.log(` ├─ Nameservers: ${this.options.dnsNsDomains.join(', ')}`);
|
||||
console.log(` ├─ Primary NS: ${this.options.dnsNsDomains[0]}`);
|
||||
console.log(` ├─ Authoritative for: ${this.options.dnsScopes.length} domains`);
|
||||
console.log(` ├─ UDP Port: 53`);
|
||||
console.log(` ├─ DNS-over-HTTPS: Enabled via socket handler`);
|
||||
console.log(` └─ DNSSEC: ${this.options.dnsNsDomains[0] ? 'Enabled' : 'Disabled'}`);
|
||||
|
||||
// Show authoritative domains
|
||||
if (this.options.dnsScopes.length > 0) {
|
||||
console.log('\n Authoritative Domains:');
|
||||
this.options.dnsScopes.forEach((domain, index) => {
|
||||
const isLast = index === this.options.dnsScopes!.length - 1;
|
||||
console.log(` ${isLast ? '└─' : '├─'} ${domain}`);
|
||||
});
|
||||
}
|
||||
logger.log('info', `DNS Service: nameservers=[${this.options.dnsNsDomains.join(', ')}], authoritative for ${this.options.dnsScopes.length} domains [${this.options.dnsScopes.join(', ')}], UDP:53, DoH enabled`);
|
||||
}
|
||||
|
||||
|
||||
// RADIUS service summary
|
||||
if (this.radiusServer && this.options.radiusConfig) {
|
||||
console.log('\n🔐 RADIUS Service:');
|
||||
console.log(` ├─ Auth Port: ${this.options.radiusConfig.authPort || 1812}`);
|
||||
console.log(` ├─ Acct Port: ${this.options.radiusConfig.acctPort || 1813}`);
|
||||
console.log(` ├─ Clients configured: ${this.options.radiusConfig.clients?.length || 0}`);
|
||||
const vlanStats = this.radiusServer.getVlanManager().getStats();
|
||||
console.log(` ├─ VLAN mappings: ${vlanStats.totalMappings}`);
|
||||
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`);
|
||||
logger.log('info', `RADIUS Service: auth=${this.options.radiusConfig.authPort || 1812}, acct=${this.options.radiusConfig.acctPort || 1813}, clients=${this.options.radiusConfig.clients?.length || 0}, VLANs=${vlanStats.totalMappings}, accounting=${this.options.radiusConfig.accounting?.enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
// Remote Ingress summary
|
||||
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
||||
console.log('\n🌐 Remote Ingress:');
|
||||
console.log(` ├─ Tunnel Port: ${this.options.remoteIngressConfig.tunnelPort || 8443}`);
|
||||
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
||||
const connectedCount = this.tunnelManager.getConnectedCount();
|
||||
console.log(` ├─ Registered Edges: ${edgeCount}`);
|
||||
console.log(` └─ Connected Edges: ${connectedCount}`);
|
||||
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
|
||||
}
|
||||
|
||||
// Storage summary
|
||||
if (this.storageManager && this.options.storage) {
|
||||
console.log('\n💾 Storage:');
|
||||
console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`);
|
||||
logger.log('info', `Storage: path=${this.options.storage.fsPath || 'default'}`);
|
||||
}
|
||||
|
||||
// Cache database summary
|
||||
if (this.cacheDb) {
|
||||
console.log('\n🗄️ Cache Database (smartdata + LocalTsmDb):');
|
||||
console.log(` ├─ Storage: ${this.cacheDb.getStoragePath()}`);
|
||||
console.log(` ├─ Database: ${this.cacheDb.getDbName()}`);
|
||||
console.log(` └─ Cleaner: ${this.cacheCleaner?.isActive() ? 'Active' : 'Inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
|
||||
logger.log('info', `Cache Database: storage=${this.cacheDb.getStoragePath()}, db=${this.cacheDb.getDbName()}, cleaner=${this.cacheCleaner?.isActive() ? 'active' : 'inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
|
||||
}
|
||||
|
||||
console.log('\n✅ All services are running\n');
|
||||
logger.log('info', 'All services are running');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,7 +416,7 @@ export class DcRouter {
|
||||
* Set up SmartProxy with direct configuration and automatic email routes
|
||||
*/
|
||||
private async setupSmartProxy(): Promise<void> {
|
||||
console.log('[DcRouter] Setting up SmartProxy...');
|
||||
logger.log('info', 'Setting up SmartProxy...');
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
|
||||
|
||||
@@ -447,22 +424,20 @@ export class DcRouter {
|
||||
if (this.options.smartProxyConfig) {
|
||||
routes = this.options.smartProxyConfig.routes || [];
|
||||
acmeConfig = this.options.smartProxyConfig.acme;
|
||||
console.log(`[DcRouter] Found ${routes.length} routes in config`);
|
||||
console.log(`[DcRouter] ACME config present: ${!!acmeConfig}`);
|
||||
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
||||
}
|
||||
|
||||
// If email config exists, automatically add email routes
|
||||
if (this.options.emailConfig) {
|
||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
console.log(`Email Routes are:`)
|
||||
console.log(emailRoutes)
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||
}
|
||||
|
||||
// If DNS is configured, add DNS routes
|
||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||
const dnsRoutes = this.generateDnsRoutes();
|
||||
console.log(`DNS Routes for nameservers ${this.options.dnsNsDomains.join(', ')}:`, dnsRoutes);
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||
routes = [...routes, ...dnsRoutes];
|
||||
}
|
||||
|
||||
@@ -480,15 +455,18 @@ export class DcRouter {
|
||||
// Configure DNS challenge if available
|
||||
let challengeHandlers: any[] = [];
|
||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
||||
console.log('Configuring Cloudflare DNS challenge for ACME');
|
||||
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
|
||||
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
|
||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
|
||||
challengeHandlers.push(dns01Handler);
|
||||
}
|
||||
|
||||
// Cache constructor routes for RouteConfigManager
|
||||
this.constructorRoutes = [...routes];
|
||||
|
||||
// If we have routes or need a basic SmartProxy instance, create it
|
||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
console.log('Setting up SmartProxy with combined configuration');
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
|
||||
// Track cert entries loaded from cert store so we can populate certificateStatusMap after start
|
||||
const loadedCertEntries: Array<{domain: string; publicKey: string; validUntil?: number; validFrom?: number}> = [];
|
||||
@@ -534,6 +512,12 @@ export class DcRouter {
|
||||
|
||||
// If we have DNS challenge handlers, create SmartAcme and wire to certProvisionFunction
|
||||
if (challengeHandlers.length > 0) {
|
||||
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
|
||||
if (this.smartAcme) {
|
||||
await this.smartAcme.stop().catch(err =>
|
||||
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
||||
);
|
||||
}
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
||||
certManager: new StorageBackedCertManager(this.storageManager),
|
||||
@@ -586,26 +570,27 @@ export class DcRouter {
|
||||
};
|
||||
}
|
||||
|
||||
// When remoteIngress is enabled, the hub binary forwards tunneled connections
|
||||
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
smartProxyConfig.proxyIPs = ['127.0.0.1'];
|
||||
}
|
||||
|
||||
// Create SmartProxy instance
|
||||
console.log('[DcRouter] Creating SmartProxy instance with config:', JSON.stringify({
|
||||
routeCount: smartProxyConfig.routes?.length,
|
||||
acmeEnabled: smartProxyConfig.acme?.enabled,
|
||||
acmeEmail: smartProxyConfig.acme?.email,
|
||||
certProvisionFunction: !!smartProxyConfig.certProvisionFunction
|
||||
}, null, 2));
|
||||
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
||||
|
||||
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
|
||||
|
||||
// Set up event listeners
|
||||
this.smartProxy.on('error', (err) => {
|
||||
console.error('[DcRouter] SmartProxy error:', err);
|
||||
console.error('[DcRouter] Error stack:', err.stack);
|
||||
logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack });
|
||||
});
|
||||
|
||||
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
|
||||
// Events are keyed by domain for domain-centric certificate tracking
|
||||
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||
console.log(`[DcRouter] Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
status: 'valid', routeNames,
|
||||
@@ -615,7 +600,7 @@ export class DcRouter {
|
||||
});
|
||||
|
||||
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||
console.log(`[DcRouter] Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
status: 'valid', routeNames,
|
||||
@@ -625,7 +610,7 @@ export class DcRouter {
|
||||
});
|
||||
|
||||
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
||||
console.error(`[DcRouter] Certificate failed for ${event.domain} (${event.source}):`, event.error);
|
||||
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
status: 'failed', routeNames, error: event.error,
|
||||
@@ -634,9 +619,9 @@ export class DcRouter {
|
||||
});
|
||||
|
||||
// Start SmartProxy
|
||||
console.log('[DcRouter] Starting SmartProxy...');
|
||||
logger.log('info', 'Starting SmartProxy...');
|
||||
await this.smartProxy.start();
|
||||
console.log('[DcRouter] SmartProxy started successfully');
|
||||
logger.log('info', 'SmartProxy started successfully');
|
||||
|
||||
// Populate certificateStatusMap for certs loaded from store at startup
|
||||
for (const entry of loadedCertEntries) {
|
||||
@@ -688,10 +673,10 @@ export class DcRouter {
|
||||
}
|
||||
}
|
||||
if (loadedCertEntries.length > 0) {
|
||||
console.log(`[DcRouter] Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
|
||||
logger.log('info', `Populated certificate status for ${loadedCertEntries.length} store-loaded domain(s)`);
|
||||
}
|
||||
|
||||
console.log(`SmartProxy started with ${routes.length} routes`);
|
||||
logger.log('info', `SmartProxy started with ${routes.length} routes`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,8 +878,27 @@ export class DcRouter {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the routes derived from constructor config (smartProxy + email + DNS).
|
||||
* Used by RouteConfigManager as the "hardcoded" base.
|
||||
*/
|
||||
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
|
||||
return this.constructorRoutes;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
console.log('Stopping DcRouter services...');
|
||||
logger.log('info', 'Stopping DcRouter services...');
|
||||
|
||||
// Flush pending DNS batch log
|
||||
if (this.dnsBatchTimer) {
|
||||
clearTimeout(this.dnsBatchTimer);
|
||||
if (this.dnsBatchCount > 0) {
|
||||
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited, final flush)`, { zone: 'dns' });
|
||||
}
|
||||
this.dnsBatchTimer = null;
|
||||
this.dnsBatchCount = 0;
|
||||
this.dnsLogWindow = [];
|
||||
}
|
||||
|
||||
await this.opsServer.stop();
|
||||
|
||||
@@ -905,41 +909,62 @@ export class DcRouter {
|
||||
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
|
||||
|
||||
// Stop metrics manager if running
|
||||
this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(),
|
||||
this.metricsManager ? this.metricsManager.stop().catch(err => logger.log('error', 'Error stopping MetricsManager', { error: String(err) })) : Promise.resolve(),
|
||||
|
||||
// Stop unified email server if running
|
||||
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(),
|
||||
this.emailServer ? this.emailServer.stop().catch(err => logger.log('error', 'Error stopping email server', { error: String(err) })) : Promise.resolve(),
|
||||
|
||||
// Stop SmartAcme if running
|
||||
this.smartAcme ? this.smartAcme.stop().catch(err => console.error('Error stopping SmartAcme:', err)) : Promise.resolve(),
|
||||
this.smartAcme ? this.smartAcme.stop().catch(err => logger.log('error', 'Error stopping SmartAcme', { error: String(err) })) : Promise.resolve(),
|
||||
|
||||
// Stop HTTP SmartProxy if running
|
||||
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
|
||||
this.smartProxy ? this.smartProxy.stop().catch(err => logger.log('error', 'Error stopping SmartProxy', { error: String(err) })) : Promise.resolve(),
|
||||
|
||||
// Stop DNS server if running
|
||||
this.dnsServer ?
|
||||
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
|
||||
this.dnsServer.stop().catch(err => logger.log('error', 'Error stopping DNS server', { error: String(err) })) :
|
||||
Promise.resolve(),
|
||||
|
||||
// Stop RADIUS server if running
|
||||
this.radiusServer ?
|
||||
this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) :
|
||||
this.radiusServer.stop().catch(err => logger.log('error', 'Error stopping RADIUS server', { error: String(err) })) :
|
||||
Promise.resolve(),
|
||||
|
||||
// Stop Remote Ingress tunnel manager if running
|
||||
this.tunnelManager ?
|
||||
this.tunnelManager.stop().catch(err => console.error('Error stopping TunnelManager:', err)) :
|
||||
this.tunnelManager.stop().catch(err => logger.log('error', 'Error stopping TunnelManager', { error: String(err) })) :
|
||||
Promise.resolve()
|
||||
]);
|
||||
|
||||
// Stop cache database after other services (they may need it during shutdown)
|
||||
if (this.cacheDb) {
|
||||
await this.cacheDb.stop().catch(err => console.error('Error stopping CacheDb:', err));
|
||||
await this.cacheDb.stop().catch(err => logger.log('error', 'Error stopping CacheDb', { error: String(err) }));
|
||||
}
|
||||
|
||||
console.log('All DcRouter services stopped');
|
||||
// Clear backoff cache in cert scheduler
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
}
|
||||
|
||||
// Allow GC of stopped services by nulling references
|
||||
this.smartProxy = undefined;
|
||||
this.emailServer = undefined;
|
||||
this.dnsServer = undefined;
|
||||
this.metricsManager = undefined;
|
||||
this.cacheCleaner = undefined;
|
||||
this.cacheDb = undefined;
|
||||
this.tunnelManager = undefined;
|
||||
this.radiusServer = undefined;
|
||||
this.smartAcme = undefined;
|
||||
this.certProvisionScheduler = undefined;
|
||||
this.remoteIngressManager = undefined;
|
||||
this.routeConfigManager = undefined;
|
||||
this.apiTokenManager = undefined;
|
||||
this.certificateStatusMap.clear();
|
||||
|
||||
logger.log('info', 'All DcRouter services stopped');
|
||||
} catch (error) {
|
||||
console.error('Error during DcRouter shutdown:', error);
|
||||
logger.log('error', 'Error during DcRouter shutdown', { error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -966,7 +991,12 @@ export class DcRouter {
|
||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||
await this.setupSmartProxy();
|
||||
|
||||
console.log('SmartProxy configuration updated');
|
||||
// Re-apply programmatic routes and overrides after SmartProxy restart
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.initialize();
|
||||
}
|
||||
|
||||
logger.log('info', 'SmartProxy configuration updated');
|
||||
}
|
||||
|
||||
|
||||
@@ -1023,7 +1053,29 @@ export class DcRouter {
|
||||
|
||||
// Start the server
|
||||
await this.emailServer.start();
|
||||
|
||||
|
||||
// Wire delivery events to MetricsManager and logger
|
||||
if (this.metricsManager && this.emailServer.deliverySystem) {
|
||||
this.emailServer.deliverySystem.on('deliveryStart', (item: any) => {
|
||||
this.metricsManager.trackEmailReceived(item?.from);
|
||||
logger.log('info', `Email delivery started: ${item?.from} → ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliverySuccess', (item: any) => {
|
||||
this.metricsManager.trackEmailSent(item?.to);
|
||||
logger.log('info', `Email delivered to ${item?.to}`, { zone: 'email' });
|
||||
});
|
||||
this.emailServer.deliverySystem.on('deliveryFailed', (item: any, error: any) => {
|
||||
this.metricsManager.trackEmailFailed(item?.to, error?.message);
|
||||
logger.log('warn', `Email delivery failed to ${item?.to}: ${error?.message}`, { zone: 'email' });
|
||||
});
|
||||
}
|
||||
if (this.metricsManager && this.emailServer) {
|
||||
this.emailServer.on('bounceProcessed', () => {
|
||||
this.metricsManager.trackEmailBounced();
|
||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||
});
|
||||
}
|
||||
|
||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -1041,7 +1093,7 @@ export class DcRouter {
|
||||
// Start email handling with new configuration
|
||||
await this.setupUnifiedEmailHandling();
|
||||
|
||||
console.log('Unified email configuration updated');
|
||||
logger.log('info', 'Unified email configuration updated');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1081,7 +1133,7 @@ export class DcRouter {
|
||||
this.emailServer.updateEmailRoutes(routes);
|
||||
}
|
||||
|
||||
console.log(`Email routes updated with ${routes.length} routes`);
|
||||
logger.log('info', `Email routes updated with ${routes.length} routes`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1206,6 +1258,45 @@ export class DcRouter {
|
||||
// Start the DNS server (UDP only)
|
||||
await this.dnsServer.start();
|
||||
logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
|
||||
|
||||
// Wire DNS query events to MetricsManager and logger with adaptive rate limiting
|
||||
if (this.metricsManager && this.dnsServer) {
|
||||
const flushDnsBatch = () => {
|
||||
if (this.dnsBatchCount > 0) {
|
||||
logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited)`, { zone: 'dns' });
|
||||
this.dnsBatchCount = 0;
|
||||
}
|
||||
this.dnsBatchTimer = null;
|
||||
};
|
||||
|
||||
this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
|
||||
// Metrics tracking
|
||||
for (const question of event.questions) {
|
||||
this.metricsManager.trackDnsQuery(
|
||||
question.type,
|
||||
question.name,
|
||||
false,
|
||||
event.responseTimeMs,
|
||||
event.answered,
|
||||
);
|
||||
}
|
||||
|
||||
// Adaptive logging: individual logs up to 2/sec, then batch
|
||||
const now = Date.now();
|
||||
this.dnsLogWindow = this.dnsLogWindow.filter(t => now - t < 1000);
|
||||
|
||||
if (this.dnsLogWindow.length < 2) {
|
||||
this.dnsLogWindow.push(now);
|
||||
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' });
|
||||
} else {
|
||||
this.dnsBatchCount++;
|
||||
if (!this.dnsBatchTimer) {
|
||||
this.dnsBatchTimer = setTimeout(flushDnsBatch, 5000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate DNS configuration
|
||||
await this.validateDnsConfiguration();
|
||||
|
||||
155
ts/config/classes.api-token-manager.ts
Normal file
155
ts/config/classes.api-token-manager.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import type {
|
||||
IStoredApiToken,
|
||||
IApiTokenInfo,
|
||||
TApiTokenScope,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const TOKENS_PREFIX = '/config-api/tokens/';
|
||||
const TOKEN_PREFIX_STR = 'dcr_';
|
||||
|
||||
export class ApiTokenManager {
|
||||
private tokens = new Map<string, IStoredApiToken>();
|
||||
|
||||
constructor(private storageManager: StorageManager) {}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadTokens();
|
||||
if (this.tokens.size > 0) {
|
||||
logger.log('info', `Loaded ${this.tokens.size} API token(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a new API token. Returns the raw token value (shown once).
|
||||
*/
|
||||
public async createToken(
|
||||
name: string,
|
||||
scopes: TApiTokenScope[],
|
||||
expiresInDays: number | null,
|
||||
createdBy: string,
|
||||
): Promise<{ id: string; rawToken: string }> {
|
||||
const id = plugins.uuid.v4();
|
||||
const randomBytes = plugins.crypto.randomBytes(32);
|
||||
const rawPayload = `${id}:${randomBytes.toString('base64url')}`;
|
||||
const rawToken = `${TOKEN_PREFIX_STR}${rawPayload}`;
|
||||
|
||||
const tokenHash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
const now = Date.now();
|
||||
const stored: IStoredApiToken = {
|
||||
id,
|
||||
name,
|
||||
tokenHash,
|
||||
scopes,
|
||||
createdAt: now,
|
||||
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||
lastUsedAt: null,
|
||||
createdBy,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
this.tokens.set(id, stored);
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${name}' created (id: ${id})`);
|
||||
return { id, rawToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw token string. Returns the stored token if valid, null otherwise.
|
||||
* Also updates lastUsedAt.
|
||||
*/
|
||||
public async validateToken(rawToken: string): Promise<IStoredApiToken | null> {
|
||||
if (!rawToken.startsWith(TOKEN_PREFIX_STR)) return null;
|
||||
|
||||
const hash = plugins.crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
for (const stored of this.tokens.values()) {
|
||||
if (stored.tokenHash === hash) {
|
||||
if (!stored.enabled) return null;
|
||||
if (stored.expiresAt !== null && stored.expiresAt < Date.now()) return null;
|
||||
|
||||
// Update lastUsedAt (fire and forget)
|
||||
stored.lastUsedAt = Date.now();
|
||||
this.persistToken(stored).catch(() => {});
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has a specific scope.
|
||||
*/
|
||||
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||
return token.scopes.includes(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens (safe info only, no hashes).
|
||||
*/
|
||||
public listTokens(): IApiTokenInfo[] {
|
||||
const result: IApiTokenInfo[] = [];
|
||||
for (const stored of this.tokens.values()) {
|
||||
result.push({
|
||||
id: stored.id,
|
||||
name: stored.name,
|
||||
scopes: stored.scopes,
|
||||
createdAt: stored.createdAt,
|
||||
expiresAt: stored.expiresAt,
|
||||
lastUsedAt: stored.lastUsedAt,
|
||||
enabled: stored.enabled,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token.
|
||||
*/
|
||||
public async revokeToken(id: string): Promise<boolean> {
|
||||
if (!this.tokens.has(id)) return false;
|
||||
const token = this.tokens.get(id)!;
|
||||
this.tokens.delete(id);
|
||||
await this.storageManager.delete(`${TOKENS_PREFIX}${id}.json`);
|
||||
logger.log('info', `API token '${token.name}' revoked (id: ${id})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a token.
|
||||
*/
|
||||
public async toggleToken(id: string, enabled: boolean): Promise<boolean> {
|
||||
const stored = this.tokens.get(id);
|
||||
if (!stored) return false;
|
||||
stored.enabled = enabled;
|
||||
await this.persistToken(stored);
|
||||
logger.log('info', `API token '${stored.name}' ${enabled ? 'enabled' : 'disabled'} (id: ${id})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private
|
||||
// =========================================================================
|
||||
|
||||
private async loadTokens(): Promise<void> {
|
||||
const keys = await this.storageManager.list(TOKENS_PREFIX);
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('.json')) continue;
|
||||
const stored = await this.storageManager.getJSON<IStoredApiToken>(key);
|
||||
if (stored?.id) {
|
||||
this.tokens.set(stored.id, stored);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async persistToken(stored: IStoredApiToken): Promise<void> {
|
||||
await this.storageManager.setJSON(`${TOKENS_PREFIX}${stored.id}.json`, stored);
|
||||
}
|
||||
}
|
||||
271
ts/config/classes.route-config-manager.ts
Normal file
271
ts/config/classes.route-config-manager.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const ROUTES_PREFIX = '/config-api/routes/';
|
||||
const OVERRIDES_PREFIX = '/config-api/overrides/';
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
|
||||
constructor(
|
||||
private storageManager: StorageManager,
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
await this.loadStoredRoutes();
|
||||
await this.loadOverrides();
|
||||
this.computeWarnings();
|
||||
this.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merged view
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
// Hardcoded routes
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
merged.push({
|
||||
route,
|
||||
source: 'hardcoded',
|
||||
enabled: override ? override.enabled : true,
|
||||
overridden: !!override,
|
||||
});
|
||||
}
|
||||
|
||||
// Programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
merged.push({
|
||||
route: stored.route,
|
||||
source: 'programmatic',
|
||||
enabled: stored.enabled,
|
||||
overridden: false,
|
||||
storedRouteId: stored.id,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
createdBy: string,
|
||||
enabled = true,
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
}
|
||||
|
||||
public async updateRoute(
|
||||
id: string,
|
||||
patch: { route?: Partial<plugins.smartproxy.IRouteConfig>; enabled?: boolean },
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
if (!stored) return false;
|
||||
|
||||
if (patch.route) {
|
||||
stored.route = { ...stored.route, ...patch.route } as plugins.smartproxy.IRouteConfig;
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
stored.enabled = patch.enabled;
|
||||
}
|
||||
stored.updatedAt = Date.now();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<boolean> {
|
||||
if (!this.storedRoutes.has(id)) return false;
|
||||
this.storedRoutes.delete(id);
|
||||
await this.storageManager.delete(`${ROUTES_PREFIX}${id}.json`);
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
||||
return this.updateRoute(id, { enabled });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hardcoded route overrides
|
||||
// =========================================================================
|
||||
|
||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||
const override: IRouteOverride = {
|
||||
routeName,
|
||||
enabled,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy,
|
||||
};
|
||||
this.overrides.set(routeName, override);
|
||||
await this.storageManager.setJSON(`${OVERRIDES_PREFIX}${routeName}.json`, override);
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
public async removeOverride(routeName: string): Promise<boolean> {
|
||||
if (!this.overrides.has(routeName)) return false;
|
||||
this.overrides.delete(routeName);
|
||||
await this.storageManager.delete(`${OVERRIDES_PREFIX}${routeName}.json`);
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: persistence
|
||||
// =========================================================================
|
||||
|
||||
private async loadStoredRoutes(): Promise<void> {
|
||||
const keys = await this.storageManager.list(ROUTES_PREFIX);
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('.json')) continue;
|
||||
const stored = await this.storageManager.getJSON<IStoredRoute>(key);
|
||||
if (stored?.id) {
|
||||
this.storedRoutes.set(stored.id, stored);
|
||||
}
|
||||
}
|
||||
if (this.storedRoutes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOverrides(): Promise<void> {
|
||||
const keys = await this.storageManager.list(OVERRIDES_PREFIX);
|
||||
for (const key of keys) {
|
||||
if (!key.endsWith('.json')) continue;
|
||||
const override = await this.storageManager.getJSON<IRouteOverride>(key);
|
||||
if (override?.routeName) {
|
||||
this.overrides.set(override.routeName, override);
|
||||
}
|
||||
}
|
||||
if (this.overrides.size > 0) {
|
||||
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
||||
await this.storageManager.setJSON(`${ROUTES_PREFIX}${stored.id}.json`, stored);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: warnings
|
||||
// =========================================================================
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||
|
||||
// Check overrides
|
||||
for (const [routeName, override] of this.overrides) {
|
||||
if (!hardcodedNames.has(routeName)) {
|
||||
this.warnings.push({
|
||||
type: 'orphaned-override',
|
||||
routeName,
|
||||
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||
});
|
||||
} else if (!override.enabled) {
|
||||
this.warnings.push({
|
||||
type: 'disabled-hardcoded',
|
||||
routeName,
|
||||
message: `Route '${routeName}' is disabled via API override`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check disabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (!stored.enabled) {
|
||||
const name = stored.route.name || stored.id;
|
||||
this.warnings.push({
|
||||
type: 'disabled-programmatic',
|
||||
routeName: name,
|
||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logWarnings(): void {
|
||||
for (const w of this.warnings) {
|
||||
logger.log('warn', w.message);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: apply merged routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
private async applyRoutes(): Promise<void> {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add enabled hardcoded routes (respecting overrides)
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
if (override && !override.enabled) {
|
||||
continue; // Skip disabled hardcoded route
|
||||
}
|
||||
enabledRoutes.push(route);
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
enabledRoutes.push(stored.route);
|
||||
}
|
||||
}
|
||||
|
||||
await smartProxy.updateRoutes(enabledRoutes);
|
||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
// Export validation tools only
|
||||
export * from './validator.js';
|
||||
export * from './validator.js';
|
||||
export { RouteConfigManager } from './classes.route-config-manager.js';
|
||||
export { ApiTokenManager } from './classes.api-token-manager.js';
|
||||
11
ts/logger.ts
11
ts/logger.ts
@@ -1,5 +1,6 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { SmartlogDestinationBuffer } from '@push.rocks/smartlog/destination-buffer';
|
||||
|
||||
// Map NODE_ENV to valid TEnvironment
|
||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||
@@ -10,8 +11,11 @@ const envMap: Record<string, 'local' | 'test' | 'staging' | 'production'> = {
|
||||
'production': 'production'
|
||||
};
|
||||
|
||||
// Default Smartlog instance
|
||||
const baseLogger = new plugins.smartlog.Smartlog({
|
||||
// In-memory log buffer for the OpsServer UI
|
||||
export const logBuffer = new SmartlogDestinationBuffer({ maxEntries: 2000 });
|
||||
|
||||
// Default Smartlog instance (exported so OpsServer can add push destinations)
|
||||
export const baseLogger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: envMap[nodeEnv] || 'production',
|
||||
runtime: 'node',
|
||||
@@ -19,6 +23,9 @@ const baseLogger = new plugins.smartlog.Smartlog({
|
||||
}
|
||||
});
|
||||
|
||||
// Wire the buffer destination so all logs are captured
|
||||
baseLogger.addLogDestination(logBuffer);
|
||||
|
||||
// Extended logger compatible with the original enhanced logger API
|
||||
class StandardLogger {
|
||||
private defaultContext: Record<string, any> = {};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { DcRouter } from '../classes.dcrouter.js';
|
||||
import { MetricsCache } from './classes.metricscache.js';
|
||||
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export class MetricsManager {
|
||||
private logger: plugins.smartlog.Smartlog;
|
||||
private metricsLogger: plugins.smartlog.Smartlog;
|
||||
private smartMetrics: plugins.smartmetrics.SmartMetrics;
|
||||
private dcRouter: DcRouter;
|
||||
private resetInterval?: NodeJS.Timeout;
|
||||
@@ -35,8 +37,13 @@ export class MetricsManager {
|
||||
lastResetDate: new Date().toDateString(),
|
||||
queryTimestamps: [] as number[], // Track query timestamps for rate calculation
|
||||
responseTimes: [] as number[], // Track response times in ms
|
||||
recentQueries: [] as Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>,
|
||||
};
|
||||
|
||||
// Per-minute time-series buckets for charts
|
||||
private emailMinuteBuckets = new Map<number, { sent: number; received: number; failed: number }>();
|
||||
private dnsMinuteBuckets = new Map<number, { queries: number }>();
|
||||
|
||||
// Track security-specific metrics
|
||||
private securityMetrics = {
|
||||
blockedIPs: 0,
|
||||
@@ -50,15 +57,15 @@ export class MetricsManager {
|
||||
|
||||
constructor(dcRouter: DcRouter) {
|
||||
this.dcRouter = dcRouter;
|
||||
// Create a new Smartlog instance for metrics
|
||||
this.logger = new plugins.smartlog.Smartlog({
|
||||
// Create a Smartlog instance for SmartMetrics (requires its own instance)
|
||||
this.metricsLogger = new plugins.smartlog.Smartlog({
|
||||
logContext: {
|
||||
environment: 'production',
|
||||
runtime: 'node',
|
||||
zone: 'dcrouter-metrics',
|
||||
}
|
||||
});
|
||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.logger, 'dcrouter');
|
||||
this.smartMetrics = new plugins.smartmetrics.SmartMetrics(this.metricsLogger, 'dcrouter');
|
||||
// Initialize metrics cache with 500ms TTL
|
||||
this.metricsCache = new MetricsCache(500);
|
||||
}
|
||||
@@ -90,6 +97,7 @@ export class MetricsManager {
|
||||
this.dnsMetrics.topDomains.clear();
|
||||
this.dnsMetrics.queryTimestamps = [];
|
||||
this.dnsMetrics.responseTimes = [];
|
||||
this.dnsMetrics.recentQueries = [];
|
||||
this.dnsMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
@@ -102,20 +110,29 @@ export class MetricsManager {
|
||||
this.securityMetrics.incidents = [];
|
||||
this.securityMetrics.lastResetDate = currentDate;
|
||||
}
|
||||
|
||||
// Prune old time-series buckets every minute (don't wait for lazy query)
|
||||
this.pruneOldBuckets();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
this.logger.log('info', 'MetricsManager started');
|
||||
logger.log('info', 'MetricsManager started');
|
||||
}
|
||||
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
// Clear the reset interval
|
||||
if (this.resetInterval) {
|
||||
clearInterval(this.resetInterval);
|
||||
this.resetInterval = undefined;
|
||||
}
|
||||
|
||||
|
||||
this.smartMetrics.stop();
|
||||
this.logger.log('info', 'MetricsManager stopped');
|
||||
|
||||
// Clear caches and time-series buckets on shutdown
|
||||
this.metricsCache.clear();
|
||||
this.emailMinuteBuckets.clear();
|
||||
this.dnsMinuteBuckets.clear();
|
||||
|
||||
logger.log('info', 'MetricsManager stopped');
|
||||
}
|
||||
|
||||
// Get server metrics from SmartMetrics and SmartProxy
|
||||
@@ -139,8 +156,8 @@ export class MetricsManager {
|
||||
actualUsagePercentage: smartMetricsData.memoryPercentage,
|
||||
},
|
||||
cpuUsage: {
|
||||
user: parseFloat(smartMetricsData.cpuUsageText || '0'),
|
||||
system: 0, // SmartMetrics doesn't separate user/system
|
||||
user: smartMetricsData.cpuPercentage,
|
||||
system: 0,
|
||||
},
|
||||
activeConnections: proxyStats ? proxyStats.activeConnections : 0,
|
||||
totalConnections: proxyMetrics ? proxyMetrics.totals.connections() : 0,
|
||||
@@ -223,24 +240,50 @@ export class MetricsManager {
|
||||
queryTypes: this.dnsMetrics.queryTypes,
|
||||
averageResponseTime: Math.round(avgResponseTime),
|
||||
activeDomains: this.dnsMetrics.topDomains.size,
|
||||
recentQueries: this.dnsMetrics.recentQueries.slice(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync security metrics from the SecurityLogger singleton (last 24h).
|
||||
* Called before returning security stats so counters reflect real events.
|
||||
*/
|
||||
private syncFromSecurityLogger(): void {
|
||||
try {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
const summary = securityLogger.getEventsSummary(86400000); // last 24h
|
||||
|
||||
this.securityMetrics.spamDetected = summary.byType[SecurityEventType.SPAM] || 0;
|
||||
this.securityMetrics.malwareDetected = summary.byType[SecurityEventType.MALWARE] || 0;
|
||||
this.securityMetrics.phishingDetected = summary.byType[SecurityEventType.DMARC] || 0; // phishing via DMARC
|
||||
this.securityMetrics.authFailures =
|
||||
summary.byType[SecurityEventType.AUTHENTICATION] || 0;
|
||||
this.securityMetrics.blockedIPs =
|
||||
(summary.byType[SecurityEventType.IP_REPUTATION] || 0) +
|
||||
(summary.byType[SecurityEventType.REJECTED_CONNECTION] || 0);
|
||||
} catch {
|
||||
// SecurityLogger may not be initialized yet — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Get security metrics
|
||||
public async getSecurityStats() {
|
||||
return this.metricsCache.get('securityStats', () => {
|
||||
// Sync counters from the real SecurityLogger events
|
||||
this.syncFromSecurityLogger();
|
||||
|
||||
// Get recent incidents (last 20)
|
||||
const recentIncidents = this.securityMetrics.incidents.slice(-20);
|
||||
|
||||
|
||||
return {
|
||||
blockedIPs: this.securityMetrics.blockedIPs,
|
||||
authFailures: this.securityMetrics.authFailures,
|
||||
spamDetected: this.securityMetrics.spamDetected,
|
||||
malwareDetected: this.securityMetrics.malwareDetected,
|
||||
phishingDetected: this.securityMetrics.phishingDetected,
|
||||
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
||||
this.securityMetrics.malwareDetected +
|
||||
totalThreatsBlocked: this.securityMetrics.spamDetected +
|
||||
this.securityMetrics.malwareDetected +
|
||||
this.securityMetrics.phishingDetected,
|
||||
recentIncidents,
|
||||
};
|
||||
@@ -275,10 +318,19 @@ export class MetricsManager {
|
||||
// Email event tracking methods
|
||||
public trackEmailSent(recipient?: string, deliveryTimeMs?: number): void {
|
||||
this.emailMetrics.sentToday++;
|
||||
this.incrementEmailBucket('sent');
|
||||
|
||||
if (recipient) {
|
||||
const count = this.emailMetrics.recipients.get(recipient) || 0;
|
||||
this.emailMetrics.recipients.set(recipient, count + 1);
|
||||
|
||||
// Cap recipients map to prevent unbounded growth within a day
|
||||
if (this.emailMetrics.recipients.size > this.MAX_TOP_DOMAINS) {
|
||||
const sorted = Array.from(this.emailMetrics.recipients.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, Math.floor(this.MAX_TOP_DOMAINS * 0.8));
|
||||
this.emailMetrics.recipients = new Map(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
if (deliveryTimeMs) {
|
||||
@@ -303,6 +355,7 @@ export class MetricsManager {
|
||||
|
||||
public trackEmailReceived(sender?: string): void {
|
||||
this.emailMetrics.receivedToday++;
|
||||
this.incrementEmailBucket('received');
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
@@ -318,6 +371,7 @@ export class MetricsManager {
|
||||
|
||||
public trackEmailFailed(recipient?: string, reason?: string): void {
|
||||
this.emailMetrics.failedToday++;
|
||||
this.incrementEmailBucket('failed');
|
||||
|
||||
this.emailMetrics.recentActivity.push({
|
||||
timestamp: Date.now(),
|
||||
@@ -351,8 +405,21 @@ export class MetricsManager {
|
||||
}
|
||||
|
||||
// DNS event tracking methods
|
||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number): void {
|
||||
public trackDnsQuery(queryType: string, domain: string, cacheHit: boolean, responseTimeMs?: number, answered?: boolean): void {
|
||||
this.dnsMetrics.totalQueries++;
|
||||
this.incrementDnsBucket();
|
||||
|
||||
// Store recent query entry
|
||||
this.dnsMetrics.recentQueries.push({
|
||||
timestamp: Date.now(),
|
||||
domain,
|
||||
type: queryType,
|
||||
answered: answered ?? true,
|
||||
responseTimeMs: responseTimeMs ?? 0,
|
||||
});
|
||||
if (this.dnsMetrics.recentQueries.length > 100) {
|
||||
this.dnsMetrics.recentQueries.shift();
|
||||
}
|
||||
|
||||
if (cacheHit) {
|
||||
this.dnsMetrics.cacheHits++;
|
||||
@@ -539,4 +606,90 @@ export class MetricsManager {
|
||||
};
|
||||
}, 200); // Use 200ms cache for more frequent updates
|
||||
}
|
||||
|
||||
// --- Time-series helpers ---
|
||||
|
||||
private static minuteKey(ts: number = Date.now()): number {
|
||||
return Math.floor(ts / 60000) * 60000;
|
||||
}
|
||||
|
||||
private incrementEmailBucket(field: 'sent' | 'received' | 'failed'): void {
|
||||
const key = MetricsManager.minuteKey();
|
||||
let bucket = this.emailMinuteBuckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { sent: 0, received: 0, failed: 0 };
|
||||
this.emailMinuteBuckets.set(key, bucket);
|
||||
}
|
||||
bucket[field]++;
|
||||
}
|
||||
|
||||
private incrementDnsBucket(): void {
|
||||
const key = MetricsManager.minuteKey();
|
||||
let bucket = this.dnsMinuteBuckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { queries: 0 };
|
||||
this.dnsMinuteBuckets.set(key, bucket);
|
||||
}
|
||||
bucket.queries++;
|
||||
}
|
||||
|
||||
private pruneOldBuckets(): void {
|
||||
const cutoff = Date.now() - 86400000; // 24h
|
||||
for (const key of this.emailMinuteBuckets.keys()) {
|
||||
if (key < cutoff) this.emailMinuteBuckets.delete(key);
|
||||
}
|
||||
for (const key of this.dnsMinuteBuckets.keys()) {
|
||||
if (key < cutoff) this.dnsMinuteBuckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get email time-series data for the last N hours, aggregated per minute.
|
||||
*/
|
||||
public getEmailTimeSeries(hours: number = 24): {
|
||||
sent: Array<{ timestamp: number; value: number }>;
|
||||
received: Array<{ timestamp: number; value: number }>;
|
||||
failed: Array<{ timestamp: number; value: number }>;
|
||||
} {
|
||||
this.pruneOldBuckets();
|
||||
const cutoff = Date.now() - hours * 3600000;
|
||||
const sent: Array<{ timestamp: number; value: number }> = [];
|
||||
const received: Array<{ timestamp: number; value: number }> = [];
|
||||
const failed: Array<{ timestamp: number; value: number }> = [];
|
||||
|
||||
const sortedKeys = Array.from(this.emailMinuteBuckets.keys())
|
||||
.filter((k) => k >= cutoff)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const bucket = this.emailMinuteBuckets.get(key)!;
|
||||
sent.push({ timestamp: key, value: bucket.sent });
|
||||
received.push({ timestamp: key, value: bucket.received });
|
||||
failed.push({ timestamp: key, value: bucket.failed });
|
||||
}
|
||||
|
||||
return { sent, received, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get DNS time-series data for the last N hours, aggregated per minute.
|
||||
*/
|
||||
public getDnsTimeSeries(hours: number = 24): {
|
||||
queries: Array<{ timestamp: number; value: number }>;
|
||||
} {
|
||||
this.pruneOldBuckets();
|
||||
const cutoff = Date.now() - hours * 3600000;
|
||||
const queries: Array<{ timestamp: number; value: number }> = [];
|
||||
|
||||
const sortedKeys = Array.from(this.dnsMinuteBuckets.keys())
|
||||
.filter((k) => k >= cutoff)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const bucket = this.dnsMinuteBuckets.get(key)!;
|
||||
queries.push({ timestamp: key, value: bucket.queries });
|
||||
}
|
||||
|
||||
return { queries };
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export class OpsServer {
|
||||
private emailOpsHandler: handlers.EmailOpsHandler;
|
||||
private certificateHandler: handlers.CertificateHandler;
|
||||
private remoteIngressHandler: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler: handlers.RouteManagementHandler;
|
||||
private apiTokenHandler: handlers.ApiTokenHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -61,6 +63,8 @@ export class OpsServer {
|
||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
this.apiTokenHandler = new handlers.ApiTokenHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
96
ts/opsserver/handlers/api-token.handler.ts
Normal file
96
ts/opsserver/handlers/api-token.handler.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class ApiTokenHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
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 {
|
||||
// Create API token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateApiToken>(
|
||||
'createApiToken',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAdmin(dataArg.identity);
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const result = await manager.createToken(
|
||||
dataArg.name,
|
||||
dataArg.scopes,
|
||||
dataArg.expiresInDays ?? null,
|
||||
userId,
|
||||
);
|
||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// List API tokens
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListApiTokens>(
|
||||
'listApiTokens',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg.identity);
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { tokens: [] };
|
||||
}
|
||||
return { tokens: manager.listTokens() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Revoke API token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RevokeApiToken>(
|
||||
'revokeApiToken',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg.identity);
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const ok = await manager.revokeToken(dataArg.id);
|
||||
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle API token
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleApiToken>(
|
||||
'toggleApiToken',
|
||||
async (dataArg) => {
|
||||
await this.requireAdmin(dataArg.identity);
|
||||
const manager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Token management not initialized' };
|
||||
}
|
||||
const ok = await manager.toggleToken(dataArg.id, dataArg.enabled);
|
||||
return { success: ok, message: ok ? undefined : 'Token not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { SecurityLogger } from '../../security/index.js';
|
||||
|
||||
export class EmailOpsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -13,68 +12,24 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get Queued Emails Handler
|
||||
// Get All Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetQueuedEmails>(
|
||||
'getQueuedEmails',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAllEmails>(
|
||||
'getAllEmails',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const stats = queue.getStats();
|
||||
|
||||
// Get all queue items and filter by status if provided
|
||||
const items = this.getQueueItems(
|
||||
dataArg.status,
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: stats.queueSize,
|
||||
};
|
||||
const emails = this.getAllQueueEmails();
|
||||
return { emails };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Sent Emails Handler
|
||||
// Get Email Detail Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSentEmails>(
|
||||
'getSentEmails',
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDetail>(
|
||||
'getEmailDetail',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'delivered',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length, // Note: total would ideally come from a counter
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Failed Emails Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetFailedEmails>(
|
||||
'getFailedEmails',
|
||||
async (dataArg) => {
|
||||
const items = this.getQueueItems(
|
||||
'failed',
|
||||
dataArg.limit || 50,
|
||||
dataArg.offset || 0
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
};
|
||||
const email = this.getEmailDetail(dataArg.emailId);
|
||||
return { email };
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -101,17 +56,12 @@ export class EmailOpsHandler {
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-enqueue the failed email by creating a new queue entry
|
||||
// with the same data but reset attempt count
|
||||
const newQueueId = await queue.enqueue(
|
||||
item.processingResult,
|
||||
item.processingMode,
|
||||
item.route
|
||||
);
|
||||
|
||||
// Optionally remove the old failed entry
|
||||
await queue.removeItem(dataArg.emailId);
|
||||
|
||||
return { success: true, newQueueId };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -122,197 +72,199 @@ export class EmailOpsHandler {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Security Incidents Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityIncidents>(
|
||||
'getSecurityIncidents',
|
||||
async (dataArg) => {
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
|
||||
const filter: {
|
||||
level?: any;
|
||||
type?: any;
|
||||
} = {};
|
||||
|
||||
if (dataArg.level) {
|
||||
filter.level = dataArg.level;
|
||||
}
|
||||
|
||||
if (dataArg.type) {
|
||||
filter.type = dataArg.type;
|
||||
}
|
||||
|
||||
const incidents = securityLogger.getRecentEvents(
|
||||
dataArg.limit || 100,
|
||||
Object.keys(filter).length > 0 ? filter : undefined
|
||||
);
|
||||
|
||||
return {
|
||||
incidents: incidents.map(event => ({
|
||||
timestamp: event.timestamp,
|
||||
level: event.level as interfaces.requests.TSecurityLogLevel,
|
||||
type: event.type as interfaces.requests.TSecurityEventType,
|
||||
message: event.message,
|
||||
details: event.details,
|
||||
ipAddress: event.ipAddress,
|
||||
userId: event.userId,
|
||||
sessionId: event.sessionId,
|
||||
emailId: event.emailId,
|
||||
domain: event.domain,
|
||||
action: event.action,
|
||||
result: event.result,
|
||||
success: event.success,
|
||||
})),
|
||||
total: incidents.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get Bounce Records Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetBounceRecords>(
|
||||
'getBounceRecords',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { records: [], suppressionList: [], total: 0 };
|
||||
}
|
||||
|
||||
// Use smartmta's public API for bounce/suppression data
|
||||
const suppressionList = emailServer.getSuppressionList();
|
||||
const hardBouncedAddresses = emailServer.getHardBouncedAddresses();
|
||||
|
||||
// Create bounce records from the available data
|
||||
const records: interfaces.requests.IBounceRecord[] = [];
|
||||
|
||||
for (const email of hardBouncedAddresses) {
|
||||
const bounceInfo = emailServer.getBounceHistory(email);
|
||||
if (bounceInfo) {
|
||||
records.push({
|
||||
id: `bounce-${email}`,
|
||||
recipient: email,
|
||||
sender: '',
|
||||
domain: email.split('@')[1] || '',
|
||||
bounceType: (bounceInfo as any).type as interfaces.requests.TBounceType,
|
||||
bounceCategory: (bounceInfo as any).category as interfaces.requests.TBounceCategory,
|
||||
timestamp: (bounceInfo as any).lastBounce,
|
||||
processed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit and offset
|
||||
const limit = dataArg.limit || 50;
|
||||
const offset = dataArg.offset || 0;
|
||||
const paginatedRecords = records.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
records: paginatedRecords,
|
||||
suppressionList,
|
||||
total: records.length,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Remove from Suppression List Handler
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveFromSuppressionList>(
|
||||
'removeFromSuppressionList',
|
||||
async (dataArg) => {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
|
||||
if (!emailServer) {
|
||||
return { success: false, error: 'Email server not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
emailServer.removeFromSuppressionList(dataArg.email);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list'
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get queue items with filtering and pagination
|
||||
* Get all queue items mapped to catalog IEmail format
|
||||
*/
|
||||
private getQueueItems(
|
||||
status?: interfaces.requests.TEmailQueueStatus,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): interfaces.requests.IEmailQueueItem[] {
|
||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const items: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
// Access the internal queue map via reflection
|
||||
// This is necessary because the queue doesn't expose iteration methods
|
||||
const queueMap = (queue as any).queue as Map<string, any>;
|
||||
|
||||
if (!queueMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter and convert items
|
||||
const emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
for (const [id, item] of queueMap.entries()) {
|
||||
// Apply status filter if provided
|
||||
if (status && item.status !== status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email details from processingResult if available
|
||||
const processingResult = item.processingResult;
|
||||
let from = '';
|
||||
let to: string[] = [];
|
||||
let subject = '';
|
||||
|
||||
if (processingResult) {
|
||||
// Check if it's an Email object or raw email data
|
||||
if (processingResult.email) {
|
||||
from = processingResult.email.from || '';
|
||||
to = processingResult.email.to || [];
|
||||
subject = processingResult.email.subject || '';
|
||||
} else if (processingResult.from) {
|
||||
from = processingResult.from;
|
||||
to = processingResult.to || [];
|
||||
subject = processingResult.subject || '';
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: item.id,
|
||||
processingMode: item.processingMode,
|
||||
status: item.status,
|
||||
attempts: item.attempts,
|
||||
nextAttempt: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : item.nextAttempt,
|
||||
lastError: item.lastError,
|
||||
createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
|
||||
updatedAt: item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt,
|
||||
deliveredAt: item.deliveredAt instanceof Date ? item.deliveredAt.getTime() : item.deliveredAt,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
});
|
||||
emails.push(this.mapQueueItemToEmail(item));
|
||||
}
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
items.sort((a, b) => b.createdAt - a.createdAt);
|
||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
// Apply pagination
|
||||
return items.slice(offset, offset + limit);
|
||||
return emails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single email detail by ID
|
||||
*/
|
||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||
if (!emailServer?.deliveryQueue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queue = emailServer.deliveryQueue;
|
||||
const item = queue.getItem(emailId);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapQueueItemToEmailDetail(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a queue item to catalog IEmail format
|
||||
*/
|
||||
private mapQueueItemToEmail(item: any): interfaces.requests.IEmail {
|
||||
const processingResult = item.processingResult;
|
||||
let from = '';
|
||||
let to = '';
|
||||
let subject = '';
|
||||
let messageId = '';
|
||||
let size = '0 B';
|
||||
|
||||
if (processingResult) {
|
||||
if (processingResult.email) {
|
||||
from = processingResult.email.from || '';
|
||||
to = (processingResult.email.to || [])[0] || '';
|
||||
subject = processingResult.email.subject || '';
|
||||
} else if (processingResult.from) {
|
||||
from = processingResult.from;
|
||||
to = (processingResult.to || [])[0] || '';
|
||||
subject = processingResult.subject || '';
|
||||
}
|
||||
|
||||
// Try to get messageId
|
||||
if (typeof processingResult.getMessageId === 'function') {
|
||||
try {
|
||||
messageId = processingResult.getMessageId() || '';
|
||||
} catch {
|
||||
messageId = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Compute approximate size
|
||||
const textLen = processingResult.text?.length || 0;
|
||||
const htmlLen = processingResult.html?.length || 0;
|
||||
let attachSize = 0;
|
||||
if (typeof processingResult.getAttachmentsSize === 'function') {
|
||||
try {
|
||||
attachSize = processingResult.getAttachmentsSize() || 0;
|
||||
} catch {
|
||||
attachSize = 0;
|
||||
}
|
||||
}
|
||||
size = this.formatSize(textLen + htmlLen + attachSize);
|
||||
}
|
||||
|
||||
// Map queue status to catalog TEmailStatus
|
||||
const status = this.mapStatus(item.status);
|
||||
|
||||
const createdAt = item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
direction: 'outbound' as interfaces.requests.TEmailDirection,
|
||||
status,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
timestamp: new Date(createdAt).toISOString(),
|
||||
messageId,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a queue item to catalog IEmailDetail format
|
||||
*/
|
||||
private mapQueueItemToEmailDetail(item: any): interfaces.requests.IEmailDetail {
|
||||
const base = this.mapQueueItemToEmail(item);
|
||||
const processingResult = item.processingResult;
|
||||
|
||||
let toList: string[] = [];
|
||||
let cc: string[] = [];
|
||||
let headers: Record<string, string> = {};
|
||||
let body = '';
|
||||
|
||||
if (processingResult) {
|
||||
if (processingResult.email) {
|
||||
toList = processingResult.email.to || [];
|
||||
cc = processingResult.email.cc || [];
|
||||
} else {
|
||||
toList = processingResult.to || [];
|
||||
cc = processingResult.cc || [];
|
||||
}
|
||||
|
||||
headers = processingResult.headers || {};
|
||||
body = processingResult.html || processingResult.text || '';
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
toList,
|
||||
cc,
|
||||
smtpLog: [],
|
||||
connectionInfo: {
|
||||
sourceIp: '',
|
||||
sourceHostname: '',
|
||||
destinationIp: '',
|
||||
destinationPort: 0,
|
||||
tlsVersion: '',
|
||||
tlsCipher: '',
|
||||
authenticated: false,
|
||||
authMethod: '',
|
||||
authUser: '',
|
||||
},
|
||||
authenticationResults: {
|
||||
spf: 'none',
|
||||
spfDomain: '',
|
||||
dkim: 'none',
|
||||
dkimDomain: '',
|
||||
dmarc: 'none',
|
||||
dmarcPolicy: '',
|
||||
},
|
||||
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
|
||||
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map queue status to catalog TEmailStatus
|
||||
*/
|
||||
private mapStatus(queueStatus: string): interfaces.requests.TEmailStatus {
|
||||
switch (queueStatus) {
|
||||
case 'pending':
|
||||
case 'processing':
|
||||
return 'pending';
|
||||
case 'delivered':
|
||||
return 'delivered';
|
||||
case 'failed':
|
||||
return 'bounced';
|
||||
case 'deferred':
|
||||
return 'deferred';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte size to human-readable string
|
||||
*/
|
||||
private formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
export * from './email-ops.handler.js';
|
||||
export * from './certificate.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
export * from './api-token.handler.js';
|
||||
@@ -1,14 +1,16 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { logBuffer, baseLogger } from '../../logger.js';
|
||||
|
||||
export class LogsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
// Add this handler's router to the parent
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
this.setupLogPushDestination();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
@@ -64,6 +66,32 @@ export class LogsHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private static mapLogLevel(smartlogLevel: string): 'debug' | 'info' | 'warn' | 'error' {
|
||||
switch (smartlogLevel) {
|
||||
case 'silly':
|
||||
case 'debug':
|
||||
return 'debug';
|
||||
case 'warn':
|
||||
return 'warn';
|
||||
case 'error':
|
||||
return 'error';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
private static deriveCategory(
|
||||
zone?: string,
|
||||
message?: string
|
||||
): 'smtp' | 'dns' | 'security' | 'system' | 'email' {
|
||||
const msg = (message || '').toLowerCase();
|
||||
if (msg.includes('[security:') || msg.includes('security')) return 'security';
|
||||
if (zone === 'email' || msg.includes('email') || msg.includes('smtp') || msg.includes('mta')) return 'email';
|
||||
if (zone === 'dns' || msg.includes('dns')) return 'dns';
|
||||
if (msg.includes('smtp')) return 'smtp';
|
||||
return 'system';
|
||||
}
|
||||
|
||||
private async getRecentLogs(
|
||||
level?: 'error' | 'warn' | 'info' | 'debug',
|
||||
category?: 'smtp' | 'dns' | 'security' | 'system' | 'email',
|
||||
@@ -78,44 +106,110 @@ export class LogsHandler {
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}>> {
|
||||
// TODO: Implement actual log retrieval from storage or logger
|
||||
// For now, return mock data
|
||||
const mockLogs: Array<{
|
||||
// Compute a timestamp cutoff from timeRange
|
||||
let since: number | undefined;
|
||||
if (timeRange) {
|
||||
const rangeMs: Record<string, number> = {
|
||||
'1h': 3600000,
|
||||
'6h': 21600000,
|
||||
'24h': 86400000,
|
||||
'7d': 604800000,
|
||||
'30d': 2592000000,
|
||||
};
|
||||
since = Date.now() - (rangeMs[timeRange] || 86400000);
|
||||
}
|
||||
|
||||
// Map the UI level to smartlog levels for filtering
|
||||
const smartlogLevels: string[] | undefined = level
|
||||
? level === 'debug'
|
||||
? ['debug', 'silly']
|
||||
: level === 'info'
|
||||
? ['info', 'ok', 'success', 'note', 'lifecycle']
|
||||
: [level]
|
||||
: undefined;
|
||||
|
||||
// Fetch a larger batch from buffer, then apply category filter client-side
|
||||
const rawEntries = logBuffer.getEntries({
|
||||
level: smartlogLevels as any,
|
||||
search,
|
||||
since,
|
||||
limit: limit * 3, // over-fetch to compensate for category filtering
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Map ILogPackage → UI log format and apply category filter
|
||||
const mapped: Array<{
|
||||
timestamp: number;
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
category: 'smtp' | 'dns' | 'security' | 'system' | 'email';
|
||||
message: string;
|
||||
metadata?: any;
|
||||
}> = [];
|
||||
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
const now = Date.now();
|
||||
|
||||
// Generate some mock log entries
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
|
||||
// Filter by requested criteria
|
||||
if (level && mockLevel !== level) continue;
|
||||
if (category && mockCategory !== category) continue;
|
||||
|
||||
mockLogs.push({
|
||||
timestamp: now - (i * 60000), // 1 minute apart
|
||||
level: mockLevel,
|
||||
category: mockCategory,
|
||||
message: `Sample log message ${i} from ${mockCategory}`,
|
||||
metadata: {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
|
||||
for (const pkg of rawEntries) {
|
||||
const uiLevel = LogsHandler.mapLogLevel(pkg.level);
|
||||
const uiCategory = LogsHandler.deriveCategory(pkg.context?.zone, pkg.message);
|
||||
|
||||
if (category && uiCategory !== category) continue;
|
||||
|
||||
mapped.push({
|
||||
timestamp: pkg.timestamp,
|
||||
level: uiLevel,
|
||||
category: uiCategory,
|
||||
message: pkg.message,
|
||||
metadata: pkg.data,
|
||||
});
|
||||
|
||||
if (mapped.length >= limit) break;
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
return mockLogs.slice(offset, offset + limit);
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log destination to the base logger that pushes entries
|
||||
* to all connected ops_dashboard TypedSocket clients.
|
||||
*/
|
||||
private setupLogPushDestination(): void {
|
||||
const opsServerRef = this.opsServerRef;
|
||||
|
||||
baseLogger.addLogDestination({
|
||||
async handleLog(logPackage: any) {
|
||||
// Access the TypedSocket server instance from OpsServer
|
||||
const typedsocket = opsServerRef.server?.typedserver?.typedsocket;
|
||||
if (!typedsocket) return;
|
||||
|
||||
let connections: any[];
|
||||
try {
|
||||
connections = await typedsocket.findAllTargetConnectionsByTag('role', 'ops_dashboard');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (connections.length === 0) return;
|
||||
|
||||
const entry: interfaces.data.ILogEntry = {
|
||||
timestamp: logPackage.timestamp || Date.now(),
|
||||
level: LogsHandler.mapLogLevel(logPackage.level),
|
||||
category: LogsHandler.deriveCategory(logPackage.context?.zone, logPackage.message),
|
||||
message: logPackage.message,
|
||||
metadata: logPackage.data,
|
||||
};
|
||||
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
const push = typedsocket.createTypedRequest<interfaces.requests.IReq_PushLogEntry>(
|
||||
'pushLogEntry',
|
||||
conn,
|
||||
);
|
||||
push.fire({ entry }).catch(() => {}); // fire-and-forget
|
||||
} catch {
|
||||
// connection may have closed
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private setupLogStream(
|
||||
virtualStream: plugins.typedrequest.VirtualStream<Uint8Array>,
|
||||
levelFilter?: string[],
|
||||
@@ -148,17 +242,17 @@ export class LogsHandler {
|
||||
}
|
||||
|
||||
// For follow mode, simulate real-time log streaming
|
||||
intervalId = setInterval(() => {
|
||||
intervalId = setInterval(async () => {
|
||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
||||
|
||||
|
||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
||||
|
||||
|
||||
// Filter by requested criteria
|
||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
||||
|
||||
|
||||
const logEntry = {
|
||||
timestamp: Date.now(),
|
||||
level: mockLevel,
|
||||
@@ -168,10 +262,16 @@ export class LogsHandler {
|
||||
requestId: plugins.uuid.v4(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const logData = JSON.stringify(logEntry);
|
||||
const encoder = new TextEncoder();
|
||||
virtualStream.sendData(encoder.encode(logData));
|
||||
try {
|
||||
await virtualStream.sendData(encoder.encode(logData));
|
||||
} catch {
|
||||
// Stream closed or errored — clean up to prevent interval leak
|
||||
clearInterval(intervalId!);
|
||||
intervalId = null;
|
||||
}
|
||||
}, 2000); // Send a log every 2 seconds
|
||||
|
||||
// TODO: Hook into actual logger events
|
||||
|
||||
@@ -117,8 +117,8 @@ export class RemoteIngressHandler {
|
||||
return { success: false, edge: null as any };
|
||||
}
|
||||
|
||||
// Sync allowed edges if enabled status changed
|
||||
if (tunnelManager && dataArg.enabled !== undefined) {
|
||||
// Sync allowed edges — ports, tags, or enabled may have changed
|
||||
if (tunnelManager) {
|
||||
await tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
|
||||
|
||||
163
ts/opsserver/handlers/route-management.handler.ts
Normal file
163
ts/opsserver/handlers/route-management.handler.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
export class RouteManagementHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate auth: JWT identity OR API token with required scope.
|
||||
* Returns a userId string on success, throws on failure.
|
||||
*/
|
||||
private async requireAuth(
|
||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||
requiredScope?: interfaces.data.TApiTokenScope,
|
||||
): Promise<string> {
|
||||
// Try JWT identity first
|
||||
if (request.identity?.jwt) {
|
||||
try {
|
||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||
identity: request.identity,
|
||||
});
|
||||
if (isAdmin) return request.identity.userId;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// Try API token
|
||||
if (request.apiToken) {
|
||||
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||
if (tokenManager) {
|
||||
const token = await tokenManager.validateToken(request.apiToken);
|
||||
if (token) {
|
||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||
return token.createdBy;
|
||||
}
|
||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Get merged routes
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetMergedRoutes>(
|
||||
'getMergedRoutes',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:read');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { routes: [], warnings: [] };
|
||||
}
|
||||
return manager.getMergedRoutes();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
|
||||
'createRoute',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true);
|
||||
return { success: true, storedRouteId: id };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRoute>(
|
||||
'updateRoute',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.updateRoute(dataArg.id, {
|
||||
route: dataArg.route as any,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteRoute>(
|
||||
'deleteRoute',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.deleteRoute(dataArg.id);
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Set override on a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove override from a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.removeOverride(dataArg.routeName);
|
||||
return { success: ok, message: ok ? undefined : 'Override not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle programmatic route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
|
||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { MetricsManager } from '../../monitoring/index.js';
|
||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||
|
||||
export class StatsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -203,6 +204,11 @@ export class StatsHandler {
|
||||
if (sections.email) {
|
||||
promises.push(
|
||||
this.collectEmailStats().then(stats => {
|
||||
// Get time-series data from MetricsManager
|
||||
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||
? this.opsServerRef.dcRouterRef.metricsManager.getEmailTimeSeries(24)
|
||||
: undefined;
|
||||
|
||||
metrics.email = {
|
||||
sent: stats.sentToday,
|
||||
received: stats.receivedToday,
|
||||
@@ -212,6 +218,7 @@ export class StatsHandler {
|
||||
averageDeliveryTime: 0,
|
||||
deliveryRate: stats.deliveryRate,
|
||||
bounceRate: stats.bounceRate,
|
||||
timeSeries,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -220,6 +227,11 @@ export class StatsHandler {
|
||||
if (sections.dns) {
|
||||
promises.push(
|
||||
this.collectDnsStats().then(stats => {
|
||||
// Get time-series data from MetricsManager
|
||||
const timeSeries = this.opsServerRef.dcRouterRef.metricsManager
|
||||
? this.opsServerRef.dcRouterRef.metricsManager.getDnsTimeSeries(24)
|
||||
: undefined;
|
||||
|
||||
metrics.dns = {
|
||||
totalQueries: stats.totalQueries,
|
||||
cacheHits: stats.cacheHits,
|
||||
@@ -228,6 +240,8 @@ export class StatsHandler {
|
||||
activeDomains: stats.topDomains.length,
|
||||
averageResponseTime: 0,
|
||||
queryTypes: stats.queryTypes,
|
||||
timeSeries,
|
||||
recentQueries: stats.recentQueries,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -236,6 +250,19 @@ export class StatsHandler {
|
||||
if (sections.security && this.opsServerRef.dcRouterRef.metricsManager) {
|
||||
promises.push(
|
||||
this.opsServerRef.dcRouterRef.metricsManager.getSecurityStats().then(stats => {
|
||||
// Get recent events from the SecurityLogger singleton
|
||||
const securityLogger = SecurityLogger.getInstance();
|
||||
const recentEvents = securityLogger.getRecentEvents(50).map((evt) => ({
|
||||
timestamp: evt.timestamp,
|
||||
level: evt.level,
|
||||
type: evt.type,
|
||||
message: evt.message,
|
||||
details: evt.details,
|
||||
ipAddress: evt.ipAddress,
|
||||
domain: evt.domain,
|
||||
success: evt.success,
|
||||
}));
|
||||
|
||||
metrics.security = {
|
||||
blockedIPs: stats.blockedIPs,
|
||||
reputationScores: {},
|
||||
@@ -244,6 +271,7 @@ export class StatsHandler {
|
||||
phishingDetected: stats.phishingDetected,
|
||||
authenticationFailures: stats.authFailures,
|
||||
suspiciousActivities: stats.totalThreatsBlocked,
|
||||
recentEvents,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -395,6 +423,7 @@ export class StatsHandler {
|
||||
count: number;
|
||||
}>;
|
||||
queryTypes: { [key: string]: number };
|
||||
recentQueries?: Array<{ timestamp: number; domain: string; type: string; answered: boolean; responseTimeMs: number }>;
|
||||
domainBreakdown?: { [domain: string]: interfaces.data.IDnsStats };
|
||||
}> {
|
||||
// Get metrics from MetricsManager if available
|
||||
@@ -408,9 +437,10 @@ export class StatsHandler {
|
||||
cacheHitRate: dnsStats.cacheHitRate,
|
||||
topDomains: dnsStats.topDomains,
|
||||
queryTypes: dnsStats.queryTypes,
|
||||
recentQueries: dnsStats.recentQueries,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Fallback if MetricsManager not available
|
||||
return {
|
||||
queriesPerSecond: 0,
|
||||
|
||||
@@ -100,6 +100,14 @@ export class VlanManager {
|
||||
// Cache the result
|
||||
this.normalizedMacCache.set(mac, normalized);
|
||||
|
||||
// Prevent unbounded cache growth
|
||||
if (this.normalizedMacCache.size > 10000) {
|
||||
const iterator = this.normalizedMacCache.keys();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
this.normalizedMacCache.delete(iterator.next().value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
146
ts/readme.md
Normal file
146
ts/readme.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# @serve.zone/dcrouter
|
||||
|
||||
The core DcRouter package — a unified datacenter gateway orchestrator. 🚀
|
||||
|
||||
This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @serve.zone/dcrouter
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { DcRouter } from '@serve.zone/dcrouter';
|
||||
|
||||
const router = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: [
|
||||
{
|
||||
name: 'web-app',
|
||||
match: { domains: ['example.com'], ports: [443] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '192.168.1.10', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' }
|
||||
}
|
||||
}
|
||||
],
|
||||
acme: { email: 'admin@example.com', enabled: true, useProduction: true }
|
||||
}
|
||||
});
|
||||
|
||||
await router.start();
|
||||
// OpsServer dashboard at http://localhost:3000
|
||||
|
||||
// Graceful shutdown
|
||||
await router.stop();
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
ts/
|
||||
├── index.ts # Main exports (DcRouter, re-exported smartmta types)
|
||||
├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
|
||||
├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
|
||||
├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
|
||||
├── logger.ts # Structured logging utility
|
||||
├── paths.ts # Centralized data directory paths
|
||||
├── plugins.ts # All dependency imports
|
||||
├── cache/ # Cache database (smartdata + LocalTsmDb)
|
||||
│ ├── classes.cachedb.ts # CacheDb singleton
|
||||
│ ├── classes.cachecleaner.ts # TTL-based cleanup
|
||||
│ └── documents/ # Cached document models
|
||||
├── config/ # Configuration utilities
|
||||
├── errors/ # Error classes and retry logic
|
||||
├── monitoring/ # MetricsManager (SmartMetrics integration)
|
||||
├── opsserver/ # OpsServer dashboard + API handlers
|
||||
│ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
|
||||
│ └── handlers/ # TypedRequest handlers by domain
|
||||
│ ├── admin.handler.ts # Auth (login/logout/verify)
|
||||
│ ├── stats.handler.ts # Statistics + health
|
||||
│ ├── config.handler.ts # Configuration (read-only)
|
||||
│ ├── logs.handler.ts # Log retrieval
|
||||
│ ├── email.handler.ts # Email operations
|
||||
│ ├── certificate.handler.ts # Certificate management
|
||||
│ ├── radius.handler.ts # RADIUS management
|
||||
│ └── remoteingress.handler.ts # Remote ingress edge + token management
|
||||
├── radius/ # RADIUS server integration
|
||||
├── remoteingress/ # Remote ingress hub integration
|
||||
│ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
|
||||
│ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
|
||||
├── security/ # Security utilities
|
||||
├── sms/ # SMS integration
|
||||
└── storage/ # StorageManager (filesystem/custom/memory)
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
```typescript
|
||||
// Main class
|
||||
export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
|
||||
|
||||
// Re-exported from smartmta
|
||||
export { UnifiedEmailServer } from '@push.rocks/smartmta';
|
||||
export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||
|
||||
// RADIUS
|
||||
export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
|
||||
|
||||
// Remote Ingress
|
||||
export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
```
|
||||
|
||||
## Key Classes
|
||||
|
||||
### `DcRouter`
|
||||
|
||||
The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
|
||||
|
||||
| Config Section | Service Started | Package |
|
||||
|----------------|----------------|---------|
|
||||
| `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
|
||||
| `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
|
||||
| `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
|
||||
| `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
|
||||
| `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
|
||||
| `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
|
||||
| `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
|
||||
| *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
|
||||
| *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
|
||||
|
||||
### `RemoteIngressManager`
|
||||
|
||||
Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
|
||||
|
||||
### `TunnelManager`
|
||||
|
||||
Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
@@ -242,11 +242,15 @@ export class RemoteIngressManager {
|
||||
/**
|
||||
* Get the list of allowed edges (enabled only) for the Rust hub.
|
||||
*/
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string }> {
|
||||
const result: Array<{ id: string; secret: string }> = [];
|
||||
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[] }> {
|
||||
const result: Array<{ id: string; secret: string; listenPorts: number[] }> = [];
|
||||
for (const edge of this.edges.values()) {
|
||||
if (edge.enabled) {
|
||||
result.push({ id: edge.id, secret: edge.secret });
|
||||
result.push({
|
||||
id: edge.id,
|
||||
secret: edge.secret,
|
||||
listenPorts: this.getEffectiveListenPorts(edge),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -35,11 +35,7 @@ export class TunnelManager {
|
||||
});
|
||||
|
||||
this.hub.on('edgeDisconnected', (data: { edgeId: string }) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing) {
|
||||
existing.connected = false;
|
||||
existing.activeTunnels = 0;
|
||||
}
|
||||
this.edgeStatuses.delete(data.edgeId);
|
||||
});
|
||||
|
||||
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './auth.js';
|
||||
export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
83
ts_interfaces/data/route-management.ts
Normal file
83
ts_interfaces/data/route-management.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Data Types
|
||||
// ============================================================================
|
||||
|
||||
export type TApiTokenScope = 'routes:read' | 'routes:write' | 'config:read' | 'tokens:read' | 'tokens:manage';
|
||||
|
||||
/**
|
||||
* A merged route combining hardcoded and programmatic sources.
|
||||
*/
|
||||
export interface IMergedRoute {
|
||||
route: IRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
storedRouteId?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning generated during route merge/startup.
|
||||
*/
|
||||
export interface IRouteWarning {
|
||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||
routeName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public info about an API token (never includes the hash).
|
||||
*/
|
||||
export interface IApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Storage Schemas (persisted via StorageManager)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A programmatic route stored in /config-api/routes/{id}.json
|
||||
*/
|
||||
export interface IStoredRoute {
|
||||
id: string;
|
||||
route: IRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
|
||||
*/
|
||||
export interface IRouteOverride {
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored API token, stored in /config-api/tokens/{id}.json
|
||||
*/
|
||||
export interface IStoredApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenHash: string;
|
||||
scopes: TApiTokenScope[];
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
createdBy: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -26,6 +26,11 @@ export interface IServerStats {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ITimeSeriesPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface IEmailStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
@@ -35,6 +40,11 @@ export interface IEmailStats {
|
||||
averageDeliveryTime: number;
|
||||
deliveryRate: number;
|
||||
bounceRate: number;
|
||||
timeSeries?: {
|
||||
sent: ITimeSeriesPoint[];
|
||||
received: ITimeSeriesPoint[];
|
||||
failed: ITimeSeriesPoint[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDnsStats {
|
||||
@@ -47,6 +57,16 @@ export interface IDnsStats {
|
||||
queryTypes: {
|
||||
[key: string]: number;
|
||||
};
|
||||
timeSeries?: {
|
||||
queries: ITimeSeriesPoint[];
|
||||
};
|
||||
recentQueries?: Array<{
|
||||
timestamp: number;
|
||||
domain: string;
|
||||
type: string;
|
||||
answered: boolean;
|
||||
responseTimeMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface IRateLimitInfo {
|
||||
@@ -58,6 +78,17 @@ export interface IRateLimitInfo {
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export interface ISecurityEvent {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
type: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
domain?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface ISecurityMetrics {
|
||||
blockedIPs: string[];
|
||||
reputationScores: {
|
||||
@@ -68,6 +99,7 @@ export interface ISecurityMetrics {
|
||||
phishingDetected: number;
|
||||
authenticationFailures: number;
|
||||
suspiciousActivities: number;
|
||||
recentEvents?: ISecurityEvent[];
|
||||
}
|
||||
|
||||
export interface ILogEntry {
|
||||
|
||||
@@ -82,6 +82,14 @@ interface IIdentity {
|
||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||
|
||||
#### Remote Ingress Interfaces
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` property |
|
||||
|
||||
### Request Interfaces (`requests`)
|
||||
|
||||
TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
@@ -134,6 +142,9 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
||||
| `IReq_GetCertificateOverview` | `getCertificateOverview` | Domain-centric certificate status |
|
||||
| `IReq_ReprovisionCertificate` | `reprovisionCertificate` | Reprovision by route name (legacy) |
|
||||
| `IReq_ReprovisionCertificateDomain` | `reprovisionCertificateDomain` | Reprovision by domain (preferred) |
|
||||
| `IReq_ImportCertificate` | `importCertificate` | Import a certificate |
|
||||
| `IReq_ExportCertificate` | `exportCertificate` | Export a certificate |
|
||||
| `IReq_DeleteCertificate` | `deleteCertificate` | Delete a certificate |
|
||||
|
||||
#### Certificate Types
|
||||
```typescript
|
||||
@@ -159,6 +170,17 @@ interface ICertificateInfo {
|
||||
}
|
||||
```
|
||||
|
||||
#### 🌍 Remote Ingress
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `IReq_CreateRemoteIngress` | `createRemoteIngress` | Register a new edge node |
|
||||
| `IReq_DeleteRemoteIngress` | `deleteRemoteIngress` | Remove an edge registration |
|
||||
| `IReq_UpdateRemoteIngress` | `updateRemoteIngress` | Update edge settings |
|
||||
| `IReq_RegenerateRemoteIngressSecret` | `regenerateRemoteIngressSecret` | Issue a new secret |
|
||||
| `IReq_GetRemoteIngresses` | `getRemoteIngresses` | List all edge registrations |
|
||||
| `IReq_GetRemoteIngressStatus` | `getRemoteIngressStatus` | Runtime status of all edges |
|
||||
| `IReq_GetRemoteIngressConnectionToken` | `getRemoteIngressConnectionToken` | Generate a connection token |
|
||||
|
||||
#### 📡 RADIUS
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
@@ -183,7 +205,7 @@ import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||
// 1. Login
|
||||
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'adminLogin'
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
const loginResponse = await loginClient.fire({
|
||||
@@ -199,10 +221,8 @@ const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMet
|
||||
);
|
||||
|
||||
const metrics = await metricsClient.fire({ identity });
|
||||
console.log('Server:', metrics.serverStats);
|
||||
console.log('Email:', metrics.emailStats);
|
||||
console.log('DNS:', metrics.dnsStats);
|
||||
console.log('Security:', metrics.securityMetrics);
|
||||
console.log('Server:', metrics.metrics.server);
|
||||
console.log('Email:', metrics.metrics.email);
|
||||
|
||||
// 3. Check certificate status
|
||||
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
|
||||
@@ -213,14 +233,23 @@ const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOve
|
||||
const certs = await certClient.fire({ identity });
|
||||
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
|
||||
|
||||
// 4. Check email queues
|
||||
const queueClient = new typedrequest.TypedRequest<requests.IReq_GetQueuedEmails>(
|
||||
// 4. List remote ingress edges
|
||||
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getQueuedEmails'
|
||||
'getRemoteIngresses'
|
||||
);
|
||||
|
||||
const queued = await queueClient.fire({ identity });
|
||||
console.log('Queued emails:', queued.emails.length);
|
||||
const edges = await edgesClient.fire({ identity });
|
||||
console.log('Registered edges:', edges.edges.length);
|
||||
|
||||
// 5. Generate a connection token for an edge
|
||||
const tokenClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngressConnectionToken>(
|
||||
'https://your-dcrouter:3000/typedrequest',
|
||||
'getRemoteIngressConnectionToken'
|
||||
);
|
||||
|
||||
const tokenResponse = await tokenClient.fire({ identity, edgeId: edges.edges[0].id });
|
||||
console.log('Connection token:', tokenResponse.token);
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
83
ts_interfaces/requests/api-tokens.ts
Normal file
83
ts_interfaces/requests/api-tokens.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
|
||||
|
||||
// ============================================================================
|
||||
// API Token Management Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new API token. Returns the raw token value once (never shown again).
|
||||
* Admin JWT only — tokens cannot create tokens.
|
||||
*/
|
||||
export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateApiToken
|
||||
> {
|
||||
method: 'createApiToken';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
name: string;
|
||||
scopes: TApiTokenScope[];
|
||||
expiresInDays?: number | null;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
tokenId?: string;
|
||||
tokenValue?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all API tokens (without hashes).
|
||||
*/
|
||||
export interface IReq_ListApiTokens extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListApiTokens
|
||||
> {
|
||||
method: 'listApiTokens';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
tokens: IApiTokenInfo[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) an API token.
|
||||
*/
|
||||
export interface IReq_RevokeApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RevokeApiToken
|
||||
> {
|
||||
method: 'revokeApiToken';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable an API token.
|
||||
*/
|
||||
export interface IReq_ToggleApiToken extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ToggleApiToken
|
||||
> {
|
||||
method: 'toggleApiToken';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -2,162 +2,93 @@ import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
// ============================================================================
|
||||
// Email Queue Item Interface (matches backend IQueueItem)
|
||||
// Catalog-compatible email types (matches @serve.zone/catalog IEmail/IEmailDetail)
|
||||
// ============================================================================
|
||||
export type TEmailQueueStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'deferred';
|
||||
export type TEmailStatus = 'delivered' | 'bounced' | 'rejected' | 'deferred' | 'pending';
|
||||
export type TEmailDirection = 'inbound' | 'outbound';
|
||||
|
||||
export interface IEmailQueueItem {
|
||||
export interface IEmail {
|
||||
id: string;
|
||||
processingMode: 'forward' | 'mta' | 'process';
|
||||
status: TEmailQueueStatus;
|
||||
attempts: number;
|
||||
nextAttempt: number; // timestamp
|
||||
lastError?: string;
|
||||
createdAt: number; // timestamp
|
||||
updatedAt: number; // timestamp
|
||||
deliveredAt?: number; // timestamp
|
||||
// Email details extracted from processingResult
|
||||
from?: string;
|
||||
to?: string[];
|
||||
subject?: string;
|
||||
direction: TEmailDirection;
|
||||
status: TEmailStatus;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
timestamp: string;
|
||||
messageId: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface ISmtpLogEntry {
|
||||
timestamp: string;
|
||||
direction: 'client' | 'server';
|
||||
command: string;
|
||||
responseCode?: number;
|
||||
}
|
||||
|
||||
export interface IConnectionInfo {
|
||||
sourceIp: string;
|
||||
sourceHostname: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
tlsVersion: string;
|
||||
tlsCipher: string;
|
||||
authenticated: boolean;
|
||||
authMethod: string;
|
||||
authUser: string;
|
||||
}
|
||||
|
||||
export interface IAuthenticationResults {
|
||||
spf: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none';
|
||||
spfDomain: string;
|
||||
dkim: 'pass' | 'fail' | 'none';
|
||||
dkimDomain: string;
|
||||
dmarc: 'pass' | 'fail' | 'none';
|
||||
dmarcPolicy: string;
|
||||
}
|
||||
|
||||
export interface IEmailDetail extends IEmail {
|
||||
toList: string[];
|
||||
cc?: string[];
|
||||
smtpLog: ISmtpLogEntry[];
|
||||
connectionInfo: IConnectionInfo;
|
||||
authenticationResults: IAuthenticationResults;
|
||||
rejectionReason?: string;
|
||||
bounceMessage?: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bounce Record Interface (matches backend BounceRecord)
|
||||
// Get All Emails Request
|
||||
// ============================================================================
|
||||
export type TBounceType =
|
||||
| 'invalid_recipient'
|
||||
| 'domain_not_found'
|
||||
| 'mailbox_full'
|
||||
| 'mailbox_inactive'
|
||||
| 'blocked'
|
||||
| 'spam_related'
|
||||
| 'policy_related'
|
||||
| 'server_unavailable'
|
||||
| 'temporary_failure'
|
||||
| 'quota_exceeded'
|
||||
| 'network_error'
|
||||
| 'timeout'
|
||||
| 'auto_response'
|
||||
| 'challenge_response'
|
||||
| 'unknown';
|
||||
|
||||
export type TBounceCategory = 'hard' | 'soft' | 'auto_response' | 'unknown';
|
||||
|
||||
export interface IBounceRecord {
|
||||
id: string;
|
||||
originalEmailId?: string;
|
||||
recipient: string;
|
||||
sender: string;
|
||||
domain: string;
|
||||
subject?: string;
|
||||
bounceType: TBounceType;
|
||||
bounceCategory: TBounceCategory;
|
||||
timestamp: number;
|
||||
smtpResponse?: string;
|
||||
diagnosticCode?: string;
|
||||
statusCode?: string;
|
||||
processed: boolean;
|
||||
retryCount?: number;
|
||||
nextRetryTime?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security Incident Interface (matches backend ISecurityEvent)
|
||||
// ============================================================================
|
||||
export type TSecurityLogLevel = 'info' | 'warn' | 'error' | 'critical';
|
||||
|
||||
export type TSecurityEventType =
|
||||
| 'authentication'
|
||||
| 'access_control'
|
||||
| 'email_validation'
|
||||
| 'email_processing'
|
||||
| 'email_forwarding'
|
||||
| 'email_delivery'
|
||||
| 'dkim'
|
||||
| 'spf'
|
||||
| 'dmarc'
|
||||
| 'rate_limit'
|
||||
| 'rate_limiting'
|
||||
| 'spam'
|
||||
| 'malware'
|
||||
| 'connection'
|
||||
| 'data_exposure'
|
||||
| 'configuration'
|
||||
| 'ip_reputation'
|
||||
| 'rejected_connection';
|
||||
|
||||
export interface ISecurityIncident {
|
||||
timestamp: number;
|
||||
level: TSecurityLogLevel;
|
||||
type: TSecurityEventType;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
emailId?: string;
|
||||
domain?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Queued Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetQueuedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetAllEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetQueuedEmails
|
||||
IReq_GetAllEmails
|
||||
> {
|
||||
method: 'getQueuedEmails';
|
||||
method: 'getAllEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
status?: TEmailQueueStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
emails: IEmail[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Sent Emails Request
|
||||
// Get Email Detail Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSentEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
export interface IReq_GetEmailDetail extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSentEmails
|
||||
IReq_GetEmailDetail
|
||||
> {
|
||||
method: 'getSentEmails';
|
||||
method: 'getEmailDetail';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
emailId: string;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Failed Emails Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetFailedEmails extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetFailedEmails
|
||||
> {
|
||||
method: 'getFailedEmails';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
items: IEmailQueueItem[];
|
||||
total: number;
|
||||
email: IEmailDetail | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,61 +110,3 @@ export interface IReq_ResendEmail extends plugins.typedrequestInterfaces.impleme
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Security Incidents Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetSecurityIncidents extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetSecurityIncidents
|
||||
> {
|
||||
method: 'getSecurityIncidents';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
type?: TSecurityEventType;
|
||||
level?: TSecurityLogLevel;
|
||||
limit?: number;
|
||||
};
|
||||
response: {
|
||||
incidents: ISecurityIncident[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Get Bounce Records Request
|
||||
// ============================================================================
|
||||
export interface IReq_GetBounceRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetBounceRecords
|
||||
> {
|
||||
method: 'getBounceRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
response: {
|
||||
records: IBounceRecord[];
|
||||
suppressionList: string[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Remove from Suppression List Request
|
||||
// ============================================================================
|
||||
export interface IReq_RemoveFromSuppressionList extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveFromSuppressionList
|
||||
> {
|
||||
method: 'removeFromSuppressionList';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
email: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ export * from './combined.stats.js';
|
||||
export * from './radius.js';
|
||||
export * from './email-ops.js';
|
||||
export * from './certificate.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './api-tokens.js';
|
||||
@@ -41,4 +41,16 @@ export interface IReq_GetLogStream extends plugins.typedrequestInterfaces.implem
|
||||
response: {
|
||||
logStream: plugins.typedrequestInterfaces.IVirtualStream;
|
||||
};
|
||||
}
|
||||
|
||||
// Push Log Entry (server → client via TypedSocket)
|
||||
export interface IReq_PushLogEntry extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_PushLogEntry
|
||||
> {
|
||||
method: 'pushLogEntry';
|
||||
request: {
|
||||
entry: statsInterfaces.ILogEntry;
|
||||
};
|
||||
response: {};
|
||||
}
|
||||
146
ts_interfaces/requests/route-management.ts
Normal file
146
ts_interfaces/requests/route-management.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IMergedRoute, IRouteWarning } from '../data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all merged routes (hardcoded + programmatic) with warnings.
|
||||
*/
|
||||
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetMergedRoutes
|
||||
> {
|
||||
method: 'getMergedRoutes';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
routes: IMergedRoute[];
|
||||
warnings: IRouteWarning[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new programmatic route.
|
||||
*/
|
||||
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateRoute
|
||||
> {
|
||||
method: 'createRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
route: IRouteConfig;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
storedRouteId?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a programmatic route.
|
||||
*/
|
||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateRoute
|
||||
> {
|
||||
method: 'updateRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
route?: Partial<IRouteConfig>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a programmatic route.
|
||||
*/
|
||||
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteRoute
|
||||
> {
|
||||
method: 'deleteRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an override on a hardcoded route (disable/enable by name).
|
||||
*/
|
||||
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SetRouteOverride
|
||||
> {
|
||||
method: 'setRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an override from a hardcoded route (restore default behavior).
|
||||
*/
|
||||
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_RemoveRouteOverride
|
||||
> {
|
||||
method: 'removeRouteOverride';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
routeName: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a programmatic route on/off by id.
|
||||
*/
|
||||
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ToggleRoute
|
||||
> {
|
||||
method: 'toggleRoute';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '6.11.0',
|
||||
version: '8.1.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -67,14 +67,7 @@ export interface ICertificateState {
|
||||
}
|
||||
|
||||
export interface IEmailOpsState {
|
||||
currentView: 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||
queuedEmails: interfaces.requests.IEmailQueueItem[];
|
||||
sentEmails: interfaces.requests.IEmailQueueItem[];
|
||||
failedEmails: interfaces.requests.IEmailQueueItem[];
|
||||
securityIncidents: interfaces.requests.ISecurityIncident[];
|
||||
bounceRecords: interfaces.requests.IBounceRecord[];
|
||||
suppressionList: string[];
|
||||
selectedEmailId: string | null;
|
||||
emails: interfaces.requests.IEmail[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
@@ -116,7 +109,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||
// Determine initial view from URL path
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'];
|
||||
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
@@ -165,14 +158,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
||||
export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
||||
'emailOps',
|
||||
{
|
||||
currentView: 'queued',
|
||||
queuedEmails: [],
|
||||
sentEmails: [],
|
||||
failedEmails: [],
|
||||
securityIncidents: [],
|
||||
bounceRecords: [],
|
||||
suppressionList: [],
|
||||
selectedEmailId: null,
|
||||
emails: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
@@ -200,7 +186,7 @@ export interface IRemoteIngressState {
|
||||
edges: interfaces.data.IRemoteIngress[];
|
||||
statuses: interfaces.data.IRemoteIngressStatus[];
|
||||
selectedEdgeId: string | null;
|
||||
newEdgeSecret: string | null;
|
||||
newEdgeId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
@@ -212,7 +198,33 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
|
||||
edges: [],
|
||||
statuses: [],
|
||||
selectedEdgeId: null,
|
||||
newEdgeSecret: null,
|
||||
newEdgeId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft'
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Route Management State
|
||||
// ============================================================================
|
||||
|
||||
export interface IRouteManagementState {
|
||||
mergedRoutes: interfaces.data.IMergedRoute[];
|
||||
warnings: interfaces.data.IRouteWarning[];
|
||||
apiTokens: interfaces.data.IApiTokenInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const routeManagementStatePart = await appState.getStatePart<IRouteManagementState>(
|
||||
'routeManagement',
|
||||
{
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
@@ -406,6 +418,20 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to routes view, ensure we fetch route data
|
||||
if (viewName === 'routes' && currentState.activeView !== 'routes') {
|
||||
setTimeout(() => {
|
||||
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to apitokens view, ensure we fetch token data
|
||||
if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') {
|
||||
setTimeout(() => {
|
||||
routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to remoteingress view, ensure we fetch edge data
|
||||
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
|
||||
setTimeout(() => {
|
||||
@@ -492,35 +518,22 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
||||
// Email Operations Actions
|
||||
// ============================================================================
|
||||
|
||||
// Set Email Ops View Action
|
||||
export const setEmailOpsViewAction = emailOpsStatePart.createAction<IEmailOpsState['currentView']>(
|
||||
async (statePartArg, view) => {
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
currentView: view,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch Queued Emails Action
|
||||
export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
// Fetch All Emails Action
|
||||
export const fetchAllEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetQueuedEmails
|
||||
>('/typedrequest', 'getQueuedEmails');
|
||||
interfaces.requests.IReq_GetAllEmails
|
||||
>('/typedrequest', 'getAllEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
status: 'pending',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
queuedEmails: response.items,
|
||||
emails: response.emails,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
@@ -529,197 +542,11 @@ export const fetchQueuedEmailsAction = emailOpsStatePart.createAction(async (sta
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch queued emails',
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Sent Emails Action
|
||||
export const fetchSentEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSentEmails
|
||||
>('/typedrequest', 'getSentEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
sentEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch sent emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Failed Emails Action
|
||||
export const fetchFailedEmailsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetFailedEmails
|
||||
>('/typedrequest', 'getFailedEmails');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
failedEmails: response.items,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch failed emails',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Security Incidents Action
|
||||
export const fetchSecurityIncidentsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetSecurityIncidents
|
||||
>('/typedrequest', 'getSecurityIncidents');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
securityIncidents: response.incidents,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch security incidents',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Bounce Records Action
|
||||
export const fetchBounceRecordsAction = emailOpsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetBounceRecords
|
||||
>('/typedrequest', 'getBounceRecords');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
bounceRecords: response.records,
|
||||
suppressionList: response.suppressionList,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch bounce records',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Resend Failed Email Action
|
||||
export const resendEmailAction = emailOpsStatePart.createAction<string>(async (statePartArg, emailId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ResendEmail
|
||||
>('/typedrequest', 'resendEmail');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
emailId,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh failed emails list
|
||||
await emailOpsStatePart.dispatchAction(fetchFailedEmailsAction, null);
|
||||
await emailOpsStatePart.dispatchAction(fetchQueuedEmailsAction, null);
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to resend email',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Remove from Suppression List Action
|
||||
export const removeFromSuppressionListAction = emailOpsStatePart.createAction<string>(
|
||||
async (statePartArg, email) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RemoveFromSuppressionList
|
||||
>('/typedrequest', 'removeFromSuppressionList');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
email,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh bounce records
|
||||
await emailOpsStatePart.dispatchAction(fetchBounceRecordsAction, null);
|
||||
}
|
||||
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove from suppression list',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Certificate Actions
|
||||
// ============================================================================
|
||||
@@ -928,11 +755,12 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Refresh the list and store the new secret for display
|
||||
// Refresh the list
|
||||
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
|
||||
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
newEdgeSecret: response.edge.secret,
|
||||
newEdgeId: response.edge.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1023,7 +851,7 @@ export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.create
|
||||
if (response.success) {
|
||||
return {
|
||||
...currentState,
|
||||
newEdgeSecret: response.secret,
|
||||
newEdgeId: edgeId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1037,11 +865,11 @@ export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.create
|
||||
}
|
||||
);
|
||||
|
||||
export const clearNewEdgeSecretAction = remoteIngressStatePart.createAction(
|
||||
export const clearNewEdgeIdAction = remoteIngressStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
newEdgeSecret: null,
|
||||
newEdgeId: null,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1074,6 +902,322 @@ export const toggleRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchMergedRoutesAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetMergedRoutes
|
||||
>('/typedrequest', 'getMergedRoutes');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
mergedRoutes: response.routes,
|
||||
warnings: response.warnings,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch routes',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const createRouteAction = routeManagementStatePart.createAction<{
|
||||
route: any;
|
||||
enabled?: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateRoute
|
||||
>('/typedrequest', 'createRoute');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
route: dataArg.route,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to create route',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
||||
async (statePartArg, routeId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteRoute
|
||||
>('/typedrequest', 'deleteRoute');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
id: routeId,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete route',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const toggleRouteAction = routeManagementStatePart.createAction<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ToggleRoute
|
||||
>('/typedrequest', 'toggleRoute');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
id: dataArg.id,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to toggle route',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
||||
routeName: string;
|
||||
enabled: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_SetRouteOverride
|
||||
>('/typedrequest', 'setRouteOverride');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
routeName: dataArg.routeName,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to set override',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
|
||||
async (statePartArg, routeName) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RemoveRouteOverride
|
||||
>('/typedrequest', 'removeRouteOverride');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
routeName,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to remove override',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// API Token Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchApiTokensAction = routeManagementStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListApiTokens
|
||||
>('/typedrequest', 'listApiTokens');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
apiTokens: response.tokens,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch tokens',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
|
||||
const context = getActionContext();
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateApiToken
|
||||
>('/typedrequest', 'createApiToken');
|
||||
|
||||
return request.fire({
|
||||
identity: context.identity,
|
||||
name,
|
||||
scopes,
|
||||
expiresInDays,
|
||||
});
|
||||
}
|
||||
|
||||
export const revokeApiTokenAction = routeManagementStatePart.createAction<string>(
|
||||
async (statePartArg, tokenId) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_RevokeApiToken
|
||||
>('/typedrequest', 'revokeApiToken');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
id: tokenId,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to revoke token',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState();
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ToggleApiToken
|
||||
>('/typedrequest', 'toggleApiToken');
|
||||
|
||||
await request.fire({
|
||||
identity: context.identity,
|
||||
id: dataArg.id,
|
||||
enabled: dataArg.enabled,
|
||||
});
|
||||
|
||||
await routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
|
||||
return statePartArg.getState();
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to toggle token',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TypedSocket Client for Real-time Log Streaming
|
||||
// ============================================================================
|
||||
|
||||
let socketClient: plugins.typedsocket.TypedSocket | null = null;
|
||||
const socketRouter = new plugins.domtools.plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Register handler for pushed log entries from the server
|
||||
socketRouter.addTypedHandler(
|
||||
new plugins.domtools.plugins.typedrequest.TypedHandler<interfaces.requests.IReq_PushLogEntry>(
|
||||
'pushLogEntry',
|
||||
async (dataArg) => {
|
||||
const current = logStatePart.getState();
|
||||
const updated = [...current.recentLogs, dataArg.entry];
|
||||
// Cap at 2000 entries
|
||||
if (updated.length > 2000) {
|
||||
updated.splice(0, updated.length - 2000);
|
||||
}
|
||||
logStatePart.setState({ ...current, recentLogs: updated });
|
||||
return {};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
async function connectSocket() {
|
||||
if (socketClient) return;
|
||||
try {
|
||||
socketClient = await plugins.typedsocket.TypedSocket.createClient(
|
||||
socketRouter,
|
||||
plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(),
|
||||
);
|
||||
await socketClient.setTag('role', 'ops_dashboard');
|
||||
} catch (err) {
|
||||
console.error('TypedSocket connection failed:', err);
|
||||
socketClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSocket() {
|
||||
if (socketClient) {
|
||||
try {
|
||||
await socketClient.stop();
|
||||
} catch {
|
||||
// ignore disconnect errors
|
||||
}
|
||||
socketClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Combined refresh action for efficient polling
|
||||
async function dispatchCombinedRefreshAction() {
|
||||
const context = getActionContext();
|
||||
@@ -1236,9 +1380,21 @@ let currentRefreshRate = 1000; // Track current refresh rate to avoid unnecessar
|
||||
if (state.isLoggedIn !== previousIsLoggedIn) {
|
||||
previousIsLoggedIn = state.isLoggedIn;
|
||||
startAutoRefresh();
|
||||
|
||||
// Connect/disconnect TypedSocket based on login state
|
||||
if (state.isLoggedIn) {
|
||||
connectSocket();
|
||||
} else {
|
||||
disconnectSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initial start
|
||||
startAutoRefresh();
|
||||
|
||||
// Connect TypedSocket if already logged in (e.g., persistent session)
|
||||
if (loginStatePart.getState().isLoggedIn) {
|
||||
connectSocket();
|
||||
}
|
||||
})();
|
||||
@@ -4,6 +4,8 @@ export * from './ops-view-network.js';
|
||||
export * from './ops-view-emails.js';
|
||||
export * from './ops-view-logs.js';
|
||||
export * from './ops-view-config.js';
|
||||
export * from './ops-view-routes.js';
|
||||
export * from './ops-view-apitokens.js';
|
||||
export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
|
||||
@@ -18,6 +18,8 @@ import { OpsViewNetwork } from './ops-view-network.js';
|
||||
import { OpsViewEmails } from './ops-view-emails.js';
|
||||
import { OpsViewLogs } from './ops-view-logs.js';
|
||||
import { OpsViewConfig } from './ops-view-config.js';
|
||||
import { OpsViewRoutes } from './ops-view-routes.js';
|
||||
import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
||||
import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
@@ -55,6 +57,14 @@ export class OpsDashboard extends DeesElement {
|
||||
name: 'Logs',
|
||||
element: OpsViewLogs,
|
||||
},
|
||||
{
|
||||
name: 'Routes',
|
||||
element: OpsViewRoutes,
|
||||
},
|
||||
{
|
||||
name: 'ApiTokens',
|
||||
element: OpsViewApiTokens,
|
||||
},
|
||||
{
|
||||
name: 'Configuration',
|
||||
element: OpsViewConfig,
|
||||
|
||||
281
ts_web/elements/ops-view-apitokens.ts
Normal file
281
ts_web/elements/ops-view-apitokens.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
type TApiTokenScope = interfaces.data.TApiTokenScope;
|
||||
|
||||
@customElement('ops-view-apitokens')
|
||||
export class OpsViewApiTokens extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.routeManagementStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
// Re-fetch tokens when user logs in (fixes race condition where
|
||||
// the view is created before authentication completes)
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.apiTokensContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.scopePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: ${cssManager.bdTheme('rgba(0, 130, 200, 0.1)', 'rgba(0, 170, 255, 0.1)')};
|
||||
color: ${cssManager.bdTheme('#0369a1', '#0af')};
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusBadge.active {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.disabled {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.statusBadge.expired {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#374151')};
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { apiTokens } = this.routeState;
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>API Tokens</ops-sectionheading>
|
||||
|
||||
<div class="apiTokensContainer">
|
||||
<dees-table
|
||||
.heading1=${'API Tokens'}
|
||||
.heading2=${'Manage programmatic access tokens'}
|
||||
.data=${apiTokens}
|
||||
.dataName=${'token'}
|
||||
.searchable=${true}
|
||||
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||||
name: token.name,
|
||||
scopes: this.renderScopePills(token.scopes),
|
||||
status: this.renderStatusBadge(token),
|
||||
created: new Date(token.createdAt).toLocaleDateString(),
|
||||
expires: token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : 'Never',
|
||||
lastUsed: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : 'Never',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Create Token',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
await this.showCreateTokenDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleApiTokenAction,
|
||||
{ id: token.id, enabled: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disable',
|
||||
iconName: 'lucide:pause',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleApiTokenAction,
|
||||
{ id: token.id, enabled: false },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Revoke',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const token = actionData.item as interfaces.data.IApiTokenInfo;
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.revokeApiTokenAction,
|
||||
token.id,
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScopePills(scopes: TApiTokenScope[]): TemplateResult {
|
||||
return html`<div style="display: flex; flex-wrap: wrap; gap: 2px;">${scopes.map(
|
||||
(s) => html`<span class="scopePill">${s}</span>`,
|
||||
)}</div>`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(token: interfaces.data.IApiTokenInfo): TemplateResult {
|
||||
if (!token.enabled) {
|
||||
return html`<span class="statusBadge disabled">Disabled</span>`;
|
||||
}
|
||||
if (token.expiresAt && token.expiresAt < Date.now()) {
|
||||
return html`<span class="statusBadge expired">Expired</span>`;
|
||||
}
|
||||
return html`<span class="statusBadge active">Active</span>`;
|
||||
}
|
||||
|
||||
private async showCreateTokenDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const allScopes: TApiTokenScope[] = [
|
||||
'routes:read',
|
||||
'routes:write',
|
||||
'config:read',
|
||||
'tokens:read',
|
||||
'tokens:manage',
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Create API Token',
|
||||
content: html`
|
||||
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
|
||||
The token value will be shown once after creation. Copy it immediately.
|
||||
</div>
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Token Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-tags
|
||||
.key=${'scopes'}
|
||||
.label=${'Token Scopes'}
|
||||
.value=${['routes:read', 'routes:write']}
|
||||
.suggestions=${allScopes}
|
||||
.required=${true}
|
||||
></dees-input-tags>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:key',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
if (!formData.name) return;
|
||||
|
||||
// dees-input-tags returns string[] directly
|
||||
const scopes = (formData.scopes || [])
|
||||
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
||||
|
||||
const expiresInDays = formData.expiresInDays
|
||||
? parseInt(formData.expiresInDays, 10)
|
||||
: null;
|
||||
|
||||
await modalArg.destroy();
|
||||
|
||||
try {
|
||||
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
|
||||
if (response.success && response.tokenValue) {
|
||||
// Refresh the list first so it's ready when user dismisses the modal
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
|
||||
// Show the token value in a new modal
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Token Created',
|
||||
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 create token:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:Search',
|
||||
iconName: 'fa:magnifyingGlass',
|
||||
type: ['doubleClick', 'contextmenu'],
|
||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||
const cert = actionData.item;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { appRouter } from '../router.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -10,67 +10,30 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
type TEmailFolder = 'queued' | 'sent' | 'failed' | 'received' | 'security';
|
||||
|
||||
@customElement('ops-view-emails')
|
||||
export class OpsViewEmails extends DeesElement {
|
||||
@state()
|
||||
accessor selectedFolder: TEmailFolder = 'queued';
|
||||
accessor emails: interfaces.requests.IEmail[] = [];
|
||||
|
||||
@state()
|
||||
accessor queuedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
accessor selectedEmail: interfaces.requests.IEmailDetail | null = null;
|
||||
|
||||
@state()
|
||||
accessor sentEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
@state()
|
||||
accessor failedEmails: interfaces.requests.IEmailQueueItem[] = [];
|
||||
|
||||
@state()
|
||||
accessor securityIncidents: interfaces.requests.ISecurityIncident[] = [];
|
||||
|
||||
@state()
|
||||
accessor selectedEmail: interfaces.requests.IEmailQueueItem | null = null;
|
||||
|
||||
@state()
|
||||
accessor selectedIncident: interfaces.requests.ISecurityIncident | null = null;
|
||||
|
||||
@state()
|
||||
accessor showCompose = false;
|
||||
accessor currentView: 'list' | 'detail' = 'list';
|
||||
|
||||
@state()
|
||||
accessor isLoading = false;
|
||||
|
||||
@state()
|
||||
accessor searchTerm = '';
|
||||
|
||||
@state()
|
||||
accessor emailDomains: string[] = [];
|
||||
|
||||
private stateSubscription: any;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loadData();
|
||||
this.loadEmailDomains();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
// Subscribe to state changes
|
||||
this.stateSubscription = appstate.emailOpsStatePart.state.subscribe((state) => {
|
||||
this.queuedEmails = state.queuedEmails;
|
||||
this.sentEmails = state.sentEmails;
|
||||
this.failedEmails = state.failedEmails;
|
||||
this.securityIncidents = state.securityIncidents;
|
||||
this.emails = state.emails;
|
||||
this.isLoading = state.isLoading;
|
||||
|
||||
// Sync folder from state (e.g., when URL changes)
|
||||
if (state.currentView !== this.selectedFolder) {
|
||||
this.selectedFolder = state.currentView as TEmailFolder;
|
||||
this.loadFolderData(state.currentView as TEmailFolder);
|
||||
}
|
||||
});
|
||||
// Initial fetch
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchAllEmailsAction, null);
|
||||
}
|
||||
|
||||
async disconnectedCallback() {
|
||||
@@ -89,730 +52,58 @@ export class OpsViewEmails extends DeesElement {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.emailLayout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
.viewContainer {
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.mainArea {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.emailList {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailPreview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emailHeader {
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
}
|
||||
|
||||
.emailSubject {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
|
||||
.emailMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
}
|
||||
|
||||
.emailMetaRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emailMetaLabel {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.emailBody {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.emailActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
background: ${cssManager.bdTheme('#fafafa', '#1a1a1a')};
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#999', '#666')};
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.status-delivered {
|
||||
color: ${cssManager.bdTheme('#10b981', '#34d399')};
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
||||
}
|
||||
|
||||
.status-deferred {
|
||||
color: ${cssManager.bdTheme('#f97316', '#fb923c')};
|
||||
}
|
||||
|
||||
.severity-info {
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
|
||||
.severity-warn {
|
||||
color: ${cssManager.bdTheme('#f59e0b', '#fbbf24')};
|
||||
}
|
||||
|
||||
.severity-error {
|
||||
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
color: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.incidentDetails {
|
||||
padding: 24px;
|
||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.incidentHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.incidentTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.incidentMeta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.incidentField {
|
||||
padding: 12px;
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')};
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.incidentFieldLabel {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#666', '#999')};
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.incidentFieldValue {
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
if (this.selectedEmail) {
|
||||
return this.renderEmailDetail();
|
||||
}
|
||||
|
||||
if (this.selectedIncident) {
|
||||
return this.renderIncidentDetail();
|
||||
}
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Email Operations</ops-sectionheading>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="emailToolbar" style="margin-bottom: 16px;">
|
||||
<dees-button @click=${() => this.openComposeModal()} type="highlighted">
|
||||
<dees-icon icon="lucide:penLine" slot="iconSlot"></dees-icon>
|
||||
Compose
|
||||
</dees-button>
|
||||
|
||||
<dees-input-text
|
||||
class="searchBox"
|
||||
placeholder="Search..."
|
||||
.value=${this.searchTerm}
|
||||
@input=${(e: Event) => this.searchTerm = (e.target as any).value}
|
||||
>
|
||||
<dees-icon icon="lucide:search" slot="iconSlot"></dees-icon>
|
||||
</dees-input-text>
|
||||
|
||||
<dees-button @click=${() => this.refreshData()}>
|
||||
${this.isLoading ? html`<dees-spinner slot="iconSlot" size="small"></dees-spinner>` : html`<dees-icon slot="iconSlot" icon="lucide:refreshCw"></dees-icon>`}
|
||||
Refresh
|
||||
</dees-button>
|
||||
|
||||
<div style="margin-left: auto; display: flex; gap: 8px;">
|
||||
<dees-button-group>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('queued')}
|
||||
.type=${this.selectedFolder === 'queued' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Queued ${this.queuedEmails.length > 0 ? `(${this.queuedEmails.length})` : ''}
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('sent')}
|
||||
.type=${this.selectedFolder === 'sent' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Sent
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('failed')}
|
||||
.type=${this.selectedFolder === 'failed' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Failed ${this.failedEmails.length > 0 ? `(${this.failedEmails.length})` : ''}
|
||||
</dees-button>
|
||||
<dees-button
|
||||
@click=${() => this.selectFolder('security')}
|
||||
.type=${this.selectedFolder === 'security' ? 'highlighted' : 'normal'}
|
||||
>
|
||||
Security ${this.securityIncidents.length > 0 ? `(${this.securityIncidents.length})` : ''}
|
||||
</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
<div class="viewContainer">
|
||||
${this.currentView === 'detail' && this.selectedEmail
|
||||
? html`
|
||||
<sz-mta-detail-view
|
||||
.email=${this.selectedEmail}
|
||||
@back=${this.handleBack}
|
||||
></sz-mta-detail-view>
|
||||
`
|
||||
: html`
|
||||
<sz-mta-list-view
|
||||
.emails=${this.emails}
|
||||
@email-click=${this.handleEmailClick}
|
||||
></sz-mta-list-view>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
${this.renderContent()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderContent() {
|
||||
switch (this.selectedFolder) {
|
||||
case 'queued':
|
||||
return this.renderEmailTable(this.queuedEmails, 'Queued Emails', 'Emails waiting to be delivered');
|
||||
case 'sent':
|
||||
return this.renderEmailTable(this.sentEmails, 'Sent Emails', 'Successfully delivered emails');
|
||||
case 'failed':
|
||||
return this.renderEmailTable(this.failedEmails, 'Failed Emails', 'Emails that failed to deliver', true);
|
||||
case 'security':
|
||||
return this.renderSecurityIncidents();
|
||||
default:
|
||||
return this.renderEmptyState('Select a folder');
|
||||
}
|
||||
}
|
||||
private async handleEmailClick(e: CustomEvent<interfaces.requests.IEmail>) {
|
||||
const emailSummary = e.detail;
|
||||
try {
|
||||
const context = appstate.loginStatePart.getState();
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDetail
|
||||
>('/typedrequest', 'getEmailDetail');
|
||||
|
||||
private renderEmailTable(
|
||||
emails: interfaces.requests.IEmailQueueItem[],
|
||||
heading1: string,
|
||||
heading2: string,
|
||||
showResend = false
|
||||
) {
|
||||
const filteredEmails = this.filterEmails(emails);
|
||||
|
||||
if (filteredEmails.length === 0) {
|
||||
return this.renderEmptyState(`No emails in ${this.selectedFolder}`);
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['doubleClick', 'inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
this.selectedEmail = actionData.item;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (showResend) {
|
||||
actions.push({
|
||||
name: 'Resend',
|
||||
iconName: 'lucide:send',
|
||||
type: ['inRow'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await this.resendEmail(actionData.item.id);
|
||||
}
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
emailId: emailSummary.id,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${filteredEmails}
|
||||
.displayFunction=${(email: interfaces.requests.IEmailQueueItem) => ({
|
||||
'Status': html`<span class="status-${email.status}">${email.status}</span>`,
|
||||
'From': email.from || 'N/A',
|
||||
'To': email.to?.join(', ') || 'N/A',
|
||||
'Subject': email.subject || 'No subject',
|
||||
'Attempts': email.attempts,
|
||||
'Created': this.formatDate(email.createdAt),
|
||||
})}
|
||||
.dataActions=${actions}
|
||||
.selectionMode=${'single'}
|
||||
heading1=${heading1}
|
||||
heading2=${`${filteredEmails.length} emails - ${heading2}`}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSecurityIncidents() {
|
||||
const incidents = this.securityIncidents;
|
||||
|
||||
if (incidents.length === 0) {
|
||||
return this.renderEmptyState('No security incidents');
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${incidents}
|
||||
.displayFunction=${(incident: interfaces.requests.ISecurityIncident) => ({
|
||||
'Severity': html`<span class="severity-${incident.level}">${incident.level.toUpperCase()}</span>`,
|
||||
'Type': incident.type,
|
||||
'Message': incident.message,
|
||||
'IP': incident.ipAddress || 'N/A',
|
||||
'Domain': incident.domain || 'N/A',
|
||||
'Time': this.formatDate(incident.timestamp),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:eye',
|
||||
type: ['doubleClick', 'inRow'],
|
||||
actionFunc: async (actionData: any) => {
|
||||
this.selectedIncident = actionData.item;
|
||||
}
|
||||
}
|
||||
]}
|
||||
.selectionMode=${'single'}
|
||||
heading1="Security Incidents"
|
||||
heading2=${`${incidents.length} incidents`}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmailDetail() {
|
||||
if (!this.selectedEmail) return '';
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Email Details</ops-sectionheading>
|
||||
<div class="emailLayout">
|
||||
<div class="sidebar">
|
||||
<dees-windowbox>
|
||||
<dees-button @click=${() => this.selectedEmail = null} type="secondary" style="width: 100%;">
|
||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
||||
Back to List
|
||||
</dees-button>
|
||||
</dees-windowbox>
|
||||
</div>
|
||||
<div class="mainArea">
|
||||
<div class="emailPreview">
|
||||
<div class="emailHeader">
|
||||
<div class="emailSubject">${this.selectedEmail.subject || 'No subject'}</div>
|
||||
<div class="emailMeta">
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Status:</span>
|
||||
<span class="status-${this.selectedEmail.status}">${this.selectedEmail.status}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">From:</span>
|
||||
<span>${this.selectedEmail.from || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">To:</span>
|
||||
<span>${this.selectedEmail.to?.join(', ') || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Mode:</span>
|
||||
<span>${this.selectedEmail.processingMode}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Attempts:</span>
|
||||
<span>${this.selectedEmail.attempts}</span>
|
||||
</div>
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Created:</span>
|
||||
<span>${new Date(this.selectedEmail.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
${this.selectedEmail.deliveredAt ? html`
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Delivered:</span>
|
||||
<span>${new Date(this.selectedEmail.deliveredAt).toLocaleString()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${this.selectedEmail.lastError ? html`
|
||||
<div class="emailMetaRow">
|
||||
<span class="emailMetaLabel">Last Error:</span>
|
||||
<span style="color: #ef4444;">${this.selectedEmail.lastError}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="emailActions">
|
||||
${this.selectedEmail.status === 'failed' ? html`
|
||||
<dees-button @click=${() => this.resendEmail(this.selectedEmail!.id)} type="highlighted">
|
||||
<dees-icon icon="lucide:send" slot="iconSlot"></dees-icon>
|
||||
Resend
|
||||
</dees-button>
|
||||
` : ''}
|
||||
<dees-button @click=${() => this.selectedEmail = null}>
|
||||
<dees-icon icon="lucide:x" slot="iconSlot"></dees-icon>
|
||||
Close
|
||||
</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderIncidentDetail() {
|
||||
if (!this.selectedIncident) return '';
|
||||
|
||||
const incident = this.selectedIncident;
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Security Incident Details</ops-sectionheading>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<dees-button @click=${() => this.selectedIncident = null} type="secondary">
|
||||
<dees-icon icon="lucide:arrowLeft" slot="iconSlot"></dees-icon>
|
||||
Back to List
|
||||
</dees-button>
|
||||
</div>
|
||||
<div class="incidentDetails">
|
||||
<div class="incidentHeader">
|
||||
<div>
|
||||
<div class="incidentTitle">${incident.message}</div>
|
||||
<div style="margin-top: 8px; color: #666;">
|
||||
${new Date(incident.timestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span class="severity-${incident.level}" style="font-size: 16px; padding: 4px 12px; background: rgba(0,0,0,0.1); border-radius: 4px;">
|
||||
${incident.level.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="incidentMeta">
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Type</div>
|
||||
<div class="incidentFieldValue">${incident.type}</div>
|
||||
</div>
|
||||
${incident.ipAddress ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">IP Address</div>
|
||||
<div class="incidentFieldValue">${incident.ipAddress}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.domain ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Domain</div>
|
||||
<div class="incidentFieldValue">${incident.domain}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.emailId ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Email ID</div>
|
||||
<div class="incidentFieldValue">${incident.emailId}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.userId ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">User ID</div>
|
||||
<div class="incidentFieldValue">${incident.userId}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.action ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Action</div>
|
||||
<div class="incidentFieldValue">${incident.action}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.result ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Result</div>
|
||||
<div class="incidentFieldValue">${incident.result}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${incident.success !== undefined ? html`
|
||||
<div class="incidentField">
|
||||
<div class="incidentFieldLabel">Success</div>
|
||||
<div class="incidentFieldValue">${incident.success ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
${incident.details ? html`
|
||||
<div style="margin-top: 24px;">
|
||||
<div class="incidentFieldLabel" style="margin-bottom: 8px;">Details</div>
|
||||
<pre style="background: #1a1a1a; color: #e5e5e5; padding: 16px; border-radius: 6px; overflow-x: auto; font-size: 13px;">
|
||||
${JSON.stringify(incident.details, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmptyState(message: string) {
|
||||
return html`
|
||||
<div class="emptyState">
|
||||
<dees-icon class="emptyIcon" icon="lucide:inbox"></dees-icon>
|
||||
<div class="emptyText">${message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async openComposeModal() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
// Ensure domains are loaded before opening modal
|
||||
if (this.emailDomains.length === 0) {
|
||||
await this.loadEmailDomains();
|
||||
}
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'New Email',
|
||||
width: 'large',
|
||||
content: html`
|
||||
<div>
|
||||
<dees-form @formData=${async (e: CustomEvent) => {
|
||||
await this.sendEmail(e.detail);
|
||||
const modals = document.querySelectorAll('dees-modal');
|
||||
modals.forEach(m => (m as any).destroy?.());
|
||||
}}>
|
||||
<div style="display: flex; gap: 8px; align-items: flex-end;">
|
||||
<dees-input-text
|
||||
key="fromUsername"
|
||||
label="From"
|
||||
placeholder="username"
|
||||
.value=${'admin'}
|
||||
required
|
||||
style="flex: 1;"
|
||||
></dees-input-text>
|
||||
<span style="padding-bottom: 12px; font-size: 18px; color: #666;">@</span>
|
||||
<dees-input-dropdown
|
||||
key="fromDomain"
|
||||
label=" "
|
||||
.options=${this.emailDomains.length > 0
|
||||
? this.emailDomains.map(domain => ({ key: domain, value: domain }))
|
||||
: [{ key: 'dcrouter.local', value: 'dcrouter.local' }]}
|
||||
.selectedKey=${this.emailDomains[0] || 'dcrouter.local'}
|
||||
required
|
||||
style="flex: 1;"
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<dees-input-tags
|
||||
key="to"
|
||||
label="To"
|
||||
placeholder="Enter recipient email addresses..."
|
||||
required
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-tags
|
||||
key="cc"
|
||||
label="CC"
|
||||
placeholder="Enter CC recipients..."
|
||||
></dees-input-tags>
|
||||
|
||||
<dees-input-text
|
||||
key="subject"
|
||||
label="Subject"
|
||||
placeholder="Enter email subject..."
|
||||
required
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-wysiwyg
|
||||
key="body"
|
||||
label="Message"
|
||||
outputFormat="html"
|
||||
></dees-input-wysiwyg>
|
||||
</dees-form>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Send',
|
||||
iconName: 'lucide:send',
|
||||
action: async (modalArg) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('dees-form') as any;
|
||||
form?.submit();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg) => await modalArg.destroy()
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private filterEmails(emails: interfaces.requests.IEmailQueueItem[]): interfaces.requests.IEmailQueueItem[] {
|
||||
if (!this.searchTerm) {
|
||||
return emails;
|
||||
}
|
||||
|
||||
const search = this.searchTerm.toLowerCase();
|
||||
return emails.filter(e =>
|
||||
(e.subject?.toLowerCase().includes(search)) ||
|
||||
(e.from?.toLowerCase().includes(search)) ||
|
||||
(e.to?.some(t => t.toLowerCase().includes(search)))
|
||||
);
|
||||
}
|
||||
|
||||
private selectFolder(folder: TEmailFolder) {
|
||||
// Use router for navigation to update URL
|
||||
appRouter.navigateToEmailFolder(folder);
|
||||
// Clear selections
|
||||
this.selectedEmail = null;
|
||||
this.selectedIncident = null;
|
||||
}
|
||||
|
||||
private formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const hours = diff / (1000 * 60 * 60);
|
||||
|
||||
if (hours < 24) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (hours < 168) { // 7 days
|
||||
return date.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
this.isLoading = true;
|
||||
await this.loadFolderData(this.selectedFolder);
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async loadFolderData(folder: TEmailFolder) {
|
||||
switch (folder) {
|
||||
case 'queued':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchQueuedEmailsAction, null);
|
||||
break;
|
||||
case 'sent':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSentEmailsAction, null);
|
||||
break;
|
||||
case 'failed':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchFailedEmailsAction, null);
|
||||
break;
|
||||
case 'security':
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.fetchSecurityIncidentsAction, null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEmailDomains() {
|
||||
try {
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
const config = appstate.configStatePart.getState().config;
|
||||
|
||||
if (config?.email?.domains && Array.isArray(config.email.domains) && config.email.domains.length > 0) {
|
||||
this.emailDomains = config.email.domains;
|
||||
} else {
|
||||
this.emailDomains = ['dcrouter.local'];
|
||||
if (response.email) {
|
||||
this.selectedEmail = response.email;
|
||||
this.currentView = 'detail';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load email domains:', error);
|
||||
this.emailDomains = ['dcrouter.local'];
|
||||
console.error('Failed to fetch email detail:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshData() {
|
||||
this.isLoading = true;
|
||||
await this.loadFolderData(this.selectedFolder);
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async sendEmail(formData: any) {
|
||||
try {
|
||||
console.log('Sending email:', formData);
|
||||
// TODO: Implement actual email sending via API
|
||||
// For now, just log the data
|
||||
const fromEmail = `${formData.fromUsername || 'admin'}@${formData.fromDomain || this.emailDomains[0] || 'dcrouter.local'}`;
|
||||
console.log('From:', fromEmail);
|
||||
console.log('To:', formData.to);
|
||||
console.log('Subject:', formData.subject);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send email', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async resendEmail(emailId: string) {
|
||||
try {
|
||||
await appstate.emailOpsStatePart.dispatchAction(appstate.resendEmailAction, emailId);
|
||||
this.selectedEmail = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to resend email:', error);
|
||||
}
|
||||
private handleBack() {
|
||||
this.selectedEmail = null;
|
||||
this.currentView = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
|
||||
@@ -20,6 +19,8 @@ export class OpsViewLogs extends DeesElement {
|
||||
filters: {},
|
||||
};
|
||||
|
||||
private lastPushedCount = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.logStatePart
|
||||
@@ -33,175 +34,76 @@ export class OpsViewLogs extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logContainer {
|
||||
background: ${cssManager.bdTheme('#f8f9fa', '#1e1e1e')};
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logEntry {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logTimestamp {
|
||||
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logLevel {
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logLevel.debug {
|
||||
color: ${cssManager.bdTheme('#6a9955', '#6a9955')};
|
||||
background: ${cssManager.bdTheme('rgba(106, 153, 85, 0.1)', 'rgba(106, 153, 85, 0.1)')};
|
||||
}
|
||||
.logLevel.info {
|
||||
color: ${cssManager.bdTheme('#569cd6', '#569cd6')};
|
||||
background: ${cssManager.bdTheme('rgba(86, 156, 214, 0.1)', 'rgba(86, 156, 214, 0.1)')};
|
||||
}
|
||||
.logLevel.warn {
|
||||
color: ${cssManager.bdTheme('#ce9178', '#ce9178')};
|
||||
background: ${cssManager.bdTheme('rgba(206, 145, 120, 0.1)', 'rgba(206, 145, 120, 0.1)')};
|
||||
}
|
||||
.logLevel.error {
|
||||
color: ${cssManager.bdTheme('#f44747', '#f44747')};
|
||||
background: ${cssManager.bdTheme('rgba(244, 71, 71, 0.1)', 'rgba(244, 71, 71, 0.1)')};
|
||||
}
|
||||
|
||||
.logCategory {
|
||||
color: ${cssManager.bdTheme('#c586c0', '#c586c0')};
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logMessage {
|
||||
color: ${cssManager.bdTheme('#333', '#d4d4d4')};
|
||||
}
|
||||
|
||||
.noLogs {
|
||||
color: ${cssManager.bdTheme('#7a7a7a', '#7a7a7a')};
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
`,
|
||||
css``,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<ops-sectionheading>Logs</ops-sectionheading>
|
||||
|
||||
<div class="controls">
|
||||
<div class="filterGroup">
|
||||
<dees-button
|
||||
@click=${() => this.fetchLogs()}
|
||||
>
|
||||
Refresh Logs
|
||||
</dees-button>
|
||||
|
||||
<dees-button
|
||||
@click=${() => this.toggleStreaming()}
|
||||
.type=${this.logState.isStreaming ? 'highlighted' : 'normal'}
|
||||
>
|
||||
${this.logState.isStreaming ? 'Stop Streaming' : 'Start Streaming'}
|
||||
</dees-button>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Level:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['all', 'debug', 'info', 'warn', 'error']}
|
||||
.selectedOption=${'all'}
|
||||
@selectedOption=${(e) => this.updateFilter('level', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Category:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['all', 'smtp', 'dns', 'security', 'system', 'email']}
|
||||
.selectedOption=${'all'}
|
||||
@selectedOption=${(e) => this.updateFilter('category', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="filterGroup">
|
||||
<label>Limit:</label>
|
||||
<dees-input-dropdown
|
||||
.options=${['50', '100', '200', '500']}
|
||||
.selectedOption=${'100'}
|
||||
@selectedOption=${(e) => this.updateFilter('limit', e.detail)}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logContainer">
|
||||
${this.logState.recentLogs.length > 0 ?
|
||||
this.logState.recentLogs.map(log => html`
|
||||
<div class="logEntry">
|
||||
<span class="logTimestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||
<span class="logLevel ${log.level}">${log.level.toUpperCase()}</span>
|
||||
<span class="logCategory">[${log.category}]</span>
|
||||
<span class="logMessage">${log.message}</span>
|
||||
</div>
|
||||
`) : html`
|
||||
<div class="noLogs">No logs to display</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<dees-chart-log
|
||||
.label=${'Application Logs'}
|
||||
.autoScroll=${true}
|
||||
.maxEntries=${2000}
|
||||
.showMetrics=${true}
|
||||
></dees-chart-log>
|
||||
`;
|
||||
}
|
||||
|
||||
private async fetchLogs() {
|
||||
const filters = this.getActiveFilters();
|
||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, {
|
||||
limit: filters.limit || 100,
|
||||
level: filters.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
||||
category: filters.category as 'smtp' | 'dns' | 'security' | 'system' | 'email' | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private updateFilter(type: string, value: string) {
|
||||
if (value === 'all') {
|
||||
value = undefined;
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.lastPushedCount = 0;
|
||||
// Only fetch if state is empty (streaming will handle new entries)
|
||||
if (this.logState.recentLogs.length === 0) {
|
||||
await appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||
}
|
||||
|
||||
// Update filters then fetch logs
|
||||
this.fetchLogs();
|
||||
}
|
||||
|
||||
private getActiveFilters() {
|
||||
return {
|
||||
level: this.logState.filters.level?.[0],
|
||||
category: this.logState.filters.category?.[0],
|
||||
limit: 100,
|
||||
};
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('logState')) {
|
||||
this.pushLogsToChart();
|
||||
}
|
||||
}
|
||||
|
||||
private toggleStreaming() {
|
||||
// TODO: Implement log streaming with VirtualStream
|
||||
console.log('Streaming toggle not yet implemented');
|
||||
private async pushLogsToChart() {
|
||||
const chartLog = this.shadowRoot?.querySelector('dees-chart-log') as any;
|
||||
if (!chartLog) return;
|
||||
|
||||
// Ensure the chart element has finished its own initialization
|
||||
await chartLog.updateComplete;
|
||||
|
||||
// Wait for xterm terminal to finish initializing (CDN load)
|
||||
if (!chartLog.terminalReady) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (chartLog.terminalReady) { resolve(); return; }
|
||||
setTimeout(check, 50);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
const allEntries = this.getMappedLogEntries();
|
||||
if (this.lastPushedCount === 0 && allEntries.length > 0) {
|
||||
// Initial load: push all entries
|
||||
chartLog.updateLog(allEntries);
|
||||
this.lastPushedCount = allEntries.length;
|
||||
} else if (allEntries.length > this.lastPushedCount) {
|
||||
// Incremental: only push new entries
|
||||
const newEntries = allEntries.slice(this.lastPushedCount);
|
||||
chartLog.updateLog(newEntries);
|
||||
this.lastPushedCount = allEntries.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getMappedLogEntries() {
|
||||
return this.logState.recentLogs.map((log) => ({
|
||||
timestamp: new Date(log.timestamp).toISOString(),
|
||||
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||
message: log.message,
|
||||
source: log.category,
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -47,9 +47,6 @@ export class OpsViewNetwork extends DeesElement {
|
||||
private lastChartUpdate = 0;
|
||||
private chartUpdateThreshold = 1000; // Minimum ms between chart updates
|
||||
|
||||
private lastTrafficUpdateTime = 0;
|
||||
private trafficUpdateInterval = 1000; // Update every 1 second
|
||||
private requestCountHistory = new Map<number, number>(); // Track requests per time bucket
|
||||
private trafficUpdateTimer: any = null;
|
||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||
private historyLoaded = false; // Whether server-side throughput history has been loaded
|
||||
@@ -106,8 +103,6 @@ export class OpsViewNetwork extends DeesElement {
|
||||
|
||||
this.trafficDataIn = [...emptyData];
|
||||
this.trafficDataOut = emptyData.map(point => ({ ...point }));
|
||||
|
||||
this.lastTrafficUpdateTime = now;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,7 +282,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:Search',
|
||||
iconName: 'fa:magnifyingGlass',
|
||||
type: ['inRow', 'doubleClick', 'contextmenu'],
|
||||
actionFunc: async (actionData) => {
|
||||
await this.showRequestDetails(actionData.item);
|
||||
@@ -413,11 +408,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
const throughput = this.calculateThroughput();
|
||||
const activeConnections = this.statsState.serverStats?.activeConnections || 0;
|
||||
|
||||
// Track requests/sec history for the trend sparkline
|
||||
this.requestsPerSecHistory.push(reqPerSec);
|
||||
if (this.requestsPerSecHistory.length > 20) {
|
||||
this.requestsPerSecHistory.shift();
|
||||
}
|
||||
// Build trend data from pre-computed history (mutated in updateNetworkData, not here)
|
||||
const trendData = [...this.requestsPerSecHistory];
|
||||
while (trendData.length < 20) {
|
||||
trendData.unshift(0);
|
||||
@@ -435,7 +426,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
actions: [
|
||||
{
|
||||
name: 'View Details',
|
||||
iconName: 'lucide:Search',
|
||||
iconName: 'fa:magnifyingGlass',
|
||||
action: async () => {
|
||||
},
|
||||
},
|
||||
@@ -529,6 +520,13 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}
|
||||
|
||||
private async updateNetworkData() {
|
||||
// Track requests/sec history for the trend sparkline (moved out of render)
|
||||
const reqPerSec = this.networkState.requestsPerSecond || 0;
|
||||
this.requestsPerSecHistory.push(reqPerSec);
|
||||
if (this.requestsPerSecHistory.length > 20) {
|
||||
this.requestsPerSecHistory.shift();
|
||||
}
|
||||
|
||||
// Only update if connections changed significantly
|
||||
const newConnectionCount = this.networkState.connections.length;
|
||||
const oldConnectionCount = this.networkRequests.length;
|
||||
@@ -602,16 +600,13 @@ export class OpsViewNetwork extends DeesElement {
|
||||
y: Math.round(throughputOutMbps * 10) / 10
|
||||
};
|
||||
|
||||
// Efficient array updates - modify in place when possible
|
||||
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
||||
if (this.trafficDataIn.length >= 60) {
|
||||
// Remove oldest and add newest
|
||||
this.trafficDataIn = [...this.trafficDataIn.slice(1), newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut.slice(1), newDataPointOut];
|
||||
} else {
|
||||
// Still filling up the initial data
|
||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||
this.trafficDataIn.shift();
|
||||
this.trafficDataOut.shift();
|
||||
}
|
||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||
|
||||
this.lastChartUpdate = now;
|
||||
}
|
||||
|
||||
@@ -26,14 +26,36 @@ export class OpsViewOverview extends DeesElement {
|
||||
error: null,
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor logState: appstate.ILogState = {
|
||||
recentLogs: [],
|
||||
isStreaming: false,
|
||||
filters: {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.statsStatePart
|
||||
const statsSub = appstate.statsStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((statsState) => {
|
||||
this.statsState = statsState;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
this.rxSubscriptions.push(statsSub);
|
||||
|
||||
const logSub = appstate.logStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((logState) => {
|
||||
this.logState = logState;
|
||||
});
|
||||
this.rxSubscriptions.push(logSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Ensure logs are fetched for the overview charts
|
||||
if (this.logState.recentLogs.length === 0) {
|
||||
appstate.logStatePart.dispatchAction(appstate.fetchRecentLogsAction, { limit: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -96,10 +118,24 @@ export class OpsViewOverview extends DeesElement {
|
||||
${this.renderDnsStats()}
|
||||
|
||||
<div class="chartGrid">
|
||||
<dees-chart-area .label=${'Email Traffic (24h)'} .data=${[]}></dees-chart-area>
|
||||
<dees-chart-area .label=${'DNS Queries (24h)'} .data=${[]}></dees-chart-area>
|
||||
<dees-chart-log .label=${'Recent Events'} .data=${[]}></dees-chart-log>
|
||||
<dees-chart-log .label=${'Security Alerts'} .data=${[]}></dees-chart-log>
|
||||
<dees-chart-area
|
||||
.label=${'Email Traffic (24h)'}
|
||||
.series=${this.getEmailTrafficSeries()}
|
||||
.yAxisFormatter=${(val: number) => `${val}`}
|
||||
></dees-chart-area>
|
||||
<dees-chart-area
|
||||
.label=${'DNS Queries (24h)'}
|
||||
.series=${this.getDnsQuerySeries()}
|
||||
.yAxisFormatter=${(val: number) => `${val}`}
|
||||
></dees-chart-area>
|
||||
<dees-chart-log
|
||||
.label=${'Recent Events'}
|
||||
.logEntries=${this.getRecentEventEntries()}
|
||||
></dees-chart-log>
|
||||
<dees-chart-log
|
||||
.label=${'DNS Queries'}
|
||||
.logEntries=${this.getDnsQueryEntries()}
|
||||
></dees-chart-log>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
@@ -337,4 +373,52 @@ export class OpsViewOverview extends DeesElement {
|
||||
<dees-statsgrid .tiles=${tiles}></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Chart data helpers ---
|
||||
|
||||
private getRecentEventEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||
return this.logState.recentLogs.map((log) => ({
|
||||
timestamp: new Date(log.timestamp).toISOString(),
|
||||
level: log.level as 'debug' | 'info' | 'warn' | 'error',
|
||||
message: log.message,
|
||||
source: log.category,
|
||||
}));
|
||||
}
|
||||
|
||||
private getSecurityAlertEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||
const events: any[] = this.statsState.securityMetrics?.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: new Date(evt.timestamp).toISOString(),
|
||||
level: evt.level === 'critical' || evt.level === 'error' ? 'error' as const : evt.level === 'warn' ? 'warn' as const : 'info' as const,
|
||||
message: evt.message,
|
||||
source: evt.type,
|
||||
}));
|
||||
}
|
||||
|
||||
private getDnsQueryEntries(): Array<{ timestamp: string; level: 'debug' | 'info' | 'warn' | 'error' | 'success'; message: string; source?: string }> {
|
||||
const queries: any[] = (this.statsState.dnsStats as any)?.recentQueries || [];
|
||||
return queries.map((q: any) => ({
|
||||
timestamp: new Date(q.timestamp).toISOString(),
|
||||
level: q.answered ? 'info' as const : 'warn' as const,
|
||||
message: `${q.type} ${q.domain} (${q.responseTimeMs}ms)`,
|
||||
source: 'dns',
|
||||
}));
|
||||
}
|
||||
|
||||
private getEmailTrafficSeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
|
||||
const ts = this.statsState.emailStats?.timeSeries;
|
||||
if (!ts) return [];
|
||||
return [
|
||||
{ name: 'Sent', color: '#22c55e', data: (ts.sent || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||
{ name: 'Received', color: '#3b82f6', data: (ts.received || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||
];
|
||||
}
|
||||
|
||||
private getDnsQuerySeries(): Array<{ name: string; color: string; data: Array<{ x: number; y: number }> }> {
|
||||
const ts = this.statsState.dnsStats?.timeSeries;
|
||||
if (!ts) return [];
|
||||
return [
|
||||
{ name: 'Queries', color: '#8b5cf6', data: (ts.queries || []).map((p: any) => ({ x: p.timestamp, y: p.value })) },
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -176,13 +176,39 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
return html`
|
||||
<ops-sectionheading>Remote Ingress</ops-sectionheading>
|
||||
|
||||
${this.riState.newEdgeSecret ? html`
|
||||
${this.riState.newEdgeId ? html`
|
||||
<div class="secretDialog">
|
||||
<strong>Edge Secret (copy now - shown only once):</strong>
|
||||
<code>${this.riState.newEdgeSecret}</code>
|
||||
<div class="warning">This secret will not be shown again. Save it securely.</div>
|
||||
<strong>Edge created successfully!</strong>
|
||||
<div class="warning">Copy the connection token now. Use it with edge.start({ token: '...' }).</div>
|
||||
<dees-button
|
||||
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeSecretAction, null)}
|
||||
@click=${async () => {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
try {
|
||||
const response = await appstate.fetchConnectionToken(this.riState.newEdgeId);
|
||||
if (response.success && response.token) {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(response.token);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = response.token;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
DeesToast.show({ message: 'Connection token copied!', type: 'success', duration: 3000 });
|
||||
} else {
|
||||
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
|
||||
}
|
||||
} catch (err) {
|
||||
DeesToast.show({ message: `Failed: ${err.message}`, type: 'error', duration: 4000 });
|
||||
}
|
||||
}}
|
||||
>Copy Connection Token</dees-button>
|
||||
<dees-button
|
||||
@click=${() => appstate.remoteIngressStatePart.dispatchAction(appstate.clearNewEdgeIdAction, null)}
|
||||
>Dismiss</dees-button>
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -348,7 +374,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
},
|
||||
{
|
||||
name: 'Copy Token',
|
||||
iconName: 'lucide:clipboard-copy',
|
||||
iconName: 'lucide:ClipboardCopy',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const edge = actionData.item as interfaces.data.IRemoteIngress;
|
||||
@@ -356,7 +382,19 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
try {
|
||||
const response = await appstate.fetchConnectionToken(edge.id);
|
||||
if (response.success && response.token) {
|
||||
await navigator.clipboard.writeText(response.token);
|
||||
// Use clipboard API with fallback for non-HTTPS contexts
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(response.token);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = response.token;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
DeesToast.show({ message: `Connection token copied for ${edge.name}`, type: 'success', duration: 3000 });
|
||||
} else {
|
||||
DeesToast.show({ message: response.message || 'Failed to get token', type: 'error', duration: 4000 });
|
||||
|
||||
389
ts_web/elements/ops-view-routes.ts
Normal file
389
ts_web/elements/ops-view-routes.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('ops-view-routes')
|
||||
export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.routeManagementStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
// Re-fetch routes when user logs in (fixes race condition where
|
||||
// the view is created before authentication completes)
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.routesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.warnings-bar {
|
||||
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.warning-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('#b45309', '#fa0')};
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#666')};
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { mergedRoutes, warnings } = this.routeState;
|
||||
|
||||
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
||||
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
||||
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalRoutes',
|
||||
title: 'Total Routes',
|
||||
type: 'number',
|
||||
value: mergedRoutes.length,
|
||||
icon: 'lucide:route',
|
||||
description: 'All configured routes',
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'hardcoded',
|
||||
title: 'Hardcoded',
|
||||
type: 'number',
|
||||
value: hardcodedCount,
|
||||
icon: 'lucide:lock',
|
||||
description: 'Routes from constructor config',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'programmatic',
|
||||
title: 'Programmatic',
|
||||
type: 'number',
|
||||
value: programmaticCount,
|
||||
icon: 'lucide:code',
|
||||
description: 'Routes added via API',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
id: 'disabled',
|
||||
title: 'Disabled',
|
||||
type: 'number',
|
||||
value: disabledCount,
|
||||
icon: 'lucide:pauseCircle',
|
||||
description: 'Currently disabled routes',
|
||||
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
|
||||
},
|
||||
];
|
||||
|
||||
// Map merged routes to sz-route-list-view format
|
||||
const szRoutes = mergedRoutes.map((mr) => {
|
||||
const tags = [...(mr.route.tags || [])];
|
||||
tags.push(mr.source);
|
||||
if (!mr.enabled) tags.push('disabled');
|
||||
if (mr.overridden) tags.push('overridden');
|
||||
|
||||
return {
|
||||
...mr.route,
|
||||
enabled: mr.enabled,
|
||||
tags,
|
||||
id: mr.storedRouteId || mr.route.name || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return html`
|
||||
<ops-sectionheading>Route Management</ops-sectionheading>
|
||||
|
||||
<div class="routesContainer">
|
||||
<dees-statsgrid
|
||||
.tiles=${statsTiles}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Add Route',
|
||||
iconName: 'lucide:plus',
|
||||
action: () => this.showCreateRouteDialog(),
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:refreshCw',
|
||||
action: () => this.refreshData(),
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
${warnings.length > 0
|
||||
? html`
|
||||
<div class="warnings-bar">
|
||||
${warnings.map(
|
||||
(w) => html`
|
||||
<div class="warning-item">
|
||||
<span class="warning-icon">⚠</span>
|
||||
<span>${w.message}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
${szRoutes.length > 0
|
||||
? html`
|
||||
<sz-route-list-view
|
||||
.routes=${szRoutes}
|
||||
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||
></sz-route-list-view>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<p>No routes configured</p>
|
||||
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async handleRouteClick(e: CustomEvent) {
|
||||
const clickedRoute = e.detail;
|
||||
if (!clickedRoute) return;
|
||||
|
||||
// Find the corresponding merged route
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged) return;
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
if (merged.source === 'hardcoded') {
|
||||
const menuOptions = merged.enabled
|
||||
? [
|
||||
{
|
||||
name: 'Disable Route',
|
||||
iconName: 'lucide:pause',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: false },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Enable Route',
|
||||
iconName: 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: true },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Remove Override',
|
||||
iconName: 'lucide:undo',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.removeRouteOverrideAction,
|
||||
merged.route.name!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
||||
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions,
|
||||
});
|
||||
} else {
|
||||
// Programmatic route
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: merged.enabled ? 'Disable' : 'Enable',
|
||||
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.toggleRouteAction,
|
||||
{ id: merged.storedRouteId!, enabled: !merged.enabled },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async showCreateRouteDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Add Programmatic Route',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .value=${'localhost'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .required=${true}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Cancel',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
if (!formData.name || !formData.ports) return;
|
||||
|
||||
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||
const domains = formData.domains
|
||||
? formData.domains.split(',').map((d: string) => d.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const route: any = {
|
||||
name: formData.name,
|
||||
match: {
|
||||
ports,
|
||||
...(domains && domains.length > 0 ? { domains } : {}),
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [
|
||||
{
|
||||
host: formData.targetHost || 'localhost',
|
||||
port: parseInt(formData.targetPort, 10),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.createRouteAction,
|
||||
{ route },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private refreshData() {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,14 @@ export class OpsViewSecurity extends DeesElement {
|
||||
private renderOverview(metrics: any) {
|
||||
const threatLevel = this.calculateThreatLevel(metrics);
|
||||
const threatScore = this.getThreatScore(metrics);
|
||||
|
||||
|
||||
// Derive active sessions from recent successful auth events (last hour)
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const recentAuthSuccesses = allEvents.filter(
|
||||
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
|
||||
).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'threatLevel',
|
||||
@@ -271,7 +278,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
{
|
||||
id: 'blockedThreats',
|
||||
title: 'Blocked Threats',
|
||||
value: metrics.blockedIPs.length + metrics.spamDetected,
|
||||
value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldCheck',
|
||||
color: '#ef4444',
|
||||
@@ -280,11 +287,11 @@ export class OpsViewSecurity extends DeesElement {
|
||||
{
|
||||
id: 'activeSessions',
|
||||
title: 'Active Sessions',
|
||||
value: 0,
|
||||
value: recentAuthSuccesses,
|
||||
type: 'number',
|
||||
icon: 'lucide:Users',
|
||||
color: '#22c55e',
|
||||
description: 'Current authenticated sessions',
|
||||
description: 'Authenticated in last hour',
|
||||
},
|
||||
{
|
||||
id: 'authFailures',
|
||||
@@ -349,6 +356,11 @@ export class OpsViewSecurity extends DeesElement {
|
||||
}
|
||||
|
||||
private renderAuthentication(metrics: any) {
|
||||
// Derive auth events from recentEvents
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'authFailures',
|
||||
@@ -362,7 +374,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
{
|
||||
id: 'successfulLogins',
|
||||
title: 'Successful Logins',
|
||||
value: 0,
|
||||
value: successfulLogins,
|
||||
type: 'number',
|
||||
icon: 'lucide:Lock',
|
||||
color: '#22c55e',
|
||||
@@ -370,6 +382,15 @@ export class OpsViewSecurity extends DeesElement {
|
||||
},
|
||||
];
|
||||
|
||||
// Map auth events to login history table data
|
||||
const loginHistory = authEvents.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
username: evt.details?.username || 'unknown',
|
||||
ipAddress: evt.ipAddress || 'unknown',
|
||||
success: evt.success ?? false,
|
||||
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||
}));
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
@@ -380,7 +401,7 @@ export class OpsViewSecurity extends DeesElement {
|
||||
<dees-table
|
||||
.heading1=${'Login History'}
|
||||
.heading2=${'Recent authentication attempts'}
|
||||
.data=${[]}
|
||||
.data=${loginHistory}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleString(),
|
||||
'Username': item.username,
|
||||
@@ -483,48 +504,38 @@ export class OpsViewSecurity extends DeesElement {
|
||||
private getThreatScore(metrics: any): number {
|
||||
// Simple scoring algorithm
|
||||
let score = 100;
|
||||
score -= metrics.blockedIPs.length * 2;
|
||||
score -= metrics.authenticationFailures * 1;
|
||||
score -= metrics.spamDetected * 0.5;
|
||||
score -= metrics.malwareDetected * 3;
|
||||
score -= metrics.phishingDetected * 3;
|
||||
score -= metrics.suspiciousActivities * 2;
|
||||
const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
|
||||
score -= blockedCount * 2;
|
||||
score -= (metrics.authenticationFailures || 0) * 1;
|
||||
score -= (metrics.spamDetected || 0) * 0.5;
|
||||
score -= (metrics.malwareDetected || 0) * 3;
|
||||
score -= (metrics.phishingDetected || 0) * 3;
|
||||
score -= (metrics.suspiciousActivities || 0) * 2;
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
private getSecurityEvents(metrics: any): any[] {
|
||||
// Mock data - in real implementation, this would come from the server
|
||||
return [
|
||||
{
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
event: 'Multiple failed login attempts',
|
||||
severity: 'warning',
|
||||
details: 'IP: 192.168.1.100',
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 1000 * 60 * 15,
|
||||
event: 'SPF check failed',
|
||||
severity: 'medium',
|
||||
details: 'Domain: example.com',
|
||||
},
|
||||
{
|
||||
timestamp: Date.now() - 1000 * 60 * 30,
|
||||
event: 'IP blocked due to spam',
|
||||
severity: 'high',
|
||||
details: 'IP: 10.0.0.1',
|
||||
},
|
||||
];
|
||||
const events: any[] = metrics.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
event: evt.message,
|
||||
severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
|
||||
details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
|
||||
}));
|
||||
}
|
||||
|
||||
private async clearBlockedIPs() {
|
||||
console.log('Clear blocked IPs');
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||
}
|
||||
|
||||
private async unblockIP(ip: string) {
|
||||
console.log('Unblock IP:', ip);
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||
}
|
||||
|
||||
private async saveEmailSecuritySettings() {
|
||||
console.log('Save email security settings');
|
||||
// Config is read-only from the UI for now
|
||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,17 @@
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
// @serve.zone scope
|
||||
import * as szCatalog from '@serve.zone/catalog';
|
||||
|
||||
// TypedSocket for real-time push communication
|
||||
import * as typedsocket from '@api.global/typedsocket';
|
||||
|
||||
export {
|
||||
deesElement,
|
||||
deesCatalog
|
||||
deesCatalog,
|
||||
szCatalog,
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
// domtools gives us TypedRequest and other utilities
|
||||
|
||||
@@ -40,6 +40,15 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- Expiry date monitoring and alerts
|
||||
- Per-domain backoff status for failed provisions
|
||||
- One-click reprovisioning per domain
|
||||
- Certificate import, export, and deletion
|
||||
|
||||
### 🌍 Remote Ingress Management
|
||||
- Edge node registration with name, ports, and tags
|
||||
- Real-time connection status (connected/disconnected/disabled)
|
||||
- Public IP and active tunnel count per edge
|
||||
- Auto-derived port display with manual/derived breakdown
|
||||
- **Connection token generation** — one-click "Copy Token" for easy edge provisioning
|
||||
- Enable/disable, edit, secret regeneration, and delete actions
|
||||
|
||||
### 📜 Log Viewer
|
||||
- Real-time log streaming
|
||||
@@ -85,6 +94,7 @@ ts_web/
|
||||
├── ops-view-network.ts # Network monitoring
|
||||
├── ops-view-emails.ts # Email queue management
|
||||
├── ops-view-certificates.ts # Certificate overview & reprovisioning
|
||||
├── ops-view-remoteingress.ts # Remote ingress edge management
|
||||
├── ops-view-logs.ts # Log viewer
|
||||
├── ops-view-config.ts # Configuration display
|
||||
├── ops-view-security.ts # Security dashboard
|
||||
@@ -106,6 +116,8 @@ The app uses `@push.rocks/smartstate` with multiple state parts:
|
||||
| `logStatePart` | Soft | Recent logs, streaming status, filters |
|
||||
| `networkStatePart` | Soft | Connections, IPs, throughput rates |
|
||||
| `emailOpsStatePart` | Soft | Email queues, bounces, suppression list |
|
||||
| `certificateStatePart` | Soft | Certificate list, summary, loading state |
|
||||
| `remoteIngressStatePart` | Soft | Edge list, statuses, new edge secret |
|
||||
|
||||
### Actions
|
||||
|
||||
@@ -128,6 +140,23 @@ fetchSecurityIncidentsAction() // Security events
|
||||
fetchBounceRecordsAction() // Bounce records
|
||||
resendEmailAction(emailId) // Re-queue failed email
|
||||
removeFromSuppressionAction(email) // Remove from suppression list
|
||||
|
||||
// Certificates
|
||||
fetchCertificateOverviewAction() // All certificates with summary
|
||||
reprovisionCertificateAction(domain) // Reprovision a certificate
|
||||
deleteCertificateAction(domain) // Delete a certificate
|
||||
importCertificateAction(cert) // Import a certificate
|
||||
fetchCertificateExport(domain) // Export (standalone function)
|
||||
|
||||
// Remote Ingress
|
||||
fetchRemoteIngressAction() // Edges + statuses
|
||||
createRemoteIngressAction(data) // Create new edge
|
||||
updateRemoteIngressAction(data) // Update edge settings
|
||||
deleteRemoteIngressAction(id) // Remove edge
|
||||
regenerateRemoteIngressSecretAction(id) // New secret
|
||||
toggleRemoteIngressAction(id, enabled) // Enable/disable
|
||||
clearNewEdgeSecretAction() // Dismiss secret banner
|
||||
fetchConnectionToken(edgeId) // Get connection token (standalone function)
|
||||
```
|
||||
|
||||
### Client-Side Routing
|
||||
@@ -141,6 +170,7 @@ removeFromSuppressionAction(email) // Remove from suppression list
|
||||
/emails/failed → Failed emails
|
||||
/emails/security → Security incidents
|
||||
/certificates → Certificate management
|
||||
/remoteingress → Remote ingress edge management
|
||||
/logs → Log viewer
|
||||
/configuration → System configuration
|
||||
/security → Security dashboard
|
||||
|
||||
@@ -3,11 +3,9 @@ import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||
export const validEmailFolders = ['queued', 'sent', 'failed', 'security'] as const;
|
||||
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress'] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
export type TValidEmailFolder = typeof validEmailFolders[number];
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
@@ -27,31 +25,10 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
// Main views
|
||||
for (const view of validViews) {
|
||||
if (view === 'emails') {
|
||||
// Email root - default to queued
|
||||
this.router.on('/emails', async () => {
|
||||
this.updateViewState('emails');
|
||||
this.updateEmailFolder('queued');
|
||||
});
|
||||
|
||||
// Email with folder parameter
|
||||
this.router.on('/emails/:folder', async (routeInfo) => {
|
||||
const folder = routeInfo.params.folder as string;
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.updateViewState('emails');
|
||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
||||
} else {
|
||||
// Invalid folder, redirect to queued
|
||||
this.navigateTo('/emails/queued');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
});
|
||||
}
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
});
|
||||
}
|
||||
|
||||
// Root redirect
|
||||
@@ -61,60 +38,32 @@ class AppRouter {
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
// Sync URL when state changes programmatically (not from router)
|
||||
appstate.uiStatePart.state.subscribe((uiState) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = this.getExpectedPath(uiState.activeView);
|
||||
const expectedPath = `/${uiState.activeView}`;
|
||||
|
||||
// Only update URL if it doesn't match current state
|
||||
if (!currentPath.startsWith(expectedPath)) {
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
if (uiState.activeView === 'emails') {
|
||||
const emailState = appstate.emailOpsStatePart.getState();
|
||||
this.router.pushUrl(`/emails/${emailState.currentView}`);
|
||||
} else {
|
||||
this.router.pushUrl(`/${uiState.activeView}`);
|
||||
}
|
||||
this.router.pushUrl(expectedPath);
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getExpectedPath(view: string): string {
|
||||
if (view === 'emails') {
|
||||
return '/emails';
|
||||
}
|
||||
return `/${view}`;
|
||||
}
|
||||
|
||||
private handleInitialRoute(): void {
|
||||
const path = window.location.pathname;
|
||||
|
||||
if (!path || path === '/') {
|
||||
// Redirect root to overview
|
||||
this.router.pushUrl('/overview');
|
||||
} else {
|
||||
// Parse current path and update state
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.updateViewState(view as TValidView);
|
||||
|
||||
if (view === 'emails' && segments[1]) {
|
||||
const folder = segments[1];
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.updateEmailFolder(folder as TValidEmailFolder);
|
||||
} else {
|
||||
this.updateEmailFolder('queued');
|
||||
}
|
||||
} else if (view === 'emails') {
|
||||
this.updateEmailFolder('queued');
|
||||
}
|
||||
} else {
|
||||
// Invalid view, redirect to overview
|
||||
this.router.pushUrl('/overview');
|
||||
}
|
||||
}
|
||||
@@ -132,18 +81,6 @@ class AppRouter {
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
private updateEmailFolder(folder: TValidEmailFolder): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.emailOpsStatePart.getState();
|
||||
if (currentState.currentView !== folder) {
|
||||
appstate.emailOpsStatePart.setState({
|
||||
...currentState,
|
||||
currentView: folder as appstate.IEmailOpsState['currentView'],
|
||||
});
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
public navigateTo(path: string): void {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
@@ -156,22 +93,10 @@ class AppRouter {
|
||||
}
|
||||
}
|
||||
|
||||
public navigateToEmailFolder(folder: string): void {
|
||||
if (validEmailFolders.includes(folder as TValidEmailFolder)) {
|
||||
this.navigateTo(`/emails/${folder}`);
|
||||
} else {
|
||||
this.navigateTo('/emails/queued');
|
||||
}
|
||||
}
|
||||
|
||||
public getCurrentView(): string {
|
||||
return appstate.uiStatePart.getState().activeView;
|
||||
}
|
||||
|
||||
public getCurrentEmailFolder(): string {
|
||||
return appstate.emailOpsStatePart.getState().currentView;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.router.destroy();
|
||||
this.initialized = false;
|
||||
|
||||
Reference in New Issue
Block a user