Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bbaf26813 | |||
| 39f449cbe4 | |||
| e0386beb15 | |||
| 1d7e5495fa | |||
| 9a378ae87f | |||
| 58fbc2b1e4 | |||
| 20ea0ce683 | |||
| bcea93753b | |||
| 848515e424 | |||
| 38c9978969 | |||
| ee863b8178 | |||
| 9bb5a8bcc1 | |||
| 5aa07e81c7 | |||
| aec8b72ca3 | |||
| 466654ee4c | |||
| f1a11e3f6a | |||
| e193b3a8eb | |||
| 1bbf31605c | |||
| f2cfa923a0 | |||
| cdc77305e5 | |||
| 835537f789 | |||
| 754b223f62 | |||
| 0a39d50d20 | |||
| de7b9f7ec5 | |||
| bd959464c7 | |||
| 36b629676f | |||
| 19398ea836 | |||
| 4aba8cc353 | |||
| 5fd036eeb6 | |||
| cfcb66f1ee | |||
| 501f4f9de6 | |||
| fa926eb10b | |||
| f2d0a9ec1b | |||
| 035173702d | |||
| 07a3365496 | |||
| 1c4f7dbb11 | |||
| 1fdff79dd0 | |||
| 59b52d08fa | |||
| 2cdc392a40 | |||
| 433047bbf1 | |||
| 0b81c95de2 | |||
| 196e5dfc1b |
17
AGENTS.md
Normal file
17
AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Agent Instructions for dcrouter
|
||||||
|
|
||||||
|
## Database & Migrations
|
||||||
|
|
||||||
|
### Collection Names
|
||||||
|
smartdata uses the **exact class name** as the MongoDB collection name. No lowercasing.
|
||||||
|
- `StoredRouteDoc` → collection `StoredRouteDoc`
|
||||||
|
- `TargetProfileDoc` → collection `TargetProfileDoc`
|
||||||
|
- `RouteDoc` → collection `RouteDoc`
|
||||||
|
|
||||||
|
When writing migrations in `ts_migrations/index.ts`, use the exact class name casing in `ctx.mongo!.collection('ClassName')` and `db.listCollections({ name: 'ClassName' })`.
|
||||||
|
|
||||||
|
### Migration Rules
|
||||||
|
- All DB schema migrations go EXCLUSIVELY in `ts_migrations/index.ts` as smartmigration steps.
|
||||||
|
- NEVER put migration logic in application code (services, managers, startup hooks).
|
||||||
|
- Migration step `.to()` version must match the release version so smartmigration can plan the step.
|
||||||
|
- Steps must be idempotent — smartmigration may re-run them in skip-forward resume mode.
|
||||||
142
changelog.md
142
changelog.md
@@ -1,5 +1,147 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-15 - 13.19.0 - feat(routes,email)
|
||||||
|
persist system DNS routes with runtime hydration and add reusable email ops DNS helpers
|
||||||
|
|
||||||
|
- Persist seeded DNS-over-HTTPS routes with stable system keys and hydrate socket handlers at runtime instead of treating them as runtime-only routes
|
||||||
|
- Restrict system-managed routes to toggle-only operations across the route manager, Ops API, and web UI while returning explicit mutation errors
|
||||||
|
- Add a shared email DNS record builder and cover email queue operations and handler behavior with new tests
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.18.0 - feat(email)
|
||||||
|
add persistent smartmta storage and runtime-managed email domain syncing
|
||||||
|
|
||||||
|
- replace the email storage shim with a filesystem-backed SmartMtaStorageManager for DKIM and queue persistence
|
||||||
|
- sync managed email domains from the database into runtime email config and update the active email server on create, update, delete, and restart
|
||||||
|
- switch email queue, metrics, ops, and DNS integrations to smartmta public APIs including persisted queue stats and DKIM record generation
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.9 - fix(monitoring)
|
||||||
|
align domain activity metrics with id-keyed route data
|
||||||
|
|
||||||
|
- Use route id as a fallback canonical key when matching route metrics to configured domains in MetricsManager.
|
||||||
|
- Add a regression test covering domain activity aggregation for routes identified only by id.
|
||||||
|
- Update the network activity UI to show formatted total connection counts in the active connections card.
|
||||||
|
- Bump @push.rocks/smartproxy from ^27.7.3 to ^27.7.4.
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.8 - fix(opsserver)
|
||||||
|
align certificate status handling with the updated smartproxy response format
|
||||||
|
|
||||||
|
- update opsserver certificate lookup to read expiresAt, source, and isValid from smartproxy responses
|
||||||
|
- bump @push.rocks/smartproxy to ^27.7.3
|
||||||
|
- enable verbose output for the test script
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.7 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-14 - 13.17.6 - fix(dns,routes)
|
||||||
|
keep DoH socket-handler routes runtime-only and prune stale persisted entries
|
||||||
|
|
||||||
|
- stops persisting generated DNS-over-HTTPS routes that depend on live socket handlers
|
||||||
|
- removes stale persisted runtime-only DoH routes from RouteDoc during startup
|
||||||
|
- applies runtime DNS routes alongside DB-backed routes through RouteConfigManager
|
||||||
|
- updates DnsManager warning to clarify that dnsNsDomains is still required for nameserver and DoH bootstrap
|
||||||
|
- adds tests covering runtime DoH route application, stale route pruning, and updated DNS warning behavior
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.5 - fix(vpn,target-profiles)
|
||||||
|
normalize target profile route references and stabilize VPN host-IP client routing behavior
|
||||||
|
|
||||||
|
- Normalize legacy target profile route name references to route IDs, reject ambiguous names, and display labeled route references in the UI.
|
||||||
|
- Skip wildcard VPN domains when generating WireGuard AllowedIPs and log a deduplicated warning instead of attempting DNS resolution.
|
||||||
|
- Normalize persisted VPN client host-IP settings, include routing fields in runtime updates, and restart in hybrid mode when a host-IP client requires it.
|
||||||
|
- Add a repair migration for previously missed TargetProfile target host-to-ip document updates.
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.3 - fix(ops-view-routes)
|
||||||
|
sync route filter toggle selection via component changeSubject
|
||||||
|
|
||||||
|
- Replaces the inline change handler on the route filter toggle with a subscription to the component's changeSubject in firstUpdated.
|
||||||
|
- Ensures switching between user and system routes updates the view reliably and is cleaned up through existing rxSubscriptions management.
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.2 - fix(monitoring)
|
||||||
|
exclude unconfigured routes from domain activity aggregation
|
||||||
|
|
||||||
|
- Removes fallback aggregation that reported routes without domain configuration as synthetic domain entries based on route names
|
||||||
|
- Keeps domain activity focused on configured domain mappings when splitting connection and throughput metrics
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.1 - fix(monitoring)
|
||||||
|
stop allocating route metrics to domains when no request data exists
|
||||||
|
|
||||||
|
- Removes the equal-split fallback for shared routes in MetricsManager.
|
||||||
|
- Sets the proportional share to zero when a route has no recorded requests, avoiding inflated per-domain connection and throughput totals.
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.17.0 - feat(monitoring,network-ui,routes)
|
||||||
|
add request-based domain activity metrics and split routes into user and system views
|
||||||
|
|
||||||
|
- Domain activity now includes per-domain request counts and distributes route throughput and connections using request-level metrics instead of equal route sharing.
|
||||||
|
- Network activity UI displays request counts and updates the domain activity description to reflect request-level aggregation.
|
||||||
|
- Routes UI adds a toggle to filter between user-created and system-generated routes, updates summary card labels, and adjusts empty states accordingly.
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.16.2 - fix(deps)
|
||||||
|
bump @push.rocks/smartproxy to ^27.6.0
|
||||||
|
|
||||||
|
- updates @push.rocks/smartproxy from ^27.5.0 to ^27.6.0 in package.json
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.16.1 - fix(migrations)
|
||||||
|
use exact smartdata collection names in route unification migration
|
||||||
|
|
||||||
|
- Update the 13.16.0 migration to rename StoredRouteDoc to RouteDoc using case-sensitive collection names
|
||||||
|
- Apply the origin backfill against the RouteDoc collection and drop RouteOverrideDoc with matching class-name casing
|
||||||
|
- Clarify migration description and comments to reflect smartdata's exact class-name collection mapping
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.16.0 - feat(routes)
|
||||||
|
unify route storage and management across config, email, dns, and API origins
|
||||||
|
|
||||||
|
- Persist config-, email-, and dns-seeded routes in the database alongside API-created routes using a single RouteDoc model with origin tracking
|
||||||
|
- Remove hardcoded-route override handling in favor of direct route CRUD and toggle operations by route id across the API client, handlers, and web UI
|
||||||
|
- Add a migration that renames stored route storage, sets migrated routes to origin="api", and drops obsolete route override data
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.15.1 - fix(monitoring)
|
||||||
|
improve domain activity aggregation for multi-domain and wildcard routes
|
||||||
|
|
||||||
|
- map route metrics across all configured domains instead of only the first domain
|
||||||
|
- resolve wildcard domain patterns against active protocol cache entries
|
||||||
|
- distribute shared route traffic across matched domains and preserve fallback reporting for routes without domain configuration
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.15.0 - feat(stats)
|
||||||
|
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
|
||||||
|
|
||||||
|
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
|
||||||
|
- updates app state to use a typed getNetworkStats request instead of casting the response to any
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.14.0 - feat(network)
|
||||||
|
add bandwidth-ranked IP and domain activity metrics to network monitoring
|
||||||
|
|
||||||
|
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
|
||||||
|
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
|
||||||
|
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
|
||||||
|
|
||||||
|
## 2026-04-13 - 13.13.0 - feat(dns)
|
||||||
|
add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling
|
||||||
|
|
||||||
|
- adds domain migration support in DnsManager, API handlers, request interfaces, app state, and domains UI
|
||||||
|
- routes ACME DNS-01 challenges through managed domains using createRecord/deleteRecord for both dcrouter-hosted and provider-managed zones
|
||||||
|
- enables immediate unregister of deleted dcrouter-hosted DNS records from the embedded DNS server
|
||||||
|
|
||||||
|
## 2026-04-12 - 13.12.0 - feat(email-domains)
|
||||||
|
support creating email domains on optional subdomains
|
||||||
|
|
||||||
|
- Add optional subdomain support to email domain creation, persistence, and API interfaces.
|
||||||
|
- Update the ops UI to collect and submit a subdomain prefix when creating an email domain.
|
||||||
|
- Bump @design.estate/dees-catalog from ^3.78.0 to ^3.78.2.
|
||||||
|
|
||||||
|
## 2026-04-12 - 13.11.0 - feat(email-domains)
|
||||||
|
add email domain management with DNS provisioning, validation, and ops dashboard support
|
||||||
|
|
||||||
|
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
|
||||||
|
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
|
||||||
|
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
|
||||||
|
|
||||||
|
## 2026-04-12 - 13.10.0 - feat(web-ui)
|
||||||
|
standardize settings views for ACME and email security panels
|
||||||
|
|
||||||
|
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
|
||||||
|
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
|
||||||
|
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
|
||||||
|
|
||||||
## 2026-04-12 - 13.9.2 - fix(web-ui)
|
## 2026-04-12 - 13.9.2 - fix(web-ui)
|
||||||
improve form field descriptions and align certificate settings with tile components
|
improve form field descriptions and align certificate settings with tile components
|
||||||
|
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "13.9.2",
|
"version": "13.19.0",
|
||||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"author": "Task Venture Capital GmbH",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --logfile --timeout 60)",
|
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||||
"start": "(node ./cli.js)",
|
"start": "(node ./cli.js)",
|
||||||
"startTs": "(node cli.ts.js)",
|
"startTs": "(node cli.ts.js)",
|
||||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"@api.global/typedserver": "^8.4.6",
|
"@api.global/typedserver": "^8.4.6",
|
||||||
"@api.global/typedsocket": "^4.1.2",
|
"@api.global/typedsocket": "^4.1.2",
|
||||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||||
"@design.estate/dees-catalog": "^3.76.1",
|
"@design.estate/dees-catalog": "^3.78.2",
|
||||||
"@design.estate/dees-element": "^2.2.4",
|
"@design.estate/dees-element": "^2.2.4",
|
||||||
"@push.rocks/lik": "^6.4.0",
|
"@push.rocks/lik": "^6.4.0",
|
||||||
"@push.rocks/projectinfo": "^5.1.0",
|
"@push.rocks/projectinfo": "^5.1.0",
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
"@push.rocks/smartlog": "^3.2.2",
|
"@push.rocks/smartlog": "^3.2.2",
|
||||||
"@push.rocks/smartmetrics": "^3.0.3",
|
"@push.rocks/smartmetrics": "^3.0.3",
|
||||||
"@push.rocks/smartmigration": "1.2.0",
|
"@push.rocks/smartmigration": "1.2.0",
|
||||||
"@push.rocks/smartmta": "^5.3.1",
|
"@push.rocks/smartmta": "^5.3.3",
|
||||||
"@push.rocks/smartnetwork": "^4.5.2",
|
"@push.rocks/smartnetwork": "^4.6.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
"@push.rocks/smartproxy": "^27.5.0",
|
"@push.rocks/smartproxy": "^27.7.4",
|
||||||
"@push.rocks/smartradius": "^1.1.1",
|
"@push.rocks/smartradius": "^1.1.1",
|
||||||
"@push.rocks/smartrequest": "^5.0.1",
|
"@push.rocks/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartrx": "^3.0.10",
|
"@push.rocks/smartrx": "^3.0.10",
|
||||||
@@ -62,12 +62,12 @@
|
|||||||
"@push.rocks/smartunique": "^3.0.9",
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
"@push.rocks/smartvpn": "1.19.2",
|
"@push.rocks/smartvpn": "1.19.2",
|
||||||
"@push.rocks/taskbuffer": "^8.0.2",
|
"@push.rocks/taskbuffer": "^8.0.2",
|
||||||
"@serve.zone/catalog": "^2.12.3",
|
"@serve.zone/catalog": "^2.12.4",
|
||||||
"@serve.zone/interfaces": "^5.3.0",
|
"@serve.zone/interfaces": "^5.3.0",
|
||||||
"@serve.zone/remoteingress": "^4.15.3",
|
"@serve.zone/remoteingress": "^4.15.3",
|
||||||
"@tsclass/tsclass": "^9.5.0",
|
"@tsclass/tsclass": "^9.5.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"lru-cache": "^11.3.3",
|
"lru-cache": "^11.3.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
156
pnpm-lock.yaml
generated
156
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
|||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.1.0
|
version: 7.1.0
|
||||||
'@design.estate/dees-catalog':
|
'@design.estate/dees-catalog':
|
||||||
specifier: ^3.76.1
|
specifier: ^3.78.2
|
||||||
version: 3.76.1(@tiptap/pm@2.27.2)
|
version: 3.78.2(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-element':
|
'@design.estate/dees-element':
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4
|
version: 2.2.4
|
||||||
@@ -69,11 +69,11 @@ importers:
|
|||||||
specifier: 1.2.0
|
specifier: 1.2.0
|
||||||
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
||||||
'@push.rocks/smartmta':
|
'@push.rocks/smartmta':
|
||||||
specifier: ^5.3.1
|
specifier: ^5.3.3
|
||||||
version: 5.3.1
|
version: 5.3.3
|
||||||
'@push.rocks/smartnetwork':
|
'@push.rocks/smartnetwork':
|
||||||
specifier: ^4.5.2
|
specifier: ^4.6.0
|
||||||
version: 4.5.2
|
version: 4.6.0
|
||||||
'@push.rocks/smartpath':
|
'@push.rocks/smartpath':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -81,8 +81,8 @@ importers:
|
|||||||
specifier: ^4.2.3
|
specifier: ^4.2.3
|
||||||
version: 4.2.3
|
version: 4.2.3
|
||||||
'@push.rocks/smartproxy':
|
'@push.rocks/smartproxy':
|
||||||
specifier: ^27.5.0
|
specifier: ^27.7.4
|
||||||
version: 27.5.0
|
version: 27.7.4
|
||||||
'@push.rocks/smartradius':
|
'@push.rocks/smartradius':
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
@@ -105,8 +105,8 @@ importers:
|
|||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2
|
version: 8.0.2
|
||||||
'@serve.zone/catalog':
|
'@serve.zone/catalog':
|
||||||
specifier: ^2.12.3
|
specifier: ^2.12.4
|
||||||
version: 2.12.3(@tiptap/pm@2.27.2)
|
version: 2.12.4(@tiptap/pm@2.27.2)
|
||||||
'@serve.zone/interfaces':
|
'@serve.zone/interfaces':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
@@ -120,8 +120,8 @@ importers:
|
|||||||
specifier: ^1.5.6
|
specifier: ^1.5.6
|
||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
lru-cache:
|
lru-cache:
|
||||||
specifier: ^11.3.3
|
specifier: ^11.3.5
|
||||||
version: 11.3.3
|
version: 11.3.5
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: ^1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
@@ -147,6 +147,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.6.0
|
specifier: ^25.6.0
|
||||||
version: 25.6.0
|
version: 25.6.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.2
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -353,8 +356,8 @@ packages:
|
|||||||
'@configvault.io/interfaces@1.0.17':
|
'@configvault.io/interfaces@1.0.17':
|
||||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.76.1':
|
'@design.estate/dees-catalog@3.78.2':
|
||||||
resolution: {integrity: sha512-DSnu1NHz0C9CI13e6HMUV6lFiAKzOoPccZUZu6wDrpTcGha1trvFftcRzsieJ0NrvNJ6qZrh1vGL6ZYhu5RO0A==}
|
resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
|
||||||
|
|
||||||
'@design.estate/dees-comms@1.0.30':
|
'@design.estate/dees-comms@1.0.30':
|
||||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||||
@@ -365,11 +368,8 @@ packages:
|
|||||||
'@design.estate/dees-element@2.2.4':
|
'@design.estate/dees-element@2.2.4':
|
||||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.0':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.4':
|
|
||||||
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
|
|
||||||
|
|
||||||
'@emnapi/core@1.9.2':
|
'@emnapi/core@1.9.2':
|
||||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||||
@@ -1251,8 +1251,8 @@ packages:
|
|||||||
'@push.rocks/smartmongo@5.1.1':
|
'@push.rocks/smartmongo@5.1.1':
|
||||||
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
|
resolution: {integrity: sha512-OFzEjTlXQ0zN9KYewhJRJxxX8bdVO7sl5H4RRd0F0PyU4FEXesLF8Sm4rsCFtQW1ifGQEBOcoruRkoiWz918Ug==}
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.1':
|
'@push.rocks/smartmta@5.3.3':
|
||||||
resolution: {integrity: sha512-cEuXO56i/zL9eZS79eAesEW16ikdBJKLlEv9pLKkt2cmaHBWADGHjeOzJmsszQ9CSFcuhd41aHYVGMZXVvsG2g==}
|
resolution: {integrity: sha512-QxNob2yosDOhHMMjfUiQHfx8z+/UQQUdZY4ECATg3/xAMwnychR41IEVp6h7Qz3RjoJqS3NjRBThm9/jT02Gxg==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
cpu: [x64, arm64]
|
cpu: [x64, arm64]
|
||||||
os: [darwin, linux, win32]
|
os: [darwin, linux, win32]
|
||||||
@@ -1260,8 +1260,8 @@ packages:
|
|||||||
'@push.rocks/smartmustache@3.0.2':
|
'@push.rocks/smartmustache@3.0.2':
|
||||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.6.0':
|
||||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
|
||||||
|
|
||||||
'@push.rocks/smartnftables@1.1.0':
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||||
@@ -1287,8 +1287,8 @@ packages:
|
|||||||
'@push.rocks/smartpromise@4.2.3':
|
'@push.rocks/smartpromise@4.2.3':
|
||||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@27.5.0':
|
'@push.rocks/smartproxy@27.7.4':
|
||||||
resolution: {integrity: sha512-QIXrVQtAoqBCv+9ScLOdGcizN55svJuGCfMDsDaBVtwS3Tva30IxuEL3usNTHABveuI8slaWzSxTabmTULDOwA==}
|
resolution: {integrity: sha512-WY9Jp6Jtqo5WbW29XpATuxzGyLs8LGkAlrycgMN/IdYfvgtEB2HWuztBZCDLFMuD3Qnv4vVdci9s0nF0ZPyJcQ==}
|
||||||
|
|
||||||
'@push.rocks/smartpuppeteer@2.0.5':
|
'@push.rocks/smartpuppeteer@2.0.5':
|
||||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||||
@@ -1591,8 +1591,8 @@ packages:
|
|||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||||
|
|
||||||
'@serve.zone/catalog@2.12.3':
|
'@serve.zone/catalog@2.12.4':
|
||||||
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
|
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
|
||||||
|
|
||||||
'@serve.zone/interfaces@5.3.0':
|
'@serve.zone/interfaces@5.3.0':
|
||||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||||
@@ -1997,6 +1997,12 @@ packages:
|
|||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-transform@0.1.11':
|
||||||
|
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||||
|
|
||||||
|
'@types/dom-webcodecs@0.1.13':
|
||||||
|
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||||
|
|
||||||
'@types/fs-extra@11.0.4':
|
'@types/fs-extra@11.0.4':
|
||||||
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
||||||
|
|
||||||
@@ -3017,6 +3023,9 @@ packages:
|
|||||||
libmime@5.3.7:
|
libmime@5.3.7:
|
||||||
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==}
|
||||||
|
|
||||||
libqp@2.1.1:
|
libqp@2.1.1:
|
||||||
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
||||||
|
|
||||||
@@ -3079,8 +3088,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
lru-cache@11.3.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
lru-cache@11.3.5:
|
||||||
|
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
lru-cache@7.18.3:
|
lru-cache@7.18.3:
|
||||||
@@ -3090,8 +3102,8 @@ packages:
|
|||||||
lucide@1.8.0:
|
lucide@1.8.0:
|
||||||
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
||||||
|
|
||||||
mailparser@3.9.6:
|
mailparser@3.9.8:
|
||||||
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
|
resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
|
||||||
|
|
||||||
make-dir@3.1.0:
|
make-dir@3.1.0:
|
||||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||||
@@ -3166,6 +3178,9 @@ packages:
|
|||||||
mdurl@2.0.0:
|
mdurl@2.0.0:
|
||||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||||
|
|
||||||
|
mediabunny@1.40.1:
|
||||||
|
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
|
||||||
|
|
||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||||
|
|
||||||
@@ -3425,8 +3440,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
nodemailer@8.0.4:
|
nodemailer@8.0.5:
|
||||||
resolution: {integrity: sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==}
|
resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
@@ -4318,7 +4333,7 @@ snapshots:
|
|||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||||
'@cloudflare/workers-types': 4.20260405.1
|
'@cloudflare/workers-types': 4.20260405.1
|
||||||
'@design.estate/dees-catalog': 3.76.1(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-comms': 1.0.30
|
'@design.estate/dees-comms': 1.0.30
|
||||||
'@push.rocks/lik': 6.4.0
|
'@push.rocks/lik': 6.4.0
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -4847,11 +4862,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
|
|
||||||
'@design.estate/dees-catalog@3.76.1(@tiptap/pm@2.27.2)':
|
'@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.4
|
'@design.estate/dees-wcctools': 3.9.0
|
||||||
'@fortawesome/fontawesome-svg-core': 7.2.0
|
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||||
'@fortawesome/free-brands-svg-icons': 7.2.0
|
'@fortawesome/free-brands-svg-icons': 7.2.0
|
||||||
'@fortawesome/free-regular-svg-icons': 7.2.0
|
'@fortawesome/free-regular-svg-icons': 7.2.0
|
||||||
@@ -4928,24 +4943,13 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.0':
|
'@design.estate/dees-wcctools@3.9.0':
|
||||||
dependencies:
|
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
|
||||||
'@design.estate/dees-element': 2.2.4
|
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
|
||||||
lit: 3.3.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@nuxt/kit'
|
|
||||||
- react
|
|
||||||
- supports-color
|
|
||||||
- vue
|
|
||||||
|
|
||||||
'@design.estate/dees-wcctools@3.8.4':
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
lit: 3.3.2
|
lit: 3.3.2
|
||||||
|
mediabunny: 1.40.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
- react
|
- react
|
||||||
@@ -5159,7 +5163,7 @@ snapshots:
|
|||||||
'@push.rocks/smartjson': 6.0.0
|
'@push.rocks/smartjson': 6.0.0
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
'@push.rocks/smartmongo': 5.1.1(socks@2.8.7)
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartrequest': 5.0.1
|
'@push.rocks/smartrequest': 5.0.1
|
||||||
@@ -5962,7 +5966,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartdns': 7.9.0
|
'@push.rocks/smartdns': 7.9.0
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartstring': 4.1.0
|
'@push.rocks/smartstring': 4.1.0
|
||||||
'@push.rocks/smarttime': 4.2.3
|
'@push.rocks/smarttime': 4.2.3
|
||||||
'@push.rocks/smartunique': 3.0.9
|
'@push.rocks/smartunique': 3.0.9
|
||||||
@@ -6410,7 +6414,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@push.rocks/smartmta@5.3.1':
|
'@push.rocks/smartmta@5.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartfile': 13.1.2
|
'@push.rocks/smartfile': 13.1.2
|
||||||
'@push.rocks/smartfs': 1.5.0
|
'@push.rocks/smartfs': 1.5.0
|
||||||
@@ -6419,8 +6423,8 @@ snapshots:
|
|||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
'@tsclass/tsclass': 9.5.0
|
'@tsclass/tsclass': 9.5.0
|
||||||
lru-cache: 11.3.3
|
lru-cache: 10.4.3
|
||||||
mailparser: 3.9.6
|
mailparser: 3.9.8
|
||||||
uuid: 13.0.0
|
uuid: 13.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -6429,7 +6433,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
handlebars: 4.7.9
|
handlebars: 4.7.9
|
||||||
|
|
||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartdns': 7.9.0
|
'@push.rocks/smartdns': 7.9.0
|
||||||
'@push.rocks/smartrust': 1.3.2
|
'@push.rocks/smartrust': 1.3.2
|
||||||
@@ -6490,7 +6494,7 @@ snapshots:
|
|||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartfs': 1.5.0
|
'@push.rocks/smartfs': 1.5.0
|
||||||
'@push.rocks/smartjimp': 1.2.0
|
'@push.rocks/smartjimp': 1.2.0
|
||||||
'@push.rocks/smartnetwork': 4.5.2
|
'@push.rocks/smartnetwork': 4.6.0
|
||||||
'@push.rocks/smartpath': 6.0.0
|
'@push.rocks/smartpath': 6.0.0
|
||||||
'@push.rocks/smartpromise': 4.2.3
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
||||||
@@ -6511,7 +6515,7 @@ snapshots:
|
|||||||
|
|
||||||
'@push.rocks/smartpromise@4.2.3': {}
|
'@push.rocks/smartpromise@4.2.3': {}
|
||||||
|
|
||||||
'@push.rocks/smartproxy@27.5.0':
|
'@push.rocks/smartproxy@27.7.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartcrypto': 2.0.4
|
'@push.rocks/smartcrypto': 2.0.4
|
||||||
'@push.rocks/smartlog': 3.2.2
|
'@push.rocks/smartlog': 3.2.2
|
||||||
@@ -6913,12 +6917,12 @@ snapshots:
|
|||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
selderee: 0.11.0
|
selderee: 0.11.0
|
||||||
|
|
||||||
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
|
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@design.estate/dees-catalog': 3.76.1(@tiptap/pm@2.27.2)
|
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
|
||||||
'@design.estate/dees-domtools': 2.5.4
|
'@design.estate/dees-domtools': 2.5.4
|
||||||
'@design.estate/dees-element': 2.2.4
|
'@design.estate/dees-element': 2.2.4
|
||||||
'@design.estate/dees-wcctools': 3.8.0
|
'@design.estate/dees-wcctools': 3.9.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
- '@tiptap/pm'
|
- '@tiptap/pm'
|
||||||
@@ -7464,6 +7468,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-transform@0.1.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/dom-webcodecs': 0.1.13
|
||||||
|
|
||||||
|
'@types/dom-webcodecs@0.1.13': {}
|
||||||
|
|
||||||
'@types/fs-extra@11.0.4':
|
'@types/fs-extra@11.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/jsonfile': 6.1.4
|
'@types/jsonfile': 6.1.4
|
||||||
@@ -8587,6 +8597,13 @@ snapshots:
|
|||||||
libbase64: 1.3.0
|
libbase64: 1.3.0
|
||||||
libqp: 2.1.1
|
libqp: 2.1.1
|
||||||
|
|
||||||
|
libmime@5.3.8:
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: 2.2.0
|
||||||
|
iconv-lite: 0.7.2
|
||||||
|
libbase64: 1.3.0
|
||||||
|
libqp: 2.1.1
|
||||||
|
|
||||||
libqp@2.1.1: {}
|
libqp@2.1.1: {}
|
||||||
|
|
||||||
lightweight-charts@5.1.0:
|
lightweight-charts@5.1.0:
|
||||||
@@ -8643,22 +8660,24 @@ snapshots:
|
|||||||
|
|
||||||
lowercase-keys@3.0.0: {}
|
lowercase-keys@3.0.0: {}
|
||||||
|
|
||||||
lru-cache@11.3.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
|
lru-cache@11.3.5: {}
|
||||||
|
|
||||||
lru-cache@7.18.3: {}
|
lru-cache@7.18.3: {}
|
||||||
|
|
||||||
lucide@1.8.0: {}
|
lucide@1.8.0: {}
|
||||||
|
|
||||||
mailparser@3.9.6:
|
mailparser@3.9.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@zone-eu/mailsplit': 5.4.8
|
'@zone-eu/mailsplit': 5.4.8
|
||||||
encoding-japanese: 2.2.0
|
encoding-japanese: 2.2.0
|
||||||
he: 1.2.0
|
he: 1.2.0
|
||||||
html-to-text: 9.0.5
|
html-to-text: 9.0.5
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
libmime: 5.3.7
|
libmime: 5.3.8
|
||||||
linkify-it: 5.0.0
|
linkify-it: 5.0.0
|
||||||
nodemailer: 8.0.4
|
nodemailer: 8.0.5
|
||||||
punycode.js: 2.3.1
|
punycode.js: 2.3.1
|
||||||
tlds: 1.261.0
|
tlds: 1.261.0
|
||||||
|
|
||||||
@@ -8819,6 +8838,11 @@ snapshots:
|
|||||||
|
|
||||||
mdurl@2.0.0: {}
|
mdurl@2.0.0: {}
|
||||||
|
|
||||||
|
mediabunny@1.40.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/dom-mediacapture-transform': 0.1.11
|
||||||
|
'@types/dom-webcodecs': 0.1.13
|
||||||
|
|
||||||
memory-pager@1.5.0: {}
|
memory-pager@1.5.0: {}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
@@ -9158,7 +9182,7 @@ snapshots:
|
|||||||
|
|
||||||
node-forge@1.4.0: {}
|
node-forge@1.4.0: {}
|
||||||
|
|
||||||
nodemailer@8.0.4: {}
|
nodemailer@8.0.5: {}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9283,7 +9307,7 @@ snapshots:
|
|||||||
|
|
||||||
path-scurry@2.0.2:
|
path-scurry@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.3.3
|
lru-cache: 11.3.5
|
||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|||||||
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
|||||||
match: { ports: 443, domains: 'example.com' },
|
match: { ports: 443, domains: 'example.com' },
|
||||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||||
},
|
},
|
||||||
source: 'programmatic',
|
id: 'route-123',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
overridden: false,
|
origin: 'api',
|
||||||
storedRouteId: 'route-123',
|
|
||||||
createdAt: 1000,
|
createdAt: 1000,
|
||||||
updatedAt: 2000,
|
updatedAt: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(route.name).toEqual('test-route');
|
expect(route.name).toEqual('test-route');
|
||||||
expect(route.source).toEqual('programmatic');
|
expect(route.id).toEqual('route-123');
|
||||||
expect(route.enabled).toEqual(true);
|
expect(route.enabled).toEqual(true);
|
||||||
expect(route.overridden).toEqual(false);
|
expect(route.origin).toEqual('api');
|
||||||
expect(route.storedRouteId).toEqual('route-123');
|
|
||||||
expect(route.routeConfig.match.ports).toEqual(443);
|
expect(route.routeConfig.match.ports).toEqual(443);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
|
|
||||||
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
|
|
||||||
const route = new Route(client, {
|
|
||||||
route: {
|
|
||||||
name: 'hardcoded-route',
|
|
||||||
match: { ports: 80 },
|
|
||||||
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
|
|
||||||
},
|
|
||||||
source: 'hardcoded',
|
|
||||||
enabled: true,
|
|
||||||
overridden: false,
|
|
||||||
// No storedRouteId for hardcoded routes
|
|
||||||
});
|
|
||||||
|
|
||||||
let updateError: Error | undefined;
|
|
||||||
try {
|
|
||||||
await route.update({ name: 'new-name' });
|
|
||||||
} catch (e) {
|
|
||||||
updateError = e as Error;
|
|
||||||
}
|
|
||||||
expect(updateError).toBeTruthy();
|
|
||||||
expect(updateError!.message).toInclude('hardcoded');
|
|
||||||
|
|
||||||
let deleteError: Error | undefined;
|
|
||||||
try {
|
|
||||||
await route.delete();
|
|
||||||
} catch (e) {
|
|
||||||
deleteError = e as Error;
|
|
||||||
}
|
|
||||||
expect(deleteError).toBeTruthy();
|
|
||||||
|
|
||||||
let toggleError: Error | undefined;
|
|
||||||
try {
|
|
||||||
await route.toggle(false);
|
|
||||||
} catch (e) {
|
|
||||||
toggleError = e as Error;
|
|
||||||
}
|
|
||||||
expect(toggleError).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Certificate resource class
|
// Certificate resource class
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
|||||||
|
|
||||||
// Verify unified email server was initialized
|
// Verify unified email server was initialized
|
||||||
expect(router.emailServer).toBeTruthy();
|
expect(router.emailServer).toBeTruthy();
|
||||||
|
expect((router.emailServer as any).options.hostname).toEqual('mail.example.com');
|
||||||
|
expect((router.emailServer as any).options.persistRoutes).toEqual(false);
|
||||||
|
expect((router.emailServer as any).options.queue.storageType).toEqual('disk');
|
||||||
|
|
||||||
// Stop the router
|
// Stop the router
|
||||||
await router.stop();
|
await router.stop();
|
||||||
|
|||||||
262
test/test.dns-runtime-routes.node.ts
Normal file
262
test/test.dns-runtime-routes.node.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||||
|
import { RouteConfigManager } from '../ts/config/index.js';
|
||||||
|
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
|
||||||
|
import { DnsManager } from '../ts/dns/manager.dns.js';
|
||||||
|
import { logger } from '../ts/logger.js';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
|
const createTestDb = async () => {
|
||||||
|
const storagePath = plugins.path.join(
|
||||||
|
plugins.os.tmpdir(),
|
||||||
|
`dcrouter-dns-runtime-routes-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
const db = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName: `dcrouter-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
await db.start();
|
||||||
|
await db.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async cleanup() {
|
||||||
|
await db.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDbPromise = createTestDb();
|
||||||
|
|
||||||
|
const clearTestState = async () => {
|
||||||
|
for (const route of await RouteDoc.findAll()) {
|
||||||
|
await route.delete();
|
||||||
|
}
|
||||||
|
for (const domain of await DomainDoc.findAll()) {
|
||||||
|
await domain.delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
smartProxyConfig: { routes: [] },
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const appliedRoutes: any[][] = [];
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (routes: any[]) => {
|
||||||
|
appliedRoutes.push(routes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(
|
||||||
|
() => smartProxy as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||||
|
);
|
||||||
|
|
||||||
|
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||||
|
|
||||||
|
const persistedRoutes = await RouteDoc.findAll();
|
||||||
|
expect(persistedRoutes.length).toEqual(2);
|
||||||
|
expect(persistedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||||
|
expect((await RouteDoc.findByName('dns-over-https-dns-query'))?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||||
|
expect((await RouteDoc.findByName('dns-over-https-resolve'))?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||||
|
|
||||||
|
const mergedRoutes = routeManager.getMergedRoutes().routes;
|
||||||
|
expect(mergedRoutes.length).toEqual(2);
|
||||||
|
expect(mergedRoutes.every((route) => route.origin === 'dns')).toEqual(true);
|
||||||
|
expect(mergedRoutes.every((route) => route.systemKey?.startsWith('dns:'))).toEqual(true);
|
||||||
|
|
||||||
|
expect(appliedRoutes.length).toEqual(1);
|
||||||
|
|
||||||
|
for (const routeSet of appliedRoutes) {
|
||||||
|
const dnsQueryRoute = routeSet.find((route) => route.name === 'dns-over-https-dns-query');
|
||||||
|
const resolveRoute = routeSet.find((route) => route.name === 'dns-over-https-resolve');
|
||||||
|
|
||||||
|
expect(dnsQueryRoute).toBeDefined();
|
||||||
|
expect(resolveRoute).toBeDefined();
|
||||||
|
expect(typeof dnsQueryRoute.action.socketHandler).toEqual('function');
|
||||||
|
expect(typeof resolveRoute.action.socketHandler).toEqual('function');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager backfills existing DoH system routes by name without duplicating them', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const dcRouter = new DcRouter({
|
||||||
|
dnsNsDomains: ['ns1.example.com', 'ns2.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
smartProxyConfig: { routes: [] },
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const staleDnsQueryRoute = new RouteDoc();
|
||||||
|
staleDnsQueryRoute.id = 'stale-doh-query';
|
||||||
|
staleDnsQueryRoute.route = {
|
||||||
|
name: 'dns-over-https-dns-query',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['ns1.example.com'],
|
||||||
|
path: '/dns-query',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
staleDnsQueryRoute.enabled = true;
|
||||||
|
staleDnsQueryRoute.createdAt = Date.now();
|
||||||
|
staleDnsQueryRoute.updatedAt = Date.now();
|
||||||
|
staleDnsQueryRoute.createdBy = 'test';
|
||||||
|
staleDnsQueryRoute.origin = 'dns';
|
||||||
|
await staleDnsQueryRoute.save();
|
||||||
|
|
||||||
|
const appliedRoutes: any[][] = [];
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (routes: any[]) => {
|
||||||
|
appliedRoutes.push(routes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(
|
||||||
|
() => smartProxy as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
(storedRoute: any) => (dcRouter as any).hydrateStoredRouteForRuntime(storedRoute),
|
||||||
|
);
|
||||||
|
await routeManager.initialize([], [], (dcRouter as any).generateDnsRoutes({ includeSocketHandler: false }));
|
||||||
|
|
||||||
|
const remainingRoutes = await RouteDoc.findAll();
|
||||||
|
expect(remainingRoutes.length).toEqual(2);
|
||||||
|
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-dns-query').length).toEqual(1);
|
||||||
|
expect(remainingRoutes.filter((route) => route.route.name === 'dns-over-https-resolve').length).toEqual(1);
|
||||||
|
|
||||||
|
const queryRoute = await RouteDoc.findByName('dns-over-https-dns-query');
|
||||||
|
expect(queryRoute?.id).toEqual('stale-doh-query');
|
||||||
|
expect(queryRoute?.systemKey).toEqual('dns:dns-over-https-dns-query');
|
||||||
|
|
||||||
|
const resolveRoute = await RouteDoc.findByName('dns-over-https-resolve');
|
||||||
|
expect(resolveRoute?.systemKey).toEqual('dns:dns-over-https-resolve');
|
||||||
|
|
||||||
|
expect(appliedRoutes.length).toEqual(1);
|
||||||
|
expect(appliedRoutes[0].length).toEqual(2);
|
||||||
|
expect(appliedRoutes[0].every((route) => typeof route.action.socketHandler === 'function')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager only allows toggling system routes', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const smartProxy = {
|
||||||
|
updateRoutes: async (_routes: any[]) => {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||||
|
await routeManager.initialize([
|
||||||
|
{
|
||||||
|
name: 'system-config-route',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['app.example.com'],
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||||
|
tls: { mode: 'terminate' as const },
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
], [], []);
|
||||||
|
|
||||||
|
const systemRoute = routeManager.getMergedRoutes().routes.find((route) => route.route.name === 'system-config-route');
|
||||||
|
expect(systemRoute).toBeDefined();
|
||||||
|
|
||||||
|
const updateResult = await routeManager.updateRoute(systemRoute!.id, {
|
||||||
|
route: { name: 'renamed-system-route' } as any,
|
||||||
|
});
|
||||||
|
expect(updateResult.success).toEqual(false);
|
||||||
|
expect(updateResult.message).toEqual('System routes are managed by the system and can only be toggled');
|
||||||
|
|
||||||
|
const deleteResult = await routeManager.deleteRoute(systemRoute!.id);
|
||||||
|
expect(deleteResult.success).toEqual(false);
|
||||||
|
expect(deleteResult.message).toEqual('System routes are managed by the system and cannot be deleted');
|
||||||
|
|
||||||
|
const toggleResult = await routeManager.toggleRoute(systemRoute!.id, false);
|
||||||
|
expect(toggleResult.success).toEqual(true);
|
||||||
|
expect((await RouteDoc.findById(systemRoute!.id))?.enabled).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('DnsManager warning keeps dnsNsDomains in scope', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
const originalLog = logger.log.bind(logger);
|
||||||
|
const warningMessages: string[] = [];
|
||||||
|
|
||||||
|
(logger as any).log = (level: 'error' | 'warn' | 'info' | 'success' | 'debug', message: string, context?: Record<string, any>) => {
|
||||||
|
if (level === 'warn') {
|
||||||
|
warningMessages.push(message);
|
||||||
|
}
|
||||||
|
return originalLog(level, message, context || {});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingDomain = new DomainDoc();
|
||||||
|
existingDomain.id = 'existing-domain';
|
||||||
|
existingDomain.name = 'example.com';
|
||||||
|
existingDomain.source = 'dcrouter';
|
||||||
|
existingDomain.authoritative = true;
|
||||||
|
existingDomain.createdAt = Date.now();
|
||||||
|
existingDomain.updatedAt = Date.now();
|
||||||
|
existingDomain.createdBy = 'test';
|
||||||
|
await existingDomain.save();
|
||||||
|
|
||||||
|
const dnsManager = new DnsManager({
|
||||||
|
dnsNsDomains: ['ns1.example.com'],
|
||||||
|
dnsScopes: ['example.com'],
|
||||||
|
dnsRecords: [{ name: 'www.example.com', type: 'A', value: '127.0.0.1' }],
|
||||||
|
smartProxyConfig: { routes: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await dnsManager.start();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
warningMessages.some((message) =>
|
||||||
|
message.includes('ignoring legacy dnsScopes/dnsRecords constructor config')
|
||||||
|
&& message.includes('dnsNsDomains is still required for nameserver and DoH bootstrap'),
|
||||||
|
),
|
||||||
|
).toEqual(true);
|
||||||
|
expect(
|
||||||
|
warningMessages.some((message) =>
|
||||||
|
message.includes('ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config'),
|
||||||
|
),
|
||||||
|
).toEqual(false);
|
||||||
|
} finally {
|
||||||
|
(logger as any).log = originalLog;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup test db', async () => {
|
||||||
|
await clearTestState();
|
||||||
|
const testDb = await testDbPromise;
|
||||||
|
await testDb.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
65
test/test.email-dns-records.node.ts
Normal file
65
test/test.email-dns-records.node.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { buildEmailDnsRecords } from '../ts/email/index.js';
|
||||||
|
|
||||||
|
tap.test('buildEmailDnsRecords uses the configured mail hostname for MX and includes DKIM when provided', async () => {
|
||||||
|
const records = buildEmailDnsRecords({
|
||||||
|
domain: 'example.com',
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
selector: 'selector1',
|
||||||
|
dkimValue: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
|
||||||
|
statuses: {
|
||||||
|
mx: 'valid',
|
||||||
|
spf: 'missing',
|
||||||
|
dkim: 'valid',
|
||||||
|
dmarc: 'unchecked',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(records).toEqual([
|
||||||
|
{
|
||||||
|
type: 'MX',
|
||||||
|
name: 'example.com',
|
||||||
|
value: '10 mail.example.com',
|
||||||
|
status: 'valid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TXT',
|
||||||
|
name: 'example.com',
|
||||||
|
value: 'v=spf1 a mx ~all',
|
||||||
|
status: 'missing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TXT',
|
||||||
|
name: 'selector1._domainkey.example.com',
|
||||||
|
value: 'v=DKIM1; h=sha256; k=rsa; p=abc123',
|
||||||
|
status: 'valid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TXT',
|
||||||
|
name: '_dmarc.example.com',
|
||||||
|
value: 'v=DMARC1; p=none; rua=mailto:dmarc@example.com',
|
||||||
|
status: 'unchecked',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('buildEmailDnsRecords omits DKIM when no value is provided', async () => {
|
||||||
|
const records = buildEmailDnsRecords({
|
||||||
|
domain: 'example.net',
|
||||||
|
hostname: 'smtp.example.net',
|
||||||
|
mxPriority: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(records.map((record) => record.name)).toEqual([
|
||||||
|
'example.net',
|
||||||
|
'example.net',
|
||||||
|
'_dmarc.example.net',
|
||||||
|
]);
|
||||||
|
expect(records[0].value).toEqual('20 smtp.example.net');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
193
test/test.email-domain-manager.node.ts
Normal file
193
test/test.email-domain-manager.node.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import { EmailDomainManager } from '../ts/email/index.js';
|
||||||
|
import { DcRouterDb, DomainDoc } from '../ts/db/index.js';
|
||||||
|
import { EmailDomainDoc } from '../ts/db/documents/classes.email-domain.doc.js';
|
||||||
|
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
const createTestDb = async () => {
|
||||||
|
const storagePath = plugins.path.join(
|
||||||
|
plugins.os.tmpdir(),
|
||||||
|
`dcrouter-email-domain-manager-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
const db = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName: `dcrouter-email-domain-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
await db.start();
|
||||||
|
await db.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
return {
|
||||||
|
async cleanup() {
|
||||||
|
await db.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testDbPromise = createTestDb();
|
||||||
|
|
||||||
|
const clearTestState = async () => {
|
||||||
|
for (const emailDomain of await EmailDomainDoc.findAll()) {
|
||||||
|
await emailDomain.delete();
|
||||||
|
}
|
||||||
|
for (const domain of await DomainDoc.findAll()) {
|
||||||
|
await domain.delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDomainDoc = async (id: string, name: string, source: 'dcrouter' | 'provider') => {
|
||||||
|
const doc = new DomainDoc();
|
||||||
|
doc.id = id;
|
||||||
|
doc.name = name;
|
||||||
|
doc.source = source;
|
||||||
|
doc.authoritative = source === 'dcrouter';
|
||||||
|
doc.createdAt = Date.now();
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
doc.createdBy = 'test';
|
||||||
|
await doc.save();
|
||||||
|
return doc;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBaseEmailConfig = (): IUnifiedEmailServerOptions => ({
|
||||||
|
ports: [2525],
|
||||||
|
hostname: 'mail.example.com',
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
domain: 'static.example.com',
|
||||||
|
dnsMode: 'external-dns',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailDomainManager syncs managed domains into runtime config and email server', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const linkedDomain = await createDomainDoc('provider-domain', 'example.com', 'provider');
|
||||||
|
const updateCalls: Array<{ domains?: any[] }> = [];
|
||||||
|
|
||||||
|
const dcRouterStub = {
|
||||||
|
options: {
|
||||||
|
emailConfig: createBaseEmailConfig(),
|
||||||
|
},
|
||||||
|
emailServer: {
|
||||||
|
updateOptions: (options: { domains?: any[] }) => {
|
||||||
|
updateCalls.push(options);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new EmailDomainManager(dcRouterStub);
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
const created = await manager.createEmailDomain({
|
||||||
|
linkedDomainId: linkedDomain.id,
|
||||||
|
subdomain: 'mail',
|
||||||
|
dkimSelector: 'selector1',
|
||||||
|
rotateKeys: true,
|
||||||
|
rotationIntervalDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const domainsAfterCreate = dcRouterStub.options.emailConfig.domains;
|
||||||
|
expect(domainsAfterCreate.length).toEqual(2);
|
||||||
|
expect(domainsAfterCreate.some((domain) => domain.domain === 'static.example.com')).toEqual(true);
|
||||||
|
|
||||||
|
const managedDomain = domainsAfterCreate.find((domain) => domain.domain === 'mail.example.com');
|
||||||
|
expect(managedDomain).toBeTruthy();
|
||||||
|
expect(managedDomain?.dnsMode).toEqual('external-dns');
|
||||||
|
expect(managedDomain?.dkim?.selector).toEqual('selector1');
|
||||||
|
expect(updateCalls.at(-1)?.domains?.some((domain) => domain.domain === 'mail.example.com')).toEqual(true);
|
||||||
|
|
||||||
|
await manager.updateEmailDomain(created.id, {
|
||||||
|
rotateKeys: false,
|
||||||
|
rateLimits: {
|
||||||
|
outbound: {
|
||||||
|
messagesPerMinute: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const domainsAfterUpdate = dcRouterStub.options.emailConfig.domains;
|
||||||
|
const updatedManagedDomain = domainsAfterUpdate.find((domain) => domain.domain === 'mail.example.com');
|
||||||
|
expect(updatedManagedDomain?.dkim?.rotateKeys).toEqual(false);
|
||||||
|
expect(updatedManagedDomain?.rateLimits?.outbound?.messagesPerMinute).toEqual(10);
|
||||||
|
|
||||||
|
await manager.deleteEmailDomain(created.id);
|
||||||
|
expect(dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain)).toEqual(['static.example.com']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailDomainManager rejects domains already present in static config', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const linkedDomain = await createDomainDoc('static-domain', 'static.example.com', 'provider');
|
||||||
|
const dcRouterStub = {
|
||||||
|
options: {
|
||||||
|
emailConfig: createBaseEmailConfig(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new EmailDomainManager(dcRouterStub);
|
||||||
|
|
||||||
|
let error: Error | undefined;
|
||||||
|
try {
|
||||||
|
await manager.createEmailDomain({ linkedDomainId: linkedDomain.id });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error = err as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.message).toEqual('Email domain already configured for static.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('EmailDomainManager start merges persisted managed domains after restart', async () => {
|
||||||
|
await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
|
||||||
|
const linkedDomain = await createDomainDoc('local-domain', 'managed.example.com', 'dcrouter');
|
||||||
|
const stored = new EmailDomainDoc();
|
||||||
|
stored.id = 'managed-email-domain';
|
||||||
|
stored.domain = 'mail.managed.example.com';
|
||||||
|
stored.linkedDomainId = linkedDomain.id;
|
||||||
|
stored.subdomain = 'mail';
|
||||||
|
stored.dkim = {
|
||||||
|
selector: 'default',
|
||||||
|
keySize: 2048,
|
||||||
|
rotateKeys: false,
|
||||||
|
rotationIntervalDays: 90,
|
||||||
|
};
|
||||||
|
stored.dnsStatus = {
|
||||||
|
mx: 'unchecked',
|
||||||
|
spf: 'unchecked',
|
||||||
|
dkim: 'unchecked',
|
||||||
|
dmarc: 'unchecked',
|
||||||
|
};
|
||||||
|
stored.createdAt = new Date().toISOString();
|
||||||
|
stored.updatedAt = new Date().toISOString();
|
||||||
|
await stored.save();
|
||||||
|
|
||||||
|
const dcRouterStub = {
|
||||||
|
options: {
|
||||||
|
emailConfig: createBaseEmailConfig(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new EmailDomainManager(dcRouterStub);
|
||||||
|
await manager.start();
|
||||||
|
|
||||||
|
const managedDomain = dcRouterStub.options.emailConfig.domains.find((domain) => domain.domain === 'mail.managed.example.com');
|
||||||
|
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
const testDb = await testDbPromise;
|
||||||
|
await clearTestState();
|
||||||
|
await testDb.cleanup();
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
167
test/test.email-ops-api.ts
Normal file
167
test/test.email-ops-api.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { TypedRequest } from '@api.global/typedrequest';
|
||||||
|
import { DcRouter } from '../ts/index.js';
|
||||||
|
import * as interfaces from '../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3201;
|
||||||
|
const BASE_URL = `http://localhost:${TEST_PORT}/typedrequest`;
|
||||||
|
|
||||||
|
let testDcRouter: DcRouter;
|
||||||
|
let adminIdentity: interfaces.data.IIdentity;
|
||||||
|
let removedQueueItemId: string | undefined;
|
||||||
|
let lastEnqueueArgs: any[] | undefined;
|
||||||
|
|
||||||
|
const queueItems = [
|
||||||
|
{
|
||||||
|
id: 'failed-email-1',
|
||||||
|
status: 'failed',
|
||||||
|
attempts: 3,
|
||||||
|
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||||
|
lastError: '550 mailbox unavailable',
|
||||||
|
processingMode: 'mta',
|
||||||
|
route: undefined,
|
||||||
|
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||||
|
processingResult: {
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: ['recipient@example.net'],
|
||||||
|
cc: ['copy@example.net'],
|
||||||
|
subject: 'Older message',
|
||||||
|
text: 'hello',
|
||||||
|
headers: { 'x-test': '1' },
|
||||||
|
getMessageId: () => 'message-older',
|
||||||
|
getAttachmentsSize: () => 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delivered-email-1',
|
||||||
|
status: 'delivered',
|
||||||
|
attempts: 1,
|
||||||
|
processingMode: 'mta',
|
||||||
|
route: undefined,
|
||||||
|
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||||
|
processingResult: {
|
||||||
|
email: {
|
||||||
|
from: 'fresh@example.com',
|
||||||
|
to: ['new@example.net'],
|
||||||
|
cc: [],
|
||||||
|
subject: 'Newest message',
|
||||||
|
},
|
||||||
|
html: '<p>newest</p>',
|
||||||
|
text: 'newest',
|
||||||
|
headers: { 'x-fresh': 'true' },
|
||||||
|
getMessageId: () => 'message-newer',
|
||||||
|
getAttachmentsSize: () => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
tap.test('should start DCRouter with OpsServer for email API tests', async () => {
|
||||||
|
testDcRouter = new DcRouter({
|
||||||
|
opsServerPort: TEST_PORT,
|
||||||
|
dbConfig: { enabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDcRouter.start();
|
||||||
|
testDcRouter.emailServer = {
|
||||||
|
getQueueItems: () => [...queueItems],
|
||||||
|
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||||
|
getQueueStats: () => ({
|
||||||
|
queueSize: 2,
|
||||||
|
status: {
|
||||||
|
pending: 0,
|
||||||
|
processing: 1,
|
||||||
|
failed: 1,
|
||||||
|
deferred: 1,
|
||||||
|
delivered: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
deliveryQueue: {
|
||||||
|
enqueue: async (...args: any[]) => {
|
||||||
|
lastEnqueueArgs = args;
|
||||||
|
return 'resent-queue-id';
|
||||||
|
},
|
||||||
|
removeItem: async (id: string) => {
|
||||||
|
removedQueueItemId = id;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(testDcRouter.opsServer).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should login as admin for email API tests', async () => {
|
||||||
|
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
BASE_URL,
|
||||||
|
'adminLoginWithUsernameAndPassword',
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await loginRequest.fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
adminIdentity = response.identity;
|
||||||
|
expect(adminIdentity.jwt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return queued emails through the email ops API', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_GetAllEmails>(BASE_URL, 'getAllEmails');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.emails.map((email) => email.id)).toEqual(['delivered-email-1', 'failed-email-1']);
|
||||||
|
expect(response.emails[0].status).toEqual('delivered');
|
||||||
|
expect(response.emails[1].status).toEqual('bounced');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should return email detail through the email ops API', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_GetEmailDetail>(BASE_URL, 'getEmailDetail');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
emailId: 'failed-email-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.email?.toList).toEqual(['recipient@example.net']);
|
||||||
|
expect(response.email?.cc).toEqual(['copy@example.net']);
|
||||||
|
expect(response.email?.rejectionReason).toEqual('550 mailbox unavailable');
|
||||||
|
expect(response.email?.headers).toEqual({ 'x-test': '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should expose queue status through the stats API', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_GetQueueStatus>(BASE_URL, 'getQueueStatus');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.queues.length).toEqual(1);
|
||||||
|
expect(response.queues[0].size).toEqual(0);
|
||||||
|
expect(response.queues[0].processing).toEqual(1);
|
||||||
|
expect(response.queues[0].failed).toEqual(1);
|
||||||
|
expect(response.queues[0].retrying).toEqual(1);
|
||||||
|
expect(response.totalItems).toEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should resend failed email through the admin email ops API', async () => {
|
||||||
|
const request = new TypedRequest<interfaces.requests.IReq_ResendEmail>(BASE_URL, 'resendEmail');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: adminIdentity,
|
||||||
|
emailId: 'failed-email-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.success).toEqual(true);
|
||||||
|
expect(response.newQueueId).toEqual('resent-queue-id');
|
||||||
|
expect(removedQueueItemId).toEqual('failed-email-1');
|
||||||
|
expect(lastEnqueueArgs?.[0]).toEqual(queueItems[0].processingResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should stop DCRouter after email API tests', async () => {
|
||||||
|
await testDcRouter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
107
test/test.email-ops-handlers.node.ts
Normal file
107
test/test.email-ops-handlers.node.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { EmailOpsHandler } from '../ts/opsserver/handlers/email-ops.handler.js';
|
||||||
|
import { StatsHandler } from '../ts/opsserver/handlers/stats.handler.js';
|
||||||
|
|
||||||
|
const createRouterStub = () => ({
|
||||||
|
addTypedHandler: (_handler: unknown) => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const queueItems = [
|
||||||
|
{
|
||||||
|
id: 'older-failed',
|
||||||
|
status: 'failed',
|
||||||
|
attempts: 3,
|
||||||
|
nextAttempt: new Date('2026-04-14T10:00:00.000Z'),
|
||||||
|
lastError: '550 mailbox unavailable',
|
||||||
|
createdAt: new Date('2026-04-14T09:00:00.000Z'),
|
||||||
|
processingResult: {
|
||||||
|
from: 'sender@example.com',
|
||||||
|
to: ['recipient@example.net'],
|
||||||
|
cc: ['copy@example.net'],
|
||||||
|
subject: 'Older message',
|
||||||
|
text: 'hello',
|
||||||
|
headers: { 'x-test': '1' },
|
||||||
|
getMessageId: () => 'message-older',
|
||||||
|
getAttachmentsSize: () => 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'newer-delivered',
|
||||||
|
status: 'delivered',
|
||||||
|
attempts: 1,
|
||||||
|
createdAt: new Date('2026-04-14T11:00:00.000Z'),
|
||||||
|
processingResult: {
|
||||||
|
email: {
|
||||||
|
from: 'fresh@example.com',
|
||||||
|
to: ['new@example.net'],
|
||||||
|
cc: [],
|
||||||
|
subject: 'Newest message',
|
||||||
|
},
|
||||||
|
html: '<p>newest</p>',
|
||||||
|
text: 'newest',
|
||||||
|
headers: { 'x-fresh': 'true' },
|
||||||
|
getMessageId: () => 'message-newer',
|
||||||
|
getAttachmentsSize: () => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
tap.test('EmailOpsHandler maps queue items using public email server APIs', async () => {
|
||||||
|
const opsHandler = new EmailOpsHandler({
|
||||||
|
viewRouter: createRouterStub(),
|
||||||
|
adminRouter: createRouterStub(),
|
||||||
|
dcRouterRef: {
|
||||||
|
emailServer: {
|
||||||
|
getQueueItems: () => queueItems,
|
||||||
|
getQueueItem: (id: string) => queueItems.find((item) => item.id === id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const emails = (opsHandler as any).getAllQueueEmails();
|
||||||
|
expect(emails.map((email: any) => email.id)).toEqual(['newer-delivered', 'older-failed']);
|
||||||
|
expect(emails[0].status).toEqual('delivered');
|
||||||
|
expect(emails[1].status).toEqual('bounced');
|
||||||
|
expect(emails[0].messageId).toEqual('message-newer');
|
||||||
|
|
||||||
|
const detail = (opsHandler as any).getEmailDetail('older-failed');
|
||||||
|
expect(detail?.toList).toEqual(['recipient@example.net']);
|
||||||
|
expect(detail?.cc).toEqual(['copy@example.net']);
|
||||||
|
expect(detail?.rejectionReason).toEqual('550 mailbox unavailable');
|
||||||
|
expect(detail?.headers).toEqual({ 'x-test': '1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('StatsHandler reports queue status using public email server APIs', async () => {
|
||||||
|
const statsHandler = new StatsHandler({
|
||||||
|
viewRouter: createRouterStub(),
|
||||||
|
dcRouterRef: {
|
||||||
|
emailServer: {
|
||||||
|
getQueueStats: () => ({
|
||||||
|
queueSize: 2,
|
||||||
|
status: {
|
||||||
|
pending: 0,
|
||||||
|
processing: 1,
|
||||||
|
failed: 1,
|
||||||
|
deferred: 1,
|
||||||
|
delivered: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getQueueItems: () => queueItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const queueStatus = await (statsHandler as any).getQueueStatus();
|
||||||
|
expect(queueStatus.pending).toEqual(0);
|
||||||
|
expect(queueStatus.active).toEqual(1);
|
||||||
|
expect(queueStatus.failed).toEqual(1);
|
||||||
|
expect(queueStatus.retrying).toEqual(1);
|
||||||
|
expect(queueStatus.items.map((item: any) => item.id)).toEqual(['newer-delivered', 'older-failed']);
|
||||||
|
expect(queueStatus.items[1].nextRetry).toEqual(new Date('2026-04-14T10:00:00.000Z').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
120
test/test.metricsmanager.route-keys.node.ts
Normal file
120
test/test.metricsmanager.route-keys.node.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { MetricsManager } from '../ts/monitoring/classes.metricsmanager.js';
|
||||||
|
|
||||||
|
const emptyProtocolDistribution = {
|
||||||
|
h1Active: 0,
|
||||||
|
h1Total: 0,
|
||||||
|
h2Active: 0,
|
||||||
|
h2Total: 0,
|
||||||
|
h3Active: 0,
|
||||||
|
h3Total: 0,
|
||||||
|
wsActive: 0,
|
||||||
|
wsTotal: 0,
|
||||||
|
otherActive: 0,
|
||||||
|
otherTotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createProxyMetrics(args: {
|
||||||
|
connectionsByRoute: Map<string, number>;
|
||||||
|
throughputByRoute: Map<string, { in: number; out: number }>;
|
||||||
|
domainRequestsByIP: Map<string, Map<string, number>>;
|
||||||
|
requestsTotal?: number;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
connections: {
|
||||||
|
active: () => 0,
|
||||||
|
total: () => 0,
|
||||||
|
byRoute: () => args.connectionsByRoute,
|
||||||
|
byIP: () => new Map<string, number>(),
|
||||||
|
topIPs: () => [],
|
||||||
|
domainRequestsByIP: () => args.domainRequestsByIP,
|
||||||
|
topDomainRequests: () => [],
|
||||||
|
frontendProtocols: () => emptyProtocolDistribution,
|
||||||
|
backendProtocols: () => emptyProtocolDistribution,
|
||||||
|
},
|
||||||
|
throughput: {
|
||||||
|
instant: () => ({ in: 0, out: 0 }),
|
||||||
|
recent: () => ({ in: 0, out: 0 }),
|
||||||
|
average: () => ({ in: 0, out: 0 }),
|
||||||
|
custom: () => ({ in: 0, out: 0 }),
|
||||||
|
history: () => [],
|
||||||
|
byRoute: () => args.throughputByRoute,
|
||||||
|
byIP: () => new Map<string, { in: number; out: number }>(),
|
||||||
|
},
|
||||||
|
requests: {
|
||||||
|
perSecond: () => 0,
|
||||||
|
perMinute: () => 0,
|
||||||
|
total: () => args.requestsTotal || 0,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
bytesIn: () => 0,
|
||||||
|
bytesOut: () => 0,
|
||||||
|
connections: () => 0,
|
||||||
|
},
|
||||||
|
backends: {
|
||||||
|
byBackend: () => new Map<string, any>(),
|
||||||
|
protocols: () => new Map<string, string>(),
|
||||||
|
topByErrors: () => [],
|
||||||
|
detectedProtocols: () => [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('MetricsManager joins domain activity to id-keyed route metrics', async () => {
|
||||||
|
const proxyMetrics = createProxyMetrics({
|
||||||
|
connectionsByRoute: new Map([
|
||||||
|
['route-id-only', 4],
|
||||||
|
]),
|
||||||
|
throughputByRoute: new Map([
|
||||||
|
['route-id-only', { in: 1200, out: 2400 }],
|
||||||
|
]),
|
||||||
|
domainRequestsByIP: new Map([
|
||||||
|
['192.0.2.10', new Map([
|
||||||
|
['alpha.example.com', 3],
|
||||||
|
['beta.example.com', 1],
|
||||||
|
])],
|
||||||
|
]),
|
||||||
|
requestsTotal: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const smartProxy = {
|
||||||
|
getMetrics: () => proxyMetrics,
|
||||||
|
routeManager: {
|
||||||
|
getRoutes: () => [
|
||||||
|
{
|
||||||
|
id: 'route-id-only',
|
||||||
|
match: {
|
||||||
|
ports: [443],
|
||||||
|
domains: ['alpha.example.com', 'beta.example.com'],
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '127.0.0.1', port: 8443 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new MetricsManager({ smartProxy } as any);
|
||||||
|
const stats = await manager.getNetworkStats();
|
||||||
|
const alpha = stats.domainActivity.find((item) => item.domain === 'alpha.example.com');
|
||||||
|
const beta = stats.domainActivity.find((item) => item.domain === 'beta.example.com');
|
||||||
|
|
||||||
|
expect(alpha).toBeDefined();
|
||||||
|
expect(beta).toBeDefined();
|
||||||
|
|
||||||
|
expect(alpha!.requestCount).toEqual(3);
|
||||||
|
expect(alpha!.routeCount).toEqual(1);
|
||||||
|
expect(alpha!.activeConnections).toEqual(3);
|
||||||
|
expect(alpha!.bytesInPerSecond).toEqual(900);
|
||||||
|
expect(alpha!.bytesOutPerSecond).toEqual(1800);
|
||||||
|
|
||||||
|
expect(beta!.requestCount).toEqual(1);
|
||||||
|
expect(beta!.routeCount).toEqual(1);
|
||||||
|
expect(beta!.activeConnections).toEqual(1);
|
||||||
|
expect(beta!.bytesInPerSecond).toEqual(300);
|
||||||
|
expect(beta!.bytesOutPerSecond).toEqual(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
31
test/test.smartmta-storage-manager.node.ts
Normal file
31
test/test.smartmta-storage-manager.node.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import { SmartMtaStorageManager } from '../ts/email/index.js';
|
||||||
|
|
||||||
|
const tempDir = plugins.path.join(process.cwd(), '.nogit', 'test-smartmta-storage');
|
||||||
|
|
||||||
|
tap.test('SmartMtaStorageManager persists, lists, and deletes keys', async () => {
|
||||||
|
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
const storageManager = new SmartMtaStorageManager(tempDir);
|
||||||
|
await storageManager.set('/email/dkim/example.com/default/metadata', 'metadata');
|
||||||
|
await storageManager.set('/email/dkim/example.com/default/public.key', 'public');
|
||||||
|
|
||||||
|
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toEqual('metadata');
|
||||||
|
|
||||||
|
const keys = await storageManager.list('/email/dkim/example.com/');
|
||||||
|
expect(keys).toEqual([
|
||||||
|
'/email/dkim/example.com/default/metadata',
|
||||||
|
'/email/dkim/example.com/default/public.key',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await storageManager.delete('/email/dkim/example.com/default/metadata');
|
||||||
|
expect(await storageManager.get('/email/dkim/example.com/default/metadata')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup', async () => {
|
||||||
|
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
await tap.stopForcefully();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.9.2',
|
version: '13.19.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type IUnifiedEmailServerOptions,
|
type IUnifiedEmailServerOptions,
|
||||||
type IEmailRoute,
|
type IEmailRoute,
|
||||||
type IEmailDomainConfig,
|
type IEmailDomainConfig,
|
||||||
|
type IStorageManagerLike,
|
||||||
} from '@push.rocks/smartmta';
|
} from '@push.rocks/smartmta';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
|
||||||
@@ -29,6 +30,8 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
|
|||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
import { DnsManager } from './dns/manager.dns.js';
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||||
|
import { EmailDomainManager, SmartMtaStorageManager, buildEmailDnsRecords } from './email/index.js';
|
||||||
|
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||||
@@ -247,15 +250,13 @@ export class DcRouter {
|
|||||||
public radiusServer?: RadiusServer;
|
public radiusServer?: RadiusServer;
|
||||||
public opsServer!: OpsServer;
|
public opsServer!: OpsServer;
|
||||||
public metricsManager?: MetricsManager;
|
public metricsManager?: MetricsManager;
|
||||||
|
private emailEventSubscriptions: Array<{
|
||||||
|
emitter: { off(eventName: string, listener: (...args: any[]) => void): void };
|
||||||
|
eventName: string;
|
||||||
|
listener: (...args: any[]) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// Compatibility shim for smartmta's DkimManager which calls dcRouter.storageManager.set()
|
public storageManager: IStorageManagerLike;
|
||||||
public storageManager: any = {
|
|
||||||
get: async (_key: string) => null,
|
|
||||||
set: async (_key: string, _value: string) => {
|
|
||||||
// DKIM keys from smartmta — logged but not yet migrated to smartdata
|
|
||||||
logger.log('debug', `storageManager.set() called (compat shim) for key: ${_key}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
// Unified database (smartdata + LocalSmartDb or external MongoDB)
|
||||||
public dcRouterDb?: DcRouterDb;
|
public dcRouterDb?: DcRouterDb;
|
||||||
@@ -279,6 +280,7 @@ export class DcRouter {
|
|||||||
|
|
||||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||||
public acmeConfigManager?: AcmeConfigManager;
|
public acmeConfigManager?: AcmeConfigManager;
|
||||||
|
public emailDomainManager?: EmailDomainManager;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
public detectedPublicIp: string | null = null;
|
public detectedPublicIp: string | null = null;
|
||||||
@@ -310,8 +312,12 @@ export class DcRouter {
|
|||||||
// TypedRouter for API endpoints
|
// TypedRouter for API endpoints
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||||
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
|
||||||
|
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Environment access
|
// Environment access
|
||||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||||
@@ -324,6 +330,10 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Resolve all data paths from baseDir
|
// Resolve all data paths from baseDir
|
||||||
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
this.resolvedPaths = paths.resolvePaths(this.options.baseDir);
|
||||||
|
paths.ensureDataDirectories(this.resolvedPaths);
|
||||||
|
this.storageManager = new SmartMtaStorageManager(
|
||||||
|
plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize service manager and register all services
|
// Initialize service manager and register all services
|
||||||
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
this.serviceManager = new plugins.taskbuffer.ServiceManager({
|
||||||
@@ -439,6 +449,25 @@ export class DcRouter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email Domain Manager: optional, depends on DcRouterDb
|
||||||
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
|
this.serviceManager.addService(
|
||||||
|
new plugins.taskbuffer.Service('EmailDomainManager')
|
||||||
|
.optional()
|
||||||
|
.dependsOn('DcRouterDb')
|
||||||
|
.withStart(async () => {
|
||||||
|
this.emailDomainManager = new EmailDomainManager(this);
|
||||||
|
await this.emailDomainManager.start();
|
||||||
|
})
|
||||||
|
.withStop(async () => {
|
||||||
|
if (this.emailDomainManager) {
|
||||||
|
await this.emailDomainManager.stop();
|
||||||
|
this.emailDomainManager = undefined;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||||
const smartProxyDeps: string[] = [];
|
const smartProxyDeps: string[] = [];
|
||||||
if (this.options.dbConfig?.enabled !== false) {
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
@@ -528,11 +557,12 @@ export class DcRouter {
|
|||||||
await this.referenceResolver.initialize();
|
await this.referenceResolver.initialize();
|
||||||
|
|
||||||
// Initialize target profile manager
|
// Initialize target profile manager
|
||||||
this.targetProfileManager = new TargetProfileManager();
|
this.targetProfileManager = new TargetProfileManager(
|
||||||
|
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||||
|
);
|
||||||
await this.targetProfileManager.initialize();
|
await this.targetProfileManager.initialize();
|
||||||
|
|
||||||
this.routeConfigManager = new RouteConfigManager(
|
this.routeConfigManager = new RouteConfigManager(
|
||||||
() => this.getConstructorRoutes(),
|
|
||||||
() => this.smartProxy,
|
() => this.smartProxy,
|
||||||
() => this.options.http3,
|
() => this.options.http3,
|
||||||
this.options.vpnConfig?.enabled
|
this.options.vpnConfig?.enabled
|
||||||
@@ -542,12 +572,15 @@ export class DcRouter {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return this.targetProfileManager.getMatchingClientIps(
|
return this.targetProfileManager.getMatchingClientIps(
|
||||||
route, routeId, this.vpnManager.listClients(),
|
route,
|
||||||
|
routeId,
|
||||||
|
this.vpnManager.listClients(),
|
||||||
|
this.routeConfigManager?.getRoutes() || new Map(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
this.referenceResolver,
|
this.referenceResolver,
|
||||||
// Sync merged routes to RemoteIngressManager whenever routes change,
|
// Sync routes to RemoteIngressManager whenever routes change,
|
||||||
// then push updated derived ports to the Rust hub binary
|
// then push updated derived ports to the Rust hub binary
|
||||||
(routes) => {
|
(routes) => {
|
||||||
if (this.remoteIngressManager) {
|
if (this.remoteIngressManager) {
|
||||||
@@ -557,10 +590,17 @@ export class DcRouter {
|
|||||||
this.tunnelManager.syncAllowedEdges();
|
this.tunnelManager.syncAllowedEdges();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
undefined,
|
||||||
|
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
|
||||||
);
|
);
|
||||||
this.apiTokenManager = new ApiTokenManager();
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
await this.apiTokenManager.initialize();
|
await this.apiTokenManager.initialize();
|
||||||
await this.routeConfigManager.initialize();
|
await this.routeConfigManager.initialize(
|
||||||
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
|
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
|
);
|
||||||
|
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||||
|
|
||||||
// Seed default profiles/targets if DB is empty and seeding is enabled
|
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||||
const seeder = new DbSeeder(this.referenceResolver);
|
const seeder = new DbSeeder(this.referenceResolver);
|
||||||
@@ -581,19 +621,20 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Email Server: optional, depends on SmartProxy
|
// Email Server: optional, depends on SmartProxy
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
|
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||||
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
|
emailServiceDeps.push('EmailDomainManager');
|
||||||
|
}
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('EmailServer')
|
new plugins.taskbuffer.Service('EmailServer')
|
||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy')
|
.dependsOn(...emailServiceDeps)
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupUnifiedEmailHandling();
|
await this.setupUnifiedEmailHandling();
|
||||||
})
|
})
|
||||||
.withStop(async () => {
|
.withStop(async () => {
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
if ((this.emailServer as any).deliverySystem) {
|
this.clearEmailEventSubscriptions();
|
||||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
|
||||||
}
|
|
||||||
this.emailServer.removeAllListeners();
|
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
}
|
}
|
||||||
@@ -607,7 +648,7 @@ export class DcRouter {
|
|||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('DnsServer')
|
new plugins.taskbuffer.Service('DnsServer')
|
||||||
.optional()
|
.optional()
|
||||||
.dependsOn('SmartProxy')
|
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||||
.withStart(async () => {
|
.withStart(async () => {
|
||||||
await this.setupDnsWithSocketHandler();
|
await this.setupDnsWithSocketHandler();
|
||||||
})
|
})
|
||||||
@@ -864,31 +905,32 @@ export class DcRouter {
|
|||||||
this.smartProxy = undefined;
|
this.smartProxy = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
// Assemble serializable seed routes from constructor config — these will be seeded into DB
|
||||||
|
// by RouteConfigManager.initialize() when the ConfigManagers service starts.
|
||||||
|
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
|
||||||
|
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
|
||||||
|
|
||||||
// If user provides full SmartProxy config, use its routes.
|
this.seedEmailRoutes = [];
|
||||||
// NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
|
|
||||||
// AcmeConfigManager on first boot. The live ACME config always comes
|
|
||||||
// from the DB via `this.acmeConfigManager.getConfig()`.
|
|
||||||
if (this.options.smartProxyConfig) {
|
|
||||||
routes = this.options.smartProxyConfig.routes || [];
|
|
||||||
logger.log('info', `Found ${routes.length} routes in config`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If email config exists, automatically add email routes
|
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If DNS is configured, add DNS routes
|
this.seedDnsRoutes = [];
|
||||||
|
this.runtimeDnsRoutes = [];
|
||||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||||
const dnsRoutes = this.generateDnsRoutes();
|
this.seedDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: false });
|
||||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
this.runtimeDnsRoutes = this.generateDnsRoutes({ includeSocketHandler: true });
|
||||||
routes = [...routes, ...dnsRoutes];
|
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Combined routes for SmartProxy bootstrap (before DB routes are loaded)
|
||||||
|
let routes: plugins.smartproxy.IRouteConfig[] = [
|
||||||
|
...this.seedConfigRoutes,
|
||||||
|
...this.seedEmailRoutes,
|
||||||
|
...this.runtimeDnsRoutes,
|
||||||
|
];
|
||||||
|
|
||||||
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
||||||
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
||||||
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
||||||
@@ -913,15 +955,16 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
||||||
// ACME is enabled. The DnsManager dispatches each challenge to the right
|
// ACME is enabled. The DnsManager dispatches each challenge through the
|
||||||
// provider client based on the FQDN being certificated.
|
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
|
||||||
|
// zones and provider-managed zones. Only domains under management get certs.
|
||||||
let challengeHandlers: any[] = [];
|
let challengeHandlers: any[] = [];
|
||||||
if (
|
if (
|
||||||
acmeConfig &&
|
acmeConfig &&
|
||||||
this.dnsManager &&
|
this.dnsManager &&
|
||||||
(await this.dnsManager.hasAcmeCapableProvider())
|
(await this.dnsManager.hasAnyManagedDomain())
|
||||||
) {
|
) {
|
||||||
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
|
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)');
|
||||||
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
||||||
challengeHandlers.push(dns01Handler);
|
challengeHandlers.push(dns01Handler);
|
||||||
@@ -934,10 +977,6 @@ export class DcRouter {
|
|||||||
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
|
|
||||||
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
|
|
||||||
this.constructorRoutes = [...routes];
|
|
||||||
|
|
||||||
// If we have routes or need a basic SmartProxy instance, create it
|
// If we have routes or need a basic SmartProxy instance, create it
|
||||||
if (routes.length > 0 || this.options.smartProxyConfig) {
|
if (routes.length > 0 || this.options.smartProxyConfig) {
|
||||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||||
@@ -1305,19 +1344,20 @@ export class DcRouter {
|
|||||||
/**
|
/**
|
||||||
* Generate SmartProxy routes for DNS configuration
|
* Generate SmartProxy routes for DNS configuration
|
||||||
*/
|
*/
|
||||||
private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] {
|
private generateDnsRoutes(options?: { includeSocketHandler?: boolean }): plugins.smartproxy.IRouteConfig[] {
|
||||||
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
|
if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const includeSocketHandler = options?.includeSocketHandler !== false;
|
||||||
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const dnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
// Create routes for DNS-over-HTTPS paths
|
// Create routes for DNS-over-HTTPS paths
|
||||||
const dohPaths = ['/dns-query', '/resolve'];
|
const dohPaths = ['/dns-query', '/resolve'];
|
||||||
|
|
||||||
// Use the first nameserver domain for DoH routes
|
// Use the first nameserver domain for DoH routes
|
||||||
const primaryNameserver = this.options.dnsNsDomains[0];
|
const primaryNameserver = this.options.dnsNsDomains[0];
|
||||||
|
|
||||||
for (const path of dohPaths) {
|
for (const path of dohPaths) {
|
||||||
const dohRoute: plugins.smartproxy.IRouteConfig = {
|
const dohRoute: plugins.smartproxy.IRouteConfig = {
|
||||||
name: `dns-over-https-${path.replace('/', '')}`,
|
name: `dns-over-https-${path.replace('/', '')}`,
|
||||||
@@ -1326,18 +1366,42 @@ export class DcRouter {
|
|||||||
domains: [primaryNameserver],
|
domains: [primaryNameserver],
|
||||||
path: path
|
path: path
|
||||||
},
|
},
|
||||||
action: {
|
action: includeSocketHandler
|
||||||
type: 'socket-handler' as any,
|
? {
|
||||||
socketHandler: this.createDnsSocketHandler()
|
type: 'socket-handler' as any,
|
||||||
} as any
|
socketHandler: this.createDnsSocketHandler()
|
||||||
|
} as any
|
||||||
|
: {
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
} as any
|
||||||
};
|
};
|
||||||
|
|
||||||
dnsRoutes.push(dohRoute);
|
dnsRoutes.push(dohRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dnsRoutes;
|
return dnsRoutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
|
||||||
|
const routeName = storedRoute.route.name || '';
|
||||||
|
const isDohRoute = storedRoute.origin === 'dns'
|
||||||
|
&& storedRoute.route.action?.type === 'socket-handler'
|
||||||
|
&& routeName.startsWith('dns-over-https-');
|
||||||
|
|
||||||
|
if (!isDohRoute) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...storedRoute.route,
|
||||||
|
action: {
|
||||||
|
...storedRoute.route.action,
|
||||||
|
type: 'socket-handler' as any,
|
||||||
|
socketHandler: this.createDnsSocketHandler(),
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a domain matches a pattern (including wildcard support)
|
* Check if a domain matches a pattern (including wildcard support)
|
||||||
* @param domain The domain to check
|
* @param domain The domain to check
|
||||||
@@ -1388,14 +1452,6 @@ export class DcRouter {
|
|||||||
return names;
|
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() {
|
public async stop() {
|
||||||
logger.log('info', 'Stopping DcRouter services...');
|
logger.log('info', 'Stopping DcRouter services...');
|
||||||
|
|
||||||
@@ -1439,17 +1495,15 @@ export class DcRouter {
|
|||||||
// Update configuration
|
// Update configuration
|
||||||
this.options.smartProxyConfig = config;
|
this.options.smartProxyConfig = config;
|
||||||
|
|
||||||
// Update routes on RemoteIngressManager so derived ports stay in sync
|
// Start new SmartProxy with updated configuration (rebuilds seed routes)
|
||||||
if (this.remoteIngressManager && config.routes) {
|
|
||||||
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
|
||||||
await this.setupSmartProxy();
|
await this.setupSmartProxy();
|
||||||
|
|
||||||
// Re-apply programmatic routes and overrides after SmartProxy restart
|
// Re-seed and re-apply all routes after SmartProxy restart
|
||||||
if (this.routeConfigManager) {
|
if (this.routeConfigManager) {
|
||||||
await this.routeConfigManager.initialize();
|
await this.routeConfigManager.initialize(
|
||||||
|
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
|
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', 'SmartProxy configuration updated');
|
logger.log('info', 'SmartProxy configuration updated');
|
||||||
@@ -1496,40 +1550,74 @@ export class DcRouter {
|
|||||||
...this.options.emailConfig,
|
...this.options.emailConfig,
|
||||||
domains: transformedDomains,
|
domains: transformedDomains,
|
||||||
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
ports: this.options.emailConfig.ports.map(port => portMapping[port] || port + 10000),
|
||||||
hostname: 'localhost' // Listen on localhost for SmartProxy forwarding
|
persistRoutes: this.options.emailConfig.persistRoutes ?? false,
|
||||||
|
queue: {
|
||||||
|
storageType: 'disk',
|
||||||
|
persistentPath: plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-queue'),
|
||||||
|
...this.options.emailConfig.queue,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create unified email server
|
// Create unified email server
|
||||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||||
|
this.clearEmailEventSubscriptions();
|
||||||
|
|
||||||
// Set up error handling
|
// Set up error handling
|
||||||
this.emailServer.on('error', (err: Error) => {
|
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
await this.emailServer.start();
|
await this.emailServer.start();
|
||||||
|
|
||||||
// Wire delivery events to MetricsManager and logger
|
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||||
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) {
|
if (this.metricsManager && this.emailServer) {
|
||||||
this.emailServer.on('bounceProcessed', () => {
|
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
||||||
|
const emailLike = item?.processingResult;
|
||||||
|
const from = emailLike?.from || emailLike?.email?.from || '';
|
||||||
|
const recipients = Array.isArray(emailLike?.to)
|
||||||
|
? emailLike.to
|
||||||
|
: Array.isArray(emailLike?.email?.to)
|
||||||
|
? emailLike.email.to
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
recipients: recipients.filter(Boolean),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const updateQueueSize = () => {
|
||||||
|
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||||
|
const envelope = getEnvelope(item);
|
||||||
|
this.metricsManager!.trackEmailReceived(envelope.from);
|
||||||
|
updateQueueSize();
|
||||||
|
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||||
|
const envelope = getEnvelope(item);
|
||||||
|
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
||||||
|
updateQueueSize();
|
||||||
|
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||||
|
const envelope = getEnvelope(item);
|
||||||
|
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
||||||
|
updateQueueSize();
|
||||||
|
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||||
|
updateQueueSize();
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||||
|
updateQueueSize();
|
||||||
|
});
|
||||||
|
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
|
||||||
this.metricsManager!.trackEmailBounced();
|
this.metricsManager!.trackEmailBounced();
|
||||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||||
});
|
});
|
||||||
|
updateQueueSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
|
||||||
@@ -1559,11 +1647,7 @@ export class DcRouter {
|
|||||||
try {
|
try {
|
||||||
// Stop the unified email server which contains all components
|
// Stop the unified email server which contains all components
|
||||||
if (this.emailServer) {
|
if (this.emailServer) {
|
||||||
// Remove listeners before stopping to prevent leaks on config update cycles
|
this.clearEmailEventSubscriptions();
|
||||||
if ((this.emailServer as any).deliverySystem) {
|
|
||||||
(this.emailServer as any).deliverySystem.removeAllListeners();
|
|
||||||
}
|
|
||||||
this.emailServer.removeAllListeners();
|
|
||||||
await this.emailServer.stop();
|
await this.emailServer.stop();
|
||||||
logger.log('info', 'Unified email server stopped');
|
logger.log('info', 'Unified email server stopped');
|
||||||
this.emailServer = undefined;
|
this.emailServer = undefined;
|
||||||
@@ -1768,14 +1852,14 @@ export class DcRouter {
|
|||||||
// Generate and register authoritative records
|
// Generate and register authoritative records
|
||||||
const authoritativeRecords = await this.generateAuthoritativeRecords();
|
const authoritativeRecords = await this.generateAuthoritativeRecords();
|
||||||
|
|
||||||
// Generate email DNS records
|
// Generate email DNS records
|
||||||
const emailDnsRecords = await this.generateEmailDnsRecords();
|
const emailDnsRecords = await this.generateEmailDnsRecords();
|
||||||
|
|
||||||
// Initialize DKIM for all email domains
|
// Ensure DKIM keys exist for internal-dns domains before generating records.
|
||||||
await this.initializeDkimForEmailDomains();
|
await this.initializeDkimForEmailDomains();
|
||||||
|
|
||||||
// Load DKIM records from JSON files (they should now exist)
|
// Generate DKIM records directly from smartmta instead of scanning legacy JSON files.
|
||||||
const dkimRecords = await this.loadDkimRecords();
|
const dkimRecords = await this.loadDkimRecords();
|
||||||
|
|
||||||
// Combine all records: authoritative, email, DKIM, and user-defined
|
// Combine all records: authoritative, email, DKIM, and user-defined
|
||||||
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
|
const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
|
||||||
@@ -1886,37 +1970,20 @@ export class DcRouter {
|
|||||||
for (const domainConfig of internalDnsDomains) {
|
for (const domainConfig of internalDnsDomains) {
|
||||||
const domain = domainConfig.domain;
|
const domain = domainConfig.domain;
|
||||||
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
const ttl = domainConfig.dns?.internal?.ttl || 3600;
|
||||||
const mxPriority = domainConfig.dns?.internal?.mxPriority || 10;
|
const requiredRecords = buildEmailDnsRecords({
|
||||||
|
domain,
|
||||||
// MX record - points to the domain itself for email handling
|
hostname: this.options.emailConfig.hostname,
|
||||||
records.push({
|
mxPriority: domainConfig.dns?.internal?.mxPriority,
|
||||||
name: domain,
|
}).filter((record) => !record.name.includes('._domainkey.'));
|
||||||
type: 'MX',
|
|
||||||
value: `${mxPriority} ${domain}`,
|
for (const record of requiredRecords) {
|
||||||
ttl
|
records.push({
|
||||||
});
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
// SPF record - using sensible defaults
|
value: record.value,
|
||||||
const spfRecord = 'v=spf1 a mx ~all';
|
ttl,
|
||||||
records.push({
|
});
|
||||||
name: domain,
|
}
|
||||||
type: 'TXT',
|
|
||||||
value: spfRecord,
|
|
||||||
ttl
|
|
||||||
});
|
|
||||||
|
|
||||||
// DMARC record - using sensible defaults
|
|
||||||
const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring
|
|
||||||
const dmarcEmail = `dmarc@${domain}`;
|
|
||||||
records.push({
|
|
||||||
name: `_dmarc.${domain}`,
|
|
||||||
type: 'TXT',
|
|
||||||
value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`,
|
|
||||||
ttl
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: DKIM records will be generated later when DKIM keys are available
|
|
||||||
// They require the DKIMCreator which is part of the email server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
|
logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
|
||||||
@@ -1924,54 +1991,30 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load DKIM records from JSON files
|
* Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
|
||||||
* Reads all *.dkimrecord.json files from the DNS records directory
|
|
||||||
*/
|
*/
|
||||||
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
|
||||||
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
|
||||||
|
if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
|
||||||
try {
|
return records;
|
||||||
// Ensure paths are imported
|
}
|
||||||
const dnsDir = this.resolvedPaths.dnsRecordsDir;
|
|
||||||
|
for (const domainConfig of this.options.emailConfig.domains) {
|
||||||
// Check if directory exists
|
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||||
if (!plugins.fs.existsSync(dnsDir)) {
|
continue;
|
||||||
logger.log('debug', 'No DNS records directory found, skipping DKIM record loading');
|
|
||||||
return records;
|
|
||||||
}
|
}
|
||||||
|
const selector = domainConfig.dkim?.selector || 'default';
|
||||||
// Read all files in the directory
|
try {
|
||||||
const files = plugins.fs.readdirSync(dnsDir);
|
const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
|
||||||
const dkimFiles = files.filter(f => f.endsWith('.dkimrecord.json'));
|
records.push({
|
||||||
|
name: dkimRecord.name,
|
||||||
logger.log('info', `Found ${dkimFiles.length} DKIM record files`);
|
type: 'TXT',
|
||||||
|
value: dkimRecord.value,
|
||||||
// Load each DKIM record
|
ttl: domainConfig.dns?.internal?.ttl || 3600,
|
||||||
for (const file of dkimFiles) {
|
});
|
||||||
try {
|
} catch (error: unknown) {
|
||||||
const filePath = plugins.path.join(dnsDir, file);
|
logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||||
const fileContent = plugins.fs.readFileSync(filePath, 'utf8');
|
|
||||||
const dkimRecord = JSON.parse(fileContent);
|
|
||||||
|
|
||||||
// Validate record structure
|
|
||||||
if (dkimRecord.name && dkimRecord.type === 'TXT' && dkimRecord.value) {
|
|
||||||
records.push({
|
|
||||||
name: dkimRecord.name,
|
|
||||||
type: 'TXT',
|
|
||||||
value: dkimRecord.value,
|
|
||||||
ttl: 3600 // Standard DKIM TTL
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log('info', `Loaded DKIM record for ${dkimRecord.name}`);
|
|
||||||
} else {
|
|
||||||
logger.log('warn', `Invalid DKIM record structure in ${file}`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to load DKIM record from ${file}: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.log('error', `Failed to load DKIM records: ${(error as Error).message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return records;
|
return records;
|
||||||
@@ -1998,12 +2041,17 @@ export class DcRouter {
|
|||||||
// Ensure necessary directories exist
|
// Ensure necessary directories exist
|
||||||
paths.ensureDataDirectories(this.resolvedPaths);
|
paths.ensureDataDirectories(this.resolvedPaths);
|
||||||
|
|
||||||
// Generate DKIM keys for each email domain
|
// Generate DKIM keys for each internal-dns email domain using the configured selector.
|
||||||
for (const domainConfig of this.options.emailConfig.domains) {
|
for (const domainConfig of this.options.emailConfig.domains) {
|
||||||
|
if (domainConfig.dnsMode !== 'internal-dns') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Generate DKIM keys for all domains, regardless of DNS mode
|
await dkimCreator.handleDKIMKeysForSelector(
|
||||||
// This ensures keys are ready even if DNS mode changes later
|
domainConfig.domain,
|
||||||
await dkimCreator.handleDKIMKeysForDomain(domainConfig.domain);
|
domainConfig.dkim?.selector || 'default',
|
||||||
|
domainConfig.dkim?.keySize || 2048,
|
||||||
|
);
|
||||||
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
|
||||||
@@ -2133,6 +2181,25 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addEmailEventSubscription(
|
||||||
|
emitter: {
|
||||||
|
on(eventName: string, listener: (...args: any[]) => void): void;
|
||||||
|
off(eventName: string, listener: (...args: any[]) => void): void;
|
||||||
|
},
|
||||||
|
eventName: string,
|
||||||
|
listener: (...args: any[]) => void,
|
||||||
|
): void {
|
||||||
|
emitter.on(eventName, listener);
|
||||||
|
this.emailEventSubscriptions.push({ emitter, eventName, listener });
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearEmailEventSubscriptions(): void {
|
||||||
|
for (const subscription of this.emailEventSubscriptions) {
|
||||||
|
subscription.emitter.off(subscription.eventName, subscription.listener);
|
||||||
|
}
|
||||||
|
this.emailEventSubscriptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect the server's public IP address
|
* Detect the server's public IP address
|
||||||
@@ -2167,13 +2234,14 @@ export class DcRouter {
|
|||||||
this.remoteIngressManager = new RemoteIngressManager();
|
this.remoteIngressManager = new RemoteIngressManager();
|
||||||
await this.remoteIngressManager.initialize();
|
await this.remoteIngressManager.initialize();
|
||||||
|
|
||||||
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||||
const currentRoutes = this.constructorRoutes;
|
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
// will push the complete merged routes here.
|
||||||
|
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
|
||||||
|
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
|
||||||
|
|
||||||
// Race-condition fix: if ConfigManagers finished before us, re-apply routes
|
// If ConfigManagers finished before us, re-apply routes
|
||||||
// so the callback delivers the full merged set (including DB-stored routes)
|
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
|
||||||
// to our newly-created remoteIngressManager.
|
|
||||||
if (this.routeConfigManager) {
|
if (this.routeConfigManager) {
|
||||||
await this.routeConfigManager.applyRoutes();
|
await this.routeConfigManager.applyRoutes();
|
||||||
}
|
}
|
||||||
@@ -2260,11 +2328,10 @@ export class DcRouter {
|
|||||||
|
|
||||||
if (!this.targetProfileManager) return [...ips];
|
if (!this.targetProfileManager) return [...ips];
|
||||||
|
|
||||||
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
|
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||||
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
|
|
||||||
|
|
||||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||||
targetProfileIds, routes, storedRoutes,
|
targetProfileIds, allRoutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add target IPs directly
|
// Add target IPs directly
|
||||||
@@ -2274,8 +2341,11 @@ export class DcRouter {
|
|||||||
|
|
||||||
// Resolve DNS A records for matched domains (with caching)
|
// Resolve DNS A records for matched domains (with caching)
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const stripped = domain.replace(/^\*\./, '');
|
if (this.isWildcardVpnDomain(domain)) {
|
||||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
this.logSkippedWildcardAllowedIp(domain);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||||
for (const ip of resolvedIps) {
|
for (const ip of resolvedIps) {
|
||||||
ips.add(`${ip}/32`);
|
ips.add(`${ip}/32`);
|
||||||
}
|
}
|
||||||
@@ -2287,14 +2357,15 @@ export class DcRouter {
|
|||||||
|
|
||||||
await this.vpnManager.start();
|
await this.vpnManager.start();
|
||||||
|
|
||||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
|
||||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
// get correct profile-based ipAllowLists
|
||||||
// VPN server wasn't ready yet)
|
|
||||||
await this.routeConfigManager?.applyRoutes();
|
await this.routeConfigManager?.applyRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||||
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
|
||||||
|
/** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
|
||||||
|
private warnedWildcardVpnDomains = new Set<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
* Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
|
||||||
@@ -2320,6 +2391,19 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isWildcardVpnDomain(domain: string): boolean {
|
||||||
|
return domain.includes('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
private logSkippedWildcardAllowedIp(domain: string): void {
|
||||||
|
if (this.warnedWildcardVpnDomains.has(domain)) return;
|
||||||
|
this.warnedWildcardVpnDomains.add(domain);
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
`VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
// VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
|
||||||
// via the getVpnAllowList callback — no longer a separate method here.
|
// via the getVpnAllowList callback — no longer a separate method here.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
ISourceProfile,
|
ISourceProfile,
|
||||||
INetworkTarget,
|
INetworkTarget,
|
||||||
IRouteMetadata,
|
IRouteMetadata,
|
||||||
IStoredRoute,
|
IRoute,
|
||||||
IRouteSecurity,
|
IRouteSecurity,
|
||||||
} from '../../ts_interfaces/data/route-management.js';
|
} from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export class ReferenceResolver {
|
|||||||
public async deleteProfile(
|
public async deleteProfile(
|
||||||
id: string,
|
id: string,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
storedRoutes?: Map<string, IStoredRoute>,
|
storedRoutes?: Map<string, IRoute>,
|
||||||
): Promise<{ success: boolean; message?: string }> {
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
const profile = this.profiles.get(id);
|
const profile = this.profiles.get(id);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
@@ -131,7 +131,7 @@ export class ReferenceResolver {
|
|||||||
return [...this.profiles.values()];
|
return [...this.profiles.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
|
||||||
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
|
||||||
for (const profile of this.profiles.values()) {
|
for (const profile of this.profiles.values()) {
|
||||||
usage.set(profile.id, []);
|
usage.set(profile.id, []);
|
||||||
@@ -147,7 +147,7 @@ export class ReferenceResolver {
|
|||||||
|
|
||||||
public getProfileUsageForId(
|
public getProfileUsageForId(
|
||||||
profileId: string,
|
profileId: string,
|
||||||
storedRoutes: Map<string, IStoredRoute>,
|
storedRoutes: Map<string, IRoute>,
|
||||||
): Array<{ id: string; routeName: string }> {
|
): Array<{ id: string; routeName: string }> {
|
||||||
const routes: Array<{ id: string; routeName: string }> = [];
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
for (const [routeId, stored] of storedRoutes) {
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
@@ -214,7 +214,7 @@ export class ReferenceResolver {
|
|||||||
public async deleteTarget(
|
public async deleteTarget(
|
||||||
id: string,
|
id: string,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
storedRoutes?: Map<string, IStoredRoute>,
|
storedRoutes?: Map<string, IRoute>,
|
||||||
): Promise<{ success: boolean; message?: string }> {
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
const target = this.targets.get(id);
|
const target = this.targets.get(id);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -263,7 +263,7 @@ export class ReferenceResolver {
|
|||||||
|
|
||||||
public getTargetUsageForId(
|
public getTargetUsageForId(
|
||||||
targetId: string,
|
targetId: string,
|
||||||
storedRoutes: Map<string, IStoredRoute>,
|
storedRoutes: Map<string, IRoute>,
|
||||||
): Array<{ id: string; routeName: string }> {
|
): Array<{ id: string; routeName: string }> {
|
||||||
const routes: Array<{ id: string; routeName: string }> = [];
|
const routes: Array<{ id: string; routeName: string }> = [];
|
||||||
for (const [routeId, stored] of storedRoutes) {
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
@@ -334,20 +334,20 @@ export class ReferenceResolver {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||||
const docs = await StoredRouteDoc.findAll();
|
const docs = await RouteDoc.findAll();
|
||||||
return docs
|
return docs
|
||||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||||
.map((doc) => doc.id);
|
.map((doc) => doc.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||||
const docs = await StoredRouteDoc.findAll();
|
const docs = await RouteDoc.findAll();
|
||||||
return docs
|
return docs
|
||||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||||
.map((doc) => doc.id);
|
.map((doc) => doc.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const [routeId, stored] of storedRoutes) {
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||||
@@ -357,7 +357,7 @@ export class ReferenceResolver {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const [routeId, stored] of storedRoutes) {
|
for (const [routeId, stored] of storedRoutes) {
|
||||||
if (stored.metadata?.networkTargetRef === targetId) {
|
if (stored.metadata?.networkTargetRef === targetId) {
|
||||||
@@ -547,7 +547,7 @@ export class ReferenceResolver {
|
|||||||
|
|
||||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
for (const routeId of routeIds) {
|
for (const routeId of routeIds) {
|
||||||
const doc = await StoredRouteDoc.findById(routeId);
|
const doc = await RouteDoc.findById(routeId);
|
||||||
if (doc?.metadata) {
|
if (doc?.metadata) {
|
||||||
doc.metadata = {
|
doc.metadata = {
|
||||||
...doc.metadata,
|
...doc.metadata,
|
||||||
@@ -562,7 +562,7 @@ export class ReferenceResolver {
|
|||||||
|
|
||||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||||
for (const routeId of routeIds) {
|
for (const routeId of routeIds) {
|
||||||
const doc = await StoredRouteDoc.findById(routeId);
|
const doc = await RouteDoc.findById(routeId);
|
||||||
if (doc?.metadata) {
|
if (doc?.metadata) {
|
||||||
doc.metadata = {
|
doc.metadata = {
|
||||||
...doc.metadata,
|
...doc.metadata,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
import { RouteDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
IStoredRoute,
|
IRoute,
|
||||||
IRouteOverride,
|
|
||||||
IMergedRoute,
|
IMergedRoute,
|
||||||
IRouteWarning,
|
IRouteWarning,
|
||||||
IRouteMetadata,
|
IRouteMetadata,
|
||||||
@@ -15,6 +14,11 @@ import type { ReferenceResolver } from './classes.reference-resolver.js';
|
|||||||
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
|
||||||
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
export type TIpAllowEntry = string | { ip: string; domains: string[] };
|
||||||
|
|
||||||
|
export interface IRouteMutationResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
||||||
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
||||||
@@ -46,66 +50,64 @@ class RouteUpdateMutex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RouteConfigManager {
|
export class RouteConfigManager {
|
||||||
private storedRoutes = new Map<string, IStoredRoute>();
|
private routes = new Map<string, IRoute>();
|
||||||
private overrides = new Map<string, IRouteOverride>();
|
|
||||||
private warnings: IRouteWarning[] = [];
|
private warnings: IRouteWarning[] = [];
|
||||||
private routeUpdateMutex = new RouteUpdateMutex();
|
private routeUpdateMutex = new RouteUpdateMutex();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
|
||||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||||
private referenceResolver?: ReferenceResolver,
|
private referenceResolver?: ReferenceResolver,
|
||||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||||
|
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||||
|
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Expose stored routes map for reference resolution lookups. */
|
/** Expose routes map for reference resolution lookups. */
|
||||||
public getStoredRoutes(): Map<string, IStoredRoute> {
|
public getRoutes(): Map<string, IRoute> {
|
||||||
return this.storedRoutes;
|
return this.routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoute(id: string): IRoute | undefined {
|
||||||
|
return this.routes.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load persisted routes and overrides, compute warnings, apply to SmartProxy.
|
* Load persisted routes, seed serializable config/email/dns routes,
|
||||||
|
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||||
*/
|
*/
|
||||||
public async initialize(): Promise<void> {
|
public async initialize(
|
||||||
await this.loadStoredRoutes();
|
configRoutes: IDcRouterRouteConfig[] = [],
|
||||||
await this.loadOverrides();
|
emailRoutes: IDcRouterRouteConfig[] = [],
|
||||||
|
dnsRoutes: IDcRouterRouteConfig[] = [],
|
||||||
|
): Promise<void> {
|
||||||
|
await this.loadRoutes();
|
||||||
|
await this.seedRoutes(configRoutes, 'config');
|
||||||
|
await this.seedRoutes(emailRoutes, 'email');
|
||||||
|
await this.seedRoutes(dnsRoutes, 'dns');
|
||||||
this.computeWarnings();
|
this.computeWarnings();
|
||||||
this.logWarnings();
|
this.logWarnings();
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Merged view
|
// Route listing
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||||
const merged: IMergedRoute[] = [];
|
const merged: IMergedRoute[] = [];
|
||||||
|
|
||||||
// Hardcoded routes
|
for (const route of this.routes.values()) {
|
||||||
for (const route of this.getHardcodedRoutes()) {
|
|
||||||
const name = route.name || '';
|
|
||||||
const override = this.overrides.get(name);
|
|
||||||
merged.push({
|
merged.push({
|
||||||
route,
|
route: route.route,
|
||||||
source: 'hardcoded',
|
id: route.id,
|
||||||
enabled: override ? override.enabled : true,
|
enabled: route.enabled,
|
||||||
overridden: !!override,
|
origin: route.origin,
|
||||||
});
|
systemKey: route.systemKey,
|
||||||
}
|
createdAt: route.createdAt,
|
||||||
|
updatedAt: route.updatedAt,
|
||||||
// Programmatic routes
|
metadata: route.metadata,
|
||||||
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,
|
|
||||||
metadata: stored.metadata,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Programmatic route CRUD
|
// Route CRUD
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async createRoute(
|
public async createRoute(
|
||||||
@@ -127,7 +129,7 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
// Ensure route has a name
|
// Ensure route has a name
|
||||||
if (!route.name) {
|
if (!route.name) {
|
||||||
route.name = `programmatic-${id.slice(0, 8)}`;
|
route.name = `route-${id.slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve references if metadata has refs and resolver is available
|
// Resolve references if metadata has refs and resolver is available
|
||||||
@@ -138,17 +140,18 @@ export class RouteConfigManager {
|
|||||||
resolvedMetadata = resolved.metadata;
|
resolvedMetadata = resolved.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored: IStoredRoute = {
|
const stored: IRoute = {
|
||||||
id,
|
id,
|
||||||
route,
|
route,
|
||||||
enabled,
|
enabled,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy,
|
createdBy,
|
||||||
|
origin: 'api',
|
||||||
metadata: resolvedMetadata,
|
metadata: resolvedMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storedRoutes.set(id, stored);
|
this.routes.set(id, stored);
|
||||||
await this.persistRoute(stored);
|
await this.persistRoute(stored);
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return id;
|
return id;
|
||||||
@@ -161,9 +164,21 @@ export class RouteConfigManager {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
metadata?: Partial<IRouteMetadata>;
|
metadata?: Partial<IRouteMetadata>;
|
||||||
},
|
},
|
||||||
): Promise<boolean> {
|
): Promise<IRouteMutationResult> {
|
||||||
const stored = this.storedRoutes.get(id);
|
const stored = this.routes.get(id);
|
||||||
if (!stored) return false;
|
if (!stored) {
|
||||||
|
return { success: false, message: 'Route not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||||
|
&& patch.route === undefined
|
||||||
|
&& patch.metadata === undefined;
|
||||||
|
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'System routes are managed by the system and can only be toggled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (patch.route) {
|
if (patch.route) {
|
||||||
const mergedAction = patch.route.action
|
const mergedAction = patch.route.action
|
||||||
@@ -197,120 +212,193 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
await this.persistRoute(stored);
|
await this.persistRoute(stored);
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return true;
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteRoute(id: string): Promise<boolean> {
|
public async deleteRoute(id: string): Promise<IRouteMutationResult> {
|
||||||
if (!this.storedRoutes.has(id)) return false;
|
const stored = this.routes.get(id);
|
||||||
this.storedRoutes.delete(id);
|
if (!stored) {
|
||||||
const doc = await StoredRouteDoc.findById(id);
|
return { success: false, message: 'Route not found' };
|
||||||
|
}
|
||||||
|
if (stored.origin !== 'api') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'System routes are managed by the system and cannot be deleted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.routes.delete(id);
|
||||||
|
const doc = await RouteDoc.findById(id);
|
||||||
if (doc) await doc.delete();
|
if (doc) await doc.delete();
|
||||||
await this.applyRoutes();
|
await this.applyRoutes();
|
||||||
return true;
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async toggleRoute(id: string, enabled: boolean): Promise<boolean> {
|
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
||||||
return this.updateRoute(id, { enabled });
|
return this.updateRoute(id, { enabled });
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Hardcoded route overrides
|
// Private: seed routes from constructor config
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
/**
|
||||||
const override: IRouteOverride = {
|
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||||
routeName,
|
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||||
enabled,
|
*/
|
||||||
updatedAt: Date.now(),
|
private async seedRoutes(
|
||||||
updatedBy,
|
seedRoutes: IDcRouterRouteConfig[],
|
||||||
};
|
origin: 'config' | 'email' | 'dns',
|
||||||
this.overrides.set(routeName, override);
|
): Promise<void> {
|
||||||
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
|
const seedSystemKeys = new Set<string>();
|
||||||
if (existingDoc) {
|
const seedNames = new Set<string>();
|
||||||
existingDoc.enabled = override.enabled;
|
let seeded = 0;
|
||||||
existingDoc.updatedAt = override.updatedAt;
|
let updated = 0;
|
||||||
existingDoc.updatedBy = override.updatedBy;
|
|
||||||
await existingDoc.save();
|
|
||||||
} else {
|
|
||||||
const doc = new RouteOverrideDoc();
|
|
||||||
doc.routeName = override.routeName;
|
|
||||||
doc.enabled = override.enabled;
|
|
||||||
doc.updatedAt = override.updatedAt;
|
|
||||||
doc.updatedBy = override.updatedBy;
|
|
||||||
await doc.save();
|
|
||||||
}
|
|
||||||
this.computeWarnings();
|
|
||||||
await this.applyRoutes();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async removeOverride(routeName: string): Promise<boolean> {
|
for (const route of seedRoutes) {
|
||||||
if (!this.overrides.has(routeName)) return false;
|
const name = route.name || '';
|
||||||
this.overrides.delete(routeName);
|
if (name) {
|
||||||
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
seedNames.add(name);
|
||||||
if (doc) await doc.delete();
|
}
|
||||||
this.computeWarnings();
|
const systemKey = this.buildSystemRouteKey(origin, route);
|
||||||
await this.applyRoutes();
|
if (systemKey) {
|
||||||
return true;
|
seedSystemKeys.add(systemKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingId = this.findExistingSeedRouteId(origin, route, systemKey);
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
// Update route config but preserve enabled state
|
||||||
|
const existing = this.routes.get(existingId)!;
|
||||||
|
existing.route = route;
|
||||||
|
existing.systemKey = systemKey;
|
||||||
|
existing.updatedAt = Date.now();
|
||||||
|
await this.persistRoute(existing);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
// Insert new seed route
|
||||||
|
const id = plugins.uuid.v4();
|
||||||
|
const now = Date.now();
|
||||||
|
const newRoute: IRoute = {
|
||||||
|
id,
|
||||||
|
route,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: 'system',
|
||||||
|
origin,
|
||||||
|
systemKey,
|
||||||
|
};
|
||||||
|
this.routes.set(id, newRoute);
|
||||||
|
await this.persistRoute(newRoute);
|
||||||
|
seeded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete stale routes: same origin but name not in current seed set
|
||||||
|
const staleIds: string[] = [];
|
||||||
|
for (const [id, r] of this.routes) {
|
||||||
|
if (r.origin !== origin) continue;
|
||||||
|
|
||||||
|
const routeName = r.route.name || '';
|
||||||
|
const matchesSeedSystemKey = r.systemKey ? seedSystemKeys.has(r.systemKey) : false;
|
||||||
|
const matchesSeedName = routeName ? seedNames.has(routeName) : false;
|
||||||
|
if (!matchesSeedSystemKey && !matchesSeedName) {
|
||||||
|
staleIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of staleIds) {
|
||||||
|
this.routes.delete(id);
|
||||||
|
const doc = await RouteDoc.findById(id);
|
||||||
|
if (doc) await doc.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seeded > 0 || updated > 0 || staleIds.length > 0) {
|
||||||
|
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: persistence
|
// Private: persistence
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
private async loadStoredRoutes(): Promise<void> {
|
private buildSystemRouteKey(
|
||||||
const docs = await StoredRouteDoc.findAll();
|
origin: 'config' | 'email' | 'dns',
|
||||||
for (const doc of docs) {
|
route: IDcRouterRouteConfig,
|
||||||
if (doc.id) {
|
): string | undefined {
|
||||||
this.storedRoutes.set(doc.id, {
|
const name = route.name?.trim();
|
||||||
id: doc.id,
|
if (!name) return undefined;
|
||||||
route: doc.route,
|
return `${origin}:${name}`;
|
||||||
enabled: doc.enabled,
|
}
|
||||||
createdAt: doc.createdAt,
|
|
||||||
updatedAt: doc.updatedAt,
|
private findExistingSeedRouteId(
|
||||||
createdBy: doc.createdBy,
|
origin: 'config' | 'email' | 'dns',
|
||||||
metadata: doc.metadata,
|
route: IDcRouterRouteConfig,
|
||||||
});
|
systemKey?: string,
|
||||||
|
): string | undefined {
|
||||||
|
const routeName = route.name || '';
|
||||||
|
|
||||||
|
for (const [id, storedRoute] of this.routes) {
|
||||||
|
if (storedRoute.origin !== origin) continue;
|
||||||
|
|
||||||
|
if (systemKey && storedRoute.systemKey === systemKey) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedRoute.route.name === routeName) {
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.storedRoutes.size > 0) {
|
|
||||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRoutes(): Promise<void> {
|
||||||
|
const docs = await RouteDoc.findAll();
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (!doc.id) continue;
|
||||||
|
|
||||||
|
const storedRoute: IRoute = {
|
||||||
|
id: doc.id,
|
||||||
|
route: doc.route,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
createdBy: doc.createdBy,
|
||||||
|
origin: doc.origin || 'api',
|
||||||
|
systemKey: doc.systemKey,
|
||||||
|
metadata: doc.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.routes.set(doc.id, storedRoute);
|
||||||
|
}
|
||||||
|
if (this.routes.size > 0) {
|
||||||
|
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadOverrides(): Promise<void> {
|
private async persistRoute(stored: IRoute): Promise<void> {
|
||||||
const docs = await RouteOverrideDoc.findAll();
|
const existingDoc = await RouteDoc.findById(stored.id);
|
||||||
for (const doc of docs) {
|
|
||||||
if (doc.routeName) {
|
|
||||||
this.overrides.set(doc.routeName, {
|
|
||||||
routeName: doc.routeName,
|
|
||||||
enabled: doc.enabled,
|
|
||||||
updatedAt: doc.updatedAt,
|
|
||||||
updatedBy: doc.updatedBy,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.overrides.size > 0) {
|
|
||||||
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistRoute(stored: IStoredRoute): Promise<void> {
|
|
||||||
const existingDoc = await StoredRouteDoc.findById(stored.id);
|
|
||||||
if (existingDoc) {
|
if (existingDoc) {
|
||||||
existingDoc.route = stored.route;
|
existingDoc.route = stored.route;
|
||||||
existingDoc.enabled = stored.enabled;
|
existingDoc.enabled = stored.enabled;
|
||||||
existingDoc.updatedAt = stored.updatedAt;
|
existingDoc.updatedAt = stored.updatedAt;
|
||||||
existingDoc.createdBy = stored.createdBy;
|
existingDoc.createdBy = stored.createdBy;
|
||||||
|
existingDoc.origin = stored.origin;
|
||||||
|
existingDoc.systemKey = stored.systemKey;
|
||||||
existingDoc.metadata = stored.metadata;
|
existingDoc.metadata = stored.metadata;
|
||||||
await existingDoc.save();
|
await existingDoc.save();
|
||||||
} else {
|
} else {
|
||||||
const doc = new StoredRouteDoc();
|
const doc = new RouteDoc();
|
||||||
doc.id = stored.id;
|
doc.id = stored.id;
|
||||||
doc.route = stored.route;
|
doc.route = stored.route;
|
||||||
doc.enabled = stored.enabled;
|
doc.enabled = stored.enabled;
|
||||||
doc.createdAt = stored.createdAt;
|
doc.createdAt = stored.createdAt;
|
||||||
doc.updatedAt = stored.updatedAt;
|
doc.updatedAt = stored.updatedAt;
|
||||||
doc.createdBy = stored.createdBy;
|
doc.createdBy = stored.createdBy;
|
||||||
|
doc.origin = stored.origin;
|
||||||
|
doc.systemKey = stored.systemKey;
|
||||||
doc.metadata = stored.metadata;
|
doc.metadata = stored.metadata;
|
||||||
await doc.save();
|
await doc.save();
|
||||||
}
|
}
|
||||||
@@ -322,33 +410,14 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
private computeWarnings(): void {
|
private computeWarnings(): void {
|
||||||
this.warnings = [];
|
this.warnings = [];
|
||||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
|
||||||
|
|
||||||
// Check overrides
|
for (const route of this.routes.values()) {
|
||||||
for (const [routeName, override] of this.overrides) {
|
if (!route.enabled) {
|
||||||
if (!hardcodedNames.has(routeName)) {
|
const name = route.route.name || route.id;
|
||||||
this.warnings.push({
|
this.warnings.push({
|
||||||
type: 'orphaned-override',
|
type: 'disabled-route',
|
||||||
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,
|
routeName: name,
|
||||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
message: `Route '${name}' (id: ${route.id}) is disabled`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +441,7 @@ export class RouteConfigManager {
|
|||||||
if (!this.referenceResolver || routeIds.length === 0) return;
|
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||||
|
|
||||||
for (const routeId of routeIds) {
|
for (const routeId of routeIds) {
|
||||||
const stored = this.storedRoutes.get(routeId);
|
const stored = this.routes.get(routeId);
|
||||||
if (!stored?.metadata) continue;
|
if (!stored?.metadata) continue;
|
||||||
|
|
||||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||||
@@ -387,7 +456,7 @@ export class RouteConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: apply merged routes to SmartProxy
|
// Apply routes to SmartProxy
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async applyRoutes(): Promise<void> {
|
public async applyRoutes(): Promise<void> {
|
||||||
@@ -397,54 +466,66 @@ export class RouteConfigManager {
|
|||||||
|
|
||||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
|
|
||||||
const http3Config = this.getHttp3Config?.();
|
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
for (const route of this.routes.values()) {
|
||||||
|
if (route.enabled) {
|
||||||
// Helper: inject VPN security into a vpnOnly route
|
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
||||||
const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
|
|
||||||
if (!vpnCallback) return route;
|
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
|
||||||
if (!dcRoute.vpnOnly) return route;
|
|
||||||
const vpnEntries = vpnCallback(dcRoute, routeId);
|
|
||||||
const existingEntries = route.security?.ipAllowList || [];
|
|
||||||
return {
|
|
||||||
...route,
|
|
||||||
security: {
|
|
||||||
...route.security,
|
|
||||||
ipAllowList: [...existingEntries, ...vpnEntries],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
|
|
||||||
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(injectVpn(route));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||||
for (const stored of this.storedRoutes.values()) {
|
for (const route of runtimeRoutes) {
|
||||||
if (stored.enabled) {
|
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||||
let route = stored.route;
|
|
||||||
if (http3Config?.enabled !== false) {
|
|
||||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
|
||||||
}
|
|
||||||
enabledRoutes.push(injectVpn(route, stored.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await smartProxy.updateRoutes(enabledRoutes);
|
await smartProxy.updateRoutes(enabledRoutes);
|
||||||
|
|
||||||
// Notify listeners (e.g. RemoteIngressManager) of the merged route set
|
// Notify listeners (e.g. RemoteIngressManager) of the route set
|
||||||
if (this.onRoutesApplied) {
|
if (this.onRoutesApplied) {
|
||||||
this.onRoutesApplied(enabledRoutes);
|
this.onRoutesApplied(enabledRoutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
|
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
||||||
|
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||||
|
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareRouteForApply(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
routeId?: string,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
let preparedRoute = route;
|
||||||
|
const http3Config = this.getHttp3Config?.();
|
||||||
|
|
||||||
|
if (http3Config?.enabled !== false) {
|
||||||
|
preparedRoute = augmentRouteWithHttp3(preparedRoute, { enabled: true, ...http3Config });
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.injectVpnSecurity(preparedRoute, routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private injectVpnSecurity(
|
||||||
|
route: plugins.smartproxy.IRouteConfig,
|
||||||
|
routeId?: string,
|
||||||
|
): plugins.smartproxy.IRouteConfig {
|
||||||
|
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||||
|
if (!vpnCallback) return route;
|
||||||
|
|
||||||
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
|
if (!dcRoute.vpnOnly) return route;
|
||||||
|
|
||||||
|
const vpnEntries = vpnCallback(dcRoute, routeId);
|
||||||
|
const existingEntries = route.security?.ipAllowList || [];
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: [...existingEntries, ...vpnEntries],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
|
|||||||
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||||
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
|
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
|
||||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||||
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js';
|
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages TargetProfiles (target-side: what can be accessed).
|
* Manages TargetProfiles (target-side: what can be accessed).
|
||||||
@@ -13,6 +13,10 @@ import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'
|
|||||||
export class TargetProfileManager {
|
export class TargetProfileManager {
|
||||||
private profiles = new Map<string, ITargetProfile>();
|
private profiles = new Map<string, ITargetProfile>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getAllRoutes?: () => Map<string, IRoute>,
|
||||||
|
) {}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
|||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||||
const profile: ITargetProfile = {
|
const profile: ITargetProfile = {
|
||||||
id,
|
id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
domains: data.domains,
|
domains: data.domains,
|
||||||
targets: data.targets,
|
targets: data.targets,
|
||||||
routeRefs: data.routeRefs,
|
routeRefs,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
|||||||
throw new Error(`Target profile '${id}' not found`);
|
throw new Error(`Target profile '${id}' not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (patch.name !== undefined && patch.name !== profile.name) {
|
||||||
|
for (const existing of this.profiles.values()) {
|
||||||
|
if (existing.id !== id && existing.name === patch.name) {
|
||||||
|
throw new Error(`Target profile with name '${patch.name}' already exists (id: ${existing.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (patch.name !== undefined) profile.name = patch.name;
|
if (patch.name !== undefined) profile.name = patch.name;
|
||||||
if (patch.description !== undefined) profile.description = patch.description;
|
if (patch.description !== undefined) profile.description = patch.description;
|
||||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||||
if (patch.targets !== undefined) profile.targets = patch.targets;
|
if (patch.targets !== undefined) profile.targets = patch.targets;
|
||||||
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
|
if (patch.routeRefs !== undefined) profile.routeRefs = this.normalizeRouteRefs(patch.routeRefs);
|
||||||
profile.updatedAt = Date.now();
|
profile.updatedAt = Date.now();
|
||||||
|
|
||||||
await this.persistProfile(profile);
|
await this.persistProfile(profile);
|
||||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
|||||||
return this.profiles.get(id);
|
return this.profiles.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize stored route references to route IDs when they can be resolved
|
||||||
|
* uniquely against the current route registry.
|
||||||
|
*/
|
||||||
|
public async normalizeAllRouteRefs(): Promise<void> {
|
||||||
|
const allRoutes = this.getAllRoutes?.();
|
||||||
|
if (!allRoutes?.size) return;
|
||||||
|
|
||||||
|
for (const profile of this.profiles.values()) {
|
||||||
|
const normalizedRouteRefs = this.normalizeRouteRefsAgainstRoutes(
|
||||||
|
profile.routeRefs,
|
||||||
|
allRoutes,
|
||||||
|
'bestEffort',
|
||||||
|
);
|
||||||
|
if (this.sameStringArray(profile.routeRefs, normalizedRouteRefs)) continue;
|
||||||
|
|
||||||
|
profile.routeRefs = normalizedRouteRefs;
|
||||||
|
profile.updatedAt = Date.now();
|
||||||
|
await this.persistProfile(profile);
|
||||||
|
logger.log('info', `Normalized route refs for target profile '${profile.name}' (${profile.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public listProfiles(): ITargetProfile[] {
|
public listProfiles(): ITargetProfile[] {
|
||||||
return [...this.profiles.values()];
|
return [...this.profiles.values()];
|
||||||
}
|
}
|
||||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
|||||||
route: IDcRouterRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
clients: VpnClientDoc[],
|
clients: VpnClientDoc[],
|
||||||
|
allRoutes: Map<string, IRoute> = new Map(),
|
||||||
): Array<string | { ip: string; domains: string[] }> {
|
): Array<string | { ip: string; domains: string[] }> {
|
||||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (!client.enabled || !client.assignedIp) continue;
|
if (!client.enabled || !client.assignedIp) continue;
|
||||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
|||||||
const profile = this.profiles.get(profileId);
|
const profile = this.profiles.get(profileId);
|
||||||
if (!profile) continue;
|
if (!profile) continue;
|
||||||
|
|
||||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
const matchResult = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
if (matchResult === 'full') {
|
if (matchResult === 'full') {
|
||||||
fullAccess = true;
|
fullAccess = true;
|
||||||
break; // No need to check more profiles
|
break; // No need to check more profiles
|
||||||
@@ -220,11 +264,11 @@ export class TargetProfileManager {
|
|||||||
*/
|
*/
|
||||||
public getClientAccessSpec(
|
public getClientAccessSpec(
|
||||||
targetProfileIds: string[],
|
targetProfileIds: string[],
|
||||||
allRoutes: IDcRouterRouteConfig[],
|
allRoutes: Map<string, IRoute>,
|
||||||
storedRoutes: Map<string, IStoredRoute>,
|
|
||||||
): { domains: string[]; targetIps: string[] } {
|
): { domains: string[]; targetIps: string[] } {
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
const targetIps = new Set<string>();
|
const targetIps = new Set<string>();
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
// Collect all access specifiers from assigned profiles
|
// Collect all access specifiers from assigned profiles
|
||||||
for (const profileId of targetProfileIds) {
|
for (const profileId of targetProfileIds) {
|
||||||
@@ -245,23 +289,16 @@ export class TargetProfileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route references: scan constructor routes
|
// Route references: scan all routes
|
||||||
for (const route of allRoutes) {
|
for (const [routeId, route] of allRoutes) {
|
||||||
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
|
if (!route.enabled) continue;
|
||||||
const routeDomains = (route.match as any)?.domains;
|
if (this.routeMatchesProfile(
|
||||||
if (Array.isArray(routeDomains)) {
|
route.route as IDcRouterRouteConfig,
|
||||||
for (const d of routeDomains) {
|
routeId,
|
||||||
domains.add(d);
|
profile,
|
||||||
}
|
routeNameIndex,
|
||||||
}
|
)) {
|
||||||
}
|
const routeDomains = (route.route.match as any)?.domains;
|
||||||
}
|
|
||||||
|
|
||||||
// Route references: scan stored routes
|
|
||||||
for (const [storedId, stored] of storedRoutes) {
|
|
||||||
if (!stored.enabled) continue;
|
|
||||||
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
|
|
||||||
const routeDomains = (stored.route.match as any)?.domains;
|
|
||||||
if (Array.isArray(routeDomains)) {
|
if (Array.isArray(routeDomains)) {
|
||||||
for (const d of routeDomains) {
|
for (const d of routeDomains) {
|
||||||
domains.add(d);
|
domains.add(d);
|
||||||
@@ -288,9 +325,16 @@ export class TargetProfileManager {
|
|||||||
route: IDcRouterRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||||
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
const result = this.routeMatchesProfileDetailed(
|
||||||
|
route,
|
||||||
|
routeId,
|
||||||
|
profile,
|
||||||
|
routeDomains,
|
||||||
|
routeNameIndex,
|
||||||
|
);
|
||||||
return result !== 'none';
|
return result !== 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,11 +351,17 @@ export class TargetProfileManager {
|
|||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
routeDomains: string[],
|
routeDomains: string[],
|
||||||
|
routeNameIndex: Map<string, string[]>,
|
||||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||||
// 1. Route reference match → full access
|
// 1. Route reference match → full access
|
||||||
if (profile.routeRefs?.length) {
|
if (profile.routeRefs?.length) {
|
||||||
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
|
||||||
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
|
if (routeId && route.name && profile.routeRefs.includes(route.name)) {
|
||||||
|
const matchingRouteIds = routeNameIndex.get(route.name) || [];
|
||||||
|
if (matchingRouteIds.length === 1 && matchingRouteIds[0] === routeId) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Domain match
|
// 2. Domain match
|
||||||
@@ -375,6 +425,66 @@ export class TargetProfileManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||||
|
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||||
|
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteRefsAgainstRoutes(
|
||||||
|
routeRefs: string[] | undefined,
|
||||||
|
allRoutes: Map<string, IRoute>,
|
||||||
|
mode: 'strict' | 'bestEffort',
|
||||||
|
): string[] | undefined {
|
||||||
|
if (!routeRefs?.length) return undefined;
|
||||||
|
if (!allRoutes.size) return [...new Set(routeRefs)];
|
||||||
|
|
||||||
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
const normalizedRefs = new Set<string>();
|
||||||
|
|
||||||
|
for (const routeRef of routeRefs) {
|
||||||
|
if (allRoutes.has(routeRef)) {
|
||||||
|
normalizedRefs.add(routeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRouteIds = routeNameIndex.get(routeRef) || [];
|
||||||
|
if (matchingRouteIds.length === 1) {
|
||||||
|
normalizedRefs.add(matchingRouteIds[0]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'bestEffort') {
|
||||||
|
normalizedRefs.add(routeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingRouteIds.length > 1) {
|
||||||
|
throw new Error(`Route reference '${routeRef}' is ambiguous; use a route ID instead`);
|
||||||
|
}
|
||||||
|
throw new Error(`Route reference '${routeRef}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...normalizedRefs];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRouteNameIndex(allRoutes: Map<string, IRoute>): Map<string, string[]> {
|
||||||
|
const routeNameIndex = new Map<string, string[]>();
|
||||||
|
for (const [routeId, route] of allRoutes) {
|
||||||
|
const routeName = route.route.name;
|
||||||
|
if (!routeName) continue;
|
||||||
|
const matchingRouteIds = routeNameIndex.get(routeName) || [];
|
||||||
|
matchingRouteIds.push(routeId);
|
||||||
|
routeNameIndex.set(routeName, matchingRouteIds);
|
||||||
|
}
|
||||||
|
return routeNameIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameStringArray(left?: string[], right?: string[]): boolean {
|
||||||
|
if (!left?.length && !right?.length) return true;
|
||||||
|
if (!left || !right || left.length !== right.length) return false;
|
||||||
|
return left.every((value, index) => value === right[index]);
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Private: persistence
|
// Private: persistence
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
56
ts/db/documents/classes.email-domain.doc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type {
|
||||||
|
IEmailDomainDkim,
|
||||||
|
IEmailDomainRateLimits,
|
||||||
|
IEmailDomainDnsStatus,
|
||||||
|
} from '../../../ts_interfaces/data/email-domain.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domain: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public linkedDomainId: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public subdomain?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public dkim!: IEmailDomainDkim;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public rateLimits?: IEmailDomainRateLimits;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public dnsStatus!: IEmailDomainDnsStatus;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<EmailDomainDoc | null> {
|
||||||
|
return await EmailDomainDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
|
||||||
|
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<EmailDomainDoc[]> {
|
||||||
|
return await EmailDomainDoc.getInstances({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
||||||
|
|
||||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => getDb())
|
|
||||||
export class RouteOverrideDoc extends plugins.smartdata.SmartDataDbDoc<RouteOverrideDoc, RouteOverrideDoc> {
|
|
||||||
@plugins.smartdata.unI()
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public routeName!: string;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public enabled!: boolean;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public updatedAt!: number;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public updatedBy!: string;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async findByRouteName(routeName: string): Promise<RouteOverrideDoc | null> {
|
|
||||||
return await RouteOverrideDoc.getInstance({ routeName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async findAll(): Promise<RouteOverrideDoc[]> {
|
|
||||||
return await RouteOverrideDoc.getInstances({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
61
ts/db/documents/classes.route.doc.ts
Normal file
61
ts/db/documents/classes.route.doc.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public route!: IDcRouterRouteConfig;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled!: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public origin!: 'config' | 'email' | 'dns' | 'api';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public systemKey?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public metadata?: IRouteMetadata;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||||
|
return await RouteDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<RouteDoc[]> {
|
||||||
|
return await RouteDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<RouteDoc | null> {
|
||||||
|
return await RouteDoc.getInstance({ 'route.name': name });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByOrigin(origin: 'config' | 'email' | 'dns' | 'api'): Promise<RouteDoc[]> {
|
||||||
|
return await RouteDoc.getInstances({ origin });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findBySystemKey(systemKey: string): Promise<RouteDoc | null> {
|
||||||
|
return await RouteDoc.getInstance({ systemKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
|
||||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
|
||||||
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
|
||||||
|
|
||||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
|
||||||
|
|
||||||
@plugins.smartdata.Collection(() => getDb())
|
|
||||||
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
|
|
||||||
@plugins.smartdata.unI()
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public id!: string;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public route!: IDcRouterRouteConfig;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public enabled!: boolean;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public createdAt!: number;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public updatedAt!: number;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public createdBy!: string;
|
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
|
||||||
public metadata?: IRouteMetadata;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
|
||||||
return await StoredRouteDoc.getInstance({ id });
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async findAll(): Promise<StoredRouteDoc[]> {
|
|
||||||
return await StoredRouteDoc.getInstances({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,7 @@ export * from './classes.cached.email.js';
|
|||||||
export * from './classes.cached.ip.reputation.js';
|
export * from './classes.cached.ip.reputation.js';
|
||||||
|
|
||||||
// Config document classes
|
// Config document classes
|
||||||
export * from './classes.stored-route.doc.js';
|
export * from './classes.route.doc.js';
|
||||||
export * from './classes.route-override.doc.js';
|
|
||||||
export * from './classes.api-token.doc.js';
|
export * from './classes.api-token.doc.js';
|
||||||
export * from './classes.source-profile.doc.js';
|
export * from './classes.source-profile.doc.js';
|
||||||
export * from './classes.target-profile.doc.js';
|
export * from './classes.target-profile.doc.js';
|
||||||
@@ -33,3 +32,6 @@ export * from './classes.dns-record.doc.js';
|
|||||||
|
|
||||||
// ACME configuration (singleton)
|
// ACME configuration (singleton)
|
||||||
export * from './classes.acme-config.doc.js';
|
export * from './classes.acme-config.doc.js';
|
||||||
|
|
||||||
|
// Email domain management
|
||||||
|
export * from './classes.email-domain.doc.js';
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ export class DnsManager {
|
|||||||
if (hasLegacyConfig) {
|
if (hasLegacyConfig) {
|
||||||
logger.log(
|
logger.log(
|
||||||
'warn',
|
'warn',
|
||||||
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
|
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords constructor config. ' +
|
||||||
'Manage DNS via the Domains UI instead.',
|
'dnsNsDomains is still required for nameserver and DoH bootstrap unless that moves into DB-backed config.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -296,70 +296,99 @@ export class DnsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
|
* Find the DomainDoc that covers a given FQDN, regardless of source
|
||||||
* to decide whether to wire SmartAcme with a DNS-01 handler.
|
* (dcrouter-hosted or provider-managed). Uses longest-suffix match.
|
||||||
*/
|
*/
|
||||||
public async hasAcmeCapableProvider(): Promise<boolean> {
|
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
|
||||||
const providers = await DnsProviderDoc.findAll();
|
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
|
||||||
return providers.length > 0;
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
// Sort by name length descending for longest-match-wins
|
||||||
|
allDomains.sort((a, b) => b.name.length - a.name.length);
|
||||||
|
for (const domain of allDomains) {
|
||||||
|
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
|
* Delete all DNS records matching a name and type under a domain.
|
||||||
* the right provider client (whichever provider type owns the parent zone),
|
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
|
||||||
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
|
*/
|
||||||
* interface, so any registered provider implementation works.
|
public async deleteRecordsByNameAndType(
|
||||||
* Returned object plugs directly into smartacme's Dns01Handler.
|
domainId: string,
|
||||||
|
name: string,
|
||||||
|
type: TDnsRecordType,
|
||||||
|
): Promise<void> {
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domainId);
|
||||||
|
for (const rec of records) {
|
||||||
|
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
|
||||||
|
await this.deleteRecord(rec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if any domain is under management (dcrouter-hosted or provider-managed).
|
||||||
|
* Used by setupSmartProxy() to decide whether to wire SmartAcme with a DNS-01 handler.
|
||||||
|
*/
|
||||||
|
public async hasAnyManagedDomain(): Promise<boolean> {
|
||||||
|
const domains = await DomainDoc.findAll();
|
||||||
|
return domains.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an IConvenientDnsProvider that routes ACME DNS-01 challenges through
|
||||||
|
* the DnsManager abstraction. Challenges are dispatched via createRecord() /
|
||||||
|
* deleteRecord(), which transparently handle both dcrouter-hosted zones
|
||||||
|
* (embedded DnsServer) and provider-managed zones (e.g. Cloudflare API).
|
||||||
|
*
|
||||||
|
* Only domains under management (with a DomainDoc in DB) are supported —
|
||||||
|
* this acts as the management gate for certificate issuance.
|
||||||
*/
|
*/
|
||||||
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
|
||||||
const self = this;
|
const self = this;
|
||||||
const adapter = {
|
const adapter = {
|
||||||
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
||||||
if (!client) {
|
if (!domainDoc) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
|
`DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
|
||||||
'Add one in the Domains > Providers UI before issuing certificates.',
|
'Add the domain in Domains before issuing certificates.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Clean any leftover challenge records first to avoid duplicates.
|
// Clean leftover challenge records first to avoid duplicates.
|
||||||
try {
|
try {
|
||||||
const existing = await client.listRecords(dnsChallenge.hostName);
|
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||||
for (const r of existing) {
|
|
||||||
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
|
||||||
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
await client.createRecord(dnsChallenge.hostName, {
|
// Create the challenge TXT record via the unified path
|
||||||
|
await self.createRecord({
|
||||||
|
domainId: domainDoc.id,
|
||||||
name: dnsChallenge.hostName,
|
name: dnsChallenge.hostName,
|
||||||
type: 'TXT',
|
type: 'TXT',
|
||||||
value: dnsChallenge.challenge,
|
value: dnsChallenge.challenge,
|
||||||
ttl: 120,
|
ttl: 120,
|
||||||
|
createdBy: 'acme-dns01',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
|
||||||
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
|
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
|
||||||
if (!client) {
|
if (!domainDoc) {
|
||||||
// The domain may have been removed; nothing to clean up.
|
// The domain may have been removed; nothing to clean up.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const existing = await client.listRecords(dnsChallenge.hostName);
|
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
|
||||||
for (const r of existing) {
|
|
||||||
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
|
|
||||||
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async isDomainSupported(domain: string): Promise<boolean> {
|
async isDomainSupported(domain: string): Promise<boolean> {
|
||||||
const client = await self.getProviderClientForDomain(domain);
|
const domainDoc = await self.findDomainForFqdn(domain);
|
||||||
return !!client;
|
return !!domainDoc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
|
||||||
@@ -642,6 +671,151 @@ export class DnsManager {
|
|||||||
return await DnsRecordDoc.findById(id);
|
return await DnsRecordDoc.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Domain migration
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a domain between dcrouter-hosted and provider-managed.
|
||||||
|
* Transfers all records to the target and updates domain metadata.
|
||||||
|
*/
|
||||||
|
public async migrateDomain(args: {
|
||||||
|
id: string;
|
||||||
|
targetSource: 'dcrouter' | 'provider';
|
||||||
|
targetProviderId?: string;
|
||||||
|
deleteExistingProviderRecords?: boolean;
|
||||||
|
}): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||||
|
const domain = await DomainDoc.findById(args.id);
|
||||||
|
if (!domain) return { success: false, message: 'Domain not found' };
|
||||||
|
|
||||||
|
if (domain.source === args.targetSource && domain.providerId === args.targetProviderId) {
|
||||||
|
return { success: false, message: 'Domain is already in the target configuration' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
||||||
|
|
||||||
|
if (args.targetSource === 'provider') {
|
||||||
|
return this.migrateToDnsProvider(domain, records, args.targetProviderId!, args.deleteExistingProviderRecords ?? false);
|
||||||
|
} else {
|
||||||
|
return this.migrateToDcrouter(domain, records);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate domain from dcrouter-hosted (or another provider) to an external DNS provider.
|
||||||
|
*/
|
||||||
|
private async migrateToDnsProvider(
|
||||||
|
domain: DomainDoc,
|
||||||
|
records: DnsRecordDoc[],
|
||||||
|
targetProviderId: string,
|
||||||
|
deleteExistingProviderRecords: boolean,
|
||||||
|
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||||
|
// Validate the target provider exists
|
||||||
|
const client = await this.getProviderClientById(targetProviderId);
|
||||||
|
if (!client) {
|
||||||
|
return { success: false, message: 'Target DNS provider not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the zone at the provider
|
||||||
|
const providerDomains = await client.listDomains();
|
||||||
|
const zone = providerDomains.find(
|
||||||
|
(z) => z.name.toLowerCase() === domain.name.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (!zone) {
|
||||||
|
return { success: false, message: `Zone "${domain.name}" not found at the target provider` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally delete existing records at the provider
|
||||||
|
if (deleteExistingProviderRecords) {
|
||||||
|
try {
|
||||||
|
const existingProviderRecords = await client.listRecords(domain.name);
|
||||||
|
for (const pr of existingProviderRecords) {
|
||||||
|
await client.deleteRecord(domain.name, pr.providerRecordId).catch(() => {});
|
||||||
|
}
|
||||||
|
logger.log('info', `Deleted ${existingProviderRecords.length} existing records at provider for ${domain.name}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to clean existing provider records for ${domain.name}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push each local record to the provider
|
||||||
|
let migrated = 0;
|
||||||
|
for (const rec of records) {
|
||||||
|
try {
|
||||||
|
const providerRecord = await client.createRecord(domain.name, {
|
||||||
|
name: rec.name,
|
||||||
|
type: rec.type as any,
|
||||||
|
value: rec.value,
|
||||||
|
ttl: rec.ttl,
|
||||||
|
});
|
||||||
|
// Unregister from embedded DnsServer if it was dcrouter-hosted
|
||||||
|
if (domain.source === 'dcrouter') {
|
||||||
|
this.unregisterRecordFromDnsServer(rec);
|
||||||
|
}
|
||||||
|
// Update the record doc to synced
|
||||||
|
rec.source = 'synced' as TDnsRecordSource;
|
||||||
|
rec.providerRecordId = providerRecord.providerRecordId;
|
||||||
|
await rec.save();
|
||||||
|
migrated++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to migrate record ${rec.name} ${rec.type} to provider: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update domain metadata
|
||||||
|
domain.source = 'provider';
|
||||||
|
domain.authoritative = false;
|
||||||
|
domain.providerId = targetProviderId;
|
||||||
|
domain.externalZoneId = zone.externalId;
|
||||||
|
domain.nameservers = zone.nameservers;
|
||||||
|
domain.lastSyncedAt = Date.now();
|
||||||
|
domain.updatedAt = Date.now();
|
||||||
|
await domain.save();
|
||||||
|
|
||||||
|
logger.log('info', `Domain ${domain.name} migrated to provider (${migrated} records)`);
|
||||||
|
return { success: true, recordsMigrated: migrated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate domain from provider-managed to dcrouter-hosted (authoritative).
|
||||||
|
*/
|
||||||
|
private async migrateToDcrouter(
|
||||||
|
domain: DomainDoc,
|
||||||
|
records: DnsRecordDoc[],
|
||||||
|
): Promise<{ success: boolean; recordsMigrated?: number; message?: string }> {
|
||||||
|
// Register each record with the embedded DnsServer
|
||||||
|
let migrated = 0;
|
||||||
|
for (const rec of records) {
|
||||||
|
try {
|
||||||
|
this.registerRecordWithDnsServer(rec);
|
||||||
|
// Update the record doc to local
|
||||||
|
rec.source = 'local' as TDnsRecordSource;
|
||||||
|
rec.providerRecordId = undefined;
|
||||||
|
await rec.save();
|
||||||
|
migrated++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to register record ${rec.name} ${rec.type} with DnsServer: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update domain metadata
|
||||||
|
domain.source = 'dcrouter';
|
||||||
|
domain.authoritative = true;
|
||||||
|
domain.providerId = undefined;
|
||||||
|
domain.externalZoneId = undefined;
|
||||||
|
domain.nameservers = undefined;
|
||||||
|
domain.lastSyncedAt = undefined;
|
||||||
|
domain.updatedAt = Date.now();
|
||||||
|
await domain.save();
|
||||||
|
|
||||||
|
logger.log('info', `Domain ${domain.name} migrated to dcrouter (${migrated} records)`);
|
||||||
|
return { success: true, recordsMigrated: migrated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Record CRUD
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
public async createRecord(args: {
|
public async createRecord(args: {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -759,14 +933,24 @@ export class DnsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// For local records: smartdns has no unregister API in the pinned version,
|
// For dcrouter-hosted records: unregister the handler from the embedded DnsServer
|
||||||
// so the record stays served until the next restart. The DB delete still
|
// so the record stops being served immediately (not just after restart).
|
||||||
// takes effect — on restart, the record will not be re-registered.
|
if (domain.source === 'dcrouter' && this.dnsServer) {
|
||||||
|
this.unregisterRecordFromDnsServer(doc);
|
||||||
|
}
|
||||||
|
|
||||||
await doc.delete();
|
await doc.delete();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a record's handler from the embedded DnsServer.
|
||||||
|
*/
|
||||||
|
public unregisterRecordFromDnsServer(rec: DnsRecordDoc): void {
|
||||||
|
if (!this.dnsServer) return;
|
||||||
|
this.dnsServer.unregisterHandler(rec.name, [rec.type]);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Internal helpers
|
// Internal helpers
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
406
ts/email/classes.email-domain.manager.ts
Normal file
406
ts/email/classes.email-domain.manager.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IEmailDomainConfig } from '@push.rocks/smartmta';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
|
||||||
|
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
|
||||||
|
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
|
||||||
|
import type { DnsManager } from '../dns/manager.dns.js';
|
||||||
|
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
|
||||||
|
import { buildEmailDnsRecords } from './email-dns-records.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmailDomainManager — orchestrates email domain setup.
|
||||||
|
*
|
||||||
|
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
|
||||||
|
* (record creation for dcrouter-hosted and provider-managed zones) to provide
|
||||||
|
* a single entry point for setting up an email domain from A to Z.
|
||||||
|
*/
|
||||||
|
export class EmailDomainManager {
|
||||||
|
private dcRouter: any; // DcRouter — avoids circular import
|
||||||
|
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||||
|
|
||||||
|
constructor(dcRouterRef: any) {
|
||||||
|
this.dcRouter = dcRouterRef;
|
||||||
|
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||||
|
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get dnsManager(): DnsManager | undefined {
|
||||||
|
return this.dcRouter.dnsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get dkimCreator(): any | undefined {
|
||||||
|
return this.dcRouter.emailServer?.dkimCreator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get emailHostname(): string {
|
||||||
|
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public async getAll(): Promise<IEmailDomain[]> {
|
||||||
|
const docs = await EmailDomainDoc.findAll();
|
||||||
|
return docs.map((d) => this.docToInterface(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getById(id: string): Promise<IEmailDomain | null> {
|
||||||
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
|
return doc ? this.docToInterface(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createEmailDomain(opts: {
|
||||||
|
linkedDomainId: string;
|
||||||
|
subdomain?: string;
|
||||||
|
dkimSelector?: string;
|
||||||
|
dkimKeySize?: number;
|
||||||
|
rotateKeys?: boolean;
|
||||||
|
rotationIntervalDays?: number;
|
||||||
|
}): Promise<IEmailDomain> {
|
||||||
|
// Resolve the linked DNS domain
|
||||||
|
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
|
||||||
|
if (!domainDoc) {
|
||||||
|
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
|
||||||
|
}
|
||||||
|
const baseDomain = domainDoc.name;
|
||||||
|
const subdomain = opts.subdomain?.trim() || undefined;
|
||||||
|
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
if (this.isDomainAlreadyConfigured(domainName)) {
|
||||||
|
throw new Error(`Email domain already configured for ${domainName}`);
|
||||||
|
}
|
||||||
|
const existing = await EmailDomainDoc.findByDomain(domainName);
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Email domain already exists for ${domainName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = opts.dkimSelector || 'default';
|
||||||
|
const keySize = opts.dkimKeySize || 2048;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Generate DKIM keys
|
||||||
|
let publicKey: string | undefined;
|
||||||
|
if (this.dkimCreator) {
|
||||||
|
try {
|
||||||
|
await this.dkimCreator.handleDKIMKeysForSelector(domainName, selector, keySize);
|
||||||
|
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domainName, selector);
|
||||||
|
// Extract public key from the DNS record value
|
||||||
|
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
|
||||||
|
publicKey = match ? match[1] : undefined;
|
||||||
|
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the document
|
||||||
|
const doc = new EmailDomainDoc();
|
||||||
|
doc.id = plugins.smartunique.shortId();
|
||||||
|
doc.domain = domainName.toLowerCase();
|
||||||
|
doc.linkedDomainId = opts.linkedDomainId;
|
||||||
|
doc.subdomain = subdomain;
|
||||||
|
doc.dkim = {
|
||||||
|
selector,
|
||||||
|
keySize,
|
||||||
|
publicKey,
|
||||||
|
rotateKeys: opts.rotateKeys ?? false,
|
||||||
|
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
|
||||||
|
};
|
||||||
|
doc.dnsStatus = {
|
||||||
|
mx: 'unchecked',
|
||||||
|
spf: 'unchecked',
|
||||||
|
dkim: 'unchecked',
|
||||||
|
dmarc: 'unchecked',
|
||||||
|
};
|
||||||
|
doc.createdAt = now;
|
||||||
|
doc.updatedAt = now;
|
||||||
|
await doc.save();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
|
|
||||||
|
logger.log('info', `Email domain created: ${domainName}`);
|
||||||
|
return this.docToInterface(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateEmailDomain(
|
||||||
|
id: string,
|
||||||
|
changes: {
|
||||||
|
rotateKeys?: boolean;
|
||||||
|
rotationIntervalDays?: number;
|
||||||
|
rateLimits?: IEmailDomain['rateLimits'];
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
|
|
||||||
|
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
|
||||||
|
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
|
||||||
|
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
|
||||||
|
doc.updatedAt = new Date().toISOString();
|
||||||
|
await doc.save();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteEmailDomain(id: string): Promise<void> {
|
||||||
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
|
await doc.delete();
|
||||||
|
await this.syncManagedDomainsToRuntime();
|
||||||
|
logger.log('info', `Email domain deleted: ${doc.domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DNS record computation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the 4 required DNS records for an email domain.
|
||||||
|
*/
|
||||||
|
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
|
||||||
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
|
|
||||||
|
const domain = doc.domain;
|
||||||
|
const selector = doc.dkim.selector;
|
||||||
|
const hostname = this.emailHostname;
|
||||||
|
let dkimValue = `v=DKIM1; h=sha256; k=rsa; p=${doc.dkim.publicKey || ''}`;
|
||||||
|
|
||||||
|
if (this.dkimCreator) {
|
||||||
|
try {
|
||||||
|
const dnsRecord = await this.dkimCreator.getDNSRecordForDomain(domain, selector);
|
||||||
|
dkimValue = dnsRecord.value;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to load DKIM DNS record for ${domain}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildEmailDnsRecords({
|
||||||
|
domain,
|
||||||
|
hostname,
|
||||||
|
selector,
|
||||||
|
dkimValue,
|
||||||
|
statuses: doc.dnsStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DNS provisioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-create missing DNS records via the linked domain's DNS path.
|
||||||
|
*/
|
||||||
|
public async provisionDnsRecords(id: string): Promise<number> {
|
||||||
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
|
if (!this.dnsManager) throw new Error('DnsManager not available');
|
||||||
|
|
||||||
|
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||||
|
const domainId = doc.linkedDomainId;
|
||||||
|
|
||||||
|
// Get existing DNS records for the linked domain
|
||||||
|
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
|
||||||
|
let provisioned = 0;
|
||||||
|
|
||||||
|
for (const required of requiredRecords) {
|
||||||
|
// Check if a matching record already exists
|
||||||
|
const exists = existingRecords.some((r) => this.recordMatchesRequired(r, required));
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
try {
|
||||||
|
await this.dnsManager.createRecord({
|
||||||
|
domainId,
|
||||||
|
name: required.name,
|
||||||
|
type: required.type as any,
|
||||||
|
value: required.value,
|
||||||
|
ttl: 3600,
|
||||||
|
createdBy: 'email-domain-manager',
|
||||||
|
});
|
||||||
|
provisioned++;
|
||||||
|
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validate after provisioning
|
||||||
|
await this.validateDns(id);
|
||||||
|
|
||||||
|
return provisioned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DNS validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate DNS records via live lookups.
|
||||||
|
*/
|
||||||
|
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
|
||||||
|
const doc = await EmailDomainDoc.findById(id);
|
||||||
|
if (!doc) throw new Error(`Email domain not found: ${id}`);
|
||||||
|
|
||||||
|
const domain = doc.domain;
|
||||||
|
const selector = doc.dkim.selector;
|
||||||
|
const resolver = new plugins.dns.promises.Resolver();
|
||||||
|
|
||||||
|
// MX check
|
||||||
|
const requiredRecords = await this.getRequiredDnsRecords(id);
|
||||||
|
|
||||||
|
const mxRecord = requiredRecords.find((record) => record.type === 'MX');
|
||||||
|
const spfRecord = requiredRecords.find((record) => record.name === domain && record.value.startsWith('v=spf1'));
|
||||||
|
const dkimRecord = requiredRecords.find((record) => record.name === `${selector}._domainkey.${domain}`);
|
||||||
|
const dmarcRecord = requiredRecords.find((record) => record.name === `_dmarc.${domain}`);
|
||||||
|
|
||||||
|
doc.dnsStatus.mx = await this.checkMx(resolver, domain, mxRecord?.value);
|
||||||
|
|
||||||
|
// SPF check
|
||||||
|
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, spfRecord?.value);
|
||||||
|
|
||||||
|
// DKIM check
|
||||||
|
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, dkimRecord?.value);
|
||||||
|
|
||||||
|
// DMARC check
|
||||||
|
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, dmarcRecord?.value);
|
||||||
|
|
||||||
|
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||||
|
doc.updatedAt = new Date().toISOString();
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
return this.getRequiredDnsRecords(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordMatchesRequired(record: DnsRecordDoc, required: IEmailDnsRecord): boolean {
|
||||||
|
if (record.type !== required.type || record.name.toLowerCase() !== required.name.toLowerCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return record.value.trim() === required.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkMx(
|
||||||
|
resolver: plugins.dns.promises.Resolver,
|
||||||
|
domain: string,
|
||||||
|
expectedValue?: string,
|
||||||
|
): Promise<TDnsRecordStatus> {
|
||||||
|
try {
|
||||||
|
const records = await resolver.resolveMx(domain);
|
||||||
|
if (!records || records.length === 0) {
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
if (!expectedValue) {
|
||||||
|
return 'valid';
|
||||||
|
}
|
||||||
|
const found = records.some((record) => `${record.priority} ${record.exchange}`.trim() === expectedValue.trim());
|
||||||
|
return found ? 'valid' : 'invalid';
|
||||||
|
} catch {
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkTxtRecord(
|
||||||
|
resolver: plugins.dns.promises.Resolver,
|
||||||
|
name: string,
|
||||||
|
expectedValue?: string,
|
||||||
|
): Promise<TDnsRecordStatus> {
|
||||||
|
try {
|
||||||
|
const records = await resolver.resolveTxt(name);
|
||||||
|
const flat = records.map((r) => r.join(''));
|
||||||
|
if (flat.length === 0) {
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
if (!expectedValue) {
|
||||||
|
return 'valid';
|
||||||
|
}
|
||||||
|
const found = flat.some((record) => record.trim() === expectedValue.trim());
|
||||||
|
return found ? 'valid' : 'invalid';
|
||||||
|
} catch {
|
||||||
|
return 'missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
domain: doc.domain,
|
||||||
|
linkedDomainId: doc.linkedDomainId,
|
||||||
|
subdomain: doc.subdomain,
|
||||||
|
dkim: doc.dkim,
|
||||||
|
rateLimits: doc.rateLimits,
|
||||||
|
dnsStatus: doc.dnsStatus,
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDomainAlreadyConfigured(domainName: string): boolean {
|
||||||
|
const configuredDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||||
|
.map((domainConfig) => domainConfig.domain.toLowerCase());
|
||||||
|
return configuredDomains.includes(domainName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildManagedDomainConfigs(): Promise<IEmailDomainConfig[]> {
|
||||||
|
const docs = await EmailDomainDoc.findAll();
|
||||||
|
const managedConfigs: IEmailDomainConfig[] = [];
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
const linkedDomain = await DomainDoc.findById(doc.linkedDomainId);
|
||||||
|
if (!linkedDomain) {
|
||||||
|
logger.log('warn', `Skipping managed email domain ${doc.domain}: linked domain missing`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
managedConfigs.push({
|
||||||
|
domain: doc.domain,
|
||||||
|
dnsMode: linkedDomain.source === 'dcrouter' ? 'internal-dns' : 'external-dns',
|
||||||
|
dkim: {
|
||||||
|
selector: doc.dkim.selector,
|
||||||
|
keySize: doc.dkim.keySize,
|
||||||
|
rotateKeys: doc.dkim.rotateKeys,
|
||||||
|
rotationInterval: doc.dkim.rotationIntervalDays,
|
||||||
|
},
|
||||||
|
rateLimits: doc.rateLimits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return managedConfigs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncManagedDomainsToRuntime(): Promise<void> {
|
||||||
|
if (!this.dcRouter.options?.emailConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedDomains = new Map<string, IEmailDomainConfig>();
|
||||||
|
for (const domainConfig of this.baseEmailDomains) {
|
||||||
|
mergedDomains.set(domainConfig.domain.toLowerCase(), JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const managedConfig of await this.buildManagedDomainConfigs()) {
|
||||||
|
const key = managedConfig.domain.toLowerCase();
|
||||||
|
if (mergedDomains.has(key)) {
|
||||||
|
logger.log('warn', `Managed email domain ${managedConfig.domain} duplicates a configured domain; keeping the configured definition`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergedDomains.set(key, managedConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = Array.from(mergedDomains.values());
|
||||||
|
this.dcRouter.options.emailConfig.domains = domains;
|
||||||
|
if (this.dcRouter.emailServer) {
|
||||||
|
this.dcRouter.emailServer.updateOptions({ domains });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
108
ts/email/classes.smartmta-storage-manager.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { IStorageManagerLike } from '@push.rocks/smartmta';
|
||||||
|
|
||||||
|
export class SmartMtaStorageManager implements IStorageManagerLike {
|
||||||
|
private readonly resolvedRootDir: string;
|
||||||
|
|
||||||
|
constructor(private rootDir: string) {
|
||||||
|
this.resolvedRootDir = plugins.path.resolve(rootDir);
|
||||||
|
plugins.fsUtils.ensureDirSync(this.resolvedRootDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKey(key: string): string {
|
||||||
|
return key.replace(/^\/+/, '').replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePathForKey(key: string): string {
|
||||||
|
const normalizedKey = this.normalizeKey(key);
|
||||||
|
const resolvedPath = plugins.path.resolve(this.resolvedRootDir, normalizedKey);
|
||||||
|
if (
|
||||||
|
resolvedPath !== this.resolvedRootDir
|
||||||
|
&& !resolvedPath.startsWith(`${this.resolvedRootDir}${plugins.path.sep}`)
|
||||||
|
) {
|
||||||
|
throw new Error(`Storage key escapes root directory: ${key}`);
|
||||||
|
}
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStorageKey(filePath: string): string {
|
||||||
|
const relativePath = plugins.path.relative(this.resolvedRootDir, filePath).split(plugins.path.sep).join('/');
|
||||||
|
return `/${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(key: string): Promise<string | null> {
|
||||||
|
const filePath = this.resolvePathForKey(key);
|
||||||
|
try {
|
||||||
|
return await plugins.fs.promises.readFile(filePath, 'utf8');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async set(key: string, value: string): Promise<void> {
|
||||||
|
const filePath = this.resolvePathForKey(key);
|
||||||
|
await plugins.fs.promises.mkdir(plugins.path.dirname(filePath), { recursive: true });
|
||||||
|
await plugins.fs.promises.writeFile(filePath, value, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(prefix: string): Promise<string[]> {
|
||||||
|
const prefixPath = this.resolvePathForKey(prefix);
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(prefixPath);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
return [this.toStorageKey(prefixPath)];
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: string[] = [];
|
||||||
|
const walk = async (currentPath: string): Promise<void> => {
|
||||||
|
const entries = await plugins.fs.promises.readdir(currentPath, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = plugins.path.join(currentPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(entryPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
results.push(this.toStorageKey(entryPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await walk(prefixPath);
|
||||||
|
return results.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(key: string): Promise<void> {
|
||||||
|
const targetPath = this.resolvePathForKey(key);
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(targetPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await plugins.fs.promises.rm(targetPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
await plugins.fs.promises.unlink(targetPath);
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDir = plugins.path.dirname(targetPath);
|
||||||
|
while (currentDir.startsWith(this.resolvedRootDir) && currentDir !== this.resolvedRootDir) {
|
||||||
|
const entries = await plugins.fs.promises.readdir(currentDir);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await plugins.fs.promises.rmdir(currentDir);
|
||||||
|
currentDir = plugins.path.dirname(currentDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
ts/email/email-dns-records.ts
Normal file
53
ts/email/email-dns-records.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type {
|
||||||
|
IEmailDnsRecord,
|
||||||
|
TDnsRecordStatus,
|
||||||
|
} from '../../ts_interfaces/data/email-domain.js';
|
||||||
|
|
||||||
|
type TEmailDnsStatusKey = 'mx' | 'spf' | 'dkim' | 'dmarc';
|
||||||
|
|
||||||
|
export interface IBuildEmailDnsRecordsOptions {
|
||||||
|
domain: string;
|
||||||
|
hostname: string;
|
||||||
|
selector?: string;
|
||||||
|
dkimValue?: string;
|
||||||
|
mxPriority?: number;
|
||||||
|
dmarcPolicy?: string;
|
||||||
|
dmarcRua?: string;
|
||||||
|
statuses?: Partial<Record<TEmailDnsStatusKey, TDnsRecordStatus>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEmailDnsRecords(options: IBuildEmailDnsRecordsOptions): IEmailDnsRecord[] {
|
||||||
|
const statusFor = (key: TEmailDnsStatusKey): TDnsRecordStatus => options.statuses?.[key] ?? 'unchecked';
|
||||||
|
const selector = options.selector || 'default';
|
||||||
|
const records: IEmailDnsRecord[] = [
|
||||||
|
{
|
||||||
|
type: 'MX',
|
||||||
|
name: options.domain,
|
||||||
|
value: `${options.mxPriority ?? 10} ${options.hostname}`,
|
||||||
|
status: statusFor('mx'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TXT',
|
||||||
|
name: options.domain,
|
||||||
|
value: 'v=spf1 a mx ~all',
|
||||||
|
status: statusFor('spf'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TXT',
|
||||||
|
name: `_dmarc.${options.domain}`,
|
||||||
|
value: `v=DMARC1; p=${options.dmarcPolicy ?? 'none'}; rua=mailto:${options.dmarcRua ?? `dmarc@${options.domain}`}`,
|
||||||
|
status: statusFor('dmarc'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.dkimValue) {
|
||||||
|
records.splice(2, 0, {
|
||||||
|
type: 'TXT',
|
||||||
|
name: `${selector}._domainkey.${options.domain}`,
|
||||||
|
value: options.dkimValue,
|
||||||
|
status: statusFor('dkim'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
3
ts/email/index.ts
Normal file
3
ts/email/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './classes.email-domain.manager.js';
|
||||||
|
export * from './classes.smartmta-storage-manager.js';
|
||||||
|
export * from './email-dns-records.js';
|
||||||
@@ -553,12 +553,14 @@ export class MetricsManager {
|
|||||||
connectionsByIP: new Map<string, number>(),
|
connectionsByIP: new Map<string, number>(),
|
||||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||||
|
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
|
||||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
backends: [] as Array<any>,
|
backends: [] as Array<any>,
|
||||||
|
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number; requestCount: number }>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +574,7 @@ export class MetricsManager {
|
|||||||
bytesOutPerSecond: instantThroughput.out
|
bytesOutPerSecond: instantThroughput.out
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get top IPs
|
// Get top IPs by connection count
|
||||||
const topIPs = proxyMetrics.connections.topIPs(10);
|
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||||
|
|
||||||
// Get total data transferred
|
// Get total data transferred
|
||||||
@@ -699,10 +701,141 @@ export class MetricsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
|
||||||
|
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
|
||||||
|
for (const [ip, count] of connectionsByIP) {
|
||||||
|
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
|
||||||
|
}
|
||||||
|
for (const [ip, tp] of throughputByIP) {
|
||||||
|
const existing = allIPData.get(ip);
|
||||||
|
if (existing) {
|
||||||
|
existing.bwIn = tp.in;
|
||||||
|
existing.bwOut = tp.out;
|
||||||
|
} else {
|
||||||
|
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topIPsByBandwidth = Array.from(allIPData.entries())
|
||||||
|
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
|
||||||
|
|
||||||
|
// Build domain activity using per-IP domain request counts from Rust engine
|
||||||
|
const connectionsByRoute = proxyMetrics.connections.byRoute();
|
||||||
|
const throughputByRoute = proxyMetrics.throughput.byRoute();
|
||||||
|
|
||||||
|
// Aggregate per-IP domain request counts into per-domain totals
|
||||||
|
const domainRequestTotals = new Map<string, number>();
|
||||||
|
const domainRequestsByIP = proxyMetrics.connections.domainRequestsByIP();
|
||||||
|
for (const [, domainMap] of domainRequestsByIP) {
|
||||||
|
for (const [domain, count] of domainMap) {
|
||||||
|
domainRequestTotals.set(domain, (domainRequestTotals.get(domain) || 0) + count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map canonical route key → domains from route config
|
||||||
|
const routeDomains = new Map<string, string[]>();
|
||||||
|
if (this.dcRouter.smartProxy) {
|
||||||
|
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||||
|
const routeKey = route.name || route.id;
|
||||||
|
if (!routeKey || !route.match.domains) continue;
|
||||||
|
const domains = Array.isArray(route.match.domains)
|
||||||
|
? route.match.domains
|
||||||
|
: [route.match.domains];
|
||||||
|
if (domains.length > 0) {
|
||||||
|
routeDomains.set(routeKey, domains);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve wildcards using domains seen in request metrics
|
||||||
|
const allKnownDomains = new Set<string>(domainRequestTotals.keys());
|
||||||
|
for (const entry of protocolCache) {
|
||||||
|
if (entry.domain) allKnownDomains.add(entry.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build reverse map: concrete domain → canonical route key(s)
|
||||||
|
const domainToRoutes = new Map<string, string[]>();
|
||||||
|
for (const [routeKey, domains] of routeDomains) {
|
||||||
|
for (const pattern of domains) {
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
||||||
|
for (const knownDomain of allKnownDomains) {
|
||||||
|
if (regex.test(knownDomain)) {
|
||||||
|
const existing = domainToRoutes.get(knownDomain);
|
||||||
|
if (existing) { existing.push(routeKey); }
|
||||||
|
else { domainToRoutes.set(knownDomain, [routeKey]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existing = domainToRoutes.get(pattern);
|
||||||
|
if (existing) { existing.push(routeKey); }
|
||||||
|
else { domainToRoutes.set(pattern, [routeKey]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each route, compute the total request count across all its resolved domains
|
||||||
|
// so we can distribute throughput/connections proportionally
|
||||||
|
const routeTotalRequests = new Map<string, number>();
|
||||||
|
for (const [domain, routeKeys] of domainToRoutes) {
|
||||||
|
const reqs = domainRequestTotals.get(domain) || 0;
|
||||||
|
for (const routeKey of routeKeys) {
|
||||||
|
routeTotalRequests.set(routeKey, (routeTotalRequests.get(routeKey) || 0) + reqs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate metrics per domain using request-count-proportional splitting
|
||||||
|
const domainAgg = new Map<string, {
|
||||||
|
activeConnections: number;
|
||||||
|
bytesInPerSec: number;
|
||||||
|
bytesOutPerSec: number;
|
||||||
|
routeCount: number;
|
||||||
|
requestCount: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const [domain, routeKeys] of domainToRoutes) {
|
||||||
|
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||||
|
let totalConns = 0;
|
||||||
|
let totalIn = 0;
|
||||||
|
let totalOut = 0;
|
||||||
|
|
||||||
|
for (const routeKey of routeKeys) {
|
||||||
|
const conns = connectionsByRoute.get(routeKey) || 0;
|
||||||
|
const tp = throughputByRoute.get(routeKey) || { in: 0, out: 0 };
|
||||||
|
const routeTotal = routeTotalRequests.get(routeKey) || 0;
|
||||||
|
|
||||||
|
const share = routeTotal > 0 ? domainReqs / routeTotal : 0;
|
||||||
|
totalConns += conns * share;
|
||||||
|
totalIn += tp.in * share;
|
||||||
|
totalOut += tp.out * share;
|
||||||
|
}
|
||||||
|
|
||||||
|
domainAgg.set(domain, {
|
||||||
|
activeConnections: Math.round(totalConns),
|
||||||
|
bytesInPerSec: totalIn,
|
||||||
|
bytesOutPerSec: totalOut,
|
||||||
|
routeCount: routeKeys.length,
|
||||||
|
requestCount: domainReqs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainActivity = Array.from(domainAgg.entries())
|
||||||
|
.map(([domain, data]) => ({
|
||||||
|
domain,
|
||||||
|
bytesInPerSecond: data.bytesInPerSec,
|
||||||
|
bytesOutPerSecond: data.bytesOutPerSec,
|
||||||
|
activeConnections: data.activeConnections,
|
||||||
|
routeCount: data.routeCount,
|
||||||
|
requestCount: data.requestCount,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionsByIP,
|
connectionsByIP,
|
||||||
throughputRate,
|
throughputRate,
|
||||||
topIPs,
|
topIPs,
|
||||||
|
topIPsByBandwidth,
|
||||||
totalDataTransferred,
|
totalDataTransferred,
|
||||||
throughputHistory,
|
throughputHistory,
|
||||||
throughputByIP,
|
throughputByIP,
|
||||||
@@ -711,6 +844,7 @@ export class MetricsManager {
|
|||||||
backends,
|
backends,
|
||||||
frontendProtocols,
|
frontendProtocols,
|
||||||
backendProtocols,
|
backendProtocols,
|
||||||
|
domainActivity,
|
||||||
};
|
};
|
||||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
}
|
}
|
||||||
@@ -857,4 +991,4 @@ export class MetricsManager {
|
|||||||
|
|
||||||
return { queries };
|
return { queries };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class OpsServer {
|
|||||||
private domainHandler!: handlers.DomainHandler;
|
private domainHandler!: handlers.DomainHandler;
|
||||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||||
|
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -104,6 +105,7 @@ export class OpsServer {
|
|||||||
this.domainHandler = new handlers.DomainHandler(this);
|
this.domainHandler = new handlers.DomainHandler(this);
|
||||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||||
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||||
|
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,12 +198,11 @@ export class CertificateHandler {
|
|||||||
try {
|
try {
|
||||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||||
if (rustStatus) {
|
if (rustStatus) {
|
||||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
if (rustStatus.expiresAt > 0) {
|
||||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
|
||||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
|
||||||
status = rustStatus.status;
|
|
||||||
}
|
}
|
||||||
|
if (rustStatus.source) issuer = rustStatus.source;
|
||||||
|
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Rust bridge may not support this command yet — ignore
|
// Rust bridge may not support this command yet — ignore
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export class ConfigHandler {
|
|||||||
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
||||||
let dnsChallengeEnabled = false;
|
let dnsChallengeEnabled = false;
|
||||||
try {
|
try {
|
||||||
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
|
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
|
||||||
} catch {
|
} catch {
|
||||||
dnsChallengeEnabled = false;
|
dnsChallengeEnabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,5 +157,23 @@ export class DomainHandler {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Migrate domain between dcrouter-hosted and provider-managed
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_MigrateDomain>(
|
||||||
|
'migrateDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'domains:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.migrateDomain({
|
||||||
|
id: dataArg.id,
|
||||||
|
targetSource: dataArg.targetSource,
|
||||||
|
targetProviderId: dataArg.targetProviderId,
|
||||||
|
deleteExistingProviderRecords: dataArg.deleteExistingProviderRecords,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
195
ts/opsserver/handlers/email-domain.handler.ts
Normal file
195
ts/opsserver/handlers/email-domain.handler.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD + DNS provisioning handler for email domains.
|
||||||
|
*
|
||||||
|
* Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope.
|
||||||
|
*/
|
||||||
|
export class EmailDomainHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 get manager() {
|
||||||
|
return this.opsServerRef.dcRouterRef.emailDomainManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// List all email domains
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomains>(
|
||||||
|
'getEmailDomains',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||||
|
if (!this.manager) return { domains: [] };
|
||||||
|
return { domains: await this.manager.getAll() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single email domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomain>(
|
||||||
|
'getEmailDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||||
|
if (!this.manager) return { domain: null };
|
||||||
|
return { domain: await this.manager.getById(dataArg.id) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create email domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateEmailDomain>(
|
||||||
|
'createEmailDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||||
|
if (!this.manager) {
|
||||||
|
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const domain = await this.manager.createEmailDomain({
|
||||||
|
linkedDomainId: dataArg.linkedDomainId,
|
||||||
|
subdomain: dataArg.subdomain,
|
||||||
|
dkimSelector: dataArg.dkimSelector,
|
||||||
|
dkimKeySize: dataArg.dkimKeySize,
|
||||||
|
rotateKeys: dataArg.rotateKeys,
|
||||||
|
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||||
|
});
|
||||||
|
return { success: true, domain };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update email domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailDomain>(
|
||||||
|
'updateEmailDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||||
|
if (!this.manager) {
|
||||||
|
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.manager.updateEmailDomain(dataArg.id, {
|
||||||
|
rotateKeys: dataArg.rotateKeys,
|
||||||
|
rotationIntervalDays: dataArg.rotationIntervalDays,
|
||||||
|
rateLimits: dataArg.rateLimits,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete email domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteEmailDomain>(
|
||||||
|
'deleteEmailDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||||
|
if (!this.manager) {
|
||||||
|
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.manager.deleteEmailDomain(dataArg.id);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate DNS records
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ValidateEmailDomain>(
|
||||||
|
'validateEmailDomain',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||||
|
if (!this.manager) {
|
||||||
|
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const records = await this.manager.validateDns(dataArg.id);
|
||||||
|
const domain = await this.manager.getById(dataArg.id);
|
||||||
|
return { success: true, domain: domain ?? undefined, records };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get required DNS records
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
|
||||||
|
'getEmailDomainDnsRecords',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:read' as any);
|
||||||
|
if (!this.manager) return { records: [] };
|
||||||
|
return { records: await this.manager.getRequiredDnsRecords(dataArg.id) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-provision DNS records
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ProvisionEmailDomainDns>(
|
||||||
|
'provisionEmailDomainDns',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'email-domains:write' as any);
|
||||||
|
if (!this.manager) {
|
||||||
|
return { success: false, message: 'EmailDomainManager not initialized' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
|
||||||
|
return { success: true, provisioned };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export class EmailOpsHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queue = emailServer.deliveryQueue;
|
const queue = emailServer.deliveryQueue;
|
||||||
const item = queue.getItem(dataArg.emailId);
|
const item = emailServer.getQueueItem(dataArg.emailId);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return { success: false, error: 'Email not found in queue' };
|
return { success: false, error: 'Email not found in queue' };
|
||||||
@@ -82,22 +82,10 @@ export class EmailOpsHandler {
|
|||||||
*/
|
*/
|
||||||
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
private getAllQueueEmails(): interfaces.requests.IEmail[] {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const queueMap = (queue as any).queue as Map<string, any>;
|
|
||||||
|
|
||||||
if (!queueMap) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const emails: interfaces.requests.IEmail[] = [];
|
|
||||||
|
|
||||||
for (const [id, item] of queueMap.entries()) {
|
|
||||||
emails.push(this.mapQueueItemToEmail(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by createdAt descending (newest first)
|
// Sort by createdAt descending (newest first)
|
||||||
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
@@ -110,12 +98,10 @@ export class EmailOpsHandler {
|
|||||||
*/
|
*/
|
||||||
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
|
||||||
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
if (!emailServer?.deliveryQueue) {
|
if (!emailServer) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const item = emailServer.getQueueItem(emailId);
|
||||||
const queue = emailServer.deliveryQueue;
|
|
||||||
const item = queue.getItem(emailId);
|
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export * from './users.handler.js';
|
|||||||
export * from './dns-provider.handler.js';
|
export * from './dns-provider.handler.js';
|
||||||
export * from './domain.handler.js';
|
export * from './domain.handler.js';
|
||||||
export * from './dns-record.handler.js';
|
export * from './dns-record.handler.js';
|
||||||
export * from './acme-config.handler.js';
|
export * from './acme-config.handler.js';
|
||||||
|
export * from './email-domain.handler.js';
|
||||||
@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
|
|||||||
const result = await resolver.deleteTarget(
|
const result = await resolver.deleteTarget(
|
||||||
dataArg.id,
|
dataArg.id,
|
||||||
dataArg.force ?? false,
|
dataArg.force ?? false,
|
||||||
manager.getStoredRoutes(),
|
manager.getRoutes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success && dataArg.force) {
|
if (result.success && dataArg.force) {
|
||||||
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
|
|||||||
if (!resolver || !manager) {
|
if (!resolver || !manager) {
|
||||||
return { routes: [] };
|
return { routes: [] };
|
||||||
}
|
}
|
||||||
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes());
|
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
|
||||||
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class RouteManagementHandler {
|
|||||||
return { success: false, message: 'Route management not initialized' };
|
return { success: false, message: 'Route management not initialized' };
|
||||||
}
|
}
|
||||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||||
return { success: true, storedRouteId: id };
|
return { success: true, routeId: id };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -87,12 +87,12 @@ export class RouteManagementHandler {
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Route management not initialized' };
|
return { success: false, message: 'Route management not initialized' };
|
||||||
}
|
}
|
||||||
const ok = await manager.updateRoute(dataArg.id, {
|
const result = await manager.updateRoute(dataArg.id, {
|
||||||
route: dataArg.route as any,
|
route: dataArg.route as any,
|
||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
metadata: dataArg.metadata,
|
metadata: dataArg.metadata,
|
||||||
});
|
});
|
||||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
return result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -107,45 +107,12 @@ export class RouteManagementHandler {
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Route management not initialized' };
|
return { success: false, message: 'Route management not initialized' };
|
||||||
}
|
}
|
||||||
const ok = await manager.deleteRoute(dataArg.id);
|
return manager.deleteRoute(dataArg.id);
|
||||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set override on a hardcoded route
|
// Toggle 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(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||||
'toggleRoute',
|
'toggleRoute',
|
||||||
@@ -155,8 +122,7 @@ export class RouteManagementHandler {
|
|||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'Route management not initialized' };
|
return { success: false, message: 'Route management not initialized' };
|
||||||
}
|
}
|
||||||
const ok = await manager.toggleRoute(dataArg.id, dataArg.enabled);
|
return manager.toggleRoute(dataArg.id, dataArg.enabled);
|
||||||
return { success: ok, message: ok ? undefined : 'Route not found' };
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export class SecurityHandler {
|
|||||||
startTime: conn.startTime,
|
startTime: conn.startTime,
|
||||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||||
state: conn.status as any,
|
state: conn.status as any,
|
||||||
bytesReceived: Math.floor(conn.bytesTransferred / 2),
|
bytesReceived: (conn as any)._throughputIn || 0,
|
||||||
bytesSent: Math.floor(conn.bytesTransferred / 2),
|
bytesSent: (conn as any)._throughputOut || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
@@ -96,9 +96,11 @@ export class SecurityHandler {
|
|||||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||||
throughputRate: networkStats.throughputRate,
|
throughputRate: networkStats.throughputRate,
|
||||||
topIPs: networkStats.topIPs,
|
topIPs: networkStats.topIPs,
|
||||||
|
topIPsByBandwidth: networkStats.topIPsByBandwidth,
|
||||||
totalDataTransferred: networkStats.totalDataTransferred,
|
totalDataTransferred: networkStats.totalDataTransferred,
|
||||||
throughputHistory: networkStats.throughputHistory || [],
|
throughputHistory: networkStats.throughputHistory || [],
|
||||||
throughputByIP,
|
throughputByIP,
|
||||||
|
domainActivity: networkStats.domainActivity || [],
|
||||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||||
requestsTotal: networkStats.requestsTotal || 0,
|
requestsTotal: networkStats.requestsTotal || 0,
|
||||||
backends: networkStats.backends || [],
|
backends: networkStats.backends || [],
|
||||||
@@ -110,9 +112,11 @@ export class SecurityHandler {
|
|||||||
connectionsByIP: [],
|
connectionsByIP: [],
|
||||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
topIPs: [],
|
topIPs: [],
|
||||||
|
topIPsByBandwidth: [],
|
||||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||||
throughputHistory: [],
|
throughputHistory: [],
|
||||||
throughputByIP: [],
|
throughputByIP: [],
|
||||||
|
domainActivity: [],
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
backends: [],
|
backends: [],
|
||||||
@@ -251,31 +255,31 @@ export class SecurityHandler {
|
|||||||
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
||||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||||
|
|
||||||
// Use IP-based connection data from the new metrics API
|
// One aggregate row per IP with real throughput data
|
||||||
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
||||||
let connIndex = 0;
|
let connIndex = 0;
|
||||||
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
||||||
|
|
||||||
for (const [ip, count] of networkStats.connectionsByIP) {
|
for (const [ip, count] of networkStats.connectionsByIP) {
|
||||||
// Create a connection entry for each active IP connection
|
const tp = networkStats.throughputByIP?.get(ip);
|
||||||
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
|
connections.push({
|
||||||
connections.push({
|
id: `ip-${connIndex++}`,
|
||||||
id: `conn-${connIndex++}`,
|
type: 'http',
|
||||||
type: 'http',
|
source: {
|
||||||
source: {
|
ip: ip,
|
||||||
ip: ip,
|
port: 0,
|
||||||
port: Math.floor(Math.random() * 50000) + 10000, // High port range
|
},
|
||||||
},
|
destination: {
|
||||||
destination: {
|
ip: publicIp,
|
||||||
ip: publicIp,
|
port: 443,
|
||||||
port: 443,
|
service: 'proxy',
|
||||||
service: 'proxy',
|
},
|
||||||
},
|
startTime: 0,
|
||||||
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
|
bytesTransferred: count, // Store connection count here
|
||||||
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
|
status: 'active',
|
||||||
status: 'active',
|
// Attach real throughput for the handler mapping
|
||||||
});
|
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
|
||||||
}
|
} as any);
|
||||||
}
|
}
|
||||||
} else if (connectionInfo.length > 0) {
|
} else if (connectionInfo.length > 0) {
|
||||||
// Fallback to route-based connection info if no IP data available
|
// Fallback to route-based connection info if no IP data available
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export class SourceProfileHandler {
|
|||||||
const result = await resolver.deleteProfile(
|
const result = await resolver.deleteProfile(
|
||||||
dataArg.id,
|
dataArg.id,
|
||||||
dataArg.force ?? false,
|
dataArg.force ?? false,
|
||||||
manager.getStoredRoutes(),
|
manager.getRoutes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// If force-deleted with affected routes, re-apply
|
// If force-deleted with affected routes, re-apply
|
||||||
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
|
|||||||
if (!resolver || !manager) {
|
if (!resolver || !manager) {
|
||||||
return { routes: [] };
|
return { routes: [] };
|
||||||
}
|
}
|
||||||
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes());
|
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
|
||||||
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -291,6 +291,20 @@ export class StatsHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build connectionDetails from real per-IP data
|
||||||
|
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
|
||||||
|
for (const [ip, count] of stats.connectionsByIP) {
|
||||||
|
const tp = stats.throughputByIP?.get(ip);
|
||||||
|
connectionDetails.push({
|
||||||
|
remoteAddress: ip,
|
||||||
|
protocol: 'https',
|
||||||
|
state: 'connected',
|
||||||
|
startTime: 0,
|
||||||
|
bytesIn: tp?.in || 0,
|
||||||
|
bytesOut: tp?.out || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
metrics.network = {
|
metrics.network = {
|
||||||
totalBandwidth: {
|
totalBandwidth: {
|
||||||
in: stats.throughputRate.bytesInPerSecond,
|
in: stats.throughputRate.bytesInPerSecond,
|
||||||
@@ -301,12 +315,18 @@ export class StatsHandler {
|
|||||||
out: stats.totalDataTransferred.bytesOut,
|
out: stats.totalDataTransferred.bytesOut,
|
||||||
},
|
},
|
||||||
activeConnections: serverStats.activeConnections,
|
activeConnections: serverStats.activeConnections,
|
||||||
connectionDetails: [],
|
connectionDetails,
|
||||||
topEndpoints: stats.topIPs.map(ip => ({
|
topEndpoints: stats.topIPs.map(ip => ({
|
||||||
endpoint: ip.ip,
|
endpoint: ip.ip,
|
||||||
requests: ip.count,
|
connections: ip.count,
|
||||||
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
|
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
|
||||||
})),
|
})),
|
||||||
|
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
|
||||||
|
endpoint: ip.ip,
|
||||||
|
connections: ip.count,
|
||||||
|
bandwidth: { in: ip.bwIn, out: ip.bwOut },
|
||||||
|
})),
|
||||||
|
domainActivity: stats.domainActivity || [],
|
||||||
throughputHistory: stats.throughputHistory || [],
|
throughputHistory: stats.throughputHistory || [],
|
||||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||||
requestsTotal: stats.requestsTotal || 0,
|
requestsTotal: stats.requestsTotal || 0,
|
||||||
@@ -510,13 +530,49 @@ export class StatsHandler {
|
|||||||
nextRetry?: number;
|
nextRetry?: number;
|
||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
// TODO: Implement actual queue status collection
|
const emailServer = this.opsServerRef.dcRouterRef.emailServer;
|
||||||
|
if (!emailServer) {
|
||||||
|
return {
|
||||||
|
pending: 0,
|
||||||
|
active: 0,
|
||||||
|
failed: 0,
|
||||||
|
retrying: 0,
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueStats = emailServer.getQueueStats();
|
||||||
|
const items = emailServer.getQueueItems()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const left = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
||||||
|
const right = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
||||||
|
return right - left;
|
||||||
|
})
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((item) => {
|
||||||
|
const emailLike = item.processingResult;
|
||||||
|
const recipients = Array.isArray(emailLike?.to)
|
||||||
|
? emailLike.to
|
||||||
|
: Array.isArray(emailLike?.email?.to)
|
||||||
|
? emailLike.email.to
|
||||||
|
: [];
|
||||||
|
const subject = emailLike?.subject || emailLike?.email?.subject || '';
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
recipient: recipients[0] || '',
|
||||||
|
subject,
|
||||||
|
status: item.status,
|
||||||
|
attempts: item.attempts,
|
||||||
|
nextRetry: item.nextAttempt instanceof Date ? item.nextAttempt.getTime() : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pending: 0,
|
pending: queueStats.status.pending,
|
||||||
active: 0,
|
active: queueStats.status.processing,
|
||||||
failed: 0,
|
failed: queueStats.status.failed,
|
||||||
retrying: 0,
|
retrying: queueStats.status.deferred,
|
||||||
items: [],
|
items,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,4 +636,4 @@ export class StatsHandler {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export class VpnManager {
|
|||||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||||
private clients: Map<string, VpnClientDoc> = new Map();
|
private clients: Map<string, VpnClientDoc> = new Map();
|
||||||
private serverKeys?: VpnServerKeysDoc;
|
private serverKeys?: VpnServerKeysDoc;
|
||||||
|
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||||
|
|
||||||
constructor(config: IVpnManagerConfig) {
|
constructor(config: IVpnManagerConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
|||||||
if (client.useHostIp) {
|
if (client.useHostIp) {
|
||||||
anyClientUsesHostIp = true;
|
anyClientUsesHostIp = true;
|
||||||
}
|
}
|
||||||
|
this.normalizeClientRoutingSettings(client);
|
||||||
const entry: plugins.smartvpn.IClientEntry = {
|
const entry: plugins.smartvpn.IClientEntry = {
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
publicKey: client.noisePublicKey,
|
publicKey: client.noisePublicKey,
|
||||||
@@ -97,13 +100,12 @@ export class VpnManager {
|
|||||||
assignedIp: client.assignedIp,
|
assignedIp: client.assignedIp,
|
||||||
expiresAt: client.expiresAt,
|
expiresAt: client.expiresAt,
|
||||||
security: this.buildClientSecurity(client),
|
security: this.buildClientSecurity(client),
|
||||||
|
useHostIp: client.useHostIp,
|
||||||
|
useDhcp: client.useDhcp,
|
||||||
|
staticIp: client.staticIp,
|
||||||
|
forceVlan: client.forceVlan,
|
||||||
|
vlanId: client.vlanId,
|
||||||
};
|
};
|
||||||
// Pass per-client bridge fields if present (for hybrid/bridge mode)
|
|
||||||
if (client.useHostIp !== undefined) (entry as any).useHostIp = client.useHostIp;
|
|
||||||
if (client.useDhcp !== undefined) (entry as any).useDhcp = client.useDhcp;
|
|
||||||
if (client.staticIp !== undefined) (entry as any).staticIp = client.staticIp;
|
|
||||||
if (client.forceVlan !== undefined) (entry as any).forceVlan = client.forceVlan;
|
|
||||||
if (client.vlanId !== undefined) (entry as any).vlanId = client.vlanId;
|
|
||||||
clientEntries.push(entry);
|
clientEntries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +114,15 @@ export class VpnManager {
|
|||||||
|
|
||||||
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
// Auto-detect hybrid mode: if any persisted client uses host IP and mode is
|
||||||
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
// 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
|
||||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||||
configuredMode = 'hybrid';
|
configuredMode = 'hybrid';
|
||||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||||
}
|
}
|
||||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||||
const isBridge = forwardingMode === 'bridge';
|
const isBridge = forwardingMode === 'bridge';
|
||||||
|
this.resolvedForwardingMode = forwardingMode;
|
||||||
|
this.forwardingModeOverride = undefined;
|
||||||
|
|
||||||
// Create and start VpnServer
|
// Create and start VpnServer
|
||||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||||
@@ -143,7 +147,7 @@ export class VpnManager {
|
|||||||
wgListenPort,
|
wgListenPort,
|
||||||
clients: clientEntries,
|
clients: clientEntries,
|
||||||
socketForwardProxyProtocol: !isBridge,
|
socketForwardProxyProtocol: !isBridge,
|
||||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||||
serverEndpoint: this.config.serverEndpoint
|
serverEndpoint: this.config.serverEndpoint
|
||||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -189,6 +193,7 @@ export class VpnManager {
|
|||||||
this.vpnServer.stop();
|
this.vpnServer.stop();
|
||||||
this.vpnServer = undefined;
|
this.vpnServer = undefined;
|
||||||
}
|
}
|
||||||
|
this.resolvedForwardingMode = undefined;
|
||||||
logger.log('info', 'VPN server stopped');
|
logger.log('info', 'VPN server stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,14 +218,38 @@ export class VpnManager {
|
|||||||
throw new Error('VPN server not running');
|
throw new Error('VPN server not running');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
|
||||||
|
|
||||||
|
const doc = new VpnClientDoc();
|
||||||
|
doc.clientId = opts.clientId;
|
||||||
|
doc.enabled = true;
|
||||||
|
doc.targetProfileIds = opts.targetProfileIds;
|
||||||
|
doc.description = opts.description;
|
||||||
|
doc.destinationAllowList = opts.destinationAllowList;
|
||||||
|
doc.destinationBlockList = opts.destinationBlockList;
|
||||||
|
doc.useHostIp = opts.useHostIp;
|
||||||
|
doc.useDhcp = opts.useDhcp;
|
||||||
|
doc.staticIp = opts.staticIp;
|
||||||
|
doc.forceVlan = opts.forceVlan;
|
||||||
|
doc.vlanId = opts.vlanId;
|
||||||
|
doc.createdAt = Date.now();
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
this.normalizeClientRoutingSettings(doc);
|
||||||
|
|
||||||
const bundle = await this.vpnServer.createClient({
|
const bundle = await this.vpnServer.createClient({
|
||||||
clientId: opts.clientId,
|
clientId: doc.clientId,
|
||||||
description: opts.description,
|
description: doc.description,
|
||||||
|
security: this.buildClientSecurity(doc),
|
||||||
|
useHostIp: doc.useHostIp,
|
||||||
|
useDhcp: doc.useDhcp,
|
||||||
|
staticIp: doc.staticIp,
|
||||||
|
forceVlan: doc.forceVlan,
|
||||||
|
vlanId: doc.vlanId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override AllowedIPs with per-client values based on target profiles
|
// Override AllowedIPs with per-client values based on target profiles
|
||||||
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
|
||||||
const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
|
const allowedIPs = await this.config.getClientAllowedIPs(doc.targetProfileIds || []);
|
||||||
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
bundle.wireguardConfig = bundle.wireguardConfig.replace(
|
||||||
/AllowedIPs\s*=\s*.+/,
|
/AllowedIPs\s*=\s*.+/,
|
||||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||||
@@ -228,40 +257,16 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist client entry (including WG private key for export/QR)
|
// Persist client entry (including WG private key for export/QR)
|
||||||
const doc = new VpnClientDoc();
|
|
||||||
doc.clientId = bundle.entry.clientId;
|
doc.clientId = bundle.entry.clientId;
|
||||||
doc.enabled = bundle.entry.enabled ?? true;
|
doc.enabled = bundle.entry.enabled ?? true;
|
||||||
doc.targetProfileIds = opts.targetProfileIds;
|
|
||||||
doc.description = bundle.entry.description;
|
doc.description = bundle.entry.description;
|
||||||
doc.assignedIp = bundle.entry.assignedIp;
|
doc.assignedIp = bundle.entry.assignedIp;
|
||||||
doc.noisePublicKey = bundle.entry.publicKey;
|
doc.noisePublicKey = bundle.entry.publicKey;
|
||||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||||
doc.createdAt = Date.now();
|
|
||||||
doc.updatedAt = Date.now();
|
doc.updatedAt = Date.now();
|
||||||
doc.expiresAt = bundle.entry.expiresAt;
|
doc.expiresAt = bundle.entry.expiresAt;
|
||||||
if (opts.destinationAllowList !== undefined) {
|
|
||||||
doc.destinationAllowList = opts.destinationAllowList;
|
|
||||||
}
|
|
||||||
if (opts.destinationBlockList !== undefined) {
|
|
||||||
doc.destinationBlockList = opts.destinationBlockList;
|
|
||||||
}
|
|
||||||
if (opts.useHostIp !== undefined) {
|
|
||||||
doc.useHostIp = opts.useHostIp;
|
|
||||||
}
|
|
||||||
if (opts.useDhcp !== undefined) {
|
|
||||||
doc.useDhcp = opts.useDhcp;
|
|
||||||
}
|
|
||||||
if (opts.staticIp !== undefined) {
|
|
||||||
doc.staticIp = opts.staticIp;
|
|
||||||
}
|
|
||||||
if (opts.forceVlan !== undefined) {
|
|
||||||
doc.forceVlan = opts.forceVlan;
|
|
||||||
}
|
|
||||||
if (opts.vlanId !== undefined) {
|
|
||||||
doc.vlanId = opts.vlanId;
|
|
||||||
}
|
|
||||||
this.clients.set(doc.clientId, doc);
|
this.clients.set(doc.clientId, doc);
|
||||||
try {
|
try {
|
||||||
await this.persistClient(doc);
|
await this.persistClient(doc);
|
||||||
@@ -276,12 +281,6 @@ export class VpnManager {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync per-client security to the running daemon
|
|
||||||
const security = this.buildClientSecurity(doc);
|
|
||||||
if (security.destinationPolicy) {
|
|
||||||
await this.vpnServer!.updateClient(doc.clientId, { security });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.config.onClientChanged?.();
|
this.config.onClientChanged?.();
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
@@ -364,13 +363,13 @@ export class VpnManager {
|
|||||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||||
|
this.normalizeClientRoutingSettings(client);
|
||||||
client.updatedAt = Date.now();
|
client.updatedAt = Date.now();
|
||||||
await this.persistClient(client);
|
await this.persistClient(client);
|
||||||
|
|
||||||
// Sync per-client security to the running daemon
|
|
||||||
if (this.vpnServer) {
|
if (this.vpnServer) {
|
||||||
const security = this.buildClientSecurity(client);
|
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||||
await this.vpnServer.updateClient(clientId, { security });
|
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.config.onClientChanged?.();
|
this.config.onClientChanged?.();
|
||||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build per-client security settings for the smartvpn daemon.
|
* Build per-client security settings for the smartvpn daemon.
|
||||||
* All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
|
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||||
* TargetProfile direct IP:port targets bypass SmartProxy via allowList.
|
|
||||||
*/
|
*/
|
||||||
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
|
||||||
const security: plugins.smartvpn.IClientSecurity = {};
|
const security: plugins.smartvpn.IClientSecurity = {};
|
||||||
|
const basePolicy = this.getBaseDestinationPolicy(client);
|
||||||
|
|
||||||
// Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
|
|
||||||
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
|
||||||
|
const mergedAllowList = this.mergeDestinationLists(
|
||||||
// Merge with per-client explicit allow list
|
basePolicy.allowList,
|
||||||
const mergedAllowList = [
|
client.destinationAllowList,
|
||||||
...(client.destinationAllowList || []),
|
profileDirectTargets,
|
||||||
...profileDirectTargets,
|
);
|
||||||
];
|
const mergedBlockList = this.mergeDestinationLists(
|
||||||
|
basePolicy.blockList,
|
||||||
|
client.destinationBlockList,
|
||||||
|
);
|
||||||
|
|
||||||
security.destinationPolicy = {
|
security.destinationPolicy = {
|
||||||
default: 'forceTarget' as const,
|
default: basePolicy.default,
|
||||||
target: '127.0.0.1',
|
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||||
blockList: client.destinationBlockList,
|
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return security;
|
return security;
|
||||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
|||||||
public async refreshAllClientSecurity(): Promise<void> {
|
public async refreshAllClientSecurity(): Promise<void> {
|
||||||
if (!this.vpnServer) return;
|
if (!this.vpnServer) return;
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
const security = this.buildClientSecurity(client);
|
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||||
if (security.destinationPolicy) {
|
|
||||||
await this.vpnServer.updateClient(client.clientId, { security });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
|||||||
private async loadPersistedClients(): Promise<void> {
|
private async loadPersistedClients(): Promise<void> {
|
||||||
const docs = await VpnClientDoc.findAll();
|
const docs = await VpnClientDoc.findAll();
|
||||||
for (const doc of docs) {
|
for (const doc of docs) {
|
||||||
|
this.normalizeClientRoutingSettings(doc);
|
||||||
this.clients.set(doc.clientId, doc);
|
this.clients.set(doc.clientId, doc);
|
||||||
}
|
}
|
||||||
if (this.clients.size > 0) {
|
if (this.clients.size > 0) {
|
||||||
@@ -557,6 +556,93 @@ export class VpnManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getResolvedForwardingMode(): 'socket' | 'bridge' | 'hybrid' {
|
||||||
|
return this.resolvedForwardingMode
|
||||||
|
?? this.forwardingModeOverride
|
||||||
|
?? this.config.forwardingMode
|
||||||
|
?? 'socket';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultDestinationPolicy(
|
||||||
|
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||||
|
useHostIp = false,
|
||||||
|
): plugins.smartvpn.IDestinationPolicy {
|
||||||
|
if (forwardingMode === 'bridge' || (forwardingMode === 'hybrid' && useHostIp)) {
|
||||||
|
return { default: 'allow' };
|
||||||
|
}
|
||||||
|
return { default: 'forceTarget', target: '127.0.0.1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServerDestinationPolicy(
|
||||||
|
forwardingMode: 'socket' | 'bridge' | 'hybrid',
|
||||||
|
fallbackPolicy = this.getDefaultDestinationPolicy(forwardingMode),
|
||||||
|
): plugins.smartvpn.IDestinationPolicy {
|
||||||
|
return this.config.destinationPolicy ?? fallbackPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBaseDestinationPolicy(client: Pick<VpnClientDoc, 'useHostIp'>): plugins.smartvpn.IDestinationPolicy {
|
||||||
|
if (this.config.destinationPolicy) {
|
||||||
|
return { ...this.config.destinationPolicy };
|
||||||
|
}
|
||||||
|
return this.getDefaultDestinationPolicy(this.getResolvedForwardingMode(), client.useHostIp === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeDestinationLists(...lists: Array<string[] | undefined>): string[] {
|
||||||
|
const merged = new Set<string>();
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const entry of list || []) {
|
||||||
|
merged.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...merged];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeClientRoutingSettings(
|
||||||
|
client: Pick<VpnClientDoc, 'useHostIp' | 'useDhcp' | 'staticIp' | 'forceVlan' | 'vlanId'>,
|
||||||
|
): void {
|
||||||
|
client.useHostIp = client.useHostIp === true;
|
||||||
|
|
||||||
|
if (!client.useHostIp) {
|
||||||
|
client.useDhcp = false;
|
||||||
|
client.staticIp = undefined;
|
||||||
|
client.forceVlan = false;
|
||||||
|
client.vlanId = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.useDhcp = client.useDhcp === true;
|
||||||
|
if (client.useDhcp) {
|
||||||
|
client.staticIp = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.forceVlan = client.forceVlan === true;
|
||||||
|
if (!client.forceVlan) {
|
||||||
|
client.vlanId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildClientRuntimeUpdate(client: VpnClientDoc): Partial<plugins.smartvpn.IClientEntry> {
|
||||||
|
return {
|
||||||
|
description: client.description,
|
||||||
|
security: this.buildClientSecurity(client),
|
||||||
|
useHostIp: client.useHostIp,
|
||||||
|
useDhcp: client.useDhcp,
|
||||||
|
staticIp: client.staticIp,
|
||||||
|
forceVlan: client.forceVlan,
|
||||||
|
vlanId: client.vlanId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
|
||||||
|
if (!useHostIp || !this.vpnServer) return;
|
||||||
|
if (this.getResolvedForwardingMode() !== 'socket') return;
|
||||||
|
|
||||||
|
logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
|
||||||
|
this.forwardingModeOverride = 'hybrid';
|
||||||
|
await this.stop();
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
|
||||||
private async persistClient(client: VpnClientDoc): Promise<void> {
|
private async persistClient(client: VpnClientDoc): Promise<void> {
|
||||||
await client.save();
|
await client.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ export class Route {
|
|||||||
|
|
||||||
// Data from IMergedRoute
|
// Data from IMergedRoute
|
||||||
public routeConfig: IRouteConfig;
|
public routeConfig: IRouteConfig;
|
||||||
public source: 'hardcoded' | 'programmatic';
|
public id: string;
|
||||||
public enabled: boolean;
|
public enabled: boolean;
|
||||||
public overridden: boolean;
|
public origin: 'config' | 'email' | 'dns' | 'api';
|
||||||
public storedRouteId?: string;
|
|
||||||
public createdAt?: number;
|
public createdAt?: number;
|
||||||
public updatedAt?: number;
|
public updatedAt?: number;
|
||||||
|
|
||||||
@@ -22,21 +21,17 @@ export class Route {
|
|||||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||||
this.clientRef = clientRef;
|
this.clientRef = clientRef;
|
||||||
this.routeConfig = data.route;
|
this.routeConfig = data.route;
|
||||||
this.source = data.source;
|
this.id = data.id;
|
||||||
this.enabled = data.enabled;
|
this.enabled = data.enabled;
|
||||||
this.overridden = data.overridden;
|
this.origin = data.origin;
|
||||||
this.storedRouteId = data.storedRouteId;
|
|
||||||
this.createdAt = data.createdAt;
|
this.createdAt = data.createdAt;
|
||||||
this.updatedAt = data.updatedAt;
|
this.updatedAt = data.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async update(changes: Partial<IRouteConfig>): Promise<void> {
|
public async update(changes: Partial<IRouteConfig>): Promise<void> {
|
||||||
if (!this.storedRouteId) {
|
|
||||||
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
|
|
||||||
}
|
|
||||||
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
|
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
|
||||||
'updateRoute',
|
'updateRoute',
|
||||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
|
||||||
);
|
);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
throw new Error(response.message || 'Failed to update route');
|
throw new Error(response.message || 'Failed to update route');
|
||||||
@@ -44,12 +39,9 @@ export class Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async delete(): Promise<void> {
|
public async delete(): Promise<void> {
|
||||||
if (!this.storedRouteId) {
|
|
||||||
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
|
|
||||||
}
|
|
||||||
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
|
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
|
||||||
'deleteRoute',
|
'deleteRoute',
|
||||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||||
);
|
);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
throw new Error(response.message || 'Failed to delete route');
|
throw new Error(response.message || 'Failed to delete route');
|
||||||
@@ -57,41 +49,15 @@ export class Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async toggle(enabled: boolean): Promise<void> {
|
public async toggle(enabled: boolean): Promise<void> {
|
||||||
if (!this.storedRouteId) {
|
|
||||||
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
|
|
||||||
}
|
|
||||||
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
|
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
|
||||||
'toggleRoute',
|
'toggleRoute',
|
||||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||||
);
|
);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
throw new Error(response.message || 'Failed to toggle route');
|
throw new Error(response.message || 'Failed to toggle route');
|
||||||
}
|
}
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setOverride(enabled: boolean): Promise<void> {
|
|
||||||
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
|
|
||||||
'setRouteOverride',
|
|
||||||
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
|
|
||||||
);
|
|
||||||
if (!response.success) {
|
|
||||||
throw new Error(response.message || 'Failed to set route override');
|
|
||||||
}
|
|
||||||
this.overridden = true;
|
|
||||||
this.enabled = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async removeOverride(): Promise<void> {
|
|
||||||
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
|
|
||||||
'removeRouteOverride',
|
|
||||||
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
|
|
||||||
);
|
|
||||||
if (!response.success) {
|
|
||||||
throw new Error(response.message || 'Failed to remove route override');
|
|
||||||
}
|
|
||||||
this.overridden = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RouteBuilder {
|
export class RouteBuilder {
|
||||||
@@ -144,9 +110,8 @@ export class RouteBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return a Route instance by re-fetching the list
|
// Return a Route instance by re-fetching the list
|
||||||
// The created route is programmatic, so we find it by storedRouteId
|
|
||||||
const { routes } = await new RouteManager(this.clientRef).list();
|
const { routes } = await new RouteManager(this.clientRef).list();
|
||||||
const created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
const created = routes.find((r) => r.id === response.routeId);
|
||||||
if (created) {
|
if (created) {
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
@@ -154,10 +119,9 @@ export class RouteBuilder {
|
|||||||
// Fallback: construct from known data
|
// Fallback: construct from known data
|
||||||
return new Route(this.clientRef, {
|
return new Route(this.clientRef, {
|
||||||
route: this.routeConfig as IRouteConfig,
|
route: this.routeConfig as IRouteConfig,
|
||||||
source: 'programmatic',
|
id: response.routeId || '',
|
||||||
enabled: this.isEnabled,
|
enabled: this.isEnabled,
|
||||||
overridden: false,
|
origin: 'api',
|
||||||
storedRouteId: response.storedRouteId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,10 +154,9 @@ export class RouteManager {
|
|||||||
}
|
}
|
||||||
return new Route(this.clientRef, {
|
return new Route(this.clientRef, {
|
||||||
route: routeConfig,
|
route: routeConfig,
|
||||||
source: 'programmatic',
|
id: response.routeId || '',
|
||||||
enabled: enabled ?? true,
|
enabled: enabled ?? true,
|
||||||
overridden: false,
|
origin: 'api',
|
||||||
storedRouteId: response.storedRouteId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# @serve.zone/dcrouter-apiclient
|
# @serve.zone/dcrouter-apiclient
|
||||||
|
|
||||||
A typed, object-oriented API client for DcRouter with a fluent builder pattern. 🔧
|
Typed, object-oriented API client for operating a running dcrouter instance. 🔧
|
||||||
|
|
||||||
Programmatically manage your DcRouter instance — routes, certificates, API tokens, remote ingress edges, RADIUS, email operations, and more — all with full TypeScript type safety and an intuitive OO interface.
|
Use this package when you want a clean TypeScript client instead of manually firing TypedRequest calls. It wraps the OpsServer API in resource managers and resource classes such as routes, certificates, tokens, edges, emails, stats, logs, config, and RADIUS.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
pnpm add @serve.zone/dcrouter-apiclient
|
pnpm add @serve.zone/dcrouter-apiclient
|
||||||
```
|
```
|
||||||
|
|
||||||
Or import directly from the main package:
|
Or import through the main package:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
||||||
@@ -23,239 +23,113 @@ import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
|
import { DcRouterApiClient } from '@serve.zone/dcrouter-apiclient';
|
||||||
|
|
||||||
const client = new DcRouterApiClient({ baseUrl: 'https://dcrouter.example.com' });
|
const client = new DcRouterApiClient({
|
||||||
|
baseUrl: 'https://dcrouter.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
// Authenticate
|
|
||||||
await client.login('admin', 'password');
|
await client.login('admin', 'password');
|
||||||
|
|
||||||
// List routes
|
const { routes } = await client.routes.list();
|
||||||
const { routes, warnings } = await client.routes.list();
|
console.log(routes.map((route) => `${route.origin}:${route.name}`));
|
||||||
console.log(`${routes.length} routes, ${warnings.length} warnings`);
|
|
||||||
|
|
||||||
// Check health
|
await client.routes.build()
|
||||||
const { health } = await client.stats.getHealth();
|
.setName('api-gateway')
|
||||||
console.log(`Healthy: ${health.healthy}`);
|
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
||||||
|
.setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
|
||||||
|
.save();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Authentication Modes
|
||||||
|
|
||||||
### 🔐 Authentication
|
| Mode | How it works |
|
||||||
|
| --- | --- |
|
||||||
|
| Admin login | Call `login(username, password)` and the client stores the returned identity for later requests |
|
||||||
|
| API token | Pass `apiToken` into the constructor for token-based automation |
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Login with credentials — identity is stored and auto-injected into all subsequent requests
|
|
||||||
const identity = await client.login('admin', 'password');
|
|
||||||
|
|
||||||
// Verify current session
|
|
||||||
const { valid } = await client.verifyIdentity();
|
|
||||||
|
|
||||||
// Logout
|
|
||||||
await client.logout();
|
|
||||||
|
|
||||||
// Or use an API token for programmatic access (route management only)
|
|
||||||
const client = new DcRouterApiClient({
|
const client = new DcRouterApiClient({
|
||||||
baseUrl: 'https://dcrouter.example.com',
|
baseUrl: 'https://dcrouter.example.com',
|
||||||
apiToken: 'dcr_your_token_here',
|
apiToken: 'dcr_your_token_here',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🌐 Routes — OO Resources + Builder
|
## Main Managers
|
||||||
|
|
||||||
Routes are returned as `Route` instances with methods for update, delete, toggle, and overrides:
|
| Manager | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `client.routes` | List routes and create API-managed routes |
|
||||||
|
| `client.certificates` | Inspect and operate on certificate records |
|
||||||
|
| `client.apiTokens` | Create, list, toggle, roll, revoke API tokens |
|
||||||
|
| `client.remoteIngress` | Manage registered remote ingress edges |
|
||||||
|
| `client.stats` | Read operational metrics and health data |
|
||||||
|
| `client.config` | Read current configuration view |
|
||||||
|
| `client.logs` | Read recent logs or stream them |
|
||||||
|
| `client.emails` | List emails and trigger resend flows |
|
||||||
|
| `client.radius` | Operate on RADIUS clients, VLANs, sessions, and accounting |
|
||||||
|
|
||||||
|
## Route Behavior
|
||||||
|
|
||||||
|
Routes are returned as `Route` instances with:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `enabled`
|
||||||
|
- `origin`
|
||||||
|
|
||||||
|
Important behavior:
|
||||||
|
|
||||||
|
- API routes can be created, updated, deleted, and toggled.
|
||||||
|
- System routes can be listed and toggled, but not edited or deleted.
|
||||||
|
- A system route is any route whose `origin !== 'api'`.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// List all routes (hardcoded + programmatic)
|
const { routes } = await client.routes.list();
|
||||||
const { routes, warnings } = await client.routes.list();
|
|
||||||
|
|
||||||
// Inspect a route
|
for (const route of routes) {
|
||||||
const route = routes[0];
|
if (route.origin !== 'api') {
|
||||||
console.log(route.name, route.source, route.enabled);
|
await route.toggle(false);
|
||||||
|
}
|
||||||
// Modify a programmatic route
|
}
|
||||||
await route.update({ name: 'renamed-route' });
|
|
||||||
await route.toggle(false);
|
|
||||||
await route.delete();
|
|
||||||
|
|
||||||
// Override a hardcoded route (disable it)
|
|
||||||
const hardcodedRoute = routes.find(r => r.source === 'hardcoded');
|
|
||||||
await hardcodedRoute.setOverride(false);
|
|
||||||
await hardcodedRoute.removeOverride();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Builder pattern** for creating new routes:
|
## Builder Example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const newRoute = await client.routes.build()
|
const route = await client.routes.build()
|
||||||
.setName('api-gateway')
|
.setName('internal-app')
|
||||||
.setMatch({ ports: 443, domains: ['api.example.com'] })
|
.setMatch({
|
||||||
.setAction({ type: 'forward', targets: [{ host: 'backend', port: 8080 }] })
|
ports: 80,
|
||||||
.setTls({ mode: 'terminate', certificate: 'auto' })
|
domains: ['internal.example.com'],
|
||||||
|
})
|
||||||
|
.setAction({
|
||||||
|
type: 'forward',
|
||||||
|
targets: [{ host: '127.0.0.1', port: 3000 }],
|
||||||
|
})
|
||||||
.setEnabled(true)
|
.setEnabled(true)
|
||||||
.save();
|
.save();
|
||||||
|
|
||||||
// Or use quick creation
|
await route.toggle(false);
|
||||||
const route = await client.routes.create(routeConfig);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🔑 API Tokens
|
## Example: Certificates and Stats
|
||||||
|
|
||||||
```typescript
|
|
||||||
// List existing tokens
|
|
||||||
const tokens = await client.apiTokens.list();
|
|
||||||
|
|
||||||
// Create with builder
|
|
||||||
const token = await client.apiTokens.build()
|
|
||||||
.setName('ci-pipeline')
|
|
||||||
.setScopes(['routes:read', 'routes:write'])
|
|
||||||
.addScope('config:read')
|
|
||||||
.setExpiresInDays(90)
|
|
||||||
.save();
|
|
||||||
|
|
||||||
console.log(token.tokenValue); // Only available at creation time!
|
|
||||||
|
|
||||||
// Manage tokens
|
|
||||||
await token.toggle(false); // Disable
|
|
||||||
const newValue = await token.roll(); // Regenerate secret
|
|
||||||
await token.revoke(); // Delete
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔐 Certificates
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { certificates, summary } = await client.certificates.list();
|
const { certificates, summary } = await client.certificates.list();
|
||||||
console.log(`${summary.valid} valid, ${summary.expiring} expiring, ${summary.failed} failed`);
|
console.log(summary.valid, summary.failed);
|
||||||
|
|
||||||
// Operate on individual certificates
|
const health = await client.stats.getHealth();
|
||||||
const cert = certificates[0];
|
const recentLogs = await client.logs.getRecent({ level: 'error', limit: 20 });
|
||||||
await cert.reprovision();
|
|
||||||
const exported = await cert.export();
|
|
||||||
await cert.delete();
|
|
||||||
|
|
||||||
// Import a certificate
|
|
||||||
await client.certificates.import({
|
|
||||||
id: 'cert-id',
|
|
||||||
domainName: 'example.com',
|
|
||||||
created: Date.now(),
|
|
||||||
validUntil: Date.now() + 90 * 24 * 3600 * 1000,
|
|
||||||
privateKey: '...',
|
|
||||||
publicKey: '...',
|
|
||||||
csr: '...',
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🌍 Remote Ingress
|
## What This Package Does Not Do
|
||||||
|
|
||||||
```typescript
|
- It does not start dcrouter.
|
||||||
// List edges and their statuses
|
- It does not embed the dashboard.
|
||||||
const edges = await client.remoteIngress.list();
|
- It does not replace the request interfaces package if you only need raw types.
|
||||||
const statuses = await client.remoteIngress.getStatuses();
|
|
||||||
|
|
||||||
// Create with builder
|
Use `@serve.zone/dcrouter` to run the server, `@serve.zone/dcrouter-web` for the dashboard bundle/components, and `@serve.zone/dcrouter-interfaces` for raw API contracts.
|
||||||
const edge = await client.remoteIngress.build()
|
|
||||||
.setName('edge-nyc-01')
|
|
||||||
.setListenPorts([80, 443])
|
|
||||||
.setAutoDerivePorts(true)
|
|
||||||
.setTags(['us-east'])
|
|
||||||
.save();
|
|
||||||
|
|
||||||
// Manage an edge
|
|
||||||
await edge.update({ name: 'edge-nyc-02' });
|
|
||||||
const newSecret = await edge.regenerateSecret();
|
|
||||||
const token = await edge.getConnectionToken();
|
|
||||||
await edge.delete();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📊 Statistics (Read-Only)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const serverStats = await client.stats.getServer({ timeRange: '24h', includeHistory: true });
|
|
||||||
const emailStats = await client.stats.getEmail({ domain: 'example.com' });
|
|
||||||
const dnsStats = await client.stats.getDns();
|
|
||||||
const security = await client.stats.getSecurity({ includeDetails: true });
|
|
||||||
const connections = await client.stats.getConnections({ protocol: 'https' });
|
|
||||||
const queues = await client.stats.getQueues();
|
|
||||||
const health = await client.stats.getHealth(true);
|
|
||||||
const network = await client.stats.getNetwork();
|
|
||||||
const combined = await client.stats.getCombined({ server: true, email: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚙️ Configuration & Logs
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Read-only configuration
|
|
||||||
const config = await client.config.get();
|
|
||||||
const emailSection = await client.config.get('email');
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
const { logs, total, hasMore } = await client.logs.getRecent({
|
|
||||||
level: 'error',
|
|
||||||
category: 'smtp',
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📧 Email Operations
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const emails = await client.emails.list();
|
|
||||||
const email = emails[0];
|
|
||||||
const detail = await email.getDetail();
|
|
||||||
await email.resend();
|
|
||||||
|
|
||||||
// Or use the manager directly
|
|
||||||
const detail2 = await client.emails.getDetail('email-id');
|
|
||||||
await client.emails.resend('email-id');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📡 RADIUS
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Client management
|
|
||||||
const clients = await client.radius.clients.list();
|
|
||||||
await client.radius.clients.set({
|
|
||||||
name: 'switch-1',
|
|
||||||
ipRange: '192.168.1.0/24',
|
|
||||||
secret: 'shared-secret',
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
await client.radius.clients.remove('switch-1');
|
|
||||||
|
|
||||||
// VLAN management
|
|
||||||
const { mappings, config: vlanConfig } = await client.radius.vlans.list();
|
|
||||||
await client.radius.vlans.set({ mac: 'aa:bb:cc:dd:ee:ff', vlan: 10, enabled: true });
|
|
||||||
const result = await client.radius.vlans.testAssignment('aa:bb:cc:dd:ee:ff');
|
|
||||||
await client.radius.vlans.updateConfig({ defaultVlan: 200 });
|
|
||||||
|
|
||||||
// Sessions
|
|
||||||
const { sessions } = await client.radius.sessions.list({ vlanId: 10 });
|
|
||||||
await client.radius.sessions.disconnect('session-id', 'Admin disconnect');
|
|
||||||
|
|
||||||
// Statistics & Accounting
|
|
||||||
const stats = await client.radius.getStatistics();
|
|
||||||
const summary = await client.radius.getAccountingSummary(startTime, endTime);
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Surface
|
|
||||||
|
|
||||||
| Manager | Methods |
|
|
||||||
|---------|---------|
|
|
||||||
| `client.login()` / `logout()` / `verifyIdentity()` | Authentication |
|
|
||||||
| `client.routes` | `list()`, `create()`, `build()` → Route: `update()`, `delete()`, `toggle()`, `setOverride()`, `removeOverride()` |
|
|
||||||
| `client.certificates` | `list()`, `import()` → Certificate: `reprovision()`, `delete()`, `export()` |
|
|
||||||
| `client.apiTokens` | `list()`, `create()`, `build()` → ApiToken: `revoke()`, `roll()`, `toggle()` |
|
|
||||||
| `client.remoteIngress` | `list()`, `getStatuses()`, `create()`, `build()` → RemoteIngress: `update()`, `delete()`, `regenerateSecret()`, `getConnectionToken()` |
|
|
||||||
| `client.stats` | `getServer()`, `getEmail()`, `getDns()`, `getRateLimits()`, `getSecurity()`, `getConnections()`, `getQueues()`, `getHealth()`, `getNetwork()`, `getCombined()` |
|
|
||||||
| `client.config` | `get(section?)` |
|
|
||||||
| `client.logs` | `getRecent()`, `getStream()` |
|
|
||||||
| `client.emails` | `list()`, `getDetail()`, `resend()` → Email: `getDetail()`, `resend()` |
|
|
||||||
| `client.radius` | `.clients.list/set/remove()`, `.vlans.list/set/remove/updateConfig/testAssignment()`, `.sessions.list/disconnect()`, `getStatistics()`, `getAccountingSummary()` |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The client uses HTTP-based [TypedRequest](https://code.foss.global/api.global/typedrequest) for transport. All requests are sent as POST to `{baseUrl}/typedrequest`. Authentication (JWT identity and/or API token) is automatically injected into every request payload via `buildRequestPayload()`.
|
|
||||||
|
|
||||||
Resource classes (`Route`, `Certificate`, `ApiToken`, `RemoteIngress`, `Email`) hold a reference to the client and provide instance methods that fire the appropriate TypedRequest operations. Builder classes (`RouteBuilder`, `ApiTokenBuilder`, `RemoteIngressBuilder`) use fluent chaining and a terminal `.save()` method.
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
75
ts_interfaces/data/email-domain.ts
Normal file
75
ts_interfaces/data/email-domain.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* DNS record validation status for a single email-related record (MX, SPF, DKIM, DMARC).
|
||||||
|
*/
|
||||||
|
export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An email domain managed by dcrouter.
|
||||||
|
*
|
||||||
|
* Each email domain is linked to an existing dcrouter DNS domain (dcrouter-hosted
|
||||||
|
* or provider-managed). The DNS management path is inherited from the linked domain
|
||||||
|
* — no separate DNS mode is needed.
|
||||||
|
*/
|
||||||
|
export interface IEmailDomain {
|
||||||
|
id: string;
|
||||||
|
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
|
||||||
|
domain: string;
|
||||||
|
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
|
||||||
|
linkedDomainId: string;
|
||||||
|
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
|
||||||
|
subdomain?: string;
|
||||||
|
/** DKIM configuration and key state. */
|
||||||
|
dkim: IEmailDomainDkim;
|
||||||
|
/** Optional per-domain rate limits. */
|
||||||
|
rateLimits?: IEmailDomainRateLimits;
|
||||||
|
/** DNS record validation status — populated by validateDns(). */
|
||||||
|
dnsStatus: IEmailDomainDnsStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEmailDomainDkim {
|
||||||
|
/** DKIM selector (default: 'default'). */
|
||||||
|
selector: string;
|
||||||
|
/** RSA key size in bits (default: 2048). */
|
||||||
|
keySize: number;
|
||||||
|
/** Base64-encoded public key — populated after key generation. */
|
||||||
|
publicKey?: string;
|
||||||
|
/** Whether automatic key rotation is enabled. */
|
||||||
|
rotateKeys: boolean;
|
||||||
|
/** Days between key rotations (default: 90). */
|
||||||
|
rotationIntervalDays: number;
|
||||||
|
/** ISO date of last key rotation. */
|
||||||
|
lastRotatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEmailDomainRateLimits {
|
||||||
|
outbound?: {
|
||||||
|
messagesPerMinute?: number;
|
||||||
|
messagesPerHour?: number;
|
||||||
|
messagesPerDay?: number;
|
||||||
|
};
|
||||||
|
inbound?: {
|
||||||
|
messagesPerMinute?: number;
|
||||||
|
connectionsPerIp?: number;
|
||||||
|
recipientsPerMessage?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEmailDomainDnsStatus {
|
||||||
|
mx: TDnsRecordStatus;
|
||||||
|
spf: TDnsRecordStatus;
|
||||||
|
dkim: TDnsRecordStatus;
|
||||||
|
dmarc: TDnsRecordStatus;
|
||||||
|
lastCheckedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single required DNS record for an email domain — used for display / copy-paste.
|
||||||
|
*/
|
||||||
|
export interface IEmailDnsRecord {
|
||||||
|
type: 'MX' | 'TXT';
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
status: TDnsRecordStatus;
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ export * from './vpn.js';
|
|||||||
export * from './dns-provider.js';
|
export * from './dns-provider.js';
|
||||||
export * from './domain.js';
|
export * from './domain.js';
|
||||||
export * from './dns-record.js';
|
export * from './dns-record.js';
|
||||||
export * from './acme-config.js';
|
export * from './acme-config.js';
|
||||||
|
export * from './email-domain.js';
|
||||||
@@ -83,24 +83,24 @@ export interface IRouteMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A merged route combining hardcoded and programmatic sources.
|
* A route entry returned by the route management API.
|
||||||
*/
|
*/
|
||||||
export interface IMergedRoute {
|
export interface IMergedRoute {
|
||||||
route: IDcRouterRouteConfig;
|
route: IDcRouterRouteConfig;
|
||||||
source: 'hardcoded' | 'programmatic';
|
id: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
overridden: boolean;
|
origin: 'config' | 'email' | 'dns' | 'api';
|
||||||
storedRouteId?: string;
|
systemKey?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
metadata?: IRouteMetadata;
|
metadata?: IRouteMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A warning generated during route merge/startup.
|
* A warning generated during route startup/apply.
|
||||||
*/
|
*/
|
||||||
export interface IRouteWarning {
|
export interface IRouteWarning {
|
||||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
type: 'disabled-route';
|
||||||
routeName: string;
|
routeName: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
@@ -123,28 +123,20 @@ export interface IApiTokenInfo {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A programmatic route stored in /config-api/routes/{id}.json
|
* A route persisted in the database.
|
||||||
*/
|
*/
|
||||||
export interface IStoredRoute {
|
export interface IRoute {
|
||||||
id: string;
|
id: string;
|
||||||
route: IDcRouterRouteConfig;
|
route: IDcRouterRouteConfig;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
origin: 'config' | 'email' | 'dns' | 'api';
|
||||||
|
systemKey?: string;
|
||||||
metadata?: IRouteMetadata;
|
metadata?: IRouteMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* A stored API token, stored in /config-api/tokens/{id}.json
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -143,6 +143,15 @@ export interface IHealthStatus {
|
|||||||
version?: string;
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IDomainActivity {
|
||||||
|
domain: string;
|
||||||
|
bytesInPerSecond: number;
|
||||||
|
bytesOutPerSecond: number;
|
||||||
|
activeConnections: number;
|
||||||
|
routeCount: number;
|
||||||
|
requestCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface INetworkMetrics {
|
export interface INetworkMetrics {
|
||||||
totalBandwidth: {
|
totalBandwidth: {
|
||||||
in: number;
|
in: number;
|
||||||
@@ -156,12 +165,21 @@ export interface INetworkMetrics {
|
|||||||
connectionDetails: IConnectionDetails[];
|
connectionDetails: IConnectionDetails[];
|
||||||
topEndpoints: Array<{
|
topEndpoints: Array<{
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
requests: number;
|
connections: number;
|
||||||
bandwidth: {
|
bandwidth: {
|
||||||
in: number;
|
in: number;
|
||||||
out: number;
|
out: number;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
topEndpointsByBandwidth: Array<{
|
||||||
|
endpoint: string;
|
||||||
|
connections: number;
|
||||||
|
bandwidth: {
|
||||||
|
in: number;
|
||||||
|
out: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
domainActivity: IDomainActivity[];
|
||||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||||
requestsPerSecond?: number;
|
requestsPerSecond?: number;
|
||||||
requestsTotal?: number;
|
requestsTotal?: number;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
|||||||
domains?: string[];
|
domains?: string[];
|
||||||
/** Specific IP:port targets this profile grants access to */
|
/** Specific IP:port targets this profile grants access to */
|
||||||
targets?: ITargetProfileTarget[];
|
targets?: ITargetProfileTarget[];
|
||||||
/** Route references by stored route ID or route name */
|
/** Route references by stored route ID. Legacy route names are normalized when unique. */
|
||||||
routeRefs?: string[];
|
routeRefs?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# @serve.zone/dcrouter-interfaces
|
# @serve.zone/dcrouter-interfaces
|
||||||
|
|
||||||
TypeScript interfaces and type definitions for the DcRouter OpsServer API. 📡
|
Shared TypeScript request and data interfaces for dcrouter's OpsServer API. 📡
|
||||||
|
|
||||||
This module provides strongly-typed interfaces for communicating with the DcRouter OpsServer via [TypedRequest](https://code.foss.global/api.global/typedrequest). Use these interfaces for type-safe API interactions in your frontend applications or integration code.
|
This package is the contract layer for typed clients, frontend code, tests, or automation that talks to a running dcrouter instance through TypedRequest.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -14,320 +14,79 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
pnpm add @serve.zone/dcrouter-interfaces
|
pnpm add @serve.zone/dcrouter-interfaces
|
||||||
```
|
```
|
||||||
|
|
||||||
Or import directly from the main package:
|
Or consume the same interfaces through the main package:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## What It Exports
|
||||||
|
|
||||||
|
The package exposes two namespaces from `index.ts`:
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `data` | Shared runtime-shaped types such as route data, auth identity, stats, domains, certificates, VPN, DNS, and email-domain data |
|
||||||
|
| `requests` | TypedRequest request and response contracts for every OpsServer endpoint |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
||||||
|
|
||||||
// Use data interfaces for type definitions
|
|
||||||
const identity: data.IIdentity = {
|
const identity: data.IIdentity = {
|
||||||
jwt: 'your-jwt-token',
|
jwt: 'jwt-token',
|
||||||
userId: 'user-123',
|
userId: 'admin-1',
|
||||||
name: 'Admin User',
|
name: 'Admin',
|
||||||
expiresAt: Date.now() + 3600000,
|
expiresAt: Date.now() + 60_000,
|
||||||
role: 'admin'
|
role: 'admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use request interfaces for API calls
|
const request = new typedrequest.TypedRequest<requests.IReq_GetMergedRoutes>(
|
||||||
import * as typedrequest from '@api.global/typedrequest';
|
'https://dcrouter.example.com/typedrequest',
|
||||||
|
'getMergedRoutes',
|
||||||
const statsClient = new typedrequest.TypedRequest<requests.IReq_GetServerStatistics>(
|
|
||||||
'https://your-dcrouter:3000/typedrequest',
|
|
||||||
'getServerStatistics'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const stats = await statsClient.fire({
|
const response = await request.fire({ identity });
|
||||||
identity,
|
|
||||||
includeHistory: true,
|
|
||||||
timeRange: '24h'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Module Structure
|
for (const route of response.routes) {
|
||||||
|
console.log(route.id, route.origin, route.systemKey, route.enabled);
|
||||||
### Data Interfaces (`data`)
|
|
||||||
|
|
||||||
Core data types used throughout the DcRouter system:
|
|
||||||
|
|
||||||
#### `IIdentity`
|
|
||||||
Authentication identity for API requests:
|
|
||||||
```typescript
|
|
||||||
interface IIdentity {
|
|
||||||
jwt: string; // JWT token
|
|
||||||
userId: string; // Unique user ID
|
|
||||||
name: string; // Display name
|
|
||||||
expiresAt: number; // Token expiration timestamp
|
|
||||||
role?: string; // User role (e.g., 'admin')
|
|
||||||
type?: string; // Identity type
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Statistics Interfaces
|
## API Domains Covered
|
||||||
| Interface | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `IServerStats` | Uptime, memory, CPU, connection counts |
|
|
||||||
| `IEmailStats` | Sent/received/bounced/queued/failed, delivery & bounce rates |
|
|
||||||
| `IDnsStats` | Total queries, cache hits/misses, query types |
|
|
||||||
| `IRateLimitInfo` | Domain rate limit status (current rate, limit, remaining) |
|
|
||||||
| `ISecurityMetrics` | Blocked IPs, spam/malware/phishing counts |
|
|
||||||
| `IConnectionInfo` | Connection ID, remote address, protocol, state, bytes |
|
|
||||||
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
|
||||||
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
|
||||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
|
||||||
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
|
|
||||||
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
|
|
||||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
|
||||||
|
|
||||||
#### Route Management Interfaces
|
| Domain | Examples |
|
||||||
| Interface | Description |
|
| --- | --- |
|
||||||
|-----------|-------------|
|
| Auth | admin login, logout, identity verification |
|
||||||
| `IMergedRoute` | Combined route: routeConfig, source (hardcoded/programmatic), enabled, overridden |
|
| Routes | merged routes, create, update, delete, toggle |
|
||||||
| `IRouteWarning` | Merge warning: disabled-hardcoded, disabled-programmatic, orphaned-override |
|
| Access | API tokens, source profiles, target profiles, network targets |
|
||||||
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
| DNS and domains | providers, domains, DNS records |
|
||||||
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
| Certificates | overview, reprovision, import, export, delete, ACME config |
|
||||||
|
| Email | email operations, email domains |
|
||||||
|
| Remote ingress | edge registrations, status, connection tokens |
|
||||||
|
| VPN | clients, status, telemetry, lifecycle |
|
||||||
|
| RADIUS | clients, VLANs, sessions, accounting |
|
||||||
|
| Observability | stats, logs, health, configuration |
|
||||||
|
|
||||||
#### Security & Reference Interfaces
|
## Notable Data Types
|
||||||
| Interface | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
|
|
||||||
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
|
|
||||||
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
|
|
||||||
|
|
||||||
#### Remote Ingress Interfaces
|
| Type | Description |
|
||||||
| Interface | Description |
|
| --- | --- |
|
||||||
|-----------|-------------|
|
| `data.IMergedRoute` | Route entry returned by route management, including `origin`, `enabled`, and optional `systemKey` |
|
||||||
| `IRemoteIngress` | Edge registration: id, name, secret, listenPorts, enabled, autoDerivePorts, tags |
|
| `data.IDcRouterRouteConfig` | dcrouter-flavored route config used across the stack |
|
||||||
| `IRemoteIngressStatus` | Runtime status: connected, publicIp, activeTunnels, lastHeartbeat |
|
| `data.IRouteMetadata` | Reference metadata connecting routes to source profiles or network targets |
|
||||||
| `IRouteRemoteIngress` | Route-level config: enabled flag and optional edgeFilter |
|
| `data.IIdentity` | Admin identity used for authenticated requests |
|
||||||
| `IDcRouterRouteConfig` | Extended SmartProxy route config with optional `remoteIngress` and `vpn` properties |
|
| `data.IApiTokenInfo` | Public token metadata without the secret |
|
||||||
| `IRouteVpn` | Route-level VPN config: `enabled`/`mandatory` flags and optional `allowedServerDefinedClientTags` |
|
|
||||||
|
|
||||||
#### VPN Interfaces
|
## When To Use This Package
|
||||||
| Interface | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `IVpnClient` | Client registration: clientId, enabled, serverDefinedClientTags, description, assignedIp, timestamps |
|
|
||||||
| `IVpnServerStatus` | Server status: running, subnet, wgListenPort, publicKeys, client counts |
|
|
||||||
| `IVpnClientTelemetry` | Per-client metrics: bytes sent/received, packets dropped, keepalives, rate limits |
|
|
||||||
|
|
||||||
### Request Interfaces (`requests`)
|
- Use it in custom dashboards or CLIs that call TypedRequest directly.
|
||||||
|
- Use it in tests that need strongly typed request payloads or response assertions.
|
||||||
|
- Use it when you want the API contract without pulling in the OO client.
|
||||||
|
|
||||||
TypedRequest interfaces for the OpsServer API, organized by domain:
|
If you want a higher-level client with managers and resource classes, use `@serve.zone/dcrouter-apiclient` instead.
|
||||||
|
|
||||||
#### 🔐 Authentication
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_AdminLoginWithUsernameAndPassword` | `adminLoginWithUsernameAndPassword` | Authenticate as admin |
|
|
||||||
| `IReq_AdminLogout` | `adminLogout` | End admin session |
|
|
||||||
| `IReq_VerifyIdentity` | `verifyIdentity` | Verify JWT token validity |
|
|
||||||
|
|
||||||
#### 📊 Statistics
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetServerStatistics` | `getServerStatistics` | Overall server stats |
|
|
||||||
| `IReq_GetEmailStatistics` | `getEmailStatistics` | Email throughput metrics |
|
|
||||||
| `IReq_GetDnsStatistics` | `getDnsStatistics` | DNS query stats |
|
|
||||||
| `IReq_GetRateLimitStatus` | `getRateLimitStatus` | Rate limit status |
|
|
||||||
| `IReq_GetSecurityMetrics` | `getSecurityMetrics` | Security event metrics |
|
|
||||||
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
|
||||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
|
||||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
|
||||||
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
|
|
||||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
|
|
||||||
|
|
||||||
#### ⚙️ Configuration
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetConfiguration` | `getConfiguration` | Current config (read-only) |
|
|
||||||
|
|
||||||
#### 📜 Logs
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetRecentLogs` | `getLogs` | Retrieve system logs |
|
|
||||||
| `IReq_GetLogStream` | `getLogStream` | Stream live logs |
|
|
||||||
|
|
||||||
#### 📧 Email Operations
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetAllEmails` | `getAllEmails` | List all emails |
|
|
||||||
| `IReq_GetEmailDetail` | `getEmailDetail` | Full detail for a specific email |
|
|
||||||
| `IReq_ResendEmail` | `resendEmail` | Re-queue a failed email |
|
|
||||||
|
|
||||||
#### 🛣️ Route Management
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetMergedRoutes` | `getMergedRoutes` | List all routes (hardcoded + programmatic) |
|
|
||||||
| `IReq_CreateRoute` | `createRoute` | Create a new programmatic route |
|
|
||||||
| `IReq_UpdateRoute` | `updateRoute` | Update a programmatic route |
|
|
||||||
| `IReq_DeleteRoute` | `deleteRoute` | Delete a programmatic route |
|
|
||||||
| `IReq_ToggleRoute` | `toggleRoute` | Enable/disable a programmatic route |
|
|
||||||
| `IReq_SetRouteOverride` | `setRouteOverride` | Override a hardcoded route |
|
|
||||||
| `IReq_RemoveRouteOverride` | `removeRouteOverride` | Remove a route override |
|
|
||||||
|
|
||||||
#### 🔑 API Token Management
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_CreateApiToken` | `createApiToken` | Create a new API token |
|
|
||||||
| `IReq_ListApiTokens` | `listApiTokens` | List all tokens |
|
|
||||||
| `IReq_RevokeApiToken` | `revokeApiToken` | Revoke (delete) a token |
|
|
||||||
| `IReq_RollApiToken` | `rollApiToken` | Regenerate token secret |
|
|
||||||
| `IReq_ToggleApiToken` | `toggleApiToken` | Enable/disable a token |
|
|
||||||
|
|
||||||
#### 🔐 Certificates
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `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
|
|
||||||
type TCertificateStatus = 'valid' | 'expiring' | 'expired' | 'provisioning' | 'failed' | 'unknown';
|
|
||||||
type TCertificateSource = 'acme' | 'provision-function' | 'static' | 'none';
|
|
||||||
|
|
||||||
interface ICertificateInfo {
|
|
||||||
domain: string;
|
|
||||||
routeNames: string[];
|
|
||||||
status: TCertificateStatus;
|
|
||||||
source: TCertificateSource;
|
|
||||||
tlsMode: 'terminate' | 'terminate-and-reencrypt' | 'passthrough';
|
|
||||||
expiryDate?: string;
|
|
||||||
issuer?: string;
|
|
||||||
issuedAt?: string;
|
|
||||||
error?: string;
|
|
||||||
canReprovision: boolean;
|
|
||||||
backoffInfo?: {
|
|
||||||
failures: number;
|
|
||||||
retryAfter?: string;
|
|
||||||
lastError?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 🌍 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 |
|
|
||||||
|
|
||||||
#### 🔐 VPN
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetVpnClients` | `getVpnClients` | List all registered VPN clients |
|
|
||||||
| `IReq_GetVpnStatus` | `getVpnStatus` | VPN server status |
|
|
||||||
| `IReq_CreateVpnClient` | `createVpnClient` | Create a new VPN client (returns WireGuard config) |
|
|
||||||
| `IReq_DeleteVpnClient` | `deleteVpnClient` | Remove a VPN client |
|
|
||||||
| `IReq_EnableVpnClient` | `enableVpnClient` | Enable a disabled client |
|
|
||||||
| `IReq_DisableVpnClient` | `disableVpnClient` | Disable a client |
|
|
||||||
| `IReq_RotateVpnClientKey` | `rotateVpnClientKey` | Generate new keys for a client |
|
|
||||||
| `IReq_ExportVpnClientConfig` | `exportVpnClientConfig` | Export WireGuard or SmartVPN config |
|
|
||||||
| `IReq_GetVpnClientTelemetry` | `getVpnClientTelemetry` | Per-client traffic metrics |
|
|
||||||
|
|
||||||
#### 📡 RADIUS
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetRadiusClients` | `getRadiusClients` | List NAS clients |
|
|
||||||
| `IReq_SetRadiusClient` | `setRadiusClient` | Add/update a NAS client |
|
|
||||||
| `IReq_RemoveRadiusClient` | `removeRadiusClient` | Remove a NAS client |
|
|
||||||
| `IReq_GetVlanMappings` | `getVlanMappings` | List VLAN mappings |
|
|
||||||
| `IReq_SetVlanMapping` | `setVlanMapping` | Add/update VLAN mapping |
|
|
||||||
| `IReq_RemoveVlanMapping` | `removeVlanMapping` | Remove VLAN mapping |
|
|
||||||
| `IReq_TestVlanAssignment` | `testVlanAssignment` | Test what VLAN a MAC gets |
|
|
||||||
| `IReq_GetRadiusSessions` | `getRadiusSessions` | List active sessions |
|
|
||||||
| `IReq_DisconnectRadiusSession` | `disconnectRadiusSession` | Force disconnect |
|
|
||||||
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
|
||||||
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
|
||||||
|
|
||||||
#### 🛡️ Security Profiles
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
|
|
||||||
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
|
|
||||||
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
|
|
||||||
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
|
|
||||||
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
|
|
||||||
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
|
|
||||||
|
|
||||||
#### 🎯 Network Targets
|
|
||||||
| Interface | Method | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
|
|
||||||
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
|
|
||||||
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
|
|
||||||
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
|
|
||||||
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
|
|
||||||
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
|
|
||||||
|
|
||||||
## Example: Full API Integration
|
|
||||||
|
|
||||||
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import * as typedrequest from '@api.global/typedrequest';
|
|
||||||
import { data, requests } from '@serve.zone/dcrouter-interfaces';
|
|
||||||
|
|
||||||
// 1. Login
|
|
||||||
const loginClient = new typedrequest.TypedRequest<requests.IReq_AdminLoginWithUsernameAndPassword>(
|
|
||||||
'https://your-dcrouter:3000/typedrequest',
|
|
||||||
'adminLoginWithUsernameAndPassword'
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginResponse = await loginClient.fire({
|
|
||||||
username: 'admin',
|
|
||||||
password: 'your-password'
|
|
||||||
});
|
|
||||||
const identity = loginResponse.identity;
|
|
||||||
|
|
||||||
// 2. Fetch combined metrics
|
|
||||||
const metricsClient = new typedrequest.TypedRequest<requests.IReq_GetCombinedMetrics>(
|
|
||||||
'https://your-dcrouter:3000/typedrequest',
|
|
||||||
'getCombinedMetrics'
|
|
||||||
);
|
|
||||||
|
|
||||||
const metrics = await metricsClient.fire({ identity });
|
|
||||||
console.log('Server:', metrics.metrics.server);
|
|
||||||
console.log('Email:', metrics.metrics.email);
|
|
||||||
|
|
||||||
// 3. Check certificate status
|
|
||||||
const certClient = new typedrequest.TypedRequest<requests.IReq_GetCertificateOverview>(
|
|
||||||
'https://your-dcrouter:3000/typedrequest',
|
|
||||||
'getCertificateOverview'
|
|
||||||
);
|
|
||||||
|
|
||||||
const certs = await certClient.fire({ identity });
|
|
||||||
console.log(`Certificates: ${certs.summary.valid} valid, ${certs.summary.failed} failed`);
|
|
||||||
|
|
||||||
// 4. List remote ingress edges
|
|
||||||
const edgesClient = new typedrequest.TypedRequest<requests.IReq_GetRemoteIngresses>(
|
|
||||||
'https://your-dcrouter:3000/typedrequest',
|
|
||||||
'getRemoteIngresses'
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,31 @@ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implemen
|
|||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a domain between dcrouter-hosted and provider-managed (or between providers).
|
||||||
|
* Records are transferred to the target and the domain source/providerId are updated.
|
||||||
|
*/
|
||||||
|
export interface IReq_MigrateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_MigrateDomain
|
||||||
|
> {
|
||||||
|
method: 'migrateDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
/** Target source type. */
|
||||||
|
targetSource: import('../data/domain.js').TDomainSource;
|
||||||
|
/** Required when targetSource is 'provider'. */
|
||||||
|
targetProviderId?: string;
|
||||||
|
/** When migrating to a provider: delete all existing records at the provider first. */
|
||||||
|
deleteExistingProviderRecords?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
/** Number of records migrated. */
|
||||||
|
recordsMigrated?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
178
ts_interfaces/requests/email-domains.ts
Normal file
178
ts_interfaces/requests/email-domains.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IEmailDomain, IEmailDnsRecord } from '../data/email-domain.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Domain Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all email domains.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetEmailDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetEmailDomains
|
||||||
|
> {
|
||||||
|
method: 'getEmailDomains';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
domains: IEmailDomain[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single email domain by id.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetEmailDomain
|
||||||
|
> {
|
||||||
|
method: 'getEmailDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
domain: IEmailDomain | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an email domain. Links to an existing dcrouter DNS domain.
|
||||||
|
* Generates DKIM keys and computes the required DNS records.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateEmailDomain
|
||||||
|
> {
|
||||||
|
method: 'createEmailDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
/** ID of the existing dcrouter DNS domain to link to. */
|
||||||
|
linkedDomainId: string;
|
||||||
|
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
|
||||||
|
subdomain?: string;
|
||||||
|
/** DKIM selector (default: 'default'). */
|
||||||
|
dkimSelector?: string;
|
||||||
|
/** RSA key size (default: 2048). */
|
||||||
|
dkimKeySize?: number;
|
||||||
|
/** Enable automatic key rotation (default: false). */
|
||||||
|
rotateKeys?: boolean;
|
||||||
|
/** Days between rotations (default: 90). */
|
||||||
|
rotationIntervalDays?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
domain?: IEmailDomain;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an email domain's configuration.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateEmailDomain
|
||||||
|
> {
|
||||||
|
method: 'updateEmailDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
rotateKeys?: boolean;
|
||||||
|
rotationIntervalDays?: number;
|
||||||
|
rateLimits?: IEmailDomain['rateLimits'];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an email domain.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteEmailDomain
|
||||||
|
> {
|
||||||
|
method: 'deleteEmailDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger DNS validation for an email domain.
|
||||||
|
* Performs live lookups for MX, SPF, DKIM, and DMARC records.
|
||||||
|
*/
|
||||||
|
export interface IReq_ValidateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ValidateEmailDomain
|
||||||
|
> {
|
||||||
|
method: 'validateEmailDomain';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
domain?: IEmailDomain;
|
||||||
|
records?: IEmailDnsRecord[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the required DNS records for an email domain (for display / copy-paste).
|
||||||
|
*/
|
||||||
|
export interface IReq_GetEmailDomainDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetEmailDomainDnsRecords
|
||||||
|
> {
|
||||||
|
method: 'getEmailDomainDnsRecords';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
records: IEmailDnsRecord[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-provision DNS records for an email domain.
|
||||||
|
* Creates any missing MX, SPF, DKIM, and DMARC records via the linked
|
||||||
|
* domain's DNS path (dcrouter zone or provider API).
|
||||||
|
*/
|
||||||
|
export interface IReq_ProvisionEmailDomainDns extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ProvisionEmailDomainDns
|
||||||
|
> {
|
||||||
|
method: 'provisionEmailDomainDns';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
/** Number of records created. */
|
||||||
|
provisioned?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,4 +17,5 @@ export * from './users.js';
|
|||||||
export * from './dns-providers.js';
|
export * from './dns-providers.js';
|
||||||
export * from './domains.js';
|
export * from './domains.js';
|
||||||
export * from './dns-records.js';
|
export * from './dns-records.js';
|
||||||
export * from './acme-config.js';
|
export * from './acme-config.js';
|
||||||
|
export * from './email-domains.js';
|
||||||
@@ -9,7 +9,7 @@ import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all merged routes (hardcoded + programmatic) with warnings.
|
* Get all routes with warnings.
|
||||||
*/
|
*/
|
||||||
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -27,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new programmatic route.
|
* Create a new route.
|
||||||
*/
|
*/
|
||||||
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
storedRouteId?: string;
|
routeId?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a programmatic route.
|
* Update a route.
|
||||||
*/
|
*/
|
||||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -71,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a programmatic route.
|
* Delete a route.
|
||||||
*/
|
*/
|
||||||
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
@@ -90,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set an override on a hardcoded route (disable/enable by name).
|
* Toggle a route on/off by id.
|
||||||
*/
|
|
||||||
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<
|
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
|||||||
@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
|||||||
requestsPerSecond: number;
|
requestsPerSecond: number;
|
||||||
requestsTotal: number;
|
requestsTotal: number;
|
||||||
backends?: statsInterfaces.IBackendInfo[];
|
backends?: statsInterfaces.IBackendInfo[];
|
||||||
|
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||||
|
domainActivity: statsInterfaces.IDomainActivity[];
|
||||||
|
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||||
|
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,57 @@ export interface IMigrationRunner {
|
|||||||
run(): Promise<IMigrationRunResult>;
|
run(): Promise<IMigrationRunResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateTargetProfileTargetHosts(ctx: {
|
||||||
|
mongo?: { collection: (name: string) => any };
|
||||||
|
log: { log: (level: 'info', message: string) => void };
|
||||||
|
}): Promise<void> {
|
||||||
|
const collection = ctx.mongo!.collection('TargetProfileDoc');
|
||||||
|
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
||||||
|
let migrated = 0;
|
||||||
|
|
||||||
|
for await (const doc of cursor) {
|
||||||
|
const targets = ((doc as any).targets || []).map((target: any) => {
|
||||||
|
if (target && typeof target === 'object' && 'host' in target && !('ip' in target)) {
|
||||||
|
const { host, ...rest } = target;
|
||||||
|
return { ...rest, ip: host };
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
|
||||||
|
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillSystemRouteKeys(ctx: {
|
||||||
|
mongo?: { collection: (name: string) => any };
|
||||||
|
log: { log: (level: 'info', message: string) => void };
|
||||||
|
}): Promise<void> {
|
||||||
|
const collection = ctx.mongo!.collection('RouteDoc');
|
||||||
|
const cursor = collection.find({
|
||||||
|
origin: { $in: ['config', 'email', 'dns'] },
|
||||||
|
systemKey: { $exists: false },
|
||||||
|
'route.name': { $type: 'string' },
|
||||||
|
});
|
||||||
|
let migrated = 0;
|
||||||
|
|
||||||
|
for await (const doc of cursor) {
|
||||||
|
const origin = typeof (doc as any).origin === 'string' ? (doc as any).origin : undefined;
|
||||||
|
const routeName = typeof (doc as any).route?.name === 'string' ? (doc as any).route.name.trim() : '';
|
||||||
|
if (!origin || !routeName) continue;
|
||||||
|
|
||||||
|
await collection.updateOne(
|
||||||
|
{ _id: (doc as any)._id },
|
||||||
|
{ $set: { systemKey: `${origin}:${routeName}` } },
|
||||||
|
);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||||
*
|
*
|
||||||
@@ -48,23 +99,7 @@ export async function createMigrationRunner(
|
|||||||
.step('rename-target-profile-host-to-ip')
|
.step('rename-target-profile-host-to-ip')
|
||||||
.from('13.0.11').to('13.1.0')
|
.from('13.0.11').to('13.1.0')
|
||||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||||
.up(async (ctx) => {
|
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||||
const collection = ctx.mongo!.collection('targetprofiledoc');
|
|
||||||
const cursor = collection.find({ 'targets.host': { $exists: true } });
|
|
||||||
let migrated = 0;
|
|
||||||
for await (const doc of cursor) {
|
|
||||||
const targets = ((doc as any).targets || []).map((t: any) => {
|
|
||||||
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
|
|
||||||
const { host, ...rest } = t;
|
|
||||||
return { ...rest, ip: host };
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
|
|
||||||
migrated++;
|
|
||||||
}
|
|
||||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
|
||||||
})
|
|
||||||
.step('rename-domain-source-manual-to-dcrouter')
|
.step('rename-domain-source-manual-to-dcrouter')
|
||||||
.from('13.1.0').to('13.8.1')
|
.from('13.1.0').to('13.8.1')
|
||||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||||
@@ -92,6 +127,46 @@ export async function createMigrationRunner(
|
|||||||
'info',
|
'info',
|
||||||
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
|
`rename-record-source-manual-to-local: migrated ${result.modifiedCount} record(s)`,
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
.step('unify-routes-rename-collection')
|
||||||
|
.from('13.8.2').to('13.16.0')
|
||||||
|
.description('Rename StoredRouteDoc → RouteDoc, add origin field, drop RouteOverrideDoc')
|
||||||
|
.up(async (ctx) => {
|
||||||
|
const db = ctx.mongo!;
|
||||||
|
|
||||||
|
// 1. Rename StoredRouteDoc → RouteDoc (smartdata uses exact class names)
|
||||||
|
const collections = await db.listCollections({ name: 'StoredRouteDoc' }).toArray();
|
||||||
|
if (collections.length > 0) {
|
||||||
|
await db.renameCollection('StoredRouteDoc', 'RouteDoc');
|
||||||
|
ctx.log.log('info', 'Renamed StoredRouteDoc → RouteDoc');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set origin='api' on all migrated docs (they were API-created)
|
||||||
|
const routeCol = db.collection('RouteDoc');
|
||||||
|
const result = await routeCol.updateMany(
|
||||||
|
{ origin: { $exists: false } },
|
||||||
|
{ $set: { origin: 'api' } },
|
||||||
|
);
|
||||||
|
ctx.log.log('info', `Set origin='api' on ${result.modifiedCount} migrated route(s)`);
|
||||||
|
|
||||||
|
// 3. Drop RouteOverrideDoc collection
|
||||||
|
const overrideCollections = await db.listCollections({ name: 'RouteOverrideDoc' }).toArray();
|
||||||
|
if (overrideCollections.length > 0) {
|
||||||
|
await db.collection('RouteOverrideDoc').drop();
|
||||||
|
ctx.log.log('info', 'Dropped RouteOverrideDoc collection');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.step('repair-target-profile-ip-migration')
|
||||||
|
.from('13.16.0').to('13.17.4')
|
||||||
|
.description('Repair TargetProfileDoc.targets host→ip migration for already-upgraded installs')
|
||||||
|
.up(async (ctx) => {
|
||||||
|
await migrateTargetProfileTargetHosts(ctx);
|
||||||
|
})
|
||||||
|
.step('backfill-system-route-keys')
|
||||||
|
.from('13.17.4').to('13.18.0')
|
||||||
|
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
|
||||||
|
.up(async (ctx) => {
|
||||||
|
await backfillSystemRouteKeys(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
return migration;
|
return migration;
|
||||||
|
|||||||
67
ts_migrations/readme.md
Normal file
67
ts_migrations/readme.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# @serve.zone/dcrouter-migrations
|
||||||
|
|
||||||
|
Migration runner package for dcrouter's smartdata-backed persistence layer. 🧱
|
||||||
|
|
||||||
|
This package provides the startup migration chain that upgrades dcrouter data across releases before the application reads from the database.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## What It Exports
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `createMigrationRunner(db, targetVersion)` | Builds the dcrouter SmartMigration runner for the current application version |
|
||||||
|
| `IMigrationRunner` | Small interface describing the runner's `run()` method |
|
||||||
|
| `IMigrationRunResult` | Logged result shape used after execution |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
|
||||||
|
|
||||||
|
const migration = await createMigrationRunner(db, '13.18.0');
|
||||||
|
const result = await migration.run();
|
||||||
|
|
||||||
|
console.log(result.currentVersionBefore, result.currentVersionAfter);
|
||||||
|
```
|
||||||
|
|
||||||
|
## What These Migrations Handle
|
||||||
|
|
||||||
|
The migration chain currently covers dcrouter-specific storage transitions such as:
|
||||||
|
|
||||||
|
- target profile target field renames
|
||||||
|
- domain and DNS record source renames
|
||||||
|
- route collection unification into `RouteDoc`
|
||||||
|
- persisted route metadata backfills such as `origin` and `systemKey`
|
||||||
|
|
||||||
|
## Important Behavior
|
||||||
|
|
||||||
|
- fresh installs are stamped directly to the current target version
|
||||||
|
- migration steps are registered in strict version order
|
||||||
|
- migrations run before services load DB-backed state
|
||||||
|
- route-related migrations use smartdata collection names exactly as declared in code
|
||||||
|
|
||||||
|
If you are embedding dcrouter's DB layer outside the main runtime, run this package before any feature code assumes the latest schema.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.9.2',
|
version: '13.19.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export interface INetworkState {
|
|||||||
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
|
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
|
||||||
totalBytes: { in: number; out: number };
|
totalBytes: { in: number; out: number };
|
||||||
topIPs: Array<{ ip: string; count: number }>;
|
topIPs: Array<{ ip: string; count: number }>;
|
||||||
|
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||||
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
throughputByIP: Array<{ ip: string; in: number; out: number }>;
|
||||||
|
domainActivity: interfaces.data.IDomainActivity[];
|
||||||
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
|
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
|
||||||
requestsPerSecond: number;
|
requestsPerSecond: number;
|
||||||
requestsTotal: number;
|
requestsTotal: number;
|
||||||
@@ -160,7 +162,9 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
|
|||||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||||
totalBytes: { in: 0, out: 0 },
|
totalBytes: { in: 0, out: 0 },
|
||||||
topIPs: [],
|
topIPs: [],
|
||||||
|
topIPsByBandwidth: [],
|
||||||
throughputByIP: [],
|
throughputByIP: [],
|
||||||
|
domainActivity: [],
|
||||||
throughputHistory: [],
|
throughputHistory: [],
|
||||||
requestsPerSecond: 0,
|
requestsPerSecond: 0,
|
||||||
requestsTotal: 0,
|
requestsTotal: 0,
|
||||||
@@ -518,14 +522,13 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get network stats for throughput and IP data
|
// Get network stats for throughput and IP data
|
||||||
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest(
|
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
'/typedrequest',
|
interfaces.requests.IReq_GetNetworkStats
|
||||||
'getNetworkStats'
|
>('/typedrequest', 'getNetworkStats');
|
||||||
);
|
|
||||||
|
|
||||||
const networkStatsResponse = await networkStatsRequest.fire({
|
const networkStatsResponse = await networkStatsRequest.fire({
|
||||||
identity: context.identity,
|
identity: context.identity,
|
||||||
}) as any;
|
});
|
||||||
|
|
||||||
// Use the connections data for the connection list
|
// Use the connections data for the connection list
|
||||||
// and network stats for throughput and IP analytics
|
// and network stats for throughput and IP analytics
|
||||||
@@ -552,7 +555,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
|
|||||||
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
|
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
|
||||||
: { in: 0, out: 0 },
|
: { in: 0, out: 0 },
|
||||||
topIPs: networkStatsResponse.topIPs || [],
|
topIPs: networkStatsResponse.topIPs || [],
|
||||||
|
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
|
||||||
throughputByIP: networkStatsResponse.throughputByIP || [],
|
throughputByIP: networkStatsResponse.throughputByIP || [],
|
||||||
|
domainActivity: networkStatsResponse.domainActivity || [],
|
||||||
throughputHistory: networkStatsResponse.throughputHistory || [],
|
throughputHistory: networkStatsResponse.throughputHistory || [],
|
||||||
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
|
||||||
requestsTotal: networkStatsResponse.requestsTotal || 0,
|
requestsTotal: networkStatsResponse.requestsTotal || 0,
|
||||||
@@ -1887,6 +1892,32 @@ export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const migrateDomainAction = domainsStatePart.createAction<{
|
||||||
|
id: string;
|
||||||
|
targetSource: interfaces.data.TDomainSource;
|
||||||
|
targetProviderId?: string;
|
||||||
|
deleteExistingProviderRecords?: boolean;
|
||||||
|
}>(
|
||||||
|
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_MigrateDomain
|
||||||
|
>('/typedrequest', 'migrateDomain');
|
||||||
|
const response = await request.fire({ identity: context.identity!, ...dataArg });
|
||||||
|
if (!response.success) {
|
||||||
|
return { ...statePartArg.getState()!, error: response.message || 'Migration failed' };
|
||||||
|
}
|
||||||
|
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
...statePartArg.getState()!,
|
||||||
|
error: error instanceof Error ? error.message : 'Migration failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const createDnsRecordAction = domainsStatePart.createAction<{
|
export const createDnsRecordAction = domainsStatePart.createAction<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2119,7 +2150,7 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
|
|||||||
interfaces.requests.IReq_UpdateRoute
|
interfaces.requests.IReq_UpdateRoute
|
||||||
>('/typedrequest', 'updateRoute');
|
>('/typedrequest', 'updateRoute');
|
||||||
|
|
||||||
await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
id: dataArg.id,
|
id: dataArg.id,
|
||||||
route: dataArg.route,
|
route: dataArg.route,
|
||||||
@@ -2127,6 +2158,10 @@ export const updateRouteAction = routeManagementStatePart.createAction<{
|
|||||||
metadata: dataArg.metadata,
|
metadata: dataArg.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to update route');
|
||||||
|
}
|
||||||
|
|
||||||
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return {
|
return {
|
||||||
@@ -2146,11 +2181,15 @@ export const deleteRouteAction = routeManagementStatePart.createAction<string>(
|
|||||||
interfaces.requests.IReq_DeleteRoute
|
interfaces.requests.IReq_DeleteRoute
|
||||||
>('/typedrequest', 'deleteRoute');
|
>('/typedrequest', 'deleteRoute');
|
||||||
|
|
||||||
await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
id: routeId,
|
id: routeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to delete route');
|
||||||
|
}
|
||||||
|
|
||||||
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return {
|
return {
|
||||||
@@ -2173,12 +2212,16 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
|
|||||||
interfaces.requests.IReq_ToggleRoute
|
interfaces.requests.IReq_ToggleRoute
|
||||||
>('/typedrequest', 'toggleRoute');
|
>('/typedrequest', 'toggleRoute');
|
||||||
|
|
||||||
await request.fire({
|
const response = await request.fire({
|
||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
id: dataArg.id,
|
id: dataArg.id,
|
||||||
enabled: dataArg.enabled,
|
enabled: dataArg.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to toggle route');
|
||||||
|
}
|
||||||
|
|
||||||
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
return {
|
return {
|
||||||
@@ -2188,58 +2231,6 @@ export const toggleRouteAction = routeManagementStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setRouteOverrideAction = routeManagementStatePart.createAction<{
|
|
||||||
routeName: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRouteManagementState> => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to set override',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const removeRouteOverrideAction = routeManagementStatePart.createAction<string>(
|
|
||||||
async (statePartArg, routeName, actionContext): Promise<IRouteManagementState> => {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await actionContext!.dispatch(fetchMergedRoutesAction, null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return {
|
|
||||||
...currentState,
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to remove override',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Token Actions
|
// API Token Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2377,6 +2368,130 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Domains State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface IEmailDomainsState {
|
||||||
|
domains: interfaces.data.IEmailDomain[];
|
||||||
|
isLoading: boolean;
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsState>(
|
||||||
|
'emailDomains',
|
||||||
|
{
|
||||||
|
domains: [],
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: 0,
|
||||||
|
},
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
|
||||||
|
async (statePartArg): Promise<IEmailDomainsState> => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
if (!context.identity) return currentState;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetEmailDomains
|
||||||
|
>('/typedrequest', 'getEmailDomains');
|
||||||
|
const response = await request.fire({ identity: context.identity });
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
domains: response.domains,
|
||||||
|
isLoading: false,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...currentState, isLoading: false };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createEmailDomainAction = emailDomainsStatePart.createAction<{
|
||||||
|
linkedDomainId: string;
|
||||||
|
subdomain?: string;
|
||||||
|
dkimSelector?: string;
|
||||||
|
dkimKeySize?: number;
|
||||||
|
rotateKeys?: boolean;
|
||||||
|
rotationIntervalDays?: number;
|
||||||
|
}>(async (statePartArg, args, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_CreateEmailDomain
|
||||||
|
>('/typedrequest', 'createEmailDomain');
|
||||||
|
await request.fire({ identity: context.identity!, ...args });
|
||||||
|
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||||
|
} catch {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string>(
|
||||||
|
async (statePartArg, id, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_DeleteEmailDomain
|
||||||
|
>('/typedrequest', 'deleteEmailDomain');
|
||||||
|
await request.fire({ identity: context.identity!, id });
|
||||||
|
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||||
|
} catch {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const validateEmailDomainAction = emailDomainsStatePart.createAction<string>(
|
||||||
|
async (statePartArg, id, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ValidateEmailDomain
|
||||||
|
>('/typedrequest', 'validateEmailDomain');
|
||||||
|
await request.fire({ identity: context.identity!, id });
|
||||||
|
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||||
|
} catch {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const provisionEmailDomainDnsAction = emailDomainsStatePart.createAction<string>(
|
||||||
|
async (statePartArg, id, actionContext) => {
|
||||||
|
const context = getActionContext();
|
||||||
|
const currentState = statePartArg.getState()!;
|
||||||
|
try {
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_ProvisionEmailDomainDns
|
||||||
|
>('/typedrequest', 'provisionEmailDomainDns');
|
||||||
|
await request.fire({ identity: context.identity!, id });
|
||||||
|
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||||
|
} catch {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Domain Standalone Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function fetchEmailDomainDnsRecords(id: string) {
|
||||||
|
const context = getActionContext();
|
||||||
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
|
interfaces.requests.IReq_GetEmailDomainDnsRecords
|
||||||
|
>('/typedrequest', 'getEmailDomainDnsRecords');
|
||||||
|
return request.fire({ identity: context.identity!, id });
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TypedSocket Client for Real-time Log Streaming
|
// TypedSocket Client for Real-time Log Streaming
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2499,67 +2614,52 @@ async function dispatchCombinedRefreshActionInner() {
|
|||||||
if (combinedResponse.metrics.network && currentView === 'network') {
|
if (combinedResponse.metrics.network && currentView === 'network') {
|
||||||
const network = combinedResponse.metrics.network;
|
const network = combinedResponse.metrics.network;
|
||||||
const connectionsByIP: { [ip: string]: number } = {};
|
const connectionsByIP: { [ip: string]: number } = {};
|
||||||
|
|
||||||
// Convert connection details to IP counts
|
// Build connectionsByIP from connectionDetails (now populated with real per-IP data)
|
||||||
network.connectionDetails.forEach(conn => {
|
network.connectionDetails.forEach(conn => {
|
||||||
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
|
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch detailed connections for the network view
|
// Build connections from connectionDetails (real per-IP aggregates)
|
||||||
try {
|
const connections: interfaces.data.IConnectionInfo[] = network.connectionDetails.map((conn, i) => ({
|
||||||
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
id: `ip-${conn.remoteAddress}`,
|
||||||
interfaces.requests.IReq_GetActiveConnections
|
remoteAddress: conn.remoteAddress,
|
||||||
>('/typedrequest', 'getActiveConnections');
|
localAddress: 'server',
|
||||||
|
startTime: conn.startTime,
|
||||||
const connectionsResponse = await connectionsRequest.fire({
|
protocol: conn.protocol as any,
|
||||||
identity: context.identity,
|
state: conn.state as any,
|
||||||
});
|
bytesReceived: conn.bytesIn,
|
||||||
|
bytesSent: conn.bytesOut,
|
||||||
|
}));
|
||||||
|
|
||||||
networkStatePart.setState({
|
networkStatePart.setState({
|
||||||
...networkStatePart.getState()!,
|
...networkStatePart.getState()!,
|
||||||
connections: connectionsResponse.connections,
|
connections,
|
||||||
connectionsByIP,
|
connectionsByIP,
|
||||||
throughputRate: {
|
throughputRate: {
|
||||||
bytesInPerSecond: network.totalBandwidth.in,
|
bytesInPerSecond: network.totalBandwidth.in,
|
||||||
bytesOutPerSecond: network.totalBandwidth.out
|
bytesOutPerSecond: network.totalBandwidth.out,
|
||||||
},
|
},
|
||||||
totalBytes: network.totalBytes || { in: 0, out: 0 },
|
totalBytes: network.totalBytes || { in: 0, out: 0 },
|
||||||
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
|
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.connections })),
|
||||||
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
|
topIPsByBandwidth: (network.topEndpointsByBandwidth || []).map(e => ({
|
||||||
throughputHistory: network.throughputHistory || [],
|
ip: e.endpoint,
|
||||||
requestsPerSecond: network.requestsPerSecond || 0,
|
count: e.connections,
|
||||||
requestsTotal: network.requestsTotal || 0,
|
bwIn: e.bandwidth?.in || 0,
|
||||||
backends: network.backends || [],
|
bwOut: e.bandwidth?.out || 0,
|
||||||
frontendProtocols: network.frontendProtocols || null,
|
})),
|
||||||
backendProtocols: network.backendProtocols || null,
|
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
|
||||||
lastUpdated: Date.now(),
|
domainActivity: network.domainActivity || [],
|
||||||
isLoading: false,
|
throughputHistory: network.throughputHistory || [],
|
||||||
error: null,
|
requestsPerSecond: network.requestsPerSecond || 0,
|
||||||
});
|
requestsTotal: network.requestsTotal || 0,
|
||||||
} catch (error: unknown) {
|
backends: network.backends || [],
|
||||||
console.error('Failed to fetch connections:', error);
|
frontendProtocols: network.frontendProtocols || null,
|
||||||
networkStatePart.setState({
|
backendProtocols: network.backendProtocols || null,
|
||||||
...networkStatePart.getState()!,
|
lastUpdated: Date.now(),
|
||||||
connections: [],
|
isLoading: false,
|
||||||
connectionsByIP,
|
error: null,
|
||||||
throughputRate: {
|
});
|
||||||
bytesInPerSecond: network.totalBandwidth.in,
|
|
||||||
bytesOutPerSecond: network.totalBandwidth.out
|
|
||||||
},
|
|
||||||
totalBytes: network.totalBytes || { in: 0, out: 0 },
|
|
||||||
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
|
|
||||||
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
|
|
||||||
throughputHistory: network.throughputHistory || [],
|
|
||||||
requestsPerSecond: network.requestsPerSecond || 0,
|
|
||||||
requestsTotal: network.requestsTotal || 0,
|
|
||||||
backends: network.backends || [],
|
|
||||||
frontendProtocols: network.frontendProtocols || null,
|
|
||||||
backendProtocols: network.backendProtocols || null,
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh certificate data if on Domains > Certificates subview
|
// Refresh certificate data if on Domains > Certificates subview
|
||||||
@@ -2677,4 +2777,4 @@ startAutoRefresh();
|
|||||||
// Connect TypedSocket if already logged in (e.g., persistent session)
|
// Connect TypedSocket if already logged in (e.g., persistent session)
|
||||||
if (loginStatePart.getState()!.isLoggedIn) {
|
if (loginStatePart.getState()!.isLoggedIn) {
|
||||||
connectSocket();
|
connectSocket();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,51 +54,6 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acmeTileHeader {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmeTileHeading {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmeEmptyContent {
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmeGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
gap: 12px 24px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmeField {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmeLabel {
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmeValue {
|
|
||||||
font-size: 13px;
|
|
||||||
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusBadge {
|
.statusBadge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -227,60 +182,26 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return html`
|
return html`
|
||||||
<dees-tile .heading=${'ACME Settings'}>
|
<dees-settings
|
||||||
<div slot="header" class="acmeTileHeader">
|
.heading=${'ACME Settings'}
|
||||||
<span class="acmeTileHeading">ACME Settings</span>
|
.description=${'No ACME configuration yet. Click Configure to set up automated TLS certificate issuance via Let\'s Encrypt. You\'ll also need at least one DNS provider under Domains > Providers.'}
|
||||||
<dees-button
|
.actions=${[{ name: 'Configure', action: () => this.showEditAcmeDialog() }]}
|
||||||
@click=${() => this.showEditAcmeDialog()}
|
></dees-settings>
|
||||||
.type=${'highlighted'}
|
|
||||||
>Configure</dees-button>
|
|
||||||
</div>
|
|
||||||
<div class="acmeEmptyContent">
|
|
||||||
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
|
|
||||||
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
|
|
||||||
under <strong>Domains > Providers</strong>.
|
|
||||||
</div>
|
|
||||||
</dees-tile>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<dees-tile>
|
<dees-settings
|
||||||
<div slot="header" class="acmeTileHeader">
|
.heading=${'ACME Settings'}
|
||||||
<span class="acmeTileHeading">ACME Settings</span>
|
.settingsFields=${[
|
||||||
<dees-button @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
|
{ key: 'email', label: 'Account email', value: config.accountEmail || '(not set)' },
|
||||||
</div>
|
{ key: 'status', label: 'Status', value: config.enabled ? 'enabled' : 'disabled' },
|
||||||
<div class="acmeGrid">
|
{ key: 'mode', label: 'Mode', value: config.useProduction ? 'production' : 'staging' },
|
||||||
<div class="acmeField">
|
{ key: 'autoRenew', label: 'Auto-renew', value: config.autoRenew ? 'on' : 'off' },
|
||||||
<span class="acmeLabel">Account email</span>
|
{ key: 'threshold', label: 'Renewal threshold', value: `${config.renewThresholdDays} days` },
|
||||||
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
|
]}
|
||||||
</div>
|
.actions=${[{ name: 'Edit', action: () => this.showEditAcmeDialog() }]}
|
||||||
<div class="acmeField">
|
></dees-settings>
|
||||||
<span class="acmeLabel">Status</span>
|
|
||||||
<span class="acmeValue">
|
|
||||||
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
|
|
||||||
${config.enabled ? 'enabled' : 'disabled'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="acmeField">
|
|
||||||
<span class="acmeLabel">Mode</span>
|
|
||||||
<span class="acmeValue">
|
|
||||||
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
|
|
||||||
${config.useProduction ? 'production' : 'staging'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="acmeField">
|
|
||||||
<span class="acmeLabel">Auto-renew</span>
|
|
||||||
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="acmeField">
|
|
||||||
<span class="acmeLabel">Renewal threshold</span>
|
|
||||||
<span class="acmeValue">${config.renewThresholdDays} days</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dees-tile>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,15 @@ export class OpsViewDomains extends DeesElement {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Migrate',
|
||||||
|
iconName: 'lucide:arrow-right-left',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const domain = actionData.item as interfaces.data.IDomain;
|
||||||
|
await this.showMigrateDialog(domain);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
@@ -308,6 +317,94 @@ export class OpsViewDomains extends DeesElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async showMigrateDialog(domain: interfaces.data.IDomain) {
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
const providers = this.domainsState.providers;
|
||||||
|
|
||||||
|
// Build target options based on current source
|
||||||
|
const targetOptions: { option: string; key: string }[] = [];
|
||||||
|
for (const p of providers) {
|
||||||
|
// Skip current source
|
||||||
|
if (p.builtIn && domain.source === 'dcrouter') continue;
|
||||||
|
if (!p.builtIn && domain.source === 'provider' && domain.providerId === p.id) continue;
|
||||||
|
|
||||||
|
const label = p.builtIn ? 'DcRouter (self)' : `${p.name} (${p.type})`;
|
||||||
|
const key = p.builtIn ? 'dcrouter' : `provider:${p.id}`;
|
||||||
|
targetOptions.push({ option: label, key });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetOptions.length === 0) {
|
||||||
|
DeesToast.show({
|
||||||
|
message: 'No migration targets available. Add a DNS provider first.',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLabel = domain.source === 'dcrouter'
|
||||||
|
? 'DcRouter (self)'
|
||||||
|
: providers.find((p) => p.id === domain.providerId)?.name || 'Provider';
|
||||||
|
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `Migrate: ${domain.name}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'currentSource'}
|
||||||
|
.label=${'Current source'}
|
||||||
|
.value=${currentLabel}
|
||||||
|
.disabled=${true}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'target'}
|
||||||
|
.label=${'Migrate to'}
|
||||||
|
.description=${'Select the target DNS management'}
|
||||||
|
.options=${targetOptions}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'deleteExisting'}
|
||||||
|
.label=${'Delete existing records at provider first'}
|
||||||
|
.description=${'Removes all records at the provider before pushing migrated records'}
|
||||||
|
.value=${true}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Migrate',
|
||||||
|
action: async (m: any) => {
|
||||||
|
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const targetKey = typeof data.target === 'object' ? data.target.key : data.target;
|
||||||
|
if (!targetKey) return;
|
||||||
|
|
||||||
|
let targetSource: interfaces.data.TDomainSource;
|
||||||
|
let targetProviderId: string | undefined;
|
||||||
|
if (targetKey === 'dcrouter') {
|
||||||
|
targetSource = 'dcrouter';
|
||||||
|
} else {
|
||||||
|
targetSource = 'provider';
|
||||||
|
targetProviderId = targetKey.replace('provider:', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.migrateDomainAction, {
|
||||||
|
id: domain.id,
|
||||||
|
targetSource,
|
||||||
|
targetProviderId,
|
||||||
|
deleteExistingProviderRecords: targetSource === 'provider' ? Boolean(data.deleteExisting) : false,
|
||||||
|
});
|
||||||
|
DeesToast.show({ message: `Domain ${domain.name} migrated successfully`, type: 'success', duration: 3000 });
|
||||||
|
m.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async deleteDomain(domain: interfaces.data.IDomain) {
|
private async deleteDomain(domain: interfaces.data.IDomain) {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
DeesModal.createAndShow({
|
DeesModal.createAndShow({
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './ops-view-emails.js';
|
export * from './ops-view-emails.js';
|
||||||
export * from './ops-view-email-security.js';
|
export * from './ops-view-email-security.js';
|
||||||
|
export * from './ops-view-email-domains.js';
|
||||||
|
|||||||
396
ts_web/elements/email/ops-view-email-domains.ts
Normal file
396
ts_web/elements/email/ops-view-email-domains.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
customElement,
|
||||||
|
type TemplateResult,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
cssManager,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
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';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ops-view-email-domains': OpsViewEmailDomains;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('ops-view-email-domains')
|
||||||
|
export class OpsViewEmailDomains extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor emailDomainsState: appstate.IEmailDomainsState =
|
||||||
|
appstate.emailDomainsStatePart.getState()!;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.emailDomainsStatePart.select().subscribe((s) => {
|
||||||
|
this.emailDomainsState = s;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
const domSub = appstate.domainsStatePart.select().subscribe((s) => {
|
||||||
|
this.domainsState = s;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(domSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await super.connectedCallback();
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(appstate.fetchEmailDomainsAction, null);
|
||||||
|
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.emailDomainsContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.valid {
|
||||||
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.missing {
|
||||||
|
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.invalid {
|
||||||
|
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
|
||||||
|
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge.unchecked {
|
||||||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||||
|
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||||
|
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const domains = this.emailDomainsState.domains;
|
||||||
|
const validCount = domains.filter(
|
||||||
|
(d) =>
|
||||||
|
d.dnsStatus.mx === 'valid' &&
|
||||||
|
d.dnsStatus.spf === 'valid' &&
|
||||||
|
d.dnsStatus.dkim === 'valid' &&
|
||||||
|
d.dnsStatus.dmarc === 'valid',
|
||||||
|
).length;
|
||||||
|
const issueCount = domains.length - validCount;
|
||||||
|
|
||||||
|
const tiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
title: 'Total Domains',
|
||||||
|
value: domains.length,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:globe',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'valid',
|
||||||
|
title: 'Valid DNS',
|
||||||
|
value: validCount,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:Check',
|
||||||
|
color: '#22c55e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'issues',
|
||||||
|
title: 'Issues',
|
||||||
|
value: issueCount,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:TriangleAlert',
|
||||||
|
color: issueCount > 0 ? '#ef4444' : '#22c55e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dkim',
|
||||||
|
title: 'DKIM Active',
|
||||||
|
value: domains.filter((d) => d.dkim.publicKey).length,
|
||||||
|
type: 'number',
|
||||||
|
icon: 'lucide:KeyRound',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-heading level="3">Email Domains</dees-heading>
|
||||||
|
|
||||||
|
<div class="emailDomainsContainer">
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${tiles}
|
||||||
|
.minTileWidth=${200}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:RefreshCw',
|
||||||
|
action: async () => {
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.fetchEmailDomainsAction,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Email Domains'}
|
||||||
|
.heading2=${'DKIM, SPF, DMARC and MX management'}
|
||||||
|
.data=${domains}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(d: interfaces.data.IEmailDomain) => ({
|
||||||
|
Domain: d.domain,
|
||||||
|
Source: this.renderSourceBadge(d.linkedDomainId),
|
||||||
|
MX: this.renderDnsStatus(d.dnsStatus.mx),
|
||||||
|
SPF: this.renderDnsStatus(d.dnsStatus.spf),
|
||||||
|
DKIM: this.renderDnsStatus(d.dnsStatus.dkim),
|
||||||
|
DMARC: this.renderDnsStatus(d.dnsStatus.dmarc),
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Email Domain',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header'] as any,
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Validate DNS',
|
||||||
|
iconName: 'lucide:search-check',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.validateEmailDomainAction,
|
||||||
|
d.id,
|
||||||
|
);
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.show({ message: `DNS validated for ${d.domain}`, type: 'success', duration: 2500 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Provision DNS',
|
||||||
|
iconName: 'lucide:wand-sparkles',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.provisionEmailDomainDnsAction,
|
||||||
|
d.id,
|
||||||
|
);
|
||||||
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesToast.show({ message: `DNS records provisioned for ${d.domain}`, type: 'success', duration: 2500 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'View DNS Records',
|
||||||
|
iconName: 'lucide:list',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||||
|
await this.showDnsRecordsDialog(d);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash2',
|
||||||
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const d = actionData.item as interfaces.data.IEmailDomain;
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.deleteEmailDomainAction,
|
||||||
|
d.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataName="email domain"
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDnsStatus(status: interfaces.data.TDnsRecordStatus): TemplateResult {
|
||||||
|
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSourceBadge(linkedDomainId: string): TemplateResult {
|
||||||
|
const domain = this.domainsState.domains.find((d) => d.id === linkedDomainId);
|
||||||
|
if (!domain) return html`<span class="sourceBadge">unknown</span>`;
|
||||||
|
const label =
|
||||||
|
domain.source === 'dcrouter'
|
||||||
|
? 'dcrouter'
|
||||||
|
: this.domainsState.providers.find((p) => p.id === domain.providerId)?.name || 'provider';
|
||||||
|
return html`<span class="sourceBadge">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateDialog() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
const domainOptions = this.domainsState.domains.map((d) => ({
|
||||||
|
option: `${d.name} (${d.source})`,
|
||||||
|
key: d.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Add Email Domain',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'linkedDomainId'}
|
||||||
|
.label=${'Domain'}
|
||||||
|
.description=${'Select an existing DNS domain'}
|
||||||
|
.options=${domainOptions}
|
||||||
|
.required=${true}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'subdomain'}
|
||||||
|
.label=${'Subdomain'}
|
||||||
|
.description=${'Leave empty for bare domain, e.g. "mail" for mail.example.com'}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-text
|
||||||
|
.key=${'dkimSelector'}
|
||||||
|
.label=${'DKIM Selector'}
|
||||||
|
.description=${'Identifier used in DNS record name'}
|
||||||
|
.value=${'default'}
|
||||||
|
></dees-input-text>
|
||||||
|
<dees-input-dropdown
|
||||||
|
.key=${'dkimKeySize'}
|
||||||
|
.label=${'DKIM Key Size'}
|
||||||
|
.options=${[
|
||||||
|
{ option: '2048 (recommended)', key: '2048' },
|
||||||
|
{ option: '1024', key: '1024' },
|
||||||
|
{ option: '4096', key: '4096' },
|
||||||
|
]}
|
||||||
|
.selectedOption=${{ option: '2048 (recommended)', key: '2048' }}
|
||||||
|
></dees-input-dropdown>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.key=${'rotateKeys'}
|
||||||
|
.label=${'Auto-rotate DKIM keys'}
|
||||||
|
.value=${false}
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
action: async (m: any) => {
|
||||||
|
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const data = await form.collectFormData();
|
||||||
|
const linkedDomainId =
|
||||||
|
typeof data.linkedDomainId === 'object'
|
||||||
|
? data.linkedDomainId.key
|
||||||
|
: data.linkedDomainId;
|
||||||
|
const keySize =
|
||||||
|
typeof data.dkimKeySize === 'object'
|
||||||
|
? parseInt(data.dkimKeySize.key, 10)
|
||||||
|
: parseInt(data.dkimKeySize || '2048', 10);
|
||||||
|
|
||||||
|
const subdomain = data.subdomain?.trim() || undefined;
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.createEmailDomainAction,
|
||||||
|
{
|
||||||
|
linkedDomainId,
|
||||||
|
subdomain,
|
||||||
|
dkimSelector: data.dkimSelector || 'default',
|
||||||
|
dkimKeySize: keySize,
|
||||||
|
rotateKeys: Boolean(data.rotateKeys),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showDnsRecordsDialog(emailDomain: interfaces.data.IEmailDomain) {
|
||||||
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
// Fetch required DNS records
|
||||||
|
let records: interfaces.data.IEmailDnsRecord[] = [];
|
||||||
|
try {
|
||||||
|
const response = await appstate.fetchEmailDomainDnsRecords(emailDomain.id);
|
||||||
|
records = response.records;
|
||||||
|
} catch {
|
||||||
|
records = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: `DNS Records: ${emailDomain.domain}`,
|
||||||
|
content: html`
|
||||||
|
<dees-table
|
||||||
|
.data=${records}
|
||||||
|
.displayFunction=${(r: interfaces.data.IEmailDnsRecord) => ({
|
||||||
|
Type: r.type,
|
||||||
|
Name: r.name,
|
||||||
|
Value: r.value,
|
||||||
|
Status: html`<span class="statusBadge ${r.status}">${r.status}</span>`,
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Copy Value',
|
||||||
|
iconName: 'lucide:copy',
|
||||||
|
type: ['inRow'] as any,
|
||||||
|
actionFunc: async (actionData: any) => {
|
||||||
|
const rec = actionData.item as interfaces.data.IEmailDnsRecord;
|
||||||
|
await navigator.clipboard.writeText(rec.value);
|
||||||
|
DeesToast.show({ message: 'Copied to clipboard', type: 'success', duration: 1500 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataName="DNS record"
|
||||||
|
></dees-table>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Auto-Provision All',
|
||||||
|
action: async (m: any) => {
|
||||||
|
await appstate.emailDomainsStatePart.dispatchAction(
|
||||||
|
appstate.provisionEmailDomainDnsAction,
|
||||||
|
emailDomain.id,
|
||||||
|
);
|
||||||
|
DeesToast.show({ message: 'DNS records provisioned', type: 'success', duration: 2500 });
|
||||||
|
m.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Close', action: async (m: any) => m.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,25 +37,10 @@ export class OpsViewEmailSecurity extends DeesElement {
|
|||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
viewHostCss,
|
viewHostCss,
|
||||||
css`
|
css`
|
||||||
h2 {
|
.securityContainer {
|
||||||
margin: 32px 0 16px 0;
|
display: flex;
|
||||||
font-size: 24px;
|
flex-direction: column;
|
||||||
font-weight: 600;
|
gap: 24px;
|
||||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
|
||||||
}
|
|
||||||
dees-statsgrid {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
.securityCard {
|
|
||||||
background: ${cssManager.bdTheme('#fff', '#222')};
|
|
||||||
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.actionButton {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
@@ -113,48 +98,44 @@ export class OpsViewEmailSecurity extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<dees-heading level="3">Email Security</dees-heading>
|
<dees-heading level="3">Email Security</dees-heading>
|
||||||
|
|
||||||
<dees-statsgrid
|
<div class="securityContainer">
|
||||||
.tiles=${tiles}
|
<dees-statsgrid
|
||||||
.minTileWidth=${200}
|
.tiles=${tiles}
|
||||||
></dees-statsgrid>
|
.minTileWidth=${200}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
<h2>Email Security Configuration</h2>
|
<dees-settings
|
||||||
<div class="securityCard">
|
.heading=${'Security Configuration'}
|
||||||
<dees-form>
|
.settingsFields=${[
|
||||||
<dees-input-checkbox
|
{ key: 'spf', label: 'SPF checking', value: 'enabled' },
|
||||||
.key=${'enableSPF'}
|
{ key: 'dkim', label: 'DKIM validation', value: 'enabled' },
|
||||||
.label=${'Enable SPF checking'}
|
{ key: 'dmarc', label: 'DMARC policy', value: 'enabled' },
|
||||||
.value=${true}
|
{ key: 'spam', label: 'Spam filtering', value: 'enabled' },
|
||||||
></dees-input-checkbox>
|
]}
|
||||||
<dees-input-checkbox
|
.actions=${[{ name: 'Edit', action: () => this.showEditSecurityDialog() }]}
|
||||||
.key=${'enableDKIM'}
|
></dees-settings>
|
||||||
.label=${'Enable DKIM validation'}
|
|
||||||
.value=${true}
|
|
||||||
></dees-input-checkbox>
|
|
||||||
<dees-input-checkbox
|
|
||||||
.key=${'enableDMARC'}
|
|
||||||
.label=${'Enable DMARC policy enforcement'}
|
|
||||||
.value=${true}
|
|
||||||
></dees-input-checkbox>
|
|
||||||
<dees-input-checkbox
|
|
||||||
.key=${'enableSpamFilter'}
|
|
||||||
.label=${'Enable spam filtering'}
|
|
||||||
.value=${true}
|
|
||||||
></dees-input-checkbox>
|
|
||||||
</dees-form>
|
|
||||||
<dees-button
|
|
||||||
class="actionButton"
|
|
||||||
type="highlighted"
|
|
||||||
@click=${() => this.saveEmailSecuritySettings()}
|
|
||||||
>
|
|
||||||
Save Settings
|
|
||||||
</dees-button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveEmailSecuritySettings() {
|
private async showEditSecurityDialog() {
|
||||||
// Config is read-only from the UI for now
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Edit Security Configuration',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-checkbox .key=${'enableSPF'} .label=${'SPF checking'} .value=${true}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'enableDKIM'} .label=${'DKIM validation'} .value=${true}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'enableDMARC'} .label=${'DMARC policy enforcement'} .value=${true}></dees-input-checkbox>
|
||||||
|
<dees-input-checkbox .key=${'enableSpamFilter'} .label=${'Spam filtering'} .value=${true}></dees-input-checkbox>
|
||||||
|
</dees-form>
|
||||||
|
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||||
|
These settings are read-only for now. Update the dcrouter configuration to change them.
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,22 +10,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface INetworkRequest {
|
|
||||||
id: string;
|
|
||||||
timestamp: number;
|
|
||||||
method: string;
|
|
||||||
url: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
protocol: 'http' | 'https' | 'tcp' | 'udp';
|
|
||||||
statusCode?: number;
|
|
||||||
duration: number;
|
|
||||||
bytesIn: number;
|
|
||||||
bytesOut: number;
|
|
||||||
remoteIp: string;
|
|
||||||
route?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@customElement('ops-view-network-activity')
|
@customElement('ops-view-network-activity')
|
||||||
export class OpsViewNetworkActivity extends DeesElement {
|
export class OpsViewNetworkActivity extends DeesElement {
|
||||||
/** How far back the traffic chart shows */
|
/** How far back the traffic chart shows */
|
||||||
@@ -42,9 +26,6 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
accessor networkState = appstate.networkStatePart.getState()!;
|
accessor networkState = appstate.networkStatePart.getState()!;
|
||||||
|
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor networkRequests: INetworkRequest[] = [];
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
|
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
|
||||||
|
|
||||||
@@ -314,108 +295,21 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
<!-- Protocol Distribution Charts -->
|
<!-- Protocol Distribution Charts -->
|
||||||
${this.renderProtocolCharts()}
|
${this.renderProtocolCharts()}
|
||||||
|
|
||||||
<!-- Top IPs Section -->
|
<!-- Top IPs by Connection Count -->
|
||||||
${this.renderTopIPs()}
|
${this.renderTopIPs()}
|
||||||
|
|
||||||
|
<!-- Top IPs by Bandwidth -->
|
||||||
|
${this.renderTopIPsByBandwidth()}
|
||||||
|
|
||||||
|
<!-- Domain Activity -->
|
||||||
|
${this.renderDomainActivity()}
|
||||||
|
|
||||||
<!-- Backend Protocols Section -->
|
<!-- Backend Protocols Section -->
|
||||||
${this.renderBackendProtocols()}
|
${this.renderBackendProtocols()}
|
||||||
|
|
||||||
<!-- Requests Table -->
|
|
||||||
<dees-table
|
|
||||||
.data=${this.networkRequests}
|
|
||||||
.rowKey=${'id'}
|
|
||||||
.highlightUpdates=${'flash'}
|
|
||||||
.displayFunction=${(req: INetworkRequest) => ({
|
|
||||||
Time: new Date(req.timestamp).toLocaleTimeString(),
|
|
||||||
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
|
|
||||||
Method: req.method,
|
|
||||||
'Host:Port': `${req.hostname}:${req.port}`,
|
|
||||||
Path: this.truncateUrl(req.url),
|
|
||||||
Status: this.renderStatus(req.statusCode),
|
|
||||||
Duration: `${req.duration}ms`,
|
|
||||||
'In/Out': `${this.formatBytes(req.bytesIn)} / ${this.formatBytes(req.bytesOut)}`,
|
|
||||||
'Remote IP': req.remoteIp,
|
|
||||||
})}
|
|
||||||
.dataActions=${[
|
|
||||||
{
|
|
||||||
name: 'View Details',
|
|
||||||
iconName: 'fa:magnifyingGlass',
|
|
||||||
type: ['inRow', 'doubleClick', 'contextmenu'],
|
|
||||||
actionFunc: async (actionData) => {
|
|
||||||
await this.showRequestDetails(actionData.item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
heading1="Recent Network Activity"
|
|
||||||
heading2="Recent network requests"
|
|
||||||
searchable
|
|
||||||
.showColumnFilters=${true}
|
|
||||||
.pagination=${true}
|
|
||||||
.paginationSize=${50}
|
|
||||||
dataName="request"
|
|
||||||
></dees-table>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showRequestDetails(request: INetworkRequest) {
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
||||||
|
|
||||||
await DeesModal.createAndShow({
|
|
||||||
heading: 'Request Details',
|
|
||||||
content: html`
|
|
||||||
<div style="padding: 20px;">
|
|
||||||
<dees-dataview-codebox
|
|
||||||
.heading=${'Request Information'}
|
|
||||||
progLang="json"
|
|
||||||
.codeToDisplay=${JSON.stringify({
|
|
||||||
id: request.id,
|
|
||||||
timestamp: new Date(request.timestamp).toISOString(),
|
|
||||||
protocol: request.protocol,
|
|
||||||
method: request.method,
|
|
||||||
url: request.url,
|
|
||||||
hostname: request.hostname,
|
|
||||||
port: request.port,
|
|
||||||
statusCode: request.statusCode,
|
|
||||||
duration: `${request.duration}ms`,
|
|
||||||
bytesIn: request.bytesIn,
|
|
||||||
bytesOut: request.bytesOut,
|
|
||||||
remoteIp: request.remoteIp,
|
|
||||||
route: request.route,
|
|
||||||
}, null, 2)}
|
|
||||||
></dees-dataview-codebox>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
menuOptions: [
|
|
||||||
{
|
|
||||||
name: 'Copy Request ID',
|
|
||||||
iconName: 'lucide:Copy',
|
|
||||||
action: async () => {
|
|
||||||
await navigator.clipboard.writeText(request.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private renderStatus(statusCode?: number): TemplateResult {
|
|
||||||
if (!statusCode) {
|
|
||||||
return html`<span class="statusBadge warning">N/A</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
|
|
||||||
statusCode >= 400 ? 'error' : 'warning';
|
|
||||||
|
|
||||||
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private truncateUrl(url: string, maxLength = 50): string {
|
|
||||||
if (url.length <= maxLength) return url;
|
|
||||||
return url.substring(0, maxLength - 3) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private formatNumber(num: number): string {
|
private formatNumber(num: number): string {
|
||||||
if (num >= 1000000) {
|
if (num >= 1000000) {
|
||||||
return (num / 1000000).toFixed(1) + 'M';
|
return (num / 1000000).toFixed(1) + 'M';
|
||||||
@@ -480,7 +374,7 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
icon: 'lucide:Plug',
|
icon: 'lucide:Plug',
|
||||||
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
color: activeConnections > 100 ? '#f59e0b' : '#22c55e',
|
||||||
description: `Total: ${this.networkState.requestsTotal || this.statsState.serverStats?.totalConnections || 0}`,
|
description: `Total: ${this.formatNumber(this.statsState.serverStats?.totalConnections || 0)} connections`,
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'View Details',
|
name: 'View Details',
|
||||||
@@ -619,6 +513,67 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderTopIPsByBandwidth(): TemplateResult {
|
||||||
|
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-table
|
||||||
|
.data=${this.networkState.topIPsByBandwidth}
|
||||||
|
.rowKey=${'ip'}
|
||||||
|
.highlightUpdates=${'flash'}
|
||||||
|
.displayFunction=${(ipData: { ip: string; count: number; bwIn: number; bwOut: number }) => {
|
||||||
|
return {
|
||||||
|
'IP Address': ipData.ip,
|
||||||
|
'Bandwidth In': this.formatBitsPerSecond(ipData.bwIn),
|
||||||
|
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
|
||||||
|
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
|
||||||
|
'Connections': ipData.count,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
heading1="Top IPs by Bandwidth"
|
||||||
|
heading2="IPs with highest throughput"
|
||||||
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.pagination=${false}
|
||||||
|
dataName="ip"
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDomainActivity(): TemplateResult {
|
||||||
|
if (!this.networkState.domainActivity || this.networkState.domainActivity.length === 0) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-table
|
||||||
|
.data=${this.networkState.domainActivity}
|
||||||
|
.rowKey=${'domain'}
|
||||||
|
.highlightUpdates=${'flash'}
|
||||||
|
.displayFunction=${(item: interfaces.data.IDomainActivity) => {
|
||||||
|
const totalBytesPerMin = (item.bytesInPerSecond + item.bytesOutPerSecond) * 60;
|
||||||
|
return {
|
||||||
|
'Domain': item.domain,
|
||||||
|
'Throughput In': this.formatBitsPerSecond(item.bytesInPerSecond),
|
||||||
|
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
|
||||||
|
'Transferred / min': this.formatBytes(totalBytesPerMin),
|
||||||
|
'Connections': item.activeConnections,
|
||||||
|
'Requests': item.requestCount?.toLocaleString() ?? '0',
|
||||||
|
'Routes': item.routeCount,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
heading1="Domain Activity"
|
||||||
|
heading2="Per-domain network activity from request-level metrics"
|
||||||
|
searchable
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.pagination=${false}
|
||||||
|
dataName="domain"
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderBackendProtocols(): TemplateResult {
|
private renderBackendProtocols(): TemplateResult {
|
||||||
const backends = this.networkState.backends;
|
const backends = this.networkState.backends;
|
||||||
if (!backends || backends.length === 0) {
|
if (!backends || backends.length === 0) {
|
||||||
@@ -730,25 +685,6 @@ export class OpsViewNetworkActivity extends DeesElement {
|
|||||||
this.requestsPerSecHistory.shift();
|
this.requestsPerSecHistory.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reassign unconditionally so dees-table's flash diff can compare per-cell
|
|
||||||
// values against the previous snapshot. Row identity is preserved via
|
|
||||||
// rowKey='id', so DOM nodes are reused across ticks.
|
|
||||||
this.networkRequests = this.networkState.connections.map((conn) => ({
|
|
||||||
id: conn.id,
|
|
||||||
timestamp: conn.startTime,
|
|
||||||
method: 'GET', // Default method for proxy connections
|
|
||||||
url: '/',
|
|
||||||
hostname: conn.remoteAddress,
|
|
||||||
port: conn.protocol === 'https' ? 443 : 80,
|
|
||||||
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
|
|
||||||
statusCode: conn.state === 'connected' ? 200 : undefined,
|
|
||||||
duration: Date.now() - conn.startTime,
|
|
||||||
bytesIn: conn.bytesReceived,
|
|
||||||
bytesOut: conn.bytesSent,
|
|
||||||
remoteIp: conn.remoteAddress,
|
|
||||||
route: 'proxy',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Load server-side throughput history into chart (once)
|
// Load server-side throughput history into chart (once)
|
||||||
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
||||||
this.loadThroughputHistory();
|
this.loadThroughputHistory();
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
|
|||||||
|
|
||||||
@customElement('ops-view-routes')
|
@customElement('ops-view-routes')
|
||||||
export class OpsViewRoutes extends DeesElement {
|
export class OpsViewRoutes extends DeesElement {
|
||||||
|
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
|
||||||
|
|
||||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||||
mergedRoutes: [],
|
mergedRoutes: [],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
@@ -140,9 +142,9 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
const { mergedRoutes, warnings } = this.routeState;
|
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 disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||||
|
const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
|
||||||
|
const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
|
||||||
|
|
||||||
const statsTiles: IStatsTile[] = [
|
const statsTiles: IStatsTile[] = [
|
||||||
{
|
{
|
||||||
@@ -155,21 +157,21 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hardcoded',
|
id: 'configRoutes',
|
||||||
title: 'Hardcoded',
|
title: 'System Routes',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
value: hardcodedCount,
|
value: configCount,
|
||||||
icon: 'lucide:lock',
|
icon: 'lucide:settings',
|
||||||
description: 'Routes from constructor config',
|
description: 'From config, email, and DNS',
|
||||||
color: '#8b5cf6',
|
color: '#8b5cf6',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'programmatic',
|
id: 'apiRoutes',
|
||||||
title: 'Programmatic',
|
title: 'User Routes',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
value: programmaticCount,
|
value: apiCount,
|
||||||
icon: 'lucide:code',
|
icon: 'lucide:code',
|
||||||
description: 'Routes added via API',
|
description: 'Created via API',
|
||||||
color: '#0ea5e9',
|
color: '#0ea5e9',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -183,18 +185,23 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Map merged routes to sz-route-list-view format
|
// Filter routes based on selected tab
|
||||||
const szRoutes = mergedRoutes.map((mr) => {
|
const isUserRoutes = this.routeFilter === 'User Routes';
|
||||||
|
const filteredRoutes = mergedRoutes.filter((mr) =>
|
||||||
|
isUserRoutes ? mr.origin === 'api' : mr.origin !== 'api'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map filtered routes to sz-route-list-view format
|
||||||
|
const szRoutes = filteredRoutes.map((mr) => {
|
||||||
const tags = [...(mr.route.tags || [])];
|
const tags = [...(mr.route.tags || [])];
|
||||||
tags.push(mr.source);
|
tags.push(mr.origin);
|
||||||
if (!mr.enabled) tags.push('disabled');
|
if (!mr.enabled) tags.push('disabled');
|
||||||
if (mr.overridden) tags.push('overridden');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...mr.route,
|
...mr.route,
|
||||||
enabled: mr.enabled,
|
enabled: mr.enabled,
|
||||||
tags,
|
tags,
|
||||||
id: mr.storedRouteId || mr.route.name || undefined,
|
id: mr.id || mr.route.name || undefined,
|
||||||
metadata: mr.metadata,
|
metadata: mr.metadata,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -219,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
]}
|
]}
|
||||||
></dees-statsgrid>
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
<dees-input-multitoggle
|
||||||
|
class="routeFilterToggle"
|
||||||
|
.type=${'single'}
|
||||||
|
.options=${['User Routes', 'System Routes']}
|
||||||
|
.selectedOption=${this.routeFilter}
|
||||||
|
></dees-input-multitoggle>
|
||||||
|
|
||||||
${warnings.length > 0
|
${warnings.length > 0
|
||||||
? html`
|
? html`
|
||||||
<div class="warnings-bar">
|
<div class="warnings-bar">
|
||||||
@@ -238,7 +252,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
? html`
|
? html`
|
||||||
<sz-route-list-view
|
<sz-route-list-view
|
||||||
.routes=${szRoutes}
|
.routes=${szRoutes}
|
||||||
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
|
.showActionsFilter=${isUserRoutes ? () => true : () => false}
|
||||||
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||||
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
||||||
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
||||||
@@ -246,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>No routes configured</p>
|
<p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
|
||||||
<p>Add a programmatic route or check your constructor configuration.</p>
|
<p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
@@ -258,130 +272,76 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
const clickedRoute = e.detail;
|
const clickedRoute = e.detail;
|
||||||
if (!clickedRoute) return;
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
// Find the corresponding merged route
|
const merged = this.findMergedRoute(clickedRoute);
|
||||||
const merged = this.routeState.mergedRoutes.find(
|
|
||||||
(mr) => mr.route.name === clickedRoute.name,
|
|
||||||
);
|
|
||||||
if (!merged) return;
|
if (!merged) return;
|
||||||
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
if (merged.source === 'hardcoded') {
|
const meta = merged.metadata;
|
||||||
const menuOptions = merged.enabled
|
const isSystemManaged = this.isSystemManagedRoute(merged);
|
||||||
? [
|
await DeesModal.createAndShow({
|
||||||
{
|
heading: `Route: ${merged.route.name}`,
|
||||||
name: 'Disable Route',
|
content: html`
|
||||||
iconName: 'lucide:pause',
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
action: async (modalArg: any) => {
|
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
|
||||||
await appstate.routeManagementStatePart.dispatchAction(
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||||
appstate.setRouteOverrideAction,
|
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
||||||
{ routeName: merged.route.name!, enabled: false },
|
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
|
||||||
);
|
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
||||||
await modalArg.destroy();
|
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
|
||||||
},
|
</div>
|
||||||
},
|
`,
|
||||||
{
|
menuOptions: [
|
||||||
name: 'Close',
|
{
|
||||||
iconName: 'lucide:x',
|
name: merged.enabled ? 'Disable' : 'Enable',
|
||||||
action: async (modalArg: any) => await modalArg.destroy(),
|
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
||||||
},
|
action: async (modalArg: any) => {
|
||||||
]
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
: [
|
appstate.toggleRouteAction,
|
||||||
{
|
{ id: merged.id, enabled: !merged.enabled },
|
||||||
name: 'Enable Route',
|
);
|
||||||
iconName: 'lucide:play',
|
await modalArg.destroy();
|
||||||
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
|
|
||||||
const meta = merged.metadata;
|
|
||||||
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>
|
|
||||||
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
|
||||||
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></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',
|
...(!isSystemManaged
|
||||||
iconName: 'lucide:trash-2',
|
? [
|
||||||
action: async (modalArg: any) => {
|
{
|
||||||
await appstate.routeManagementStatePart.dispatchAction(
|
name: 'Edit',
|
||||||
appstate.deleteRouteAction,
|
iconName: 'lucide:pencil',
|
||||||
merged.storedRouteId!,
|
action: async (modalArg: any) => {
|
||||||
);
|
await modalArg.destroy();
|
||||||
await modalArg.destroy();
|
this.showEditRouteDialog(merged);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Close',
|
name: 'Delete',
|
||||||
iconName: 'lucide:x',
|
iconName: 'lucide:trash-2',
|
||||||
action: async (modalArg: any) => await modalArg.destroy(),
|
action: async (modalArg: any) => {
|
||||||
},
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
],
|
appstate.deleteRouteAction,
|
||||||
});
|
merged.id,
|
||||||
}
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRouteEdit(e: CustomEvent) {
|
private async handleRouteEdit(e: CustomEvent) {
|
||||||
const clickedRoute = e.detail;
|
const clickedRoute = e.detail;
|
||||||
if (!clickedRoute) return;
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
const merged = this.routeState.mergedRoutes.find(
|
const merged = this.findMergedRoute(clickedRoute);
|
||||||
(mr) => mr.route.name === clickedRoute.name,
|
if (!merged) return;
|
||||||
);
|
if (this.isSystemManagedRoute(merged)) return;
|
||||||
if (!merged || !merged.storedRouteId) return;
|
|
||||||
|
|
||||||
this.showEditRouteDialog(merged);
|
this.showEditRouteDialog(merged);
|
||||||
}
|
}
|
||||||
@@ -390,10 +350,9 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
const clickedRoute = e.detail;
|
const clickedRoute = e.detail;
|
||||||
if (!clickedRoute) return;
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
const merged = this.routeState.mergedRoutes.find(
|
const merged = this.findMergedRoute(clickedRoute);
|
||||||
(mr) => mr.route.name === clickedRoute.name,
|
if (!merged) return;
|
||||||
);
|
if (this.isSystemManagedRoute(merged)) return;
|
||||||
if (!merged || !merged.storedRouteId) return;
|
|
||||||
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
await DeesModal.createAndShow({
|
await DeesModal.createAndShow({
|
||||||
@@ -415,7 +374,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
action: async (modalArg: any) => {
|
action: async (modalArg: any) => {
|
||||||
await appstate.routeManagementStatePart.dispatchAction(
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
appstate.deleteRouteAction,
|
appstate.deleteRouteAction,
|
||||||
merged.storedRouteId!,
|
merged.id,
|
||||||
);
|
);
|
||||||
await modalArg.destroy();
|
await modalArg.destroy();
|
||||||
},
|
},
|
||||||
@@ -563,7 +522,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
await appstate.routeManagementStatePart.dispatchAction(
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
appstate.updateRouteAction,
|
appstate.updateRouteAction,
|
||||||
{
|
{
|
||||||
id: merged.storedRouteId!,
|
id: merged.id,
|
||||||
route: updatedRoute,
|
route: updatedRoute,
|
||||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||||
},
|
},
|
||||||
@@ -603,7 +562,7 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const createModal = await DeesModal.createAndShow({
|
const createModal = await DeesModal.createAndShow({
|
||||||
heading: 'Add Programmatic Route',
|
heading: 'Add Route',
|
||||||
content: html`
|
content: html`
|
||||||
<dees-form>
|
<dees-form>
|
||||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||||
@@ -717,7 +676,32 @@ export class OpsViewRoutes extends DeesElement {
|
|||||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined {
|
||||||
|
if (clickedRoute.id) {
|
||||||
|
const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id);
|
||||||
|
if (routeById) return routeById;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clickedRoute.name) {
|
||||||
|
return this.routeState.mergedRoutes.find((mr) => mr.route.name === clickedRoute.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSystemManagedRoute(merged: interfaces.data.IMergedRoute): boolean {
|
||||||
|
return merged.origin !== 'api';
|
||||||
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
|
||||||
|
const toggle = this.shadowRoot!.querySelector('.routeFilterToggle') as any;
|
||||||
|
if (toggle) {
|
||||||
|
const sub = toggle.changeSubject.subscribe(() => {
|
||||||
|
this.routeFilter = toggle.selectedOption;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
'Route Refs': profile.routeRefs?.length
|
'Route Refs': profile.routeRefs?.length
|
||||||
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
|
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
|
||||||
: '-',
|
: '-',
|
||||||
Created: new Date(profile.createdAt).toLocaleDateString(),
|
Created: new Date(profile.createdAt).toLocaleDateString(),
|
||||||
})}
|
})}
|
||||||
@@ -149,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRouteCandidates() {
|
private getRouteChoices() {
|
||||||
const routeState = appstate.routeManagementStatePart.getState();
|
const routeState = appstate.routeManagementStatePart.getState();
|
||||||
const routes = routeState?.mergedRoutes || [];
|
const routes = routeState?.mergedRoutes || [];
|
||||||
return routes
|
return routes
|
||||||
.filter((mr) => mr.route.name)
|
.filter((mr) => mr.route.name && mr.id)
|
||||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
.map((mr) => ({
|
||||||
|
routeId: mr.id!,
|
||||||
|
routeName: mr.route.name!,
|
||||||
|
label: `${mr.route.name} (${mr.id})`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRouteCandidates() {
|
||||||
|
return this.getRouteChoices().map((route) => ({ viewKey: route.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRouteRefsToLabels(routeRefs?: string[]): string[] | undefined {
|
||||||
|
if (!routeRefs?.length) return undefined;
|
||||||
|
|
||||||
|
const routeChoices = this.getRouteChoices();
|
||||||
|
const routeById = new Map(routeChoices.map((route) => [route.routeId, route.label]));
|
||||||
|
const routeByName = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (const route of routeChoices) {
|
||||||
|
const labels = routeByName.get(route.routeName) || [];
|
||||||
|
labels.push(route.label);
|
||||||
|
routeByName.set(route.routeName, labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
return routeRefs.map((routeRef) => {
|
||||||
|
const routeLabel = routeById.get(routeRef);
|
||||||
|
if (routeLabel) return routeLabel;
|
||||||
|
|
||||||
|
const labelsForName = routeByName.get(routeRef) || [];
|
||||||
|
if (labelsForName.length === 1) return labelsForName[0];
|
||||||
|
|
||||||
|
return routeRef;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRouteLabelsToRefs(routeRefs: string[]): string[] {
|
||||||
|
if (!routeRefs.length) return [];
|
||||||
|
|
||||||
|
const labelToId = new Map(
|
||||||
|
this.getRouteChoices().map((route) => [route.label, route.routeId]),
|
||||||
|
);
|
||||||
|
return routeRefs.map((routeRef) => labelToId.get(routeRef) || routeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatRouteRef(routeRef: string): string {
|
||||||
|
return this.resolveRouteRefsToLabels([routeRef])?.[0] || routeRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureRoutesLoaded() {
|
private async ensureRoutesLoaded() {
|
||||||
@@ -203,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||||
|
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||||
|
);
|
||||||
|
|
||||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||||
name: String(data.name),
|
name: String(data.name),
|
||||||
@@ -222,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||||
const currentDomains = profile.domains || [];
|
const currentDomains = profile.domains || [];
|
||||||
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
|
||||||
const currentRouteRefs = profile.routeRefs || [];
|
const currentRouteRefs = this.resolveRouteRefsToLabels(profile.routeRefs) || [];
|
||||||
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
await this.ensureRoutesLoaded();
|
await this.ensureRoutesLoaded();
|
||||||
@@ -261,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
|
||||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
const routeRefs = this.resolveRouteLabelsToRefs(
|
||||||
|
Array.isArray(data.routeRefs) ? data.routeRefs : [],
|
||||||
|
);
|
||||||
|
|
||||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
@@ -336,7 +385,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
|||||||
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
|
||||||
<div style="font-size: 14px; margin-top: 4px;">
|
<div style="font-size: 14px; margin-top: 4px;">
|
||||||
${profile.routeRefs?.length
|
${profile.routeRefs?.length
|
||||||
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
|
? profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)
|
||||||
: '-'}
|
: '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
|||||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
|
||||||
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
|
||||||
if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
|
if (hostIpGroup) hostIpGroup.style.display = show;
|
||||||
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
|
||||||
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||||
@@ -390,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
if (!form) return;
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
if (!data.clientId) return;
|
if (!data.clientId) return;
|
||||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -414,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
targetProfileIds,
|
targetProfileIds,
|
||||||
|
|
||||||
useHostIp: useHostIp || undefined,
|
useHostIp,
|
||||||
useDhcp: useDhcp || undefined,
|
useDhcp,
|
||||||
staticIp,
|
staticIp,
|
||||||
forceVlan: forceVlan || undefined,
|
forceVlan,
|
||||||
vlanId,
|
vlanId,
|
||||||
destinationAllowList,
|
destinationAllowList,
|
||||||
destinationBlockList,
|
destinationBlockList,
|
||||||
@@ -485,7 +485,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
|
||||||
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToLabels(client.targetProfileIds)?.join(', ') || '-'}</span></div>
|
||||||
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
|
||||||
${client.useHostIp ? html`
|
${client.useHostIp ? html`
|
||||||
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
|
||||||
@@ -649,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const client = actionData.item as interfaces.data.IVpnClient;
|
const client = actionData.item as interfaces.data.IVpnClient;
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
const currentDescription = client.description ?? '';
|
const currentDescription = client.description ?? '';
|
||||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||||
const profileCandidates = this.getTargetProfileCandidates();
|
const profileCandidates = this.getTargetProfileCandidates();
|
||||||
const currentUseHostIp = client.useHostIp ?? false;
|
const currentUseHostIp = client.useHostIp ?? false;
|
||||||
const currentUseDhcp = client.useDhcp ?? false;
|
const currentUseDhcp = client.useDhcp ?? false;
|
||||||
@@ -695,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -719,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
targetProfileIds,
|
targetProfileIds,
|
||||||
|
|
||||||
useHostIp: useHostIp || undefined,
|
useHostIp,
|
||||||
useDhcp: useDhcp || undefined,
|
useDhcp,
|
||||||
staticIp,
|
staticIp,
|
||||||
forceVlan: forceVlan || undefined,
|
forceVlan,
|
||||||
vlanId,
|
vlanId,
|
||||||
destinationAllowList,
|
destinationAllowList,
|
||||||
destinationBlockList,
|
destinationBlockList,
|
||||||
@@ -811,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build autocomplete candidates from loaded target profiles.
|
* Build stable profile labels for list inputs.
|
||||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
|
||||||
*/
|
*/
|
||||||
private getTargetProfileCandidates() {
|
private getTargetProfileChoices() {
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
const profileState = appstate.targetProfilesStatePart.getState();
|
||||||
const profiles = profileState?.profiles || [];
|
const profiles = profileState?.profiles || [];
|
||||||
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
|
const nameCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
nameCounts.set(profile.name, (nameCounts.get(profile.name) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles.map((profile) => ({
|
||||||
|
id: profile.id,
|
||||||
|
label: (nameCounts.get(profile.name) || 0) > 1
|
||||||
|
? `${profile.name} (${profile.id})`
|
||||||
|
: profile.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTargetProfileCandidates() {
|
||||||
|
return this.getTargetProfileChoices().map((profile) => ({ viewKey: profile.label }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert profile IDs to profile names (for populating edit form values).
|
* Convert profile IDs to form labels (for populating edit form values).
|
||||||
*/
|
*/
|
||||||
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
|
private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
|
||||||
if (!ids?.length) return undefined;
|
if (!ids?.length) return undefined;
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
const choices = this.getTargetProfileChoices();
|
||||||
const profiles = profileState?.profiles || [];
|
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||||
return ids.map((id) => {
|
return ids.map((id) => {
|
||||||
const profile = profiles.find((p) => p.id === id);
|
return labelsById.get(id) || id;
|
||||||
return profile?.name || id;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert profile names back to IDs (for saving form data).
|
* Convert profile form labels back to IDs.
|
||||||
* Uses the dees-input-list candidates' payload when available.
|
|
||||||
*/
|
*/
|
||||||
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
|
private resolveProfileLabelsToIds(labels: string[]): string[] {
|
||||||
if (!names.length) return undefined;
|
if (!labels.length) return [];
|
||||||
const profileState = appstate.targetProfilesStatePart.getState();
|
|
||||||
const profiles = profileState?.profiles || [];
|
const labelsToIds = new Map(
|
||||||
return names
|
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
|
||||||
.map((name) => {
|
);
|
||||||
const profile = profiles.find((p) => p.name === name);
|
return labels
|
||||||
return profile?.id;
|
.map((label) => labelsToIds.get(label))
|
||||||
})
|
|
||||||
.filter((id): id is string => !!id);
|
.filter((id): id is string => !!id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { OpsViewVpn } from './network/ops-view-vpn.js';
|
|||||||
// Email group
|
// Email group
|
||||||
import { OpsViewEmails } from './email/ops-view-emails.js';
|
import { OpsViewEmails } from './email/ops-view-emails.js';
|
||||||
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
||||||
|
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
|
||||||
|
|
||||||
// Access group
|
// Access group
|
||||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||||
@@ -108,6 +109,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
subViews: [
|
subViews: [
|
||||||
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
||||||
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
||||||
|
{ slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
281
ts_web/readme.md
281
ts_web/readme.md
@@ -1,273 +1,72 @@
|
|||||||
# @serve.zone/dcrouter-web
|
# @serve.zone/dcrouter-web
|
||||||
|
|
||||||
Web-based Operations Dashboard for DcRouter. 🖥️
|
Browser UI package for dcrouter's operations dashboard. 🖥️
|
||||||
|
|
||||||
A modern, reactive web application for monitoring and managing your DcRouter instance in real-time. Built with web components using [@design.estate/dees-element](https://code.foss.global/design.estate/dees-element) and [@design.estate/dees-catalog](https://code.foss.global/design.estate/dees-catalog).
|
This package contains the browser entrypoint, app state, router, and web components that power the Ops dashboard served by dcrouter.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## 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.
|
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.
|
||||||
|
|
||||||
## Features
|
## What Is In Here
|
||||||
|
|
||||||
### 🔐 Secure Authentication
|
| Path | Purpose |
|
||||||
- JWT-based login with persistent sessions (IndexedDB)
|
| --- | --- |
|
||||||
- Automatic session expiry detection and cleanup
|
| `index.ts` | Browser entrypoint that initializes routing and renders `<ops-dashboard>` |
|
||||||
- Secure username/password authentication
|
| `appstate.ts` | Central reactive state and action definitions |
|
||||||
|
| `router.ts` | URL-based dashboard routing |
|
||||||
|
| `elements/` | Dashboard views and reusable UI pieces |
|
||||||
|
|
||||||
### 📊 Overview Dashboard
|
## Main Views
|
||||||
- Real-time server statistics (CPU, memory, uptime)
|
|
||||||
- Active connection counts and email throughput
|
|
||||||
- DNS query metrics and RADIUS session tracking
|
|
||||||
- Auto-refreshing with configurable intervals
|
|
||||||
|
|
||||||
### 🌐 Network View
|
The dashboard currently includes views for:
|
||||||
- Active connection monitoring with real-time data from SmartProxy
|
|
||||||
- Top connected IPs with connection counts and percentages
|
|
||||||
- Throughput rates (inbound/outbound in kbit/s, Mbit/s, Gbit/s)
|
|
||||||
- Traffic chart with selectable time ranges
|
|
||||||
|
|
||||||
### 📧 Email Management
|
- overview and configuration
|
||||||
- **Queued** — Emails pending delivery with queue position
|
- network activity and route management
|
||||||
- **Sent** — Successfully delivered emails with timestamps
|
- source profiles, target profiles, and network targets
|
||||||
- **Failed** — Failed emails with resend capability
|
- email activity and email domains
|
||||||
- **Security** — Security incidents from email processing
|
- DNS providers, domains, DNS records, and certificates
|
||||||
- Bounce record management and suppression list controls
|
- API tokens and users
|
||||||
|
- VPN, remote ingress, logs, and security views
|
||||||
|
|
||||||
### 🔐 Certificate Management
|
## Route Management UX
|
||||||
- Domain-centric certificate overview with status indicators
|
|
||||||
- Certificate source tracking (ACME, provision function, static)
|
|
||||||
- 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
|
The web UI reflects dcrouter's current route ownership model:
|
||||||
- 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
|
|
||||||
|
|
||||||
### 🔐 VPN Management
|
- system routes are shown separately from user routes
|
||||||
- VPN server status with forwarding mode, subnet, and WireGuard port
|
- system routes are visible and toggleable
|
||||||
- Client registration table with create, enable/disable, and delete actions
|
- system routes are not directly editable or deletable
|
||||||
- WireGuard config download, clipboard copy, and **QR code display** on client creation
|
- API routes are fully managed through the route-management forms
|
||||||
- QR code export for existing clients — scan with WireGuard mobile app (iOS/Android)
|
|
||||||
- Per-client telemetry (bytes sent/received, keepalives)
|
|
||||||
- Server public key display for manual client configuration
|
|
||||||
|
|
||||||
### 📜 Log Viewer
|
## How It Talks To dcrouter
|
||||||
- Real-time log streaming
|
|
||||||
- Filter by log level (error, warning, info, debug)
|
|
||||||
- Search and time-range selection
|
|
||||||
|
|
||||||
### 🛣️ Route & API Token Management
|
The frontend uses TypedRequest and shared interfaces from `@serve.zone/dcrouter-interfaces`.
|
||||||
- Programmatic route CRUD with enable/disable and override controls
|
|
||||||
- API token creation, revocation, and scope management
|
|
||||||
- Routes tab and API Tokens tab in unified view
|
|
||||||
|
|
||||||
### 🛡️ Security Profiles & Network Targets
|
State actions in `appstate.ts` fetch and mutate:
|
||||||
- Create, edit, and delete reusable security profiles (IP allow/block lists, rate limits, max connections)
|
|
||||||
- Create, edit, and delete reusable network targets (host:port destinations)
|
|
||||||
- In-row and context menu actions for quick editing
|
|
||||||
- Changes propagate automatically to all referencing routes
|
|
||||||
|
|
||||||
### ⚙️ Configuration
|
- stats and health
|
||||||
- Read-only display of current system configuration
|
- logs
|
||||||
- Status badges for boolean values (enabled/disabled)
|
- routes and tokens
|
||||||
- Array values displayed as pills with counts
|
- certificates and ACME config
|
||||||
- Section icons and formatted byte/time values
|
- DNS providers, domains, and records
|
||||||
|
- email domains and email operations
|
||||||
|
- VPN, remote ingress, and RADIUS data
|
||||||
|
|
||||||
### 🛡️ Security Dashboard
|
## Development Notes
|
||||||
- IP reputation monitoring
|
|
||||||
- Rate limit status across domains
|
|
||||||
- Blocked connection tracking
|
|
||||||
- Security event timeline
|
|
||||||
|
|
||||||
## Architecture
|
The browser bundle is built from this package and served by the main dcrouter package.
|
||||||
|
|
||||||
### Technology Stack
|
|
||||||
|
|
||||||
| Layer | Package | Purpose |
|
|
||||||
|-------|---------|---------|
|
|
||||||
| **Components** | `@design.estate/dees-element` | Web component framework (lit-element based) |
|
|
||||||
| **UI Kit** | `@design.estate/dees-catalog` | Pre-built components (tables, charts, forms, app shell) |
|
|
||||||
| **State** | `@push.rocks/smartstate` | Reactive state management with persistent/soft modes |
|
|
||||||
| **Routing** | Client-side router | URL-synchronized view navigation |
|
|
||||||
| **API** | `@api.global/typedrequest` | Type-safe communication with OpsServer |
|
|
||||||
| **Types** | `@serve.zone/dcrouter-interfaces` | Shared TypedRequest interface definitions |
|
|
||||||
|
|
||||||
### Component Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
ts_web/
|
|
||||||
├── index.ts # Entry point — renders <ops-dashboard>
|
|
||||||
├── appstate.ts # State management (all state parts + actions)
|
|
||||||
├── router.ts # Client-side routing (AppRouter)
|
|
||||||
├── plugins.ts # Dependency imports
|
|
||||||
└── elements/
|
|
||||||
├── ops-dashboard.ts # Main app shell
|
|
||||||
├── ops-view-overview.ts # Overview statistics
|
|
||||||
├── 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-vpn.ts # VPN client management
|
|
||||||
├── ops-view-logs.ts # Log viewer
|
|
||||||
├── ops-view-routes.ts # Route & API token management
|
|
||||||
├── ops-view-config.ts # Configuration display
|
|
||||||
├── ops-view-security.ts # Security dashboard
|
|
||||||
└── shared/
|
|
||||||
├── css.ts # Shared styles
|
|
||||||
└── ops-sectionheading.ts # Section heading component
|
|
||||||
```
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
The app uses `@push.rocks/smartstate` v2.3+ with multiple state parts, scheduled actions with `autoPause: 'visibility'`, and batched updates:
|
|
||||||
|
|
||||||
| State Part | Mode | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `loginStatePart` | Persistent (IndexedDB) | JWT identity and login status |
|
|
||||||
| `statsStatePart` | Soft (memory) | Server, email, DNS, security, RADIUS, VPN metrics |
|
|
||||||
| `configStatePart` | Soft | Current system configuration |
|
|
||||||
| `uiStatePart` | Soft | Active view, sidebar, auto-refresh, theme |
|
|
||||||
| `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 |
|
|
||||||
| `vpnStatePart` | Soft | VPN clients, server status, new client config |
|
|
||||||
|
|
||||||
### Tab Visibility Optimization
|
|
||||||
|
|
||||||
The dashboard automatically pauses all background activity when the browser tab is hidden and resumes when visible:
|
|
||||||
|
|
||||||
- **Auto-refresh polling** uses `createScheduledAction` with `autoPause: 'visibility'` — stops HTTP requests while the tab is sleeping
|
|
||||||
- **In-flight guard** prevents concurrent refresh requests from piling up
|
|
||||||
- **WebSocket connection** disconnects when hidden and reconnects when visible, preventing log entry accumulation
|
|
||||||
- **Network traffic timer** pauses chart updates when the tab is backgrounded
|
|
||||||
- **Log entry batching** — incoming WebSocket log pushes are buffered and flushed once per animation frame to avoid per-entry re-renders
|
|
||||||
|
|
||||||
### Actions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Authentication
|
|
||||||
loginAction(username, password) // JWT login
|
|
||||||
logoutAction() // Clear session
|
|
||||||
|
|
||||||
// Data fetching (auto-refresh compatible)
|
|
||||||
fetchAllStatsAction() // Server + email + DNS + security stats
|
|
||||||
fetchConfigurationAction() // System configuration
|
|
||||||
fetchRecentLogsAction() // Log entries
|
|
||||||
fetchNetworkStatsAction() // Connection + throughput data
|
|
||||||
|
|
||||||
// Email operations
|
|
||||||
fetchQueuedEmailsAction() // Pending emails
|
|
||||||
fetchSentEmailsAction() // Delivered emails
|
|
||||||
fetchFailedEmailsAction() // Failed emails
|
|
||||||
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)
|
|
||||||
|
|
||||||
// VPN
|
|
||||||
fetchVpnAction() // Clients + server status
|
|
||||||
createVpnClientAction(data) // Create new VPN client
|
|
||||||
deleteVpnClientAction(clientId) // Remove VPN client
|
|
||||||
toggleVpnClientAction(id, enabled) // Enable/disable
|
|
||||||
clearNewClientConfigAction() // Dismiss config banner
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client-Side Routing
|
|
||||||
|
|
||||||
```
|
|
||||||
/overview → Overview dashboard
|
|
||||||
/network → Network monitoring
|
|
||||||
/emails → Email management
|
|
||||||
/emails/queued → Queued emails
|
|
||||||
/emails/sent → Sent emails
|
|
||||||
/emails/failed → Failed emails
|
|
||||||
/emails/security → Security incidents
|
|
||||||
/certificates → Certificate management
|
|
||||||
/remoteingress → Remote ingress edge management
|
|
||||||
/vpn → VPN client management
|
|
||||||
/routes → Route & API token management
|
|
||||||
/logs → Log viewer
|
|
||||||
/configuration → System configuration
|
|
||||||
/security → Security dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
URL state is synchronized with the UI — bookmarking and deep linking fully supported.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Running Locally
|
|
||||||
|
|
||||||
Start DcRouter with OpsServer enabled:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { DcRouter } from '@serve.zone/dcrouter';
|
|
||||||
|
|
||||||
const router = new DcRouter({
|
|
||||||
// OpsServer starts automatically on port 3000
|
|
||||||
smartProxyConfig: { routes: [/* your routes */] }
|
|
||||||
});
|
|
||||||
|
|
||||||
await router.start();
|
|
||||||
// Dashboard at http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the bundle
|
|
||||||
pnpm run bundle
|
pnpm run bundle
|
||||||
|
|
||||||
# Watch for development (auto-rebuild + restart)
|
|
||||||
pnpm run watch
|
pnpm run watch
|
||||||
```
|
```
|
||||||
|
|
||||||
The bundle is output to `./dist_serve/bundle.js` and served by the OpsServer.
|
The generated bundle is written into `dist_serve/` by the main build pipeline.
|
||||||
|
|
||||||
### Adding a New View
|
## When To Use This Package
|
||||||
|
|
||||||
1. Create a view component in `elements/`:
|
- Use it if you want the dashboard frontend as a package/module boundary.
|
||||||
```typescript
|
- Use the main `@serve.zone/dcrouter` package if you want the server that actually serves this UI.
|
||||||
import { DeesElement, customElement, html, css } from '@design.estate/dees-element';
|
|
||||||
|
|
||||||
@customElement('ops-view-myview')
|
|
||||||
export class OpsViewMyView extends DeesElement {
|
|
||||||
public static styles = [css`:host { display: block; padding: 24px; }`];
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
return html`<ops-sectionheading>My View</ops-sectionheading>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Add it to the dashboard tabs in `ops-dashboard.ts`
|
|
||||||
3. Add the route in `router.ts`
|
|
||||||
4. Add any state management in `appstate.ts`
|
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const flatViews = ['logs'] as const;
|
|||||||
const subviewMap: Record<string, readonly string[]> = {
|
const subviewMap: Record<string, readonly string[]> = {
|
||||||
overview: ['stats', 'configuration'] as const,
|
overview: ['stats', 'configuration'] as const,
|
||||||
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
|
||||||
email: ['log', 'security'] as const,
|
email: ['log', 'security', 'domains'] as const,
|
||||||
access: ['apitokens', 'users'] as const,
|
access: ['apitokens', 'users'] as const,
|
||||||
security: ['overview', 'blocked', 'authentication'] as const,
|
security: ['overview', 'blocked', 'authentication'] as const,
|
||||||
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
|
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user