Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 60d095cd78 | |||
| 2861511d20 | |||
| b582d44502 | |||
| 36a2ebc94e | |||
| ed52a3188d | |||
| 93cc5c7b06 | |||
| 5689e93665 | |||
| c224028495 | |||
| 4fbe01823b | |||
| 34ba2c9f02 | |||
| 52aed0e96e | |||
| ea2e618990 | |||
| 140637a307 | |||
| 21c80e173d | |||
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 |
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.
|
||||
215
changelog.md
215
changelog.md
@@ -1,5 +1,220 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
improve form field descriptions and align certificate settings with tile components
|
||||
|
||||
- Refines labels and adds descriptive helper text across API token, DNS, domain, route, edge, target profile, and VPN forms for clearer operator input
|
||||
- Updates the DNS provider form to surface provider and credential guidance through built-in input metadata instead of custom help blocks
|
||||
- Restyles the certificates ACME settings section to use tile-based layout and improves related form wording and file upload metadata
|
||||
- Refreshes the Cloudflare DNS provider description and bumps UI-related dependencies
|
||||
|
||||
## 2026-04-08 - 13.9.1 - fix(network-ui)
|
||||
enable flashing table updates for network activity, remote ingress, and VPN views
|
||||
|
||||
- adds stable row keys to dees-table instances so existing rows can be diffed correctly
|
||||
- enables flash highlighting for changed rows and cells across network activity, top IPs, backends, remote ingress edges, and VPN clients
|
||||
- updates network activity request data on every refresh so live metrics like duration and byte counts visibly refresh
|
||||
|
||||
## 2026-04-08 - 13.9.0 - feat(dns)
|
||||
add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local
|
||||
|
||||
- Expose a synthetic built-in "DcRouter" provider in provider listings and block create, edit, delete, test, and external domain listing operations for it
|
||||
- Rename domain and record source semantics from "manual" to "dcrouter" and "local" across backend, interfaces, and UI
|
||||
- Add database migrations to convert existing DomainDoc.source and DnsRecordDoc.source values to the new naming
|
||||
- Update domain creation flows and provider UI labels to reflect dcrouter-hosted authoritative domains
|
||||
|
||||
## 2026-04-08 - 13.8.0 - feat(acme)
|
||||
add DB-backed ACME configuration management and OpsServer certificate settings UI
|
||||
|
||||
- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options
|
||||
- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled
|
||||
- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration
|
||||
- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI
|
||||
|
||||
## 2026-04-08 - 13.7.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.7.0 - feat(dns-providers)
|
||||
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
|
||||
|
||||
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
|
||||
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
|
||||
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
|
||||
|
||||
## 2026-04-08 - 13.6.0 - feat(dns)
|
||||
add db-backed DNS provider, domain, and record management with ops UI support
|
||||
|
||||
- introduce DnsManager-backed persistence for DNS providers, domains, and records with Cloudflare provider support
|
||||
- replace constructor-based ACME DNS challenge configuration with provider records stored in the database
|
||||
- add opsserver typed request handlers and API token scopes for managing DNS providers, domains, and records
|
||||
- add a new Domains section in the ops UI for providers, domains, DNS records, and certificates
|
||||
|
||||
## 2026-04-08 - 13.5.0 - feat(opsserver-access)
|
||||
add admin user listing to the access dashboard
|
||||
|
||||
- register a new admin-only typed request endpoint to list users with id, username, and role while excluding passwords
|
||||
- add users state management and a dedicated access dashboard view for browsing OpsServer user accounts
|
||||
- update access routing to include the new users subview and improve related table filtering and section headings
|
||||
|
||||
## 2026-04-08 - 13.4.2 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.4.1 - fix(repo)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.4.0 - feat(web-ui)
|
||||
reorganize dashboard views into grouped navigation with new email, access, and network subviews
|
||||
|
||||
- Restructures the ops dashboard and router to use grouped top-level sections with subviews for overview, network, email, access, and security.
|
||||
- Adds dedicated Email Security and API Tokens views and exposes Remote Ingress and VPN under Network subnavigation.
|
||||
- Updates refresh and initial view handling to work with nested subviews, including remote ingress and VPN refresh behavior.
|
||||
- Moves overview, configuration, email, API token, and remote ingress components into feature directories and standardizes shared view styling.
|
||||
|
||||
## 2026-04-08 - 13.3.0 - feat(web-ui)
|
||||
reorganize network and security views into tabbed subviews with route-aware navigation
|
||||
|
||||
- add URL-based subview support in app state and router for network and security sections
|
||||
- group routes, source profiles, network targets, and target profiles under the network view with tab navigation
|
||||
- split security into dedicated overview, blocked IPs, authentication, and email security subviews
|
||||
- update configuration navigation to deep-link directly to the network routes subview
|
||||
|
||||
## 2026-04-08 - 13.2.2 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.2.1 - fix(project)
|
||||
no changes to commit
|
||||
|
||||
|
||||
## 2026-04-08 - 13.2.0 - feat(ops-ui)
|
||||
add column filters to operations tables across admin views
|
||||
|
||||
- Enable table column filters for API tokens, certificates, network requests, top IPs, backends, network targets, remote ingress edges, security views, source profiles, target profiles, and VPN clients.
|
||||
- Improves filtering and exploration of operational data throughout the admin interface without changing backend behavior.
|
||||
|
||||
## 2026-04-08 - 13.1.3 - fix(certificate-handler)
|
||||
preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains
|
||||
|
||||
|
||||
18
package.json
18
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.1.3",
|
||||
"version": "13.17.8",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --logfile --timeout 60)",
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"start": "(node ./cli.js)",
|
||||
"startTs": "(node cli.ts.js)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tswatch": "^3.3.2",
|
||||
"@types/node": "^25.5.2"
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.3.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.67.1",
|
||||
"@design.estate/dees-catalog": "^3.78.2",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
@@ -49,12 +49,12 @@
|
||||
"@push.rocks/smartjwt": "^2.2.1",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartmetrics": "^3.0.3",
|
||||
"@push.rocks/smartmigration": "1.1.1",
|
||||
"@push.rocks/smartmigration": "1.2.0",
|
||||
"@push.rocks/smartmta": "^5.3.1",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartnetwork": "^4.6.0",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.3",
|
||||
"@push.rocks/smartproxy": "^27.5.0",
|
||||
"@push.rocks/smartproxy": "^27.7.3",
|
||||
"@push.rocks/smartradius": "^1.1.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -62,12 +62,12 @@
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/smartvpn": "1.19.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/remoteingress": "^4.15.3",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.3.2",
|
||||
"lru-cache": "^11.3.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
|
||||
163
pnpm-lock.yaml
generated
163
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.67.1
|
||||
version: 3.67.1(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.78.2
|
||||
version: 3.78.2(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -66,14 +66,14 @@ importers:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@push.rocks/smartmigration':
|
||||
specifier: 1.1.1
|
||||
version: 1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
|
||||
'@push.rocks/smartmta':
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1
|
||||
'@push.rocks/smartnetwork':
|
||||
specifier: ^4.5.2
|
||||
version: 4.5.2
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -81,8 +81,8 @@ importers:
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
'@push.rocks/smartproxy':
|
||||
specifier: ^27.5.0
|
||||
version: 27.5.0
|
||||
specifier: ^27.7.3
|
||||
version: 27.7.3
|
||||
'@push.rocks/smartradius':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
@@ -105,8 +105,8 @@ importers:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.2
|
||||
'@serve.zone/catalog':
|
||||
specifier: ^2.12.3
|
||||
version: 2.12.3(@tiptap/pm@2.27.2)
|
||||
specifier: ^2.12.4
|
||||
version: 2.12.4(@tiptap/pm@2.27.2)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -120,8 +120,8 @@ importers:
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
lru-cache:
|
||||
specifier: ^11.3.2
|
||||
version: 11.3.2
|
||||
specifier: ^11.3.5
|
||||
version: 11.3.5
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -145,8 +145,8 @@ importers:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@tiptap/pm@2.27.2)
|
||||
'@types/node':
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2
|
||||
specifier: ^25.6.0
|
||||
version: 25.6.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -353,8 +353,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.67.1':
|
||||
resolution: {integrity: sha512-8zaVNP70IbcB6pEmLoBxVA5WD0N5gQr12ylTdILtvds6rftKLCI1i2jx4RBztIy4FpZv0wIewJBtRvSUjK8Ysw==}
|
||||
'@design.estate/dees-catalog@3.78.2':
|
||||
resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -365,8 +365,8 @@ packages:
|
||||
'@design.estate/dees-element@2.2.4':
|
||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.0':
|
||||
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
@@ -1231,8 +1231,8 @@ packages:
|
||||
'@push.rocks/smartmetrics@3.0.3':
|
||||
resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==}
|
||||
|
||||
'@push.rocks/smartmigration@1.1.1':
|
||||
resolution: {integrity: sha512-K/eLN9cNy+CLOT73rhI93vOy/vGwpV46iJpjRUyPwHsMcQcV6po2idk5+XZQzeuq2x7KpKuUPtZ6gXMtf5Y/ig==}
|
||||
'@push.rocks/smartmigration@1.2.0':
|
||||
resolution: {integrity: sha512-H2diE1UbZm4cXjxgxkt2YQW3aUQ3QVVU/e8Ws30hzIep0xIqL1BH6//WawA5ZBQhnAOBssZpVOuWOd3GIeBq+Q==}
|
||||
peerDependencies:
|
||||
'@push.rocks/smartbucket': ^4.6.0
|
||||
'@push.rocks/smartdata': ^7.1.7
|
||||
@@ -1257,8 +1257,8 @@ packages:
|
||||
'@push.rocks/smartmustache@3.0.2':
|
||||
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
|
||||
|
||||
'@push.rocks/smartnetwork@4.5.2':
|
||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
||||
'@push.rocks/smartnetwork@4.6.0':
|
||||
resolution: {integrity: sha512-ubaO/Qp8r30A+qwk33M/0+nQi+o8gNHEI9zq3jv1MwqiLxhiV1hnbr4CL9AUcvs4EhwUBiw0EswKjCJROwDqvQ==}
|
||||
|
||||
'@push.rocks/smartnftables@1.1.0':
|
||||
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||
@@ -1284,8 +1284,8 @@ packages:
|
||||
'@push.rocks/smartpromise@4.2.3':
|
||||
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
|
||||
|
||||
'@push.rocks/smartproxy@27.5.0':
|
||||
resolution: {integrity: sha512-QIXrVQtAoqBCv+9ScLOdGcizN55svJuGCfMDsDaBVtwS3Tva30IxuEL3usNTHABveuI8slaWzSxTabmTULDOwA==}
|
||||
'@push.rocks/smartproxy@27.7.3':
|
||||
resolution: {integrity: sha512-1NER+2nM2GWzrMB1xoCzkV9in8a1gWNwOimgE/f2kPYnhy5DsA2XX/KiLGJ0ge3cpCVgoK1nNe3byXQHWvmPbA==}
|
||||
|
||||
'@push.rocks/smartpuppeteer@2.0.5':
|
||||
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
|
||||
@@ -1588,8 +1588,8 @@ packages:
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@serve.zone/catalog@2.12.3':
|
||||
resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
|
||||
'@serve.zone/catalog@2.12.4':
|
||||
resolution: {integrity: sha512-GRfJZ0yQxChUy7Gp4mxhuN5y4GXZMOEk0W7rJiyZbezA938q+pFTplb9ahSaEHjiUht1MmTu/5WtoJFwgAP8SQ==}
|
||||
|
||||
'@serve.zone/interfaces@5.3.0':
|
||||
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
|
||||
@@ -1994,6 +1994,12 @@ packages:
|
||||
'@types/debug@4.1.13':
|
||||
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':
|
||||
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
||||
|
||||
@@ -2054,8 +2060,8 @@ packages:
|
||||
'@types/node@22.19.17':
|
||||
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
|
||||
|
||||
'@types/node@25.5.2':
|
||||
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==}
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
@@ -2858,8 +2864,8 @@ packages:
|
||||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=}
|
||||
|
||||
ibantools@4.5.2:
|
||||
resolution: {integrity: sha512-is+8TgZcKS/AMv/z9nW1zz0bhjhoyjpA1p0nc3A6GkW/InOdcQiUZpkufADzh/aO/LY/TOD/P3oPWncNRn5QMA==}
|
||||
ibantools@4.5.4:
|
||||
resolution: {integrity: sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
@@ -3076,16 +3082,16 @@ packages:
|
||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lru-cache@11.3.2:
|
||||
resolution: {integrity: sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==}
|
||||
lru-cache@11.3.5:
|
||||
resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@7.18.3:
|
||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lucide@0.577.0:
|
||||
resolution: {integrity: sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==}
|
||||
lucide@1.8.0:
|
||||
resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
|
||||
|
||||
mailparser@3.9.6:
|
||||
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
|
||||
@@ -3163,6 +3169,9 @@ packages:
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
mediabunny@1.40.1:
|
||||
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -4098,8 +4107,8 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
@@ -4315,7 +4324,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||
'@cloudflare/workers-types': 4.20260405.1
|
||||
'@design.estate/dees-catalog': 3.67.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
|
||||
'@push.rocks/lik': 6.4.0
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -4844,11 +4853,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.67.1(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||
'@fortawesome/free-brands-svg-icons': 7.2.0
|
||||
'@fortawesome/free-regular-svg-icons': 7.2.0
|
||||
@@ -4866,9 +4875,9 @@ snapshots:
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
echarts: 5.6.0
|
||||
highlight.js: 11.11.1
|
||||
ibantools: 4.5.2
|
||||
ibantools: 4.5.4
|
||||
lightweight-charts: 5.1.0
|
||||
lucide: 0.577.0
|
||||
lucide: 1.8.0
|
||||
monaco-editor: 0.55.1
|
||||
pdfjs-dist: 4.10.38
|
||||
xterm: 5.3.0
|
||||
@@ -4925,12 +4934,13 @@ snapshots:
|
||||
- supports-color
|
||||
- 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
|
||||
mediabunny: 1.40.1
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
@@ -5144,7 +5154,7 @@ snapshots:
|
||||
'@push.rocks/smartjson': 6.0.0
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@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/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrequest': 5.0.1
|
||||
@@ -5947,7 +5957,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@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/smarttime': 4.2.3
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
@@ -6354,7 +6364,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
|
||||
'@push.rocks/smartmigration@1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))':
|
||||
'@push.rocks/smartmigration@1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))':
|
||||
dependencies:
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartversion': 3.1.0
|
||||
@@ -6404,7 +6414,7 @@ snapshots:
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
'@tsclass/tsclass': 9.5.0
|
||||
lru-cache: 11.3.2
|
||||
lru-cache: 11.3.5
|
||||
mailparser: 3.9.6
|
||||
uuid: 13.0.0
|
||||
transitivePeerDependencies:
|
||||
@@ -6414,7 +6424,7 @@ snapshots:
|
||||
dependencies:
|
||||
handlebars: 4.7.9
|
||||
|
||||
'@push.rocks/smartnetwork@4.5.2':
|
||||
'@push.rocks/smartnetwork@4.6.0':
|
||||
dependencies:
|
||||
'@push.rocks/smartdns': 7.9.0
|
||||
'@push.rocks/smartrust': 1.3.2
|
||||
@@ -6475,7 +6485,7 @@ snapshots:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartfs': 1.5.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/smartpromise': 4.2.3
|
||||
'@push.rocks/smartpuppeteer': 2.0.5(typescript@6.0.2)
|
||||
@@ -6496,7 +6506,7 @@ snapshots:
|
||||
|
||||
'@push.rocks/smartpromise@4.2.3': {}
|
||||
|
||||
'@push.rocks/smartproxy@27.5.0':
|
||||
'@push.rocks/smartproxy@27.7.3':
|
||||
dependencies:
|
||||
'@push.rocks/smartcrypto': 2.0.4
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
@@ -6898,12 +6908,12 @@ snapshots:
|
||||
domhandler: 5.0.3
|
||||
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:
|
||||
'@design.estate/dees-catalog': 3.67.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-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@tiptap/pm'
|
||||
@@ -7442,17 +7452,23 @@ snapshots:
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@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':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -7472,12 +7488,12 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7498,16 +7514,16 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/node@16.9.1': {}
|
||||
|
||||
@@ -7519,13 +7535,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.5.2':
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/randomatic@3.1.5': {}
|
||||
|
||||
@@ -7535,11 +7551,11 @@ snapshots:
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7569,11 +7585,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.5.2
|
||||
'@types/node': 25.6.0
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@@ -8390,7 +8406,7 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
ibantools@4.5.2: {}
|
||||
ibantools@4.5.4: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
@@ -8628,11 +8644,11 @@ snapshots:
|
||||
|
||||
lowercase-keys@3.0.0: {}
|
||||
|
||||
lru-cache@11.3.2: {}
|
||||
lru-cache@11.3.5: {}
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lucide@0.577.0: {}
|
||||
lucide@1.8.0: {}
|
||||
|
||||
mailparser@3.9.6:
|
||||
dependencies:
|
||||
@@ -8804,6 +8820,11 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
@@ -9268,7 +9289,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.3.2
|
||||
lru-cache: 11.3.5
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@8.4.2: {}
|
||||
@@ -9942,7 +9963,7 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
|
||||
@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
|
||||
match: { ports: 443, domains: 'example.com' },
|
||||
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
|
||||
},
|
||||
source: 'programmatic',
|
||||
id: 'route-123',
|
||||
enabled: true,
|
||||
overridden: false,
|
||||
storedRouteId: 'route-123',
|
||||
origin: 'api',
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
});
|
||||
|
||||
expect(route.name).toEqual('test-route');
|
||||
expect(route.source).toEqual('programmatic');
|
||||
expect(route.id).toEqual('route-123');
|
||||
expect(route.enabled).toEqual(true);
|
||||
expect(route.overridden).toEqual(false);
|
||||
expect(route.storedRouteId).toEqual('route-123');
|
||||
expect(route.origin).toEqual('api');
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
230
test/test.dns-runtime-routes.node.ts
Normal file
230
test/test.dns-runtime-routes.node.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
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 applies runtime DoH routes without persisting 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 appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(
|
||||
() => smartProxy as any,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => (dcRouter as any).generateDnsRoutes(),
|
||||
);
|
||||
|
||||
await routeManager.initialize([], [], []);
|
||||
await routeManager.applyRoutes();
|
||||
|
||||
const persistedRoutes = await RouteDoc.findAll();
|
||||
expect(persistedRoutes.length).toEqual(0);
|
||||
expect(appliedRoutes.length).toEqual(2);
|
||||
|
||||
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 removes stale persisted DoH socket-handler routes on startup', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
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 staleResolveRoute = new RouteDoc();
|
||||
staleResolveRoute.id = 'stale-doh-resolve';
|
||||
staleResolveRoute.route = {
|
||||
name: 'dns-over-https-resolve',
|
||||
match: {
|
||||
ports: [443],
|
||||
domains: ['ns1.example.com'],
|
||||
path: '/resolve',
|
||||
},
|
||||
action: {
|
||||
type: 'socket-handler' as any,
|
||||
} as any,
|
||||
};
|
||||
staleResolveRoute.enabled = true;
|
||||
staleResolveRoute.createdAt = Date.now();
|
||||
staleResolveRoute.updatedAt = Date.now();
|
||||
staleResolveRoute.createdBy = 'test';
|
||||
staleResolveRoute.origin = 'dns';
|
||||
await staleResolveRoute.save();
|
||||
|
||||
const validRoute = new RouteDoc();
|
||||
validRoute.id = 'valid-forward-route';
|
||||
validRoute.route = {
|
||||
name: 'valid-forward-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;
|
||||
validRoute.enabled = true;
|
||||
validRoute.createdAt = Date.now();
|
||||
validRoute.updatedAt = Date.now();
|
||||
validRoute.createdBy = 'test';
|
||||
validRoute.origin = 'api';
|
||||
await validRoute.save();
|
||||
|
||||
const appliedRoutes: any[][] = [];
|
||||
const smartProxy = {
|
||||
updateRoutes: async (routes: any[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
};
|
||||
|
||||
const routeManager = new RouteConfigManager(() => smartProxy as any);
|
||||
await routeManager.initialize([], [], []);
|
||||
|
||||
expect((await RouteDoc.findByName('dns-over-https-dns-query'))).toEqual(null);
|
||||
expect((await RouteDoc.findByName('dns-over-https-resolve'))).toEqual(null);
|
||||
|
||||
const remainingRoutes = await RouteDoc.findAll();
|
||||
expect(remainingRoutes.length).toEqual(1);
|
||||
expect(remainingRoutes[0].route.name).toEqual('valid-forward-route');
|
||||
|
||||
expect(appliedRoutes.length).toEqual(1);
|
||||
expect(appliedRoutes[0].length).toEqual(1);
|
||||
expect(appliedRoutes[0][0].name).toEqual('valid-forward-route');
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.1.3',
|
||||
version: '13.17.8',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
1
ts/acme/index.ts
Normal file
1
ts/acme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './manager.acme-config.js';
|
||||
182
ts/acme/manager.acme-config.ts
Normal file
182
ts/acme/manager.acme-config.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { logger } from '../logger.js';
|
||||
import { AcmeConfigDoc } from '../db/documents/index.js';
|
||||
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
|
||||
|
||||
/**
|
||||
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
|
||||
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
|
||||
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
|
||||
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
|
||||
*
|
||||
* Reload semantics: updates take effect on the next dcrouter restart because
|
||||
* `SmartAcme` is instantiated once in `setupSmartProxy()`. `renewThresholdDays`
|
||||
* applies immediately to the next renewal check. See
|
||||
* `ts_web/elements/domains/ops-view-certificates.ts` for the UI warning.
|
||||
*/
|
||||
export class AcmeConfigManager {
|
||||
private cached: IAcmeConfig | null = null;
|
||||
|
||||
constructor(private options: IDcRouterOptions) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
logger.log('info', 'AcmeConfigManager: starting');
|
||||
let doc = await AcmeConfigDoc.load();
|
||||
|
||||
if (!doc) {
|
||||
// First-boot path: seed from legacy constructor fields if present.
|
||||
const seed = this.deriveSeedFromOptions();
|
||||
if (seed) {
|
||||
doc = await this.createSeedDoc(seed);
|
||||
logger.log(
|
||||
'info',
|
||||
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
'info',
|
||||
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
|
||||
);
|
||||
}
|
||||
} else if (this.deriveSeedFromOptions()) {
|
||||
logger.log(
|
||||
'warn',
|
||||
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
|
||||
);
|
||||
}
|
||||
|
||||
this.cached = doc ? this.toPlain(doc) : null;
|
||||
if (this.cached) {
|
||||
logger.log(
|
||||
'info',
|
||||
`AcmeConfigManager: loaded ACME config (accountEmail=${this.cached.accountEmail}, enabled=${this.cached.enabled}, useProduction=${this.cached.useProduction})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current ACME config, or null if not configured.
|
||||
* In-memory — does not hit the DB.
|
||||
*/
|
||||
public getConfig(): IAcmeConfig | null {
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if there is an enabled ACME config. Used by `setupSmartProxy()` to
|
||||
* decide whether to instantiate SmartAcme.
|
||||
*/
|
||||
public hasEnabledConfig(): boolean {
|
||||
return this.cached !== null && this.cached.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert the ACME config. All fields are optional; missing fields are
|
||||
* preserved from the existing row (or defaulted if there is no row yet).
|
||||
*/
|
||||
public async updateConfig(
|
||||
args: Partial<Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>>,
|
||||
updatedBy: string,
|
||||
): Promise<IAcmeConfig> {
|
||||
let doc = await AcmeConfigDoc.load();
|
||||
const now = Date.now();
|
||||
|
||||
if (!doc) {
|
||||
doc = new AcmeConfigDoc();
|
||||
doc.configId = 'acme-config';
|
||||
doc.accountEmail = args.accountEmail ?? '';
|
||||
doc.enabled = args.enabled ?? true;
|
||||
doc.useProduction = args.useProduction ?? true;
|
||||
doc.autoRenew = args.autoRenew ?? true;
|
||||
doc.renewThresholdDays = args.renewThresholdDays ?? 30;
|
||||
} else {
|
||||
if (args.accountEmail !== undefined) doc.accountEmail = args.accountEmail;
|
||||
if (args.enabled !== undefined) doc.enabled = args.enabled;
|
||||
if (args.useProduction !== undefined) doc.useProduction = args.useProduction;
|
||||
if (args.autoRenew !== undefined) doc.autoRenew = args.autoRenew;
|
||||
if (args.renewThresholdDays !== undefined) doc.renewThresholdDays = args.renewThresholdDays;
|
||||
}
|
||||
|
||||
doc.updatedAt = now;
|
||||
doc.updatedBy = updatedBy;
|
||||
await doc.save();
|
||||
|
||||
this.cached = this.toPlain(doc);
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Internal helpers
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Build a seed object from the legacy constructor fields. Returns null
|
||||
* if the user has not provided any of them.
|
||||
*
|
||||
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
|
||||
* (full form). `smartProxyConfig.acme` wins when both are present.
|
||||
*/
|
||||
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
|
||||
const acme = this.options.smartProxyConfig?.acme;
|
||||
const tls = this.options.tls;
|
||||
|
||||
// Prefer the explicit smartProxyConfig.acme block if present.
|
||||
if (acme?.accountEmail) {
|
||||
return {
|
||||
accountEmail: acme.accountEmail,
|
||||
enabled: acme.enabled !== false,
|
||||
useProduction: acme.useProduction !== false,
|
||||
autoRenew: acme.autoRenew !== false,
|
||||
renewThresholdDays: acme.renewThresholdDays ?? 30,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to the short tls.contactEmail form.
|
||||
if (tls?.contactEmail) {
|
||||
return {
|
||||
accountEmail: tls.contactEmail,
|
||||
enabled: true,
|
||||
useProduction: true,
|
||||
autoRenew: true,
|
||||
renewThresholdDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async createSeedDoc(
|
||||
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
|
||||
): Promise<AcmeConfigDoc> {
|
||||
const doc = new AcmeConfigDoc();
|
||||
doc.configId = 'acme-config';
|
||||
doc.accountEmail = seed.accountEmail;
|
||||
doc.enabled = seed.enabled;
|
||||
doc.useProduction = seed.useProduction;
|
||||
doc.autoRenew = seed.autoRenew;
|
||||
doc.renewThresholdDays = seed.renewThresholdDays;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'seed';
|
||||
await doc.save();
|
||||
return doc;
|
||||
}
|
||||
|
||||
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
|
||||
return {
|
||||
accountEmail: doc.accountEmail,
|
||||
enabled: doc.enabled,
|
||||
useProduction: doc.useProduction,
|
||||
autoRenew: doc.autoRenew,
|
||||
renewThresholdDays: doc.renewThresholdDays,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
||||
@@ -116,13 +119,6 @@ export interface IDcRouterOptions {
|
||||
useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true)
|
||||
}>;
|
||||
|
||||
/** DNS challenge configuration for ACME (optional) */
|
||||
dnsChallenge?: {
|
||||
/** Cloudflare API key for DNS challenges */
|
||||
cloudflareApiKey?: string;
|
||||
/** Other DNS providers can be added here */
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified database configuration.
|
||||
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
|
||||
@@ -279,6 +275,13 @@ export class DcRouter {
|
||||
public referenceResolver?: ReferenceResolver;
|
||||
public targetProfileManager?: TargetProfileManager;
|
||||
|
||||
// Domain / DNS management (DB-backed providers, domains, records)
|
||||
public dnsManager?: DnsManager;
|
||||
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
|
||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||
public detectedPublicIp: string | null = null;
|
||||
|
||||
@@ -309,8 +312,11 @@ export class DcRouter {
|
||||
// TypedRouter for API endpoints
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager)
|
||||
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Runtime-only DoH routes. These carry live socket handlers and must never be persisted.
|
||||
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Environment access
|
||||
private qenv = new plugins.qenv.Qenv('./', '.nogit/');
|
||||
@@ -393,10 +399,72 @@ export class DcRouter {
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }),
|
||||
);
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb (if enabled)
|
||||
// DnsManager: optional, depends on DcRouterDb — owns DB-backed DNS state
|
||||
// (providers, domains, records). Must run before SmartProxy so ACME DNS-01
|
||||
// wiring can look up providers.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.dnsManager = new DnsManager(this.options);
|
||||
await this.dnsManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.dnsManager) {
|
||||
await this.dnsManager.stop();
|
||||
this.dnsManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// AcmeConfigManager: optional, depends on DcRouterDb — owns the singleton
|
||||
// ACME configuration (accountEmail, useProduction, etc.). Must run before
|
||||
// SmartProxy so setupSmartProxy() can read the ACME config from the DB.
|
||||
// On first boot, seeds from legacy `tls.contactEmail` / `smartProxyConfig.acme`.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('AcmeConfigManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.acmeConfigManager = new AcmeConfigManager(this.options);
|
||||
await this.acmeConfigManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.acmeConfigManager) {
|
||||
await this.acmeConfigManager.stop();
|
||||
this.acmeConfigManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
})
|
||||
.withStop(async () => {
|
||||
this.emailDomainManager = undefined;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -415,9 +483,11 @@ export class DcRouter {
|
||||
.withRetry({ maxRetries: 0 }),
|
||||
);
|
||||
|
||||
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits
|
||||
// Only registered if DNS challenge is configured
|
||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
||||
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits.
|
||||
// Always registered when the DB is enabled; setupSmartProxy() decides whether
|
||||
// to actually instantiate SmartAcme based on whether any DnsProviderDoc exists.
|
||||
// If `this.smartAcme` is unset by the time this service starts, withStart is a no-op.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartAcme')
|
||||
.optional()
|
||||
@@ -478,11 +548,12 @@ export class DcRouter {
|
||||
await this.referenceResolver.initialize();
|
||||
|
||||
// Initialize target profile manager
|
||||
this.targetProfileManager = new TargetProfileManager();
|
||||
this.targetProfileManager = new TargetProfileManager(
|
||||
() => this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
await this.targetProfileManager.initialize();
|
||||
|
||||
this.routeConfigManager = new RouteConfigManager(
|
||||
() => this.getConstructorRoutes(),
|
||||
() => this.smartProxy,
|
||||
() => this.options.http3,
|
||||
this.options.vpnConfig?.enabled
|
||||
@@ -492,12 +563,15 @@ export class DcRouter {
|
||||
return [];
|
||||
}
|
||||
return this.targetProfileManager.getMatchingClientIps(
|
||||
route, routeId, this.vpnManager.listClients(),
|
||||
route,
|
||||
routeId,
|
||||
this.vpnManager.listClients(),
|
||||
this.routeConfigManager?.getRoutes() || new Map(),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
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
|
||||
(routes) => {
|
||||
if (this.remoteIngressManager) {
|
||||
@@ -507,10 +581,15 @@ export class DcRouter {
|
||||
this.tunnelManager.syncAllowedEdges();
|
||||
}
|
||||
},
|
||||
() => this.runtimeDnsRoutes,
|
||||
);
|
||||
this.apiTokenManager = new ApiTokenManager();
|
||||
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[],
|
||||
);
|
||||
await this.targetProfileManager.normalizeAllRouteRefs();
|
||||
|
||||
// Seed default profiles/targets if DB is empty and seeding is enabled
|
||||
const seeder = new DbSeeder(this.referenceResolver);
|
||||
@@ -814,47 +893,66 @@ export class DcRouter {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
|
||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
|
||||
|
||||
// If user provides full SmartProxy config, use it directly
|
||||
if (this.options.smartProxyConfig) {
|
||||
routes = this.options.smartProxyConfig.routes || [];
|
||||
acmeConfig = this.options.smartProxyConfig.acme;
|
||||
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
||||
}
|
||||
|
||||
// If email config exists, automatically add email routes
|
||||
// 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`);
|
||||
|
||||
this.seedEmailRoutes = [];
|
||||
if (this.options.emailConfig) {
|
||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||
this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
|
||||
}
|
||||
|
||||
// If DNS is configured, add DNS routes
|
||||
|
||||
this.runtimeDnsRoutes = [];
|
||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||
const dnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||
routes = [...routes, ...dnsRoutes];
|
||||
this.runtimeDnsRoutes = this.generateDnsRoutes();
|
||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.runtimeDnsRoutes) });
|
||||
}
|
||||
|
||||
// Merge TLS/ACME configuration if provided at root level
|
||||
if (this.options.tls && !acmeConfig) {
|
||||
acmeConfig = {
|
||||
accountEmail: this.options.tls.contactEmail,
|
||||
enabled: true,
|
||||
useProduction: true,
|
||||
autoRenew: true,
|
||||
renewThresholdDays: 30
|
||||
};
|
||||
|
||||
// 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.
|
||||
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
||||
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
||||
const dbAcme = this.acmeConfigManager?.getConfig();
|
||||
const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
|
||||
dbAcme && dbAcme.enabled
|
||||
? {
|
||||
accountEmail: dbAcme.accountEmail,
|
||||
enabled: true,
|
||||
useProduction: dbAcme.useProduction,
|
||||
autoRenew: dbAcme.autoRenew,
|
||||
renewThresholdDays: dbAcme.renewThresholdDays,
|
||||
}
|
||||
: undefined;
|
||||
if (acmeConfig) {
|
||||
logger.log(
|
||||
'info',
|
||||
`ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`,
|
||||
);
|
||||
} else {
|
||||
logger.log('info', 'ACME config: disabled or not yet configured in DB');
|
||||
}
|
||||
|
||||
// Configure DNS challenge if available
|
||||
|
||||
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
||||
// ACME is enabled. The DnsManager dispatches each challenge through the
|
||||
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
|
||||
// zones and provider-managed zones. Only domains under management get certs.
|
||||
let challengeHandlers: any[] = [];
|
||||
if (this.options.dnsChallenge?.cloudflareApiKey) {
|
||||
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME');
|
||||
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey);
|
||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount);
|
||||
if (
|
||||
acmeConfig &&
|
||||
this.dnsManager &&
|
||||
(await this.dnsManager.hasAnyManagedDomain())
|
||||
) {
|
||||
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (managed domains)');
|
||||
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
||||
challengeHandlers.push(dns01Handler);
|
||||
}
|
||||
|
||||
@@ -865,10 +963,6 @@ export class DcRouter {
|
||||
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 (routes.length > 0 || this.options.smartProxyConfig) {
|
||||
logger.log('info', 'Setting up SmartProxy with combined configuration');
|
||||
@@ -953,10 +1047,12 @@ export class DcRouter {
|
||||
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
|
||||
);
|
||||
}
|
||||
// Safe non-null: challengeHandlers.length > 0 implies both dnsManager
|
||||
// and acmeConfig exist (enforced above).
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
||||
accountEmail: dbAcme!.accountEmail,
|
||||
certManager: new StorageBackedCertManager(),
|
||||
environment: 'production',
|
||||
environment: dbAcme!.useProduction ? 'production' : 'integration',
|
||||
challengeHandlers: challengeHandlers,
|
||||
challengePriority: ['dns-01'],
|
||||
});
|
||||
@@ -1317,14 +1413,6 @@ export class DcRouter {
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the routes derived from constructor config (smartProxy + email + DNS).
|
||||
* Used by RouteConfigManager as the "hardcoded" base.
|
||||
*/
|
||||
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
|
||||
return this.constructorRoutes;
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
logger.log('info', 'Stopping DcRouter services...');
|
||||
|
||||
@@ -1368,17 +1456,15 @@ export class DcRouter {
|
||||
// Update configuration
|
||||
this.options.smartProxyConfig = config;
|
||||
|
||||
// Update routes on RemoteIngressManager so derived ports stay in sync
|
||||
if (this.remoteIngressManager && config.routes) {
|
||||
this.remoteIngressManager.setRoutes(config.routes as any[]);
|
||||
}
|
||||
|
||||
// Start new SmartProxy with updated configuration (will include email routes if configured)
|
||||
// Start new SmartProxy with updated configuration (rebuilds seed routes)
|
||||
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) {
|
||||
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');
|
||||
@@ -1720,8 +1806,14 @@ export class DcRouter {
|
||||
this.registerDnsRecords(allRecords);
|
||||
logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
|
||||
}
|
||||
|
||||
// Hand the DnsServer to DnsManager so DB-backed local records on
|
||||
// dcrouter-hosted domains get registered too.
|
||||
if (this.dnsManager && this.dnsServer) {
|
||||
await this.dnsManager.attachDnsServer(this.dnsServer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create DNS socket handler for DoH
|
||||
*/
|
||||
@@ -2090,13 +2182,14 @@ export class DcRouter {
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
|
||||
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes
|
||||
const currentRoutes = this.constructorRoutes;
|
||||
this.remoteIngressManager.setRoutes(currentRoutes as any[]);
|
||||
// Pass current bootstrap routes so the manager can derive edge ports initially.
|
||||
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
|
||||
// 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
|
||||
// so the callback delivers the full merged set (including DB-stored routes)
|
||||
// to our newly-created remoteIngressManager.
|
||||
// If ConfigManagers finished before us, re-apply routes
|
||||
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.applyRoutes();
|
||||
}
|
||||
@@ -2183,11 +2276,10 @@ export class DcRouter {
|
||||
|
||||
if (!this.targetProfileManager) return [...ips];
|
||||
|
||||
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
|
||||
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
|
||||
const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
|
||||
|
||||
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||
targetProfileIds, routes, storedRoutes,
|
||||
targetProfileIds, allRoutes,
|
||||
);
|
||||
|
||||
// Add target IPs directly
|
||||
@@ -2197,8 +2289,11 @@ export class DcRouter {
|
||||
|
||||
// Resolve DNS A records for matched domains (with caching)
|
||||
for (const domain of domains) {
|
||||
const stripped = domain.replace(/^\*\./, '');
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
|
||||
if (this.isWildcardVpnDomain(domain)) {
|
||||
this.logSkippedWildcardAllowedIp(domain);
|
||||
continue;
|
||||
}
|
||||
const resolvedIps = await this.resolveVpnDomainIPs(domain);
|
||||
for (const ip of resolvedIps) {
|
||||
ips.add(`${ip}/32`);
|
||||
}
|
||||
@@ -2210,14 +2305,15 @@ export class DcRouter {
|
||||
|
||||
await this.vpnManager.start();
|
||||
|
||||
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes
|
||||
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since
|
||||
// VPN server wasn't ready yet)
|
||||
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
|
||||
// get correct profile-based ipAllowLists
|
||||
await this.routeConfigManager?.applyRoutes();
|
||||
}
|
||||
|
||||
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
|
||||
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.
|
||||
@@ -2243,6 +2339,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()
|
||||
// via the getVpnAllowList callback — no longer a separate method here.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
|
||||
import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
ISourceProfile,
|
||||
INetworkTarget,
|
||||
IRouteMetadata,
|
||||
IStoredRoute,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
@@ -81,7 +81,7 @@ export class ReferenceResolver {
|
||||
public async deleteProfile(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const profile = this.profiles.get(id);
|
||||
if (!profile) {
|
||||
@@ -131,7 +131,7 @@ export class ReferenceResolver {
|
||||
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 }>>();
|
||||
for (const profile of this.profiles.values()) {
|
||||
usage.set(profile.id, []);
|
||||
@@ -147,7 +147,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getProfileUsageForId(
|
||||
profileId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -214,7 +214,7 @@ export class ReferenceResolver {
|
||||
public async deleteTarget(
|
||||
id: string,
|
||||
force: boolean,
|
||||
storedRoutes?: Map<string, IStoredRoute>,
|
||||
storedRoutes?: Map<string, IRoute>,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const target = this.targets.get(id);
|
||||
if (!target) {
|
||||
@@ -263,7 +263,7 @@ export class ReferenceResolver {
|
||||
|
||||
public getTargetUsageForId(
|
||||
targetId: string,
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
storedRoutes: Map<string, IRoute>,
|
||||
): Array<{ id: string; routeName: string }> {
|
||||
const routes: Array<{ id: string; routeName: string }> = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
@@ -334,20 +334,20 @@ export class ReferenceResolver {
|
||||
// =========================================================================
|
||||
|
||||
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
|
||||
.map((doc) => doc.id);
|
||||
}
|
||||
|
||||
public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
const docs = await RouteDoc.findAll();
|
||||
return docs
|
||||
.filter((doc) => doc.metadata?.networkTargetRef === targetId)
|
||||
.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[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.sourceProfileRef === profileId) {
|
||||
@@ -357,7 +357,7 @@ export class ReferenceResolver {
|
||||
return ids;
|
||||
}
|
||||
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
|
||||
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [routeId, stored] of storedRoutes) {
|
||||
if (stored.metadata?.networkTargetRef === targetId) {
|
||||
@@ -547,7 +547,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
@@ -562,7 +562,7 @@ export class ReferenceResolver {
|
||||
|
||||
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
|
||||
for (const routeId of routeIds) {
|
||||
const doc = await StoredRouteDoc.findById(routeId);
|
||||
const doc = await RouteDoc.findById(routeId);
|
||||
if (doc?.metadata) {
|
||||
doc.metadata = {
|
||||
...doc.metadata,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js';
|
||||
import { RouteDoc } from '../db/index.js';
|
||||
import type {
|
||||
IStoredRoute,
|
||||
IRouteOverride,
|
||||
IRoute,
|
||||
IMergedRoute,
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
@@ -46,66 +45,58 @@ class RouteUpdateMutex {
|
||||
}
|
||||
|
||||
export class RouteConfigManager {
|
||||
private storedRoutes = new Map<string, IStoredRoute>();
|
||||
private overrides = new Map<string, IRouteOverride>();
|
||||
private routes = new Map<string, IRoute>();
|
||||
private warnings: IRouteWarning[] = [];
|
||||
private routeUpdateMutex = new RouteUpdateMutex();
|
||||
|
||||
constructor(
|
||||
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
|
||||
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
|
||||
private getHttp3Config?: () => IHttp3Config | undefined,
|
||||
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
|
||||
private referenceResolver?: ReferenceResolver,
|
||||
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
|
||||
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
|
||||
) {}
|
||||
|
||||
/** Expose stored routes map for reference resolution lookups. */
|
||||
public getStoredRoutes(): Map<string, IStoredRoute> {
|
||||
return this.storedRoutes;
|
||||
/** Expose routes map for reference resolution lookups. */
|
||||
public getRoutes(): Map<string, IRoute> {
|
||||
return this.routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
await this.loadStoredRoutes();
|
||||
await this.loadOverrides();
|
||||
public async initialize(
|
||||
configRoutes: IDcRouterRouteConfig[] = [],
|
||||
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.logWarnings();
|
||||
await this.applyRoutes();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Merged view
|
||||
// Route listing
|
||||
// =========================================================================
|
||||
|
||||
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
|
||||
const merged: IMergedRoute[] = [];
|
||||
|
||||
// Hardcoded routes
|
||||
for (const route of this.getHardcodedRoutes()) {
|
||||
const name = route.name || '';
|
||||
const override = this.overrides.get(name);
|
||||
for (const route of this.routes.values()) {
|
||||
merged.push({
|
||||
route,
|
||||
source: 'hardcoded',
|
||||
enabled: override ? override.enabled : true,
|
||||
overridden: !!override,
|
||||
});
|
||||
}
|
||||
|
||||
// Programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
merged.push({
|
||||
route: stored.route,
|
||||
source: 'programmatic',
|
||||
enabled: stored.enabled,
|
||||
overridden: false,
|
||||
storedRouteId: stored.id,
|
||||
createdAt: stored.createdAt,
|
||||
updatedAt: stored.updatedAt,
|
||||
metadata: stored.metadata,
|
||||
route: route.route,
|
||||
id: route.id,
|
||||
enabled: route.enabled,
|
||||
origin: route.origin,
|
||||
createdAt: route.createdAt,
|
||||
updatedAt: route.updatedAt,
|
||||
metadata: route.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +104,7 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Programmatic route CRUD
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
|
||||
public async createRoute(
|
||||
@@ -127,7 +118,7 @@ export class RouteConfigManager {
|
||||
|
||||
// Ensure route has a 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
|
||||
@@ -138,17 +129,18 @@ export class RouteConfigManager {
|
||||
resolvedMetadata = resolved.metadata;
|
||||
}
|
||||
|
||||
const stored: IStoredRoute = {
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
route,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy,
|
||||
origin: 'api',
|
||||
metadata: resolvedMetadata,
|
||||
};
|
||||
|
||||
this.storedRoutes.set(id, stored);
|
||||
this.routes.set(id, stored);
|
||||
await this.persistRoute(stored);
|
||||
await this.applyRoutes();
|
||||
return id;
|
||||
@@ -162,7 +154,7 @@ export class RouteConfigManager {
|
||||
metadata?: Partial<IRouteMetadata>;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const stored = this.storedRoutes.get(id);
|
||||
const stored = this.routes.get(id);
|
||||
if (!stored) return false;
|
||||
|
||||
if (patch.route) {
|
||||
@@ -201,9 +193,9 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
public async deleteRoute(id: string): Promise<boolean> {
|
||||
if (!this.storedRoutes.has(id)) return false;
|
||||
this.storedRoutes.delete(id);
|
||||
const doc = await StoredRouteDoc.findById(id);
|
||||
if (!this.routes.has(id)) return false;
|
||||
this.routes.delete(id);
|
||||
const doc = await RouteDoc.findById(id);
|
||||
if (doc) await doc.delete();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
@@ -214,103 +206,141 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hardcoded route overrides
|
||||
// Private: seed routes from constructor config
|
||||
// =========================================================================
|
||||
|
||||
public async setOverride(routeName: string, enabled: boolean, updatedBy: string): Promise<void> {
|
||||
const override: IRouteOverride = {
|
||||
routeName,
|
||||
enabled,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy,
|
||||
};
|
||||
this.overrides.set(routeName, override);
|
||||
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
if (existingDoc) {
|
||||
existingDoc.enabled = override.enabled;
|
||||
existingDoc.updatedAt = override.updatedAt;
|
||||
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();
|
||||
}
|
||||
/**
|
||||
* Upsert seed routes by name+origin. Preserves user's `enabled` state.
|
||||
* Deletes stale DB routes whose origin matches but name is not in the seed set.
|
||||
*/
|
||||
private async seedRoutes(
|
||||
seedRoutes: IDcRouterRouteConfig[],
|
||||
origin: 'config' | 'email' | 'dns',
|
||||
): Promise<void> {
|
||||
if (seedRoutes.length === 0) return;
|
||||
|
||||
public async removeOverride(routeName: string): Promise<boolean> {
|
||||
if (!this.overrides.has(routeName)) return false;
|
||||
this.overrides.delete(routeName);
|
||||
const doc = await RouteOverrideDoc.findByRouteName(routeName);
|
||||
if (doc) await doc.delete();
|
||||
this.computeWarnings();
|
||||
await this.applyRoutes();
|
||||
return true;
|
||||
const seedNames = new Set<string>();
|
||||
let seeded = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const route of seedRoutes) {
|
||||
const name = route.name || '';
|
||||
seedNames.add(name);
|
||||
|
||||
// Check if a route with this name+origin already exists in memory
|
||||
let existingId: string | undefined;
|
||||
for (const [id, r] of this.routes) {
|
||||
if (r.origin === origin && r.route.name === name) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (existingId) {
|
||||
// Update route config but preserve enabled state
|
||||
const existing = this.routes.get(existingId)!;
|
||||
existing.route = route;
|
||||
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,
|
||||
};
|
||||
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 && !seedNames.has(r.route.name || '')) {
|
||||
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 async loadStoredRoutes(): Promise<void> {
|
||||
const docs = await StoredRouteDoc.findAll();
|
||||
private async loadRoutes(): Promise<void> {
|
||||
const docs = await RouteDoc.findAll();
|
||||
let prunedRuntimeRoutes = 0;
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc.id) {
|
||||
this.storedRoutes.set(doc.id, {
|
||||
id: doc.id,
|
||||
route: doc.route,
|
||||
enabled: doc.enabled,
|
||||
createdAt: doc.createdAt,
|
||||
updatedAt: doc.updatedAt,
|
||||
createdBy: doc.createdBy,
|
||||
metadata: doc.metadata,
|
||||
});
|
||||
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',
|
||||
metadata: doc.metadata,
|
||||
};
|
||||
|
||||
if (this.isPersistedRuntimeRoute(storedRoute)) {
|
||||
await doc.delete();
|
||||
prunedRuntimeRoutes++;
|
||||
logger.log(
|
||||
'warn',
|
||||
`Removed persisted runtime-only route '${storedRoute.route.name || storedRoute.id}' (${storedRoute.id}) from RouteDoc`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.routes.set(doc.id, storedRoute);
|
||||
}
|
||||
if (this.storedRoutes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`);
|
||||
if (this.routes.size > 0) {
|
||||
logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
|
||||
}
|
||||
if (prunedRuntimeRoutes > 0) {
|
||||
logger.log('info', `Pruned ${prunedRuntimeRoutes} persisted runtime-only route(s) from RouteDoc`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOverrides(): Promise<void> {
|
||||
const docs = await RouteOverrideDoc.findAll();
|
||||
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);
|
||||
private async persistRoute(stored: IRoute): Promise<void> {
|
||||
const existingDoc = await RouteDoc.findById(stored.id);
|
||||
if (existingDoc) {
|
||||
existingDoc.route = stored.route;
|
||||
existingDoc.enabled = stored.enabled;
|
||||
existingDoc.updatedAt = stored.updatedAt;
|
||||
existingDoc.createdBy = stored.createdBy;
|
||||
existingDoc.origin = stored.origin;
|
||||
existingDoc.metadata = stored.metadata;
|
||||
await existingDoc.save();
|
||||
} else {
|
||||
const doc = new StoredRouteDoc();
|
||||
const doc = new RouteDoc();
|
||||
doc.id = stored.id;
|
||||
doc.route = stored.route;
|
||||
doc.enabled = stored.enabled;
|
||||
doc.createdAt = stored.createdAt;
|
||||
doc.updatedAt = stored.updatedAt;
|
||||
doc.createdBy = stored.createdBy;
|
||||
doc.origin = stored.origin;
|
||||
doc.metadata = stored.metadata;
|
||||
await doc.save();
|
||||
}
|
||||
@@ -322,33 +352,14 @@ export class RouteConfigManager {
|
||||
|
||||
private computeWarnings(): void {
|
||||
this.warnings = [];
|
||||
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
|
||||
|
||||
// Check overrides
|
||||
for (const [routeName, override] of this.overrides) {
|
||||
if (!hardcodedNames.has(routeName)) {
|
||||
for (const route of this.routes.values()) {
|
||||
if (!route.enabled) {
|
||||
const name = route.route.name || route.id;
|
||||
this.warnings.push({
|
||||
type: 'orphaned-override',
|
||||
routeName,
|
||||
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
|
||||
});
|
||||
} else if (!override.enabled) {
|
||||
this.warnings.push({
|
||||
type: 'disabled-hardcoded',
|
||||
routeName,
|
||||
message: `Route '${routeName}' is disabled via API override`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check disabled programmatic routes
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (!stored.enabled) {
|
||||
const name = stored.route.name || stored.id;
|
||||
this.warnings.push({
|
||||
type: 'disabled-programmatic',
|
||||
type: 'disabled-route',
|
||||
routeName: name,
|
||||
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`,
|
||||
message: `Route '${name}' (id: ${route.id}) is disabled`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -372,7 +383,7 @@ export class RouteConfigManager {
|
||||
if (!this.referenceResolver || routeIds.length === 0) return;
|
||||
|
||||
for (const routeId of routeIds) {
|
||||
const stored = this.storedRoutes.get(routeId);
|
||||
const stored = this.routes.get(routeId);
|
||||
if (!stored?.metadata) continue;
|
||||
|
||||
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
|
||||
@@ -387,7 +398,7 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: apply merged routes to SmartProxy
|
||||
// Apply routes to SmartProxy
|
||||
// =========================================================================
|
||||
|
||||
public async applyRoutes(): Promise<void> {
|
||||
@@ -397,54 +408,69 @@ export class RouteConfigManager {
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
const http3Config = this.getHttp3Config?.();
|
||||
const vpnCallback = this.getVpnClientIpsForRoute;
|
||||
|
||||
// Helper: inject VPN security into a vpnOnly 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
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route.route, route.id));
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route));
|
||||
}
|
||||
|
||||
// Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
|
||||
for (const stored of this.storedRoutes.values()) {
|
||||
if (stored.enabled) {
|
||||
let route = stored.route;
|
||||
if (http3Config?.enabled !== false) {
|
||||
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
|
||||
}
|
||||
enabledRoutes.push(injectVpn(route, stored.id));
|
||||
}
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
|
||||
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) {
|
||||
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 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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private isPersistedRuntimeRoute(storedRoute: IRoute): boolean {
|
||||
const routeName = storedRoute.route.name || '';
|
||||
const actionType = storedRoute.route.action?.type;
|
||||
|
||||
return (routeName.startsWith('dns-over-https-') && actionType === 'socket-handler')
|
||||
|| (storedRoute.origin === 'dns' && actionType === 'socket-handler');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
|
||||
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
|
||||
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.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).
|
||||
@@ -13,6 +13,10 @@ import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'
|
||||
export class TargetProfileManager {
|
||||
private profiles = new Map<string, ITargetProfile>();
|
||||
|
||||
constructor(
|
||||
private getAllRoutes?: () => Map<string, IRoute>,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
@@ -43,13 +47,14 @@ export class TargetProfileManager {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
|
||||
const routeRefs = this.normalizeRouteRefs(data.routeRefs);
|
||||
const profile: ITargetProfile = {
|
||||
id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
domains: data.domains,
|
||||
targets: data.targets,
|
||||
routeRefs: data.routeRefs,
|
||||
routeRefs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: data.createdBy,
|
||||
@@ -70,11 +75,19 @@ export class TargetProfileManager {
|
||||
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.description !== undefined) profile.description = patch.description;
|
||||
if (patch.domains !== undefined) profile.domains = patch.domains;
|
||||
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();
|
||||
|
||||
await this.persistProfile(profile);
|
||||
@@ -127,6 +140,29 @@ export class TargetProfileManager {
|
||||
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[] {
|
||||
return [...this.profiles.values()];
|
||||
}
|
||||
@@ -178,9 +214,11 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
clients: VpnClientDoc[],
|
||||
allRoutes: Map<string, IRoute> = new Map(),
|
||||
): Array<string | { ip: string; domains: string[] }> {
|
||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
for (const client of clients) {
|
||||
if (!client.enabled || !client.assignedIp) continue;
|
||||
@@ -194,7 +232,13 @@ export class TargetProfileManager {
|
||||
const profile = this.profiles.get(profileId);
|
||||
if (!profile) continue;
|
||||
|
||||
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
|
||||
const matchResult = this.routeMatchesProfileDetailed(
|
||||
route,
|
||||
routeId,
|
||||
profile,
|
||||
routeDomains,
|
||||
routeNameIndex,
|
||||
);
|
||||
if (matchResult === 'full') {
|
||||
fullAccess = true;
|
||||
break; // No need to check more profiles
|
||||
@@ -220,11 +264,11 @@ export class TargetProfileManager {
|
||||
*/
|
||||
public getClientAccessSpec(
|
||||
targetProfileIds: string[],
|
||||
allRoutes: IDcRouterRouteConfig[],
|
||||
storedRoutes: Map<string, IStoredRoute>,
|
||||
allRoutes: Map<string, IRoute>,
|
||||
): { domains: string[]; targetIps: string[] } {
|
||||
const domains = new Set<string>();
|
||||
const targetIps = new Set<string>();
|
||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||
|
||||
// Collect all access specifiers from assigned profiles
|
||||
for (const profileId of targetProfileIds) {
|
||||
@@ -245,23 +289,16 @@ export class TargetProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Route references: scan constructor routes
|
||||
for (const route of allRoutes) {
|
||||
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
|
||||
const routeDomains = (route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Route references: scan all routes
|
||||
for (const [routeId, route] of allRoutes) {
|
||||
if (!route.enabled) continue;
|
||||
if (this.routeMatchesProfile(
|
||||
route.route as IDcRouterRouteConfig,
|
||||
routeId,
|
||||
profile,
|
||||
routeNameIndex,
|
||||
)) {
|
||||
const routeDomains = (route.route.match as any)?.domains;
|
||||
if (Array.isArray(routeDomains)) {
|
||||
for (const d of routeDomains) {
|
||||
domains.add(d);
|
||||
@@ -288,9 +325,16 @@ export class TargetProfileManager {
|
||||
route: IDcRouterRouteConfig,
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): boolean {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -307,11 +351,17 @@ export class TargetProfileManager {
|
||||
routeId: string | undefined,
|
||||
profile: ITargetProfile,
|
||||
routeDomains: string[],
|
||||
routeNameIndex: Map<string, string[]>,
|
||||
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
|
||||
// 1. Route reference match → full access
|
||||
if (profile.routeRefs?.length) {
|
||||
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
|
||||
@@ -375,6 +425,66 @@ export class TargetProfileManager {
|
||||
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
|
||||
// =========================================================================
|
||||
|
||||
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
/**
|
||||
* Singleton ACME configuration document. One row per dcrouter instance,
|
||||
* keyed on the fixed `configId = 'acme-config'` following the
|
||||
* `VpnServerKeysDoc` pattern.
|
||||
*
|
||||
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
||||
* constructor fields. Managed via the OpsServer UI at
|
||||
* **Domains > Certificates > Settings**.
|
||||
*/
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public configId: string = 'acme-config';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public accountEmail: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public useProduction: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public autoRenew: boolean = true;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public renewThresholdDays: number = 30;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedBy: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<AcmeConfigDoc | null> {
|
||||
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
|
||||
}
|
||||
}
|
||||
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderStatus,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsProviderType;
|
||||
|
||||
/**
|
||||
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
|
||||
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
|
||||
*/
|
||||
@plugins.smartdata.svDb()
|
||||
public credentials!: TDnsProviderCredentials;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public status: TDnsProviderStatus = 'untested';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastTestedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastError?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsProviderDoc | null> {
|
||||
return await DnsProviderDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
|
||||
return await DnsProviderDoc.getInstances({ type });
|
||||
}
|
||||
}
|
||||
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public domainId!: string;
|
||||
|
||||
/** FQDN of the record (e.g. 'www.example.com'). */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public type!: TDnsRecordType;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public value!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public ttl: number = 300;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public proxied?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDnsRecordSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerRecordId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DnsRecordDoc | null> {
|
||||
return await DnsRecordDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
|
||||
return await DnsRecordDoc.getInstances({ domainId });
|
||||
}
|
||||
}
|
||||
66
ts/db/documents/classes.domain.doc.ts
Normal file
66
ts/db/documents/classes.domain.doc.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
|
||||
/** FQDN — kept lowercased on save. */
|
||||
@plugins.smartdata.svDb()
|
||||
public name: string = '';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public source!: TDomainSource;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public providerId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public authoritative: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public nameservers?: string[];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public externalZoneId?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public lastSyncedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public description?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt!: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findByName(name: string): Promise<DomainDoc | null> {
|
||||
return await DomainDoc.getInstance({ name: name.toLowerCase() });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({});
|
||||
}
|
||||
|
||||
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
|
||||
return await DomainDoc.getInstances({ providerId });
|
||||
}
|
||||
}
|
||||
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({});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteing
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRouteDoc, StoredRouteDoc> {
|
||||
export class RouteDoc extends plugins.smartdata.SmartDataDbDoc<RouteDoc, RouteDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public id!: string;
|
||||
@@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
||||
@plugins.smartdata.svDb()
|
||||
public createdBy!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public origin!: 'config' | 'email' | 'dns' | 'api';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public metadata?: IRouteMetadata;
|
||||
|
||||
@@ -33,11 +36,19 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
||||
super();
|
||||
}
|
||||
|
||||
public static async findById(id: string): Promise<StoredRouteDoc | null> {
|
||||
return await StoredRouteDoc.getInstance({ id });
|
||||
public static async findById(id: string): Promise<RouteDoc | null> {
|
||||
return await RouteDoc.getInstance({ id });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<StoredRouteDoc[]> {
|
||||
return await StoredRouteDoc.getInstances({});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@ export * from './classes.cached.email.js';
|
||||
export * from './classes.cached.ip.reputation.js';
|
||||
|
||||
// Config document classes
|
||||
export * from './classes.stored-route.doc.js';
|
||||
export * from './classes.route-override.doc.js';
|
||||
export * from './classes.route.doc.js';
|
||||
export * from './classes.api-token.doc.js';
|
||||
export * from './classes.source-profile.doc.js';
|
||||
export * from './classes.target-profile.doc.js';
|
||||
@@ -25,3 +24,14 @@ export * from './classes.remote-ingress-edge.doc.js';
|
||||
// RADIUS document classes
|
||||
export * from './classes.vlan-mappings.doc.js';
|
||||
export * from './classes.accounting-session.doc.js';
|
||||
|
||||
// DNS / Domain management document classes
|
||||
export * from './classes.dns-provider.doc.js';
|
||||
export * from './classes.domain.doc.js';
|
||||
export * from './classes.dns-record.doc.js';
|
||||
|
||||
// ACME configuration (singleton)
|
||||
export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
|
||||
2
ts/dns/index.ts
Normal file
2
ts/dns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './manager.dns.js';
|
||||
export * from './providers/index.js';
|
||||
1064
ts/dns/manager.dns.ts
Normal file
1064
ts/dns/manager.dns.ts
Normal file
File diff suppressed because it is too large
Load Diff
131
ts/dns/providers/cloudflare.provider.ts
Normal file
131
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import type {
|
||||
IDnsProviderClient,
|
||||
IConnectionTestResult,
|
||||
IProviderRecord,
|
||||
IProviderRecordInput,
|
||||
} from './interfaces.js';
|
||||
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||
|
||||
/**
|
||||
* Cloudflare implementation of IDnsProviderClient.
|
||||
*
|
||||
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||
* can have multiple records of the same name+type).
|
||||
*/
|
||||
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||
|
||||
constructor(apiToken: string) {
|
||||
if (!apiToken) {
|
||||
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||
}
|
||||
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||
}
|
||||
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
try {
|
||||
// Listing zones is the lightest-weight call that proves the token works.
|
||||
await this.cfAccount.zoneManager.listZones();
|
||||
return { ok: true };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||
const zones = await this.cfAccount.zoneManager.listZones();
|
||||
return zones.map((zone) => ({
|
||||
name: zone.name,
|
||||
externalId: zone.id,
|
||||
nameservers: zone.name_servers ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||
return records
|
||||
.filter((r) => this.isSupportedType(r.type))
|
||||
.map((r) => ({
|
||||
providerRecordId: r.id,
|
||||
name: r.name,
|
||||
type: r.type as TDnsRecordType,
|
||||
value: r.content,
|
||||
ttl: r.ttl,
|
||||
proxied: r.proxied,
|
||||
}));
|
||||
}
|
||||
|
||||
public async createRecord(
|
||||
domain: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
const apiRecord: any = {
|
||||
zone_id: zoneId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.value,
|
||||
ttl: record.ttl ?? 1, // 1 = automatic
|
||||
};
|
||||
if (record.proxied !== undefined) {
|
||||
apiRecord.proxied = record.proxied;
|
||||
}
|
||||
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||
return {
|
||||
providerRecordId: created.id,
|
||||
name: created.name,
|
||||
type: created.type as TDnsRecordType,
|
||||
value: created.content,
|
||||
ttl: created.ttl,
|
||||
proxied: created.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateRecord(
|
||||
domain: string,
|
||||
providerRecordId: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
const apiRecord: any = {
|
||||
zone_id: zoneId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.value,
|
||||
ttl: record.ttl ?? 1,
|
||||
};
|
||||
if (record.proxied !== undefined) {
|
||||
apiRecord.proxied = record.proxied;
|
||||
}
|
||||
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||
providerRecordId,
|
||||
apiRecord,
|
||||
);
|
||||
return {
|
||||
providerRecordId: updated.id,
|
||||
name: updated.name,
|
||||
type: updated.type as TDnsRecordType,
|
||||
value: updated.content,
|
||||
ttl: updated.ttl,
|
||||
proxied: updated.proxied,
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||
zone_id: zoneId,
|
||||
});
|
||||
}
|
||||
|
||||
private isSupportedType(type: string): boolean {
|
||||
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||
}
|
||||
}
|
||||
59
ts/dns/providers/factory.ts
Normal file
59
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { IDnsProviderClient } from './interfaces.js';
|
||||
import type {
|
||||
TDnsProviderType,
|
||||
TDnsProviderCredentials,
|
||||
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||
import { CloudflareDnsProvider } from './cloudflare.provider.js';
|
||||
|
||||
/**
|
||||
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
|
||||
*
|
||||
* @throws if the provider type is not supported.
|
||||
*
|
||||
* ## Adding a new provider (e.g. Route53)
|
||||
*
|
||||
* 1. **Type union** — extend `TDnsProviderType` in
|
||||
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
|
||||
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
|
||||
* the `TDnsProviderCredentials` discriminated union.
|
||||
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
|
||||
* the OpsServer UI picks up the new type and renders the right credential
|
||||
* form fields automatically.
|
||||
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
|
||||
* implementing `IDnsProviderClient`.
|
||||
* 5. **Factory case** — add a new `case 'route53':` below. The
|
||||
* `_exhaustive: never` line will fail to compile until you do.
|
||||
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
|
||||
*/
|
||||
export function createDnsProvider(
|
||||
type: TDnsProviderType,
|
||||
credentials: TDnsProviderCredentials,
|
||||
): IDnsProviderClient {
|
||||
switch (type) {
|
||||
case 'cloudflare': {
|
||||
if (credentials.type !== 'cloudflare') {
|
||||
throw new Error(
|
||||
`createDnsProvider: type mismatch — provider type is 'cloudflare' but credentials.type is '${credentials.type}'`,
|
||||
);
|
||||
}
|
||||
return new CloudflareDnsProvider(credentials.apiToken);
|
||||
}
|
||||
case 'dcrouter': {
|
||||
// The built-in DcRouter pseudo-provider has no runtime client — dcrouter
|
||||
// itself serves the records via the embedded smartdns.DnsServer. This
|
||||
// case exists only to satisfy the exhaustive switch; it should never
|
||||
// actually run because the handler layer rejects any CRUD that would
|
||||
// result in a DnsProviderDoc with type: 'dcrouter'.
|
||||
throw new Error(
|
||||
`createDnsProvider: 'dcrouter' is a built-in pseudo-provider — no runtime client exists. ` +
|
||||
`This call indicates a DnsProviderDoc with type: 'dcrouter' was persisted, which should never happen.`,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
// If you see a TypeScript error here after extending TDnsProviderType,
|
||||
// add a `case` for the new type above. The `never` enforces exhaustiveness.
|
||||
const _exhaustive: never = type;
|
||||
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './interfaces.js';
|
||||
export * from './cloudflare.provider.js';
|
||||
export * from './factory.js';
|
||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||
|
||||
/**
|
||||
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||
* is the provider's internal identifier, used for subsequent updates and
|
||||
* deletes (since providers can have multiple records of the same name+type).
|
||||
*/
|
||||
export interface IProviderRecord {
|
||||
providerRecordId: string;
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input shape for creating / updating a DNS record at a provider.
|
||||
*/
|
||||
export interface IProviderRecordInput {
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a connection test against a provider's API.
|
||||
*/
|
||||
export interface IConnectionTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pluggable DNS provider client interface. One implementation per provider type
|
||||
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||
* instantiated by `createDnsProvider()` in factory.ts.
|
||||
*
|
||||
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||
*/
|
||||
export interface IDnsProviderClient {
|
||||
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||
testConnection(): Promise<IConnectionTestResult>;
|
||||
|
||||
/** List all DNS zones visible to this provider account. */
|
||||
listDomains(): Promise<IProviderDomainListing[]>;
|
||||
|
||||
/** List all DNS records for a zone (FQDN). */
|
||||
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||
|
||||
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||
|
||||
/** Update an existing record by provider id; returns the updated record. */
|
||||
updateRecord(
|
||||
domain: string,
|
||||
providerRecordId: string,
|
||||
record: IProviderRecordInput,
|
||||
): Promise<IProviderRecord>;
|
||||
|
||||
/** Delete a record by provider id. */
|
||||
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||
}
|
||||
321
ts/email/classes.email-domain.manager.ts
Normal file
321
ts/email/classes.email-domain.manager.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
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.handleDKIMKeysForDomain(domainName);
|
||||
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(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();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
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 publicKey = doc.dkim.publicKey || '';
|
||||
const hostname = this.emailHostname;
|
||||
|
||||
const records: IEmailDnsRecord[] = [
|
||||
{
|
||||
type: 'MX',
|
||||
name: domain,
|
||||
value: `10 ${hostname}`,
|
||||
status: doc.dnsStatus.mx,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: domain,
|
||||
value: 'v=spf1 a mx ~all',
|
||||
status: doc.dnsStatus.spf,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `${selector}._domainkey.${domain}`,
|
||||
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
|
||||
status: doc.dnsStatus.dkim,
|
||||
},
|
||||
{
|
||||
type: 'TXT',
|
||||
name: `_dmarc.${domain}`,
|
||||
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
|
||||
status: doc.dnsStatus.dmarc,
|
||||
},
|
||||
];
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) => {
|
||||
if (required.type === 'MX') {
|
||||
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
|
||||
}
|
||||
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
|
||||
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
|
||||
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
|
||||
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
|
||||
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
|
||||
return false;
|
||||
});
|
||||
|
||||
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
|
||||
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
|
||||
|
||||
// SPF check
|
||||
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
|
||||
|
||||
// DKIM check
|
||||
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
|
||||
|
||||
// DMARC check
|
||||
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
|
||||
|
||||
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await doc.save();
|
||||
|
||||
return this.getRequiredDnsRecords(id);
|
||||
}
|
||||
|
||||
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveMx(domain);
|
||||
return records && records.length > 0 ? 'valid' : 'missing';
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTxtRecord(
|
||||
resolver: plugins.dns.promises.Resolver,
|
||||
name: string,
|
||||
prefix: string,
|
||||
): Promise<TDnsRecordStatus> {
|
||||
try {
|
||||
const records = await resolver.resolveTxt(name);
|
||||
const flat = records.map((r) => r.join(''));
|
||||
const found = flat.some((r) => r.startsWith(prefix));
|
||||
return found ? 'valid' : 'missing';
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
1
ts/email/index.ts
Normal file
1
ts/email/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
@@ -553,12 +553,14 @@ export class MetricsManager {
|
||||
connectionsByIP: new Map<string, number>(),
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [] as Array<{ ip: string; count: number }>,
|
||||
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
|
||||
throughputByIP: new Map<string, { in: number; out: number }>(),
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
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
|
||||
};
|
||||
|
||||
// Get top IPs
|
||||
// Get top IPs by connection count
|
||||
const topIPs = proxyMetrics.connections.topIPs(10);
|
||||
|
||||
// Get total data transferred
|
||||
@@ -699,10 +701,140 @@ 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 route name → domains from route config
|
||||
const routeDomains = new Map<string, string[]>();
|
||||
if (this.dcRouter.smartProxy) {
|
||||
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
|
||||
if (!route.name || !route.match.domains) continue;
|
||||
const domains = Array.isArray(route.match.domains)
|
||||
? route.match.domains
|
||||
: [route.match.domains];
|
||||
if (domains.length > 0) {
|
||||
routeDomains.set(route.name, 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 → route name(s)
|
||||
const domainToRoutes = new Map<string, string[]>();
|
||||
for (const [routeName, 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(routeName); }
|
||||
else { domainToRoutes.set(knownDomain, [routeName]); }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const existing = domainToRoutes.get(pattern);
|
||||
if (existing) { existing.push(routeName); }
|
||||
else { domainToRoutes.set(pattern, [routeName]); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, routeNames] of domainToRoutes) {
|
||||
const reqs = domainRequestTotals.get(domain) || 0;
|
||||
for (const routeName of routeNames) {
|
||||
routeTotalRequests.set(routeName, (routeTotalRequests.get(routeName) || 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, routeNames] of domainToRoutes) {
|
||||
const domainReqs = domainRequestTotals.get(domain) || 0;
|
||||
let totalConns = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
|
||||
for (const routeName of routeNames) {
|
||||
const conns = connectionsByRoute.get(routeName) || 0;
|
||||
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
|
||||
const routeTotal = routeTotalRequests.get(routeName) || 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: routeNames.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 {
|
||||
connectionsByIP,
|
||||
throughputRate,
|
||||
topIPs,
|
||||
topIPsByBandwidth,
|
||||
totalDataTransferred,
|
||||
throughputHistory,
|
||||
throughputByIP,
|
||||
@@ -711,6 +843,7 @@ export class MetricsManager {
|
||||
backends,
|
||||
frontendProtocols,
|
||||
backendProtocols,
|
||||
domainActivity,
|
||||
};
|
||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ export class OpsServer {
|
||||
private sourceProfileHandler!: handlers.SourceProfileHandler;
|
||||
private targetProfileHandler!: handlers.TargetProfileHandler;
|
||||
private networkTargetHandler!: handlers.NetworkTargetHandler;
|
||||
private usersHandler!: handlers.UsersHandler;
|
||||
private dnsProviderHandler!: handlers.DnsProviderHandler;
|
||||
private domainHandler!: handlers.DomainHandler;
|
||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||
private emailDomainHandler!: handlers.EmailDomainHandler;
|
||||
|
||||
constructor(dcRouterRefArg: DcRouter) {
|
||||
this.dcRouterRef = dcRouterRefArg;
|
||||
@@ -94,6 +100,12 @@ export class OpsServer {
|
||||
this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
|
||||
this.targetProfileHandler = new handlers.TargetProfileHandler(this);
|
||||
this.networkTargetHandler = new handlers.NetworkTargetHandler(this);
|
||||
this.usersHandler = new handlers.UsersHandler(this);
|
||||
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
|
||||
this.domainHandler = new handlers.DomainHandler(this);
|
||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
|
||||
|
||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
||||
}
|
||||
|
||||
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD handler for the singleton `AcmeConfigDoc`.
|
||||
*
|
||||
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
|
||||
* with `acme-config:read` / `acme-config:write` scope.
|
||||
*/
|
||||
export class AcmeConfigHandler {
|
||||
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 registerHandlers(): void {
|
||||
// Get current ACME config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
|
||||
'getAcmeConfig',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'acme-config:read');
|
||||
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||
if (!mgr) return { config: null };
|
||||
return { config: mgr.getConfig() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update (upsert) the ACME config
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
|
||||
'updateAcmeConfig',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'acme-config:write');
|
||||
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||
if (!mgr) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'AcmeConfigManager not initialized (DB disabled?)',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const updated = await mgr.updateConfig(
|
||||
{
|
||||
accountEmail: dataArg.accountEmail,
|
||||
enabled: dataArg.enabled,
|
||||
useProduction: dataArg.useProduction,
|
||||
autoRenew: dataArg.autoRenew,
|
||||
renewThresholdDays: dataArg.renewThresholdDays,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
return { success: true, config: updated };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,18 @@ export class AdminHandler {
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe projection of the users Map — excludes password fields.
|
||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||
*/
|
||||
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
||||
return Array.from(this.users.values()).map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
}));
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
// Admin Login Handler
|
||||
|
||||
@@ -198,12 +198,11 @@ export class CertificateHandler {
|
||||
try {
|
||||
const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
|
||||
if (rustStatus) {
|
||||
if (rustStatus.expiryDate) expiryDate = rustStatus.expiryDate;
|
||||
if (rustStatus.issuer) issuer = rustStatus.issuer;
|
||||
if (rustStatus.issuedAt) issuedAt = rustStatus.issuedAt;
|
||||
if (rustStatus.status === 'valid' || rustStatus.status === 'expired') {
|
||||
status = rustStatus.status;
|
||||
if (rustStatus.expiresAt > 0) {
|
||||
expiryDate = new Date(rustStatus.expiresAt).toISOString();
|
||||
}
|
||||
if (rustStatus.source) issuer = rustStatus.source;
|
||||
status = rustStatus.isValid ? 'valid' : 'expired';
|
||||
}
|
||||
} catch {
|
||||
// Rust bridge may not support this command yet — ignore
|
||||
|
||||
@@ -123,6 +123,15 @@ export class ConfigHandler {
|
||||
ttl: r.ttl,
|
||||
}));
|
||||
|
||||
// dnsChallenge: true when at least one DnsProviderDoc exists in the DB
|
||||
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
|
||||
let dnsChallengeEnabled = false;
|
||||
try {
|
||||
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
|
||||
} catch {
|
||||
dnsChallengeEnabled = false;
|
||||
}
|
||||
|
||||
const dns: interfaces.requests.IConfigData['dns'] = {
|
||||
enabled: !!dcRouter.dnsServer,
|
||||
port: 53,
|
||||
@@ -130,7 +139,7 @@ export class ConfigHandler {
|
||||
scopes: opts.dnsScopes || [],
|
||||
recordCount: dnsRecords.length,
|
||||
records: dnsRecords,
|
||||
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey,
|
||||
dnsChallenge: dnsChallengeEnabled,
|
||||
};
|
||||
|
||||
// --- TLS ---
|
||||
|
||||
197
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
197
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD + connection-test handlers for DnsProviderDoc.
|
||||
*
|
||||
* Auth: same dual-mode pattern as TargetProfileHandler — admin JWT or
|
||||
* API token with the appropriate `dns-providers:read|write` scope.
|
||||
*/
|
||||
export class DnsProviderHandler {
|
||||
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 registerHandlers(): void {
|
||||
// Get all providers — prepends the built-in DcRouter pseudo-provider
|
||||
// so operators see a uniform "who serves this?" list that includes the
|
||||
// authoritative dcrouter alongside external accounts.
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProviders>(
|
||||
'getDnsProviders',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
const synthetic: interfaces.data.IDnsProviderPublic = {
|
||||
id: interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID,
|
||||
name: 'DcRouter',
|
||||
type: 'dcrouter',
|
||||
status: 'ok',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
createdBy: 'system',
|
||||
hasCredentials: false,
|
||||
builtIn: true,
|
||||
};
|
||||
const real = dnsManager ? await dnsManager.listProviders() : [];
|
||||
return { providers: [synthetic, ...real] };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsProvider>(
|
||||
'getDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { provider: null };
|
||||
return { provider: await dnsManager.getProvider(dataArg.id) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsProvider>(
|
||||
'createDnsProvider',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'dns-providers:write');
|
||||
if (dataArg.type === 'dcrouter') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'cannot create built-in provider',
|
||||
};
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) {
|
||||
return { success: false, message: 'DnsManager not initialized (DB disabled?)' };
|
||||
}
|
||||
const id = await dnsManager.createProvider({
|
||||
name: dataArg.name,
|
||||
type: dataArg.type,
|
||||
credentials: dataArg.credentials,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, id };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsProvider>(
|
||||
'updateDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return { success: false, message: 'cannot edit built-in provider' };
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
const ok = await dnsManager.updateProvider(dataArg.id, {
|
||||
name: dataArg.name,
|
||||
credentials: dataArg.credentials,
|
||||
});
|
||||
return ok ? { success: true } : { success: false, message: 'Provider not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsProvider>(
|
||||
'deleteDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:write');
|
||||
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return { success: false, message: 'cannot delete built-in provider' };
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.deleteProvider(dataArg.id, dataArg.force ?? false);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Test provider connection
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestDnsProvider>(
|
||||
'testDnsProvider',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
if (dataArg.id === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'built-in provider has no external connection to test',
|
||||
testedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) {
|
||||
return { ok: false, error: 'DnsManager not initialized', testedAt: Date.now() };
|
||||
}
|
||||
return await dnsManager.testProvider(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// List domains visible to a provider's account (without importing them)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListProviderDomains>(
|
||||
'listProviderDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-providers:read');
|
||||
if (dataArg.providerId === interfaces.data.DCROUTER_BUILTIN_PROVIDER_ID) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'built-in provider has no external domain listing — use "Add DcRouter Domain" instead',
|
||||
};
|
||||
}
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
try {
|
||||
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
|
||||
return { success: true, domains };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD handlers for DnsRecordDoc.
|
||||
*/
|
||||
export class DnsRecordHandler {
|
||||
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 registerHandlers(): void {
|
||||
// Get records by domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
|
||||
'getDnsRecords',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { records: [] };
|
||||
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
|
||||
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
|
||||
'getDnsRecord',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { record: null };
|
||||
const doc = await dnsManager.getRecord(dataArg.id);
|
||||
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
|
||||
'createDnsRecord',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.createRecord({
|
||||
domainId: dataArg.domainId,
|
||||
name: dataArg.name,
|
||||
type: dataArg.type,
|
||||
value: dataArg.value,
|
||||
ttl: dataArg.ttl,
|
||||
proxied: dataArg.proxied,
|
||||
createdBy: userId,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
|
||||
'updateDnsRecord',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.updateRecord({
|
||||
id: dataArg.id,
|
||||
name: dataArg.name,
|
||||
value: dataArg.value,
|
||||
ttl: dataArg.ttl,
|
||||
proxied: dataArg.proxied,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete record
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
||||
'deleteDnsRecord',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'dns-records:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
return await dnsManager.deleteRecord(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
ts/opsserver/handlers/domain.handler.ts
Normal file
179
ts/opsserver/handlers/domain.handler.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* CRUD handlers for DomainDoc.
|
||||
*/
|
||||
export class DomainHandler {
|
||||
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 registerHandlers(): void {
|
||||
// Get all domains
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomains>(
|
||||
'getDomains',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { domains: [] };
|
||||
const docs = await dnsManager.listDomains();
|
||||
return { domains: docs.map((d) => dnsManager.toPublicDomain(d)) };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Get single domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDomain>(
|
||||
'getDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:read');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { domain: null };
|
||||
const doc = await dnsManager.getDomain(dataArg.id);
|
||||
return { domain: doc ? dnsManager.toPublicDomain(doc) : null };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create dcrouter-hosted domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDomain>(
|
||||
'createDomain',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
try {
|
||||
const id = await dnsManager.createDcrouterDomain({
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, id };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Import domains from a provider
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ImportDomain>(
|
||||
'importDomain',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
try {
|
||||
const importedIds = await dnsManager.importDomainsFromProvider({
|
||||
providerId: dataArg.providerId,
|
||||
domainNames: dataArg.domainNames,
|
||||
createdBy: userId,
|
||||
});
|
||||
return { success: true, importedIds };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Update domain metadata
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDomain>(
|
||||
'updateDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
const ok = await dnsManager.updateDomain(dataArg.id, {
|
||||
description: dataArg.description,
|
||||
});
|
||||
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Delete domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDomain>(
|
||||
'deleteDomain',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'domains:write');
|
||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||
const ok = await dnsManager.deleteDomain(dataArg.id);
|
||||
return ok ? { success: true } : { success: false, message: 'Domain not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Force-resync provider domain
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncDomain>(
|
||||
'syncDomain',
|
||||
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.syncDomain(dataArg.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 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 };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,10 @@ export * from './api-token.handler.js';
|
||||
export * from './vpn.handler.js';
|
||||
export * from './source-profile.handler.js';
|
||||
export * from './target-profile.handler.js';
|
||||
export * from './network-target.handler.js';
|
||||
export * from './network-target.handler.js';
|
||||
export * from './users.handler.js';
|
||||
export * from './dns-provider.handler.js';
|
||||
export * from './domain.handler.js';
|
||||
export * from './dns-record.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(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
if (result.success && dataArg.force) {
|
||||
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
|
||||
if (!resolver || !manager) {
|
||||
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 })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -72,7 +72,7 @@ export class RouteManagementHandler {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
|
||||
return { success: true, storedRouteId: id };
|
||||
return { success: true, routeId: id };
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -113,39 +113,7 @@ export class RouteManagementHandler {
|
||||
),
|
||||
);
|
||||
|
||||
// Set override on a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
|
||||
'setRouteOverride',
|
||||
async (dataArg) => {
|
||||
const userId = await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Remove override from a hardcoded route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
|
||||
'removeRouteOverride',
|
||||
async (dataArg) => {
|
||||
await this.requireAuth(dataArg, 'routes:write');
|
||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'Route management not initialized' };
|
||||
}
|
||||
const ok = await manager.removeOverride(dataArg.routeName);
|
||||
return { success: ok, message: ok ? undefined : 'Override not found' };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Toggle programmatic route
|
||||
// Toggle route
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
|
||||
'toggleRoute',
|
||||
|
||||
@@ -51,8 +51,8 @@ export class SecurityHandler {
|
||||
startTime: conn.startTime,
|
||||
protocol: conn.type === 'http' ? 'https' : conn.type as any,
|
||||
state: conn.status as any,
|
||||
bytesReceived: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesSent: Math.floor(conn.bytesTransferred / 2),
|
||||
bytesReceived: (conn as any)._throughputIn || 0,
|
||||
bytesSent: (conn as any)._throughputOut || 0,
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
@@ -96,9 +96,11 @@ export class SecurityHandler {
|
||||
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
|
||||
throughputRate: networkStats.throughputRate,
|
||||
topIPs: networkStats.topIPs,
|
||||
topIPsByBandwidth: networkStats.topIPsByBandwidth,
|
||||
totalDataTransferred: networkStats.totalDataTransferred,
|
||||
throughputHistory: networkStats.throughputHistory || [],
|
||||
throughputByIP,
|
||||
domainActivity: networkStats.domainActivity || [],
|
||||
requestsPerSecond: networkStats.requestsPerSecond || 0,
|
||||
requestsTotal: networkStats.requestsTotal || 0,
|
||||
backends: networkStats.backends || [],
|
||||
@@ -110,9 +112,11 @@ export class SecurityHandler {
|
||||
connectionsByIP: [],
|
||||
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
|
||||
topIPs: [],
|
||||
topIPsByBandwidth: [],
|
||||
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
|
||||
throughputHistory: [],
|
||||
throughputByIP: [],
|
||||
domainActivity: [],
|
||||
requestsPerSecond: 0,
|
||||
requestsTotal: 0,
|
||||
backends: [],
|
||||
@@ -251,31 +255,31 @@ export class SecurityHandler {
|
||||
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
|
||||
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
|
||||
|
||||
// Use IP-based connection data from the new metrics API
|
||||
// One aggregate row per IP with real throughput data
|
||||
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
|
||||
let connIndex = 0;
|
||||
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
|
||||
|
||||
|
||||
for (const [ip, count] of networkStats.connectionsByIP) {
|
||||
// Create a connection entry for each active IP connection
|
||||
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
|
||||
connections.push({
|
||||
id: `conn-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: Math.floor(Math.random() * 50000) + 10000, // High port range
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
|
||||
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
const tp = networkStats.throughputByIP?.get(ip);
|
||||
connections.push({
|
||||
id: `ip-${connIndex++}`,
|
||||
type: 'http',
|
||||
source: {
|
||||
ip: ip,
|
||||
port: 0,
|
||||
},
|
||||
destination: {
|
||||
ip: publicIp,
|
||||
port: 443,
|
||||
service: 'proxy',
|
||||
},
|
||||
startTime: 0,
|
||||
bytesTransferred: count, // Store connection count here
|
||||
status: 'active',
|
||||
// Attach real throughput for the handler mapping
|
||||
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
|
||||
} as any);
|
||||
}
|
||||
} else if (connectionInfo.length > 0) {
|
||||
// Fallback to route-based connection info if no IP data available
|
||||
|
||||
@@ -136,7 +136,7 @@ export class SourceProfileHandler {
|
||||
const result = await resolver.deleteProfile(
|
||||
dataArg.id,
|
||||
dataArg.force ?? false,
|
||||
manager.getStoredRoutes(),
|
||||
manager.getRoutes(),
|
||||
);
|
||||
|
||||
// If force-deleted with affected routes, re-apply
|
||||
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
|
||||
if (!resolver || !manager) {
|
||||
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 })) };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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 = {
|
||||
totalBandwidth: {
|
||||
in: stats.throughputRate.bytesInPerSecond,
|
||||
@@ -301,12 +315,18 @@ export class StatsHandler {
|
||||
out: stats.totalDataTransferred.bytesOut,
|
||||
},
|
||||
activeConnections: serverStats.activeConnections,
|
||||
connectionDetails: [],
|
||||
connectionDetails,
|
||||
topEndpoints: stats.topIPs.map(ip => ({
|
||||
endpoint: ip.ip,
|
||||
requests: ip.count,
|
||||
connections: ip.count,
|
||||
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 || [],
|
||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||
requestsTotal: stats.requestsTotal || 0,
|
||||
|
||||
30
ts/opsserver/handlers/users.handler.ts
Normal file
30
ts/opsserver/handlers/users.handler.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
|
||||
* so admin middleware enforces auth + role check before the handler runs.
|
||||
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||
* projection of it via TypedRequest.
|
||||
*/
|
||||
export class UsersHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
const router = this.opsServerRef.adminRouter;
|
||||
|
||||
// List users (admin-only, read-only)
|
||||
router.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||
'listUsers',
|
||||
async (_dataArg) => {
|
||||
const users = this.opsServerRef.adminHandler.listUsers();
|
||||
return { users };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,8 @@ export class VpnManager {
|
||||
private vpnServer?: plugins.smartvpn.VpnServer;
|
||||
private clients: Map<string, VpnClientDoc> = new Map();
|
||||
private serverKeys?: VpnServerKeysDoc;
|
||||
private resolvedForwardingMode?: 'socket' | 'bridge' | 'hybrid';
|
||||
private forwardingModeOverride?: 'socket' | 'bridge' | 'hybrid';
|
||||
|
||||
constructor(config: IVpnManagerConfig) {
|
||||
this.config = config;
|
||||
@@ -88,6 +90,7 @@ export class VpnManager {
|
||||
if (client.useHostIp) {
|
||||
anyClientUsesHostIp = true;
|
||||
}
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
const entry: plugins.smartvpn.IClientEntry = {
|
||||
clientId: client.clientId,
|
||||
publicKey: client.noisePublicKey,
|
||||
@@ -97,13 +100,12 @@ export class VpnManager {
|
||||
assignedIp: client.assignedIp,
|
||||
expiresAt: client.expiresAt,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -112,13 +114,15 @@ export class VpnManager {
|
||||
|
||||
// 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
|
||||
let configuredMode = this.config.forwardingMode ?? 'socket';
|
||||
let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
|
||||
if (anyClientUsesHostIp && configuredMode === 'socket') {
|
||||
configuredMode = 'hybrid';
|
||||
logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
|
||||
}
|
||||
const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
|
||||
const isBridge = forwardingMode === 'bridge';
|
||||
this.resolvedForwardingMode = forwardingMode;
|
||||
this.forwardingModeOverride = undefined;
|
||||
|
||||
// Create and start VpnServer
|
||||
this.vpnServer = new plugins.smartvpn.VpnServer({
|
||||
@@ -143,7 +147,7 @@ export class VpnManager {
|
||||
wgListenPort,
|
||||
clients: clientEntries,
|
||||
socketForwardProxyProtocol: !isBridge,
|
||||
destinationPolicy: this.config.destinationPolicy ?? defaultDestinationPolicy,
|
||||
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
|
||||
serverEndpoint: this.config.serverEndpoint
|
||||
? `${this.config.serverEndpoint}:${wgListenPort}`
|
||||
: undefined,
|
||||
@@ -189,6 +193,7 @@ export class VpnManager {
|
||||
this.vpnServer.stop();
|
||||
this.vpnServer = undefined;
|
||||
}
|
||||
this.resolvedForwardingMode = undefined;
|
||||
logger.log('info', 'VPN server stopped');
|
||||
}
|
||||
|
||||
@@ -213,14 +218,38 @@ export class VpnManager {
|
||||
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({
|
||||
clientId: opts.clientId,
|
||||
description: opts.description,
|
||||
clientId: doc.clientId,
|
||||
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
|
||||
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(
|
||||
/AllowedIPs\s*=\s*.+/,
|
||||
`AllowedIPs = ${allowedIPs.join(', ')}`,
|
||||
@@ -228,40 +257,16 @@ export class VpnManager {
|
||||
}
|
||||
|
||||
// Persist client entry (including WG private key for export/QR)
|
||||
const doc = new VpnClientDoc();
|
||||
doc.clientId = bundle.entry.clientId;
|
||||
doc.enabled = bundle.entry.enabled ?? true;
|
||||
doc.targetProfileIds = opts.targetProfileIds;
|
||||
doc.description = bundle.entry.description;
|
||||
doc.assignedIp = bundle.entry.assignedIp;
|
||||
doc.noisePublicKey = bundle.entry.publicKey;
|
||||
doc.wgPublicKey = bundle.entry.wgPublicKey || '';
|
||||
doc.wgPrivateKey = bundle.secrets?.wgPrivateKey
|
||||
|| bundle.wireguardConfig?.match(/PrivateKey\s*=\s*(.+)/)?.[1]?.trim();
|
||||
doc.createdAt = Date.now();
|
||||
doc.updatedAt = Date.now();
|
||||
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);
|
||||
try {
|
||||
await this.persistClient(doc);
|
||||
@@ -276,12 +281,6 @@ export class VpnManager {
|
||||
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?.();
|
||||
return bundle;
|
||||
}
|
||||
@@ -364,13 +363,13 @@ export class VpnManager {
|
||||
if (update.staticIp !== undefined) client.staticIp = update.staticIp;
|
||||
if (update.forceVlan !== undefined) client.forceVlan = update.forceVlan;
|
||||
if (update.vlanId !== undefined) client.vlanId = update.vlanId;
|
||||
this.normalizeClientRoutingSettings(client);
|
||||
client.updatedAt = Date.now();
|
||||
await this.persistClient(client);
|
||||
|
||||
// Sync per-client security to the running daemon
|
||||
if (this.vpnServer) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
await this.vpnServer.updateClient(clientId, { security });
|
||||
await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
|
||||
await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
|
||||
this.config.onClientChanged?.();
|
||||
@@ -478,26 +477,28 @@ export class VpnManager {
|
||||
|
||||
/**
|
||||
* 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 bypass SmartProxy via allowList.
|
||||
* TargetProfile direct IP:port targets extend the effective allow-list.
|
||||
*/
|
||||
private buildClientSecurity(client: VpnClientDoc): 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 || []) || [];
|
||||
|
||||
// Merge with per-client explicit allow list
|
||||
const mergedAllowList = [
|
||||
...(client.destinationAllowList || []),
|
||||
...profileDirectTargets,
|
||||
];
|
||||
const mergedAllowList = this.mergeDestinationLists(
|
||||
basePolicy.allowList,
|
||||
client.destinationAllowList,
|
||||
profileDirectTargets,
|
||||
);
|
||||
const mergedBlockList = this.mergeDestinationLists(
|
||||
basePolicy.blockList,
|
||||
client.destinationBlockList,
|
||||
);
|
||||
|
||||
security.destinationPolicy = {
|
||||
default: 'forceTarget' as const,
|
||||
target: '127.0.0.1',
|
||||
default: basePolicy.default,
|
||||
target: basePolicy.default === 'forceTarget' ? basePolicy.target : undefined,
|
||||
allowList: mergedAllowList.length ? mergedAllowList : undefined,
|
||||
blockList: client.destinationBlockList,
|
||||
blockList: mergedBlockList.length ? mergedBlockList : undefined,
|
||||
};
|
||||
|
||||
return security;
|
||||
@@ -510,10 +511,7 @@ export class VpnManager {
|
||||
public async refreshAllClientSecurity(): Promise<void> {
|
||||
if (!this.vpnServer) return;
|
||||
for (const client of this.clients.values()) {
|
||||
const security = this.buildClientSecurity(client);
|
||||
if (security.destinationPolicy) {
|
||||
await this.vpnServer.updateClient(client.clientId, { security });
|
||||
}
|
||||
await this.vpnServer.updateClient(client.clientId, this.buildClientRuntimeUpdate(client));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +548,7 @@ export class VpnManager {
|
||||
private async loadPersistedClients(): Promise<void> {
|
||||
const docs = await VpnClientDoc.findAll();
|
||||
for (const doc of docs) {
|
||||
this.normalizeClientRoutingSettings(doc);
|
||||
this.clients.set(doc.clientId, doc);
|
||||
}
|
||||
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> {
|
||||
await client.save();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ export class Route {
|
||||
|
||||
// Data from IMergedRoute
|
||||
public routeConfig: IRouteConfig;
|
||||
public source: 'hardcoded' | 'programmatic';
|
||||
public id: string;
|
||||
public enabled: boolean;
|
||||
public overridden: boolean;
|
||||
public storedRouteId?: string;
|
||||
public origin: 'config' | 'email' | 'dns' | 'api';
|
||||
public createdAt?: number;
|
||||
public updatedAt?: number;
|
||||
|
||||
@@ -22,21 +21,17 @@ export class Route {
|
||||
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
|
||||
this.clientRef = clientRef;
|
||||
this.routeConfig = data.route;
|
||||
this.source = data.source;
|
||||
this.id = data.id;
|
||||
this.enabled = data.enabled;
|
||||
this.overridden = data.overridden;
|
||||
this.storedRouteId = data.storedRouteId;
|
||||
this.origin = data.origin;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
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>(
|
||||
'updateRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to update route');
|
||||
@@ -44,12 +39,9 @@ export class Route {
|
||||
}
|
||||
|
||||
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>(
|
||||
'deleteRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to delete route');
|
||||
@@ -57,41 +49,15 @@ export class Route {
|
||||
}
|
||||
|
||||
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>(
|
||||
'toggleRoute',
|
||||
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any,
|
||||
this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'Failed to toggle route');
|
||||
}
|
||||
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 {
|
||||
@@ -144,9 +110,8 @@ export class RouteBuilder {
|
||||
}
|
||||
|
||||
// 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 created = routes.find((r) => r.storedRouteId === response.storedRouteId);
|
||||
const created = routes.find((r) => r.id === response.routeId);
|
||||
if (created) {
|
||||
return created;
|
||||
}
|
||||
@@ -154,10 +119,9 @@ export class RouteBuilder {
|
||||
// Fallback: construct from known data
|
||||
return new Route(this.clientRef, {
|
||||
route: this.routeConfig as IRouteConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: this.isEnabled,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -190,10 +154,9 @@ export class RouteManager {
|
||||
}
|
||||
return new Route(this.clientRef, {
|
||||
route: routeConfig,
|
||||
source: 'programmatic',
|
||||
id: response.routeId || '',
|
||||
enabled: enabled ?? true,
|
||||
overridden: false,
|
||||
storedRouteId: response.storedRouteId,
|
||||
origin: 'api',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
25
ts_interfaces/data/acme-config.ts
Normal file
25
ts_interfaces/data/acme-config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* ACME configuration for automated TLS certificate issuance via Let's Encrypt.
|
||||
*
|
||||
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the
|
||||
* legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*`
|
||||
* which are now seed-only (used once on first boot if the DB is empty).
|
||||
*
|
||||
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
|
||||
*/
|
||||
export interface IAcmeConfig {
|
||||
/** Contact email used for Let's Encrypt account registration. */
|
||||
accountEmail: string;
|
||||
/** Whether ACME is enabled. If false, no certs are issued via ACME. */
|
||||
enabled: boolean;
|
||||
/** True = Let's Encrypt production, false = staging. */
|
||||
useProduction: boolean;
|
||||
/** Whether to automatically renew certs before expiry. */
|
||||
autoRenew: boolean;
|
||||
/** Renew when a cert has fewer than this many days of validity left. */
|
||||
renewThresholdDays: number;
|
||||
/** Unix ms timestamp of last config change. */
|
||||
updatedAt: number;
|
||||
/** Who last updated the config (userId or 'seed' / 'system'). */
|
||||
updatedBy: string;
|
||||
}
|
||||
174
ts_interfaces/data/dns-provider.ts
Normal file
174
ts_interfaces/data/dns-provider.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Stable ID for the built-in DcRouter pseudo-provider. The Providers list
|
||||
* surfaces this as the first, non-deletable row so operators see a uniform
|
||||
* "who serves this?" answer for every domain. The ID is magic — it never
|
||||
* exists in the DnsProviderDoc collection; handlers inject it at read time
|
||||
* and reject any mutation that targets it.
|
||||
*/
|
||||
export const DCROUTER_BUILTIN_PROVIDER_ID = '__dcrouter__';
|
||||
|
||||
/**
|
||||
* Supported DNS provider types.
|
||||
*
|
||||
* - 'cloudflare' → Cloudflare account (API token-based). Provider stays
|
||||
* authoritative; dcrouter pushes record changes via API.
|
||||
* - 'dcrouter' → Built-in pseudo-provider for dcrouter-hosted zones.
|
||||
* dcrouter itself is the authoritative DNS server. No
|
||||
* credentials, cannot be created/edited/deleted through
|
||||
* the provider CRUD — the Providers view renders it from
|
||||
* a handler-level synthetic row.
|
||||
*
|
||||
* The abstraction is designed so additional providers (Route53, Gandi,
|
||||
* DigitalOcean, foreign dcrouters…) can be added by implementing the
|
||||
* IDnsProvider class interface in ts/dns/providers/.
|
||||
*/
|
||||
export type TDnsProviderType = 'cloudflare' | 'dcrouter';
|
||||
|
||||
/**
|
||||
* Status of the last connection test against a provider.
|
||||
*/
|
||||
export type TDnsProviderStatus = 'untested' | 'ok' | 'error';
|
||||
|
||||
/**
|
||||
* Cloudflare-specific credential shape.
|
||||
*/
|
||||
export interface ICloudflareCredentials {
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of all supported provider credential shapes.
|
||||
* Persisted opaquely on `IDnsProvider.credentials`.
|
||||
*/
|
||||
export type TDnsProviderCredentials =
|
||||
| ({ type: 'cloudflare' } & ICloudflareCredentials);
|
||||
|
||||
/**
|
||||
* A registered DNS provider account. Holds the credentials needed to
|
||||
* call the provider's API and a snapshot of its last health check.
|
||||
*/
|
||||
export interface IDnsProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TDnsProviderType;
|
||||
/** Opaque credentials object — shape depends on `type`. */
|
||||
credentials: TDnsProviderCredentials;
|
||||
status: TDnsProviderStatus;
|
||||
lastTestedAt?: number;
|
||||
lastError?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A redacted view of IDnsProvider safe to send to the UI / over the wire.
|
||||
* Strips secret fields from `credentials` while preserving the rest.
|
||||
*/
|
||||
export interface IDnsProviderPublic {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TDnsProviderType;
|
||||
status: TDnsProviderStatus;
|
||||
lastTestedAt?: number;
|
||||
lastError?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
/** Whether credentials are configured (true after creation). Never the secret itself. */
|
||||
hasCredentials: boolean;
|
||||
/**
|
||||
* True for the built-in DcRouter pseudo-provider — read-only, cannot be
|
||||
* created / edited / deleted. Injected by the handler layer, never
|
||||
* persisted in the DnsProviderDoc collection.
|
||||
*/
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A domain reported by a provider's API (not yet imported into dcrouter).
|
||||
*/
|
||||
export interface IProviderDomainListing {
|
||||
/** FQDN of the zone (e.g. 'example.com'). */
|
||||
name: string;
|
||||
/** Provider's internal zone identifier (zone_id for Cloudflare). */
|
||||
externalId: string;
|
||||
/** Authoritative nameservers reported by the provider. */
|
||||
nameservers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema entry for a single credential field, used by the OpsServer UI to
|
||||
* render a provider's credential form dynamically.
|
||||
*/
|
||||
export interface IDnsProviderCredentialField {
|
||||
/** Key under which the value is stored in the credentials object. */
|
||||
key: string;
|
||||
/** Label shown to the user. */
|
||||
label: string;
|
||||
/** Optional inline help text. */
|
||||
helpText?: string;
|
||||
/** Whether the field must be filled. */
|
||||
required: boolean;
|
||||
/** True for secret fields (rendered as password input, never echoed back). */
|
||||
secret: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata describing a DNS provider type. Drives:
|
||||
* - the OpsServer UI's provider type picker + credential form,
|
||||
* - documentation of which credentials each provider needs,
|
||||
* - end-to-end consistency between the type union, the discriminated
|
||||
* credentials union, the runtime factory, and the form rendering.
|
||||
*
|
||||
* To add a new provider, append a new entry to `dnsProviderTypeDescriptors`
|
||||
* below — and follow the checklist in `ts/dns/providers/factory.ts`.
|
||||
*/
|
||||
export interface IDnsProviderTypeDescriptor {
|
||||
type: TDnsProviderType;
|
||||
/** Human-readable name for the UI. */
|
||||
displayName: string;
|
||||
/** One-line description shown next to the type picker. */
|
||||
description: string;
|
||||
/** Schema for the credentials form. */
|
||||
credentialFields: IDnsProviderCredentialField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for which DNS provider types exist and what
|
||||
* credentials each one needs. Used by both backend and frontend.
|
||||
*/
|
||||
export const dnsProviderTypeDescriptors: ReadonlyArray<IDnsProviderTypeDescriptor> = [
|
||||
{
|
||||
type: 'dcrouter',
|
||||
displayName: 'DcRouter (built-in)',
|
||||
description:
|
||||
'Built-in authoritative DNS. Records are served directly by dcrouter — delegate the domain\'s NS records to make this effective.',
|
||||
credentialFields: [],
|
||||
},
|
||||
{
|
||||
type: 'cloudflare',
|
||||
displayName: 'Cloudflare',
|
||||
description:
|
||||
'External DNS provider. The provider stays authoritative; dcrouter pushes record changes via its API.',
|
||||
credentialFields: [
|
||||
{
|
||||
key: 'apiToken',
|
||||
label: 'API Token',
|
||||
helpText:
|
||||
'A Cloudflare API token with Zone:Read and DNS:Edit permissions for the target zones.',
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Look up the descriptor for a given provider type.
|
||||
*/
|
||||
export function getDnsProviderTypeDescriptor(
|
||||
type: TDnsProviderType,
|
||||
): IDnsProviderTypeDescriptor | undefined {
|
||||
return dnsProviderTypeDescriptors.find((d) => d.type === type);
|
||||
}
|
||||
44
ts_interfaces/data/dns-record.ts
Normal file
44
ts_interfaces/data/dns-record.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Supported DNS record types.
|
||||
*/
|
||||
export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'CAA';
|
||||
|
||||
/**
|
||||
* Where a DNS record came from.
|
||||
*
|
||||
* - 'local' → originated in this dcrouter (created via UI / API)
|
||||
* - 'synced' → pulled from an upstream provider (Cloudflare, foreign
|
||||
* dcrouter, …) during a sync operation
|
||||
*/
|
||||
export type TDnsRecordSource = 'local' | 'synced';
|
||||
|
||||
/**
|
||||
* A DNS record. For dcrouter-hosted (authoritative) domains, the record is
|
||||
* registered with the embedded smartdns.DnsServer. For provider-managed
|
||||
* domains, the record is mirrored from / pushed to the provider API and
|
||||
* `providerRecordId` holds the provider's internal record id (for updates
|
||||
* and deletes).
|
||||
*/
|
||||
export interface IDnsRecord {
|
||||
id: string;
|
||||
/** ID of the parent IDomain. */
|
||||
domainId: string;
|
||||
/** Fully qualified record name (e.g. 'www.example.com'). */
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
/**
|
||||
* Record value as a string. For MX records, formatted as
|
||||
* `<priority> <exchange>` (e.g. `10 mail.example.com`).
|
||||
*/
|
||||
value: string;
|
||||
/** TTL in seconds. */
|
||||
ttl: number;
|
||||
/** Cloudflare-specific: whether the record is proxied through Cloudflare. */
|
||||
proxied?: boolean;
|
||||
source: TDnsRecordSource;
|
||||
/** Provider's internal record id (for updates/deletes). Only set for provider records. */
|
||||
providerRecordId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
36
ts_interfaces/data/domain.ts
Normal file
36
ts_interfaces/data/domain.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Where a domain came from / how it is managed.
|
||||
*
|
||||
* - 'dcrouter' → dcrouter is the authoritative DNS server for this domain;
|
||||
* records are served by the embedded smartdns.DnsServer.
|
||||
* Operators delegate the domain's NS records to make this
|
||||
* effective.
|
||||
* - 'provider' → domain was imported from an external DNS provider
|
||||
* (e.g. Cloudflare). The provider stays authoritative;
|
||||
* dcrouter only reads/writes records via the provider API.
|
||||
*/
|
||||
export type TDomainSource = 'dcrouter' | 'provider';
|
||||
|
||||
/**
|
||||
* A domain under management by dcrouter.
|
||||
*/
|
||||
export interface IDomain {
|
||||
id: string;
|
||||
/** Fully qualified domain name (e.g. 'example.com'). */
|
||||
name: string;
|
||||
source: TDomainSource;
|
||||
/** ID of the DnsProvider that owns this domain — only set when source === 'provider'. */
|
||||
providerId?: string;
|
||||
/** True when dcrouter is the authoritative DNS server for this domain (source === 'dcrouter'). */
|
||||
authoritative: boolean;
|
||||
/** Authoritative nameservers (display only — populated from provider for imported domains). */
|
||||
nameservers?: string[];
|
||||
/** Provider's internal zone identifier — only set when source === 'provider'. */
|
||||
externalZoneId?: string;
|
||||
/** Last time records were synced from the provider — only set when source === 'provider'. */
|
||||
lastSyncedAt?: number;
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -3,4 +3,9 @@ export * from './stats.js';
|
||||
export * from './remoteingress.js';
|
||||
export * from './route-management.js';
|
||||
export * from './target-profile.js';
|
||||
export * from './vpn.js';
|
||||
export * from './vpn.js';
|
||||
export * from './dns-provider.js';
|
||||
export * from './domain.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
@@ -14,7 +14,11 @@ export type TApiTokenScope =
|
||||
| 'tokens:read' | 'tokens:manage'
|
||||
| 'source-profiles:read' | 'source-profiles:write'
|
||||
| 'target-profiles:read' | 'target-profiles:write'
|
||||
| 'targets:read' | 'targets:write';
|
||||
| 'targets:read' | 'targets:write'
|
||||
| 'dns-providers:read' | 'dns-providers:write'
|
||||
| 'domains:read' | 'domains:write'
|
||||
| 'dns-records:read' | 'dns-records:write'
|
||||
| 'acme-config:read' | 'acme-config:write';
|
||||
|
||||
// ============================================================================
|
||||
// Source Profile Types (source-side: who can access)
|
||||
@@ -79,24 +83,23 @@ export interface IRouteMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* A merged route combining hardcoded and programmatic sources.
|
||||
* A route entry returned by the route management API.
|
||||
*/
|
||||
export interface IMergedRoute {
|
||||
route: IDcRouterRouteConfig;
|
||||
source: 'hardcoded' | 'programmatic';
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
overridden: boolean;
|
||||
storedRouteId?: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
metadata?: IRouteMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning generated during route merge/startup.
|
||||
* A warning generated during route startup/apply.
|
||||
*/
|
||||
export interface IRouteWarning {
|
||||
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override';
|
||||
type: 'disabled-route';
|
||||
routeName: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -119,28 +122,19 @@ 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;
|
||||
route: IDcRouterRouteConfig;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
origin: 'config' | 'email' | 'dns' | 'api';
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -143,6 +143,15 @@ export interface IHealthStatus {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface IDomainActivity {
|
||||
domain: string;
|
||||
bytesInPerSecond: number;
|
||||
bytesOutPerSecond: number;
|
||||
activeConnections: number;
|
||||
routeCount: number;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
export interface INetworkMetrics {
|
||||
totalBandwidth: {
|
||||
in: number;
|
||||
@@ -156,12 +165,21 @@ export interface INetworkMetrics {
|
||||
connectionDetails: IConnectionDetails[];
|
||||
topEndpoints: Array<{
|
||||
endpoint: string;
|
||||
requests: number;
|
||||
connections: number;
|
||||
bandwidth: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
topEndpointsByBandwidth: Array<{
|
||||
endpoint: string;
|
||||
connections: number;
|
||||
bandwidth: {
|
||||
in: number;
|
||||
out: number;
|
||||
};
|
||||
}>;
|
||||
domainActivity: IDomainActivity[];
|
||||
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
|
||||
requestsPerSecond?: number;
|
||||
requestsTotal?: number;
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface ITargetProfile {
|
||||
domains?: string[];
|
||||
/** Specific IP:port targets this profile grants access to */
|
||||
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[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
|
||||
54
ts_interfaces/requests/acme-config.ts
Normal file
54
ts_interfaces/requests/acme-config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IAcmeConfig } from '../data/acme-config.js';
|
||||
|
||||
// ============================================================================
|
||||
// ACME Config Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the current ACME configuration. Returns null if no config has been
|
||||
* set yet (neither from DB nor seeded from the constructor).
|
||||
*/
|
||||
export interface IReq_GetAcmeConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAcmeConfig
|
||||
> {
|
||||
method: 'getAcmeConfig';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
config: IAcmeConfig | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the ACME configuration (upsert). All fields are required on first
|
||||
* create, optional on subsequent updates (partial update).
|
||||
*
|
||||
* NOTE: Most fields take effect on the next dcrouter restart — SmartAcme is
|
||||
* instantiated once at startup. `renewThresholdDays` applies immediately to
|
||||
* the next renewal check.
|
||||
*/
|
||||
export interface IReq_UpdateAcmeConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateAcmeConfig
|
||||
> {
|
||||
method: 'updateAcmeConfig';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
accountEmail?: string;
|
||||
enabled?: boolean;
|
||||
useProduction?: boolean;
|
||||
autoRenew?: boolean;
|
||||
renewThresholdDays?: number;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
config?: IAcmeConfig;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
154
ts_interfaces/requests/dns-providers.ts
Normal file
154
ts_interfaces/requests/dns-providers.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type {
|
||||
IDnsProviderPublic,
|
||||
IProviderDomainListing,
|
||||
TDnsProviderType,
|
||||
TDnsProviderCredentials,
|
||||
} from '../data/dns-provider.js';
|
||||
|
||||
// ============================================================================
|
||||
// DNS Provider Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all DNS providers (public view, no secrets).
|
||||
*/
|
||||
export interface IReq_GetDnsProviders extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsProviders
|
||||
> {
|
||||
method: 'getDnsProviders';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
providers: IDnsProviderPublic[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single DNS provider by id.
|
||||
*/
|
||||
export interface IReq_GetDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsProvider
|
||||
> {
|
||||
method: 'getDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
provider: IDnsProviderPublic | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DNS provider.
|
||||
*/
|
||||
export interface IReq_CreateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDnsProvider
|
||||
> {
|
||||
method: 'createDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
type: TDnsProviderType;
|
||||
credentials: TDnsProviderCredentials;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a DNS provider. Only supplied fields are updated.
|
||||
* Pass `credentials` to rotate the secret.
|
||||
*/
|
||||
export interface IReq_UpdateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateDnsProvider
|
||||
> {
|
||||
method: 'updateDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
credentials?: TDnsProviderCredentials;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DNS provider. Fails if any IDomain still references it
|
||||
* unless `force: true` is set.
|
||||
*/
|
||||
export interface IReq_DeleteDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDnsProvider
|
||||
> {
|
||||
method: 'deleteDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to a DNS provider. Used both for newly-saved
|
||||
* providers and on demand from the UI.
|
||||
*/
|
||||
export interface IReq_TestDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_TestDnsProvider
|
||||
> {
|
||||
method: 'testDnsProvider';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
testedAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List the domains visible to a DNS provider's API account.
|
||||
* Used when importing — does NOT persist anything.
|
||||
*/
|
||||
export interface IReq_ListProviderDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListProviderDomains
|
||||
> {
|
||||
method: 'listProviderDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
providerId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
domains?: IProviderDomainListing[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
113
ts_interfaces/requests/dns-records.ts
Normal file
113
ts_interfaces/requests/dns-records.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IDnsRecord, TDnsRecordType } from '../data/dns-record.js';
|
||||
|
||||
// ============================================================================
|
||||
// DNS Record Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all DNS records for a domain.
|
||||
*/
|
||||
export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsRecords
|
||||
> {
|
||||
method: 'getDnsRecords';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domainId: string;
|
||||
};
|
||||
response: {
|
||||
records: IDnsRecord[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single DNS record by id.
|
||||
*/
|
||||
export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDnsRecord
|
||||
> {
|
||||
method: 'getDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
record: IDnsRecord | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new DNS record.
|
||||
*
|
||||
* For dcrouter-hosted domains: registers the record with the embedded DnsServer.
|
||||
* For provider domains: pushes the record to the provider API.
|
||||
*/
|
||||
export interface IReq_CreateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDnsRecord
|
||||
> {
|
||||
method: 'createDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
domainId: string;
|
||||
name: string;
|
||||
type: TDnsRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a DNS record.
|
||||
*/
|
||||
export interface IReq_UpdateDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateDnsRecord
|
||||
> {
|
||||
method: 'updateDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DNS record.
|
||||
*/
|
||||
export interface IReq_DeleteDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDnsRecord
|
||||
> {
|
||||
method: 'deleteDnsRecord';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
178
ts_interfaces/requests/domains.ts
Normal file
178
ts_interfaces/requests/domains.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IDomain } from '../data/domain.js';
|
||||
|
||||
// ============================================================================
|
||||
// Domain Endpoints
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all domains under management.
|
||||
*/
|
||||
export interface IReq_GetDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDomains
|
||||
> {
|
||||
method: 'getDomains';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
domains: IDomain[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single domain by id.
|
||||
*/
|
||||
export interface IReq_GetDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetDomain
|
||||
> {
|
||||
method: 'getDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
domain: IDomain | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
|
||||
* DNS records for this domain via the embedded smartdns.DnsServer.
|
||||
*/
|
||||
export interface IReq_CreateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateDomain
|
||||
> {
|
||||
method: 'createDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import one or more domains from a DNS provider. For each imported
|
||||
* domain, records are pulled from the provider into DnsRecordDoc.
|
||||
*/
|
||||
export interface IReq_ImportDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ImportDomain
|
||||
> {
|
||||
method: 'importDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
providerId: string;
|
||||
/** FQDN(s) of the zone(s) to import — must be visible to the provider account. */
|
||||
domainNames: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
importedIds?: string[];
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a domain's metadata. Cannot change source / providerId once set.
|
||||
*/
|
||||
export interface IReq_UpdateDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateDomain
|
||||
> {
|
||||
method: 'updateDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
description?: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a domain and all of its DNS records.
|
||||
* For provider-managed domains, this only removes dcrouter's local record —
|
||||
* it does NOT delete the zone at the provider.
|
||||
*/
|
||||
export interface IReq_DeleteDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteDomain
|
||||
> {
|
||||
method: 'deleteDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-resync a provider-managed domain: re-pulls all records from the
|
||||
* provider API, replacing the cached DnsRecordDocs.
|
||||
* No-op for dcrouter-hosted domains.
|
||||
*/
|
||||
export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_SyncDomain
|
||||
> {
|
||||
method: 'syncDomain';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
id: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
recordCount?: number;
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -12,4 +12,10 @@ export * from './api-tokens.js';
|
||||
export * from './vpn.js';
|
||||
export * from './source-profiles.js';
|
||||
export * from './target-profiles.js';
|
||||
export * from './network-targets.js';
|
||||
export * from './network-targets.js';
|
||||
export * from './users.js';
|
||||
export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.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<
|
||||
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<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
storedRouteId?: string;
|
||||
routeId?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a programmatic route.
|
||||
* Update a route.
|
||||
*/
|
||||
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
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<
|
||||
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).
|
||||
*/
|
||||
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.
|
||||
* Toggle a route on/off by id.
|
||||
*/
|
||||
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
|
||||
@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
|
||||
requestsPerSecond: number;
|
||||
requestsTotal: number;
|
||||
backends?: statsInterfaces.IBackendInfo[];
|
||||
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
|
||||
domainActivity: statsInterfaces.IDomainActivity[];
|
||||
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
|
||||
};
|
||||
}
|
||||
23
ts_interfaces/requests/users.ts
Normal file
23
ts_interfaces/requests/users.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
/**
|
||||
* List all OpsServer users (admin-only, read-only).
|
||||
* Deliberately omits password/secret fields from the response.
|
||||
*/
|
||||
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_ListUsers
|
||||
> {
|
||||
method: 'listUsers';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
users: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,30 @@ export interface IMigrationRunner {
|
||||
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)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -48,22 +72,68 @@ export async function createMigrationRunner(
|
||||
.step('rename-target-profile-host-to-ip')
|
||||
.from('13.0.11').to('13.1.0')
|
||||
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
|
||||
.up(async (ctx) => migrateTargetProfileTargetHosts(ctx))
|
||||
.step('rename-domain-source-manual-to-dcrouter')
|
||||
.from('13.1.0').to('13.8.1')
|
||||
.description('Rename DomainDoc.source value from "manual" to "dcrouter"')
|
||||
.up(async (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++;
|
||||
const collection = ctx.mongo!.collection('domaindoc');
|
||||
const result = await collection.updateMany(
|
||||
{ source: 'manual' },
|
||||
{ $set: { source: 'dcrouter' } },
|
||||
);
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`rename-domain-source-manual-to-dcrouter: migrated ${result.modifiedCount} domain(s)`,
|
||||
);
|
||||
})
|
||||
.step('rename-record-source-manual-to-local')
|
||||
.from('13.8.1').to('13.8.2')
|
||||
.description('Rename DnsRecordDoc.source value from "manual" to "local"')
|
||||
.up(async (ctx) => {
|
||||
const collection = ctx.mongo!.collection('dnsrecorddoc');
|
||||
const result = await collection.updateMany(
|
||||
{ source: 'manual' },
|
||||
{ $set: { source: 'local' } },
|
||||
);
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`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');
|
||||
}
|
||||
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
return migration;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.1.3',
|
||||
version: '13.17.8',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2
ts_web/elements/access/index.ts
Normal file
2
ts_web/elements/access/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ops-view-apitokens.js';
|
||||
export * from './ops-view-users.js';
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
const { apiTokens } = this.routeState;
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">API Tokens</dees-heading>
|
||||
<dees-heading level="3">API Tokens</dees-heading>
|
||||
|
||||
<div class="apiTokensContainer">
|
||||
<dees-table
|
||||
@@ -109,6 +109,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
.data=${apiTokens}
|
||||
.dataName=${'token'}
|
||||
.searchable=${true}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
|
||||
name: token.name,
|
||||
scopes: this.renderScopePills(token.scopes),
|
||||
@@ -221,7 +222,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
.suggestions=${allScopes}
|
||||
.required=${true}
|
||||
></dees-input-tags>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in (days, blank = never)'}></dees-input-text>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
140
ts_web/elements/access/ops-view-users.ts
Normal file
140
ts_web/elements/access/ops-view-users.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('ops-view-users')
|
||||
export class OpsViewUsers extends DeesElement {
|
||||
@state() accessor usersState: appstate.IUsersState = {
|
||||
users: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
@state() accessor loginState: appstate.ILoginState = {
|
||||
identity: null,
|
||||
isLoggedIn: false,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const usersSub = appstate.usersStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((usersState) => {
|
||||
this.usersState = usersState;
|
||||
});
|
||||
this.rxSubscriptions.push(usersSub);
|
||||
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((loginState) => {
|
||||
this.loginState = loginState;
|
||||
// Re-fetch users when user logs in (fixes race condition where
|
||||
// the view is created before authentication completes)
|
||||
if (loginState.isLoggedIn) {
|
||||
appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.usersContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
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;
|
||||
}
|
||||
|
||||
.roleBadge.admin {
|
||||
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
||||
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||
}
|
||||
|
||||
.roleBadge.user {
|
||||
background: ${cssManager.bdTheme('#e0f2fe', '#0c4a6e')};
|
||||
color: ${cssManager.bdTheme('#075985', '#7dd3fc')};
|
||||
}
|
||||
|
||||
.sessionBadge {
|
||||
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;
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.userIdCell {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const { users } = this.usersState;
|
||||
const currentUserId = this.loginState.identity?.userId;
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Users</dees-heading>
|
||||
|
||||
<div class="usersContainer">
|
||||
<dees-table
|
||||
.heading1=${'Users'}
|
||||
.heading2=${'OpsServer user accounts'}
|
||||
.data=${users}
|
||||
.dataName=${'user'}
|
||||
.searchable=${true}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(user: appstate.IUser) => ({
|
||||
ID: html`<span class="userIdCell">${user.id}</span>`,
|
||||
Username: user.username,
|
||||
Role: this.renderRoleBadge(user.role),
|
||||
Session: user.id === currentUserId
|
||||
? html`<span class="sessionBadge">current</span>`
|
||||
: '',
|
||||
})}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRoleBadge(role: string): TemplateResult {
|
||||
const cls = role === 'admin' ? 'admin' : 'user';
|
||||
return html`<span class="roleBadge ${cls}">${role}</span>`;
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
if (this.loginState.isLoggedIn) {
|
||||
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
209
ts_web/elements/domains/dns-provider-form.ts
Normal file
209
ts_web/elements/domains/dns-provider-form.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
DeesElement,
|
||||
html,
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
css,
|
||||
state,
|
||||
property,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dns-provider-form': DnsProviderForm;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive credential form for a DNS provider. Renders the type picker
|
||||
* and the credential fields for the currently-selected type.
|
||||
*
|
||||
* Provider-agnostic — driven entirely by `dnsProviderTypeDescriptors` from
|
||||
* `ts_interfaces/data/dns-provider.ts`. Adding a new provider type means
|
||||
* appending one entry to the descriptors array; this form picks it up
|
||||
* automatically.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* const formEl = document.createElement('dns-provider-form');
|
||||
* formEl.providerName = 'My provider';
|
||||
* // ... pass element into a DeesModal as content ...
|
||||
* // on submit:
|
||||
* const data = formEl.collectData();
|
||||
* // → { name, type, credentials }
|
||||
*
|
||||
* In edit mode, set `lockType = true` so the user cannot change provider
|
||||
* type after creation (credentials shapes don't transfer between types).
|
||||
*/
|
||||
@customElement('dns-provider-form')
|
||||
export class DnsProviderForm extends DeesElement {
|
||||
/** Pre-populated provider name. */
|
||||
@property({ type: String })
|
||||
accessor providerName: string = '';
|
||||
|
||||
/**
|
||||
* Currently selected provider type. Initialized to the first user-creatable
|
||||
* descriptor; caller can override before mounting (e.g. for edit dialogs).
|
||||
* The built-in 'dcrouter' pseudo-provider is excluded from the picker —
|
||||
* operators cannot create another one.
|
||||
*/
|
||||
@state()
|
||||
accessor selectedType: interfaces.data.TDnsProviderType =
|
||||
interfaces.data.dnsProviderTypeDescriptors.find((d) => d.type !== 'dcrouter')?.type ??
|
||||
'cloudflare';
|
||||
|
||||
/** When true, hide the type picker — used in edit dialogs. */
|
||||
@property({ type: Boolean })
|
||||
accessor lockType: boolean = false;
|
||||
|
||||
/**
|
||||
* Help text shown above credentials. Useful for edit dialogs to indicate
|
||||
* that fields can be left blank to keep current values.
|
||||
*/
|
||||
@property({ type: String })
|
||||
accessor credentialsHint: string = '';
|
||||
|
||||
/** Internal map of credential field values, keyed by the descriptor's `key`. */
|
||||
@state()
|
||||
accessor credentialValues: Record<string, string> = {};
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.credentialsHint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
// Exclude the built-in 'dcrouter' pseudo-provider from the type picker —
|
||||
// operators cannot create another one, it's surfaced at read time by the
|
||||
// backend handler instead.
|
||||
const descriptors = interfaces.data.dnsProviderTypeDescriptors.filter(
|
||||
(d) => d.type !== 'dcrouter',
|
||||
);
|
||||
const descriptor = interfaces.data.getDnsProviderTypeDescriptor(this.selectedType);
|
||||
|
||||
return html`
|
||||
<dees-form>
|
||||
<div class="field">
|
||||
<dees-input-text
|
||||
.key=${'name'}
|
||||
.label=${'Provider name'}
|
||||
.value=${this.providerName}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
|
||||
${this.lockType
|
||||
? html`
|
||||
<div class="field">
|
||||
<dees-input-text
|
||||
.key=${'__type_display'}
|
||||
.label=${'Type'}
|
||||
.infoText=${descriptor?.description || ''}
|
||||
.value=${descriptor?.displayName ?? this.selectedType}
|
||||
.disabled=${true}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="field">
|
||||
<dees-input-dropdown
|
||||
.key=${'__type'}
|
||||
.label=${'Provider type'}
|
||||
.infoText=${descriptor?.description || ''}
|
||||
.options=${descriptors.map((d) => ({ option: d.displayName, key: d.type }))}
|
||||
.selectedOption=${descriptor
|
||||
? { option: descriptor.displayName, key: descriptor.type }
|
||||
: undefined}
|
||||
@selectedOption=${(e: CustomEvent) => {
|
||||
const newType = (e.detail as any)?.key as
|
||||
| interfaces.data.TDnsProviderType
|
||||
| undefined;
|
||||
if (newType && newType !== this.selectedType) {
|
||||
this.selectedType = newType;
|
||||
this.credentialValues = {};
|
||||
}
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
`}
|
||||
${descriptor
|
||||
? html`
|
||||
${this.credentialsHint
|
||||
? html`<div class="credentialsHint">${this.credentialsHint}</div>`
|
||||
: ''}
|
||||
${descriptor.credentialFields.map(
|
||||
(f) => html`
|
||||
<div class="field">
|
||||
<dees-input-text
|
||||
.key=${f.key}
|
||||
.label=${f.label}
|
||||
.description=${f.helpText || ''}
|
||||
.required=${f.required && !this.lockType}
|
||||
></dees-input-text>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
`
|
||||
: html`<p>No provider types registered.</p>`}
|
||||
</dees-form>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the form values and assemble the create/update payload.
|
||||
* Returns the typed credentials object built from the descriptor's keys.
|
||||
*/
|
||||
public async collectData(): Promise<{
|
||||
name: string;
|
||||
type: interfaces.data.TDnsProviderType;
|
||||
credentials: interfaces.data.TDnsProviderCredentials;
|
||||
credentialsTouched: boolean;
|
||||
} | null> {
|
||||
const form = this.shadowRoot?.querySelector('dees-form') as any;
|
||||
if (!form) return null;
|
||||
const data = await form.collectFormData();
|
||||
const descriptor = interfaces.data.getDnsProviderTypeDescriptor(this.selectedType);
|
||||
if (!descriptor) return null;
|
||||
|
||||
// Build the credentials object from the descriptor's field keys.
|
||||
const credsBody: Record<string, string> = {};
|
||||
let credentialsTouched = false;
|
||||
for (const f of descriptor.credentialFields) {
|
||||
const value = data[f.key];
|
||||
if (value !== undefined && value !== null && String(value).length > 0) {
|
||||
credsBody[f.key] = String(value);
|
||||
credentialsTouched = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The discriminator goes on the credentials object so the backend
|
||||
// factory and the discriminated union both stay happy.
|
||||
const credentials = {
|
||||
type: this.selectedType,
|
||||
...credsBody,
|
||||
} as unknown as interfaces.data.TDnsProviderCredentials;
|
||||
|
||||
return {
|
||||
name: String(data.name ?? ''),
|
||||
type: this.selectedType,
|
||||
credentials,
|
||||
credentialsTouched,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
ts_web/elements/domains/index.ts
Normal file
5
ts_web/elements/domains/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './dns-provider-form.js';
|
||||
export * from './ops-view-providers.js';
|
||||
export * from './ops-view-domains.js';
|
||||
export * from './ops-view-dns.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
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 * 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 {
|
||||
@@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement {
|
||||
@state()
|
||||
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
|
||||
|
||||
@state()
|
||||
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||
this.certState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
this.rxSubscriptions.push(certSub);
|
||||
const acmeSub = appstate.acmeConfigStatePart.select().subscribe((newState) => {
|
||||
this.acmeState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(acmeSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
||||
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -159,15 +167,121 @@ export class OpsViewCertificates extends DeesElement {
|
||||
const { summary } = this.certState;
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Certificates</dees-heading>
|
||||
<dees-heading level="3">Certificates</dees-heading>
|
||||
|
||||
<div class="certificatesContainer">
|
||||
${this.renderStatsTiles(summary)}
|
||||
${this.renderAcmeSettingsTile()}
|
||||
${this.renderCertificateTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAcmeSettingsTile(): TemplateResult {
|
||||
const config = this.acmeState.config;
|
||||
|
||||
if (!config) {
|
||||
return html`
|
||||
<dees-settings
|
||||
.heading=${'ACME Settings'}
|
||||
.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.'}
|
||||
.actions=${[{ name: 'Configure', action: () => this.showEditAcmeDialog() }]}
|
||||
></dees-settings>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<dees-settings
|
||||
.heading=${'ACME Settings'}
|
||||
.settingsFields=${[
|
||||
{ key: 'email', label: 'Account email', value: config.accountEmail || '(not set)' },
|
||||
{ key: 'status', label: 'Status', value: config.enabled ? 'enabled' : 'disabled' },
|
||||
{ key: 'mode', label: 'Mode', value: config.useProduction ? 'production' : 'staging' },
|
||||
{ key: 'autoRenew', label: 'Auto-renew', value: config.autoRenew ? 'on' : 'off' },
|
||||
{ key: 'threshold', label: 'Renewal threshold', value: `${config.renewThresholdDays} days` },
|
||||
]}
|
||||
.actions=${[{ name: 'Edit', action: () => this.showEditAcmeDialog() }]}
|
||||
></dees-settings>
|
||||
`;
|
||||
}
|
||||
|
||||
private async showEditAcmeDialog() {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const current = this.acmeState.config;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: current ? 'Edit ACME Settings' : 'Configure ACME',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.key=${'accountEmail'}
|
||||
.label=${'Account email'}
|
||||
.value=${current?.accountEmail ?? ''}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enabled'}
|
||||
.value=${current?.enabled ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'useProduction'}
|
||||
.label=${"Use Let's Encrypt production"}
|
||||
.description=${'Uncheck to use the staging environment'}
|
||||
.value=${current?.useProduction ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'autoRenew'}
|
||||
.label=${'Auto-renew certificates'}
|
||||
.value=${current?.autoRenew ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'renewThresholdDays'}
|
||||
.label=${'Renewal threshold'}
|
||||
.description=${'Number of days before expiry to trigger renewal'}
|
||||
.value=${String(current?.renewThresholdDays ?? 30)}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
Most fields take effect on the next dcrouter restart (SmartAcme is instantiated once at
|
||||
startup). Changing the account email creates a new Let's Encrypt account — only do this
|
||||
if you know what you're doing.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot
|
||||
?.querySelector('.content')
|
||||
?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const email = String(data.accountEmail ?? '').trim();
|
||||
if (!email) {
|
||||
DeesToast.show({
|
||||
message: 'Account email is required',
|
||||
type: 'warning',
|
||||
duration: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const threshold = parseInt(String(data.renewThresholdDays ?? '30'), 10);
|
||||
await appstate.acmeConfigStatePart.dispatchAction(appstate.updateAcmeConfigAction, {
|
||||
accountEmail: email,
|
||||
enabled: Boolean(data.enabled),
|
||||
useProduction: Boolean(data.useProduction),
|
||||
autoRenew: Boolean(data.autoRenew),
|
||||
renewThresholdDays: Number.isFinite(threshold) ? threshold : 30,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
@@ -228,6 +342,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.certState.certificates}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
|
||||
Domain: cert.domain,
|
||||
Routes: this.renderRoutePills(cert.routeNames),
|
||||
@@ -252,11 +367,12 @@ export class OpsViewCertificates extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-fileupload
|
||||
key="certJsonFile"
|
||||
label="Certificate JSON (.tsclass.cert.json)"
|
||||
accept=".json"
|
||||
.key=${'certJsonFile'}
|
||||
.label=${'Certificate JSON'}
|
||||
.description=${'Upload a .tsclass.cert.json file'}
|
||||
.accept=${'.json'}
|
||||
.multiple=${false}
|
||||
required
|
||||
.required=${true}
|
||||
></dees-input-fileupload>
|
||||
</dees-form>
|
||||
`,
|
||||
274
ts_web/elements/domains/ops-view-dns.ts
Normal file
274
ts_web/elements/domains/ops-view-dns.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
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';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-dns': OpsViewDns;
|
||||
}
|
||||
}
|
||||
|
||||
const RECORD_TYPES: interfaces.data.TDnsRecordType[] = [
|
||||
'A',
|
||||
'AAAA',
|
||||
'CNAME',
|
||||
'MX',
|
||||
'TXT',
|
||||
'NS',
|
||||
'CAA',
|
||||
];
|
||||
|
||||
@customElement('ops-view-dns')
|
||||
export class OpsViewDns extends DeesElement {
|
||||
@state()
|
||||
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||
this.domainsState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||
// If a domain is already selected (e.g. via "View Records" navigation), refresh its records
|
||||
const selected = this.domainsState.selectedDomainId;
|
||||
if (selected) {
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.fetchDnsRecordsForDomainAction, {
|
||||
domainId: selected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.dnsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.domainPicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sourceBadge.local {
|
||||
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||
}
|
||||
|
||||
.sourceBadge.synced {
|
||||
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
||||
color: ${cssManager.bdTheme('#92400e', '#fde047')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const domains = this.domainsState.domains;
|
||||
const selectedId = this.domainsState.selectedDomainId;
|
||||
const records = this.domainsState.records;
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">DNS Records</dees-heading>
|
||||
<div class="dnsContainer">
|
||||
<div class="domainPicker">
|
||||
<dees-input-dropdown
|
||||
.label=${'Domain'}
|
||||
.options=${domains.map((d) => ({ option: d.name, key: d.id }))}
|
||||
.selectedOption=${selectedId
|
||||
? { option: domains.find((d) => d.id === selectedId)?.name || '', key: selectedId }
|
||||
: undefined}
|
||||
@selectedOption=${async (e: CustomEvent) => {
|
||||
const id = (e.detail as any)?.key;
|
||||
if (!id) return;
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.fetchDnsRecordsForDomainAction,
|
||||
{ domainId: id },
|
||||
);
|
||||
}}
|
||||
></dees-input-dropdown>
|
||||
</div>
|
||||
|
||||
${selectedId
|
||||
? html`
|
||||
<dees-table
|
||||
.heading1=${'DNS Records'}
|
||||
.heading2=${this.domainHint(selectedId)}
|
||||
.data=${records}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(r: interfaces.data.IDnsRecord) => ({
|
||||
Name: r.name,
|
||||
Type: r.type,
|
||||
Value: r.value,
|
||||
TTL: r.ttl,
|
||||
Source: html`<span class="sourceBadge ${r.source}">${r.source}</span>`,
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Add Record',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.showCreateRecordDialog(selectedId);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:rotateCw',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.fetchDnsRecordsForDomainAction,
|
||||
{ domainId: selectedId },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const rec = actionData.item as interfaces.data.IDnsRecord;
|
||||
await this.showEditRecordDialog(rec);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const rec = actionData.item as interfaces.data.IDnsRecord;
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.deleteDnsRecordAction,
|
||||
{ id: rec.id, domainId: rec.domainId },
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`
|
||||
: html`<p style="opacity: 0.7;">Pick a domain above to view its records.</p>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private domainHint(domainId: string): string {
|
||||
const domain = this.domainsState.domains.find((d) => d.id === domainId);
|
||||
if (!domain) return '';
|
||||
if (domain.source === 'dcrouter') {
|
||||
return 'Records are served by dcrouter (authoritative).';
|
||||
}
|
||||
return 'Records are stored at the provider — changes here are pushed via the provider API.';
|
||||
}
|
||||
|
||||
private async showCreateRecordDialog(domainId: string) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Add DNS Record',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .description=${'Fully qualified domain name'} .required=${true}></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'type'}
|
||||
.label=${'Type'}
|
||||
.options=${RECORD_TYPES.map((t) => ({ option: t, key: t }))}
|
||||
.required=${true}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'value'}
|
||||
.label=${'Value'}
|
||||
.description=${'For MX records use priority format, e.g. "10 mail.example.com"'}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-text .key=${'ttl'} .label=${'TTL'} .description=${'Time to live in seconds'} .value=${'300'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot
|
||||
?.querySelector('.content')
|
||||
?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const type = (data.type?.key ?? data.type) as interfaces.data.TDnsRecordType;
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.createDnsRecordAction, {
|
||||
domainId,
|
||||
name: String(data.name),
|
||||
type,
|
||||
value: String(data.value),
|
||||
ttl: parseInt(String(data.ttl || '300'), 10),
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showEditRecordDialog(rec: interfaces.data.IDnsRecord) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: `Edit ${rec.type} ${rec.name}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .description=${'Fully qualified domain name'} .value=${rec.name}></dees-input-text>
|
||||
<dees-input-text .key=${'value'} .label=${'Value'} .value=${rec.value}></dees-input-text>
|
||||
<dees-input-text .key=${'ttl'} .label=${'TTL'} .description=${'Time to live in seconds'} .value=${String(rec.ttl)}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot
|
||||
?.querySelector('.content')
|
||||
?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsRecordAction, {
|
||||
id: rec.id,
|
||||
domainId: rec.domainId,
|
||||
name: String(data.name),
|
||||
value: String(data.value),
|
||||
ttl: parseInt(String(data.ttl || '300'), 10),
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
433
ts_web/elements/domains/ops-view-domains.ts
Normal file
433
ts_web/elements/domains/ops-view-domains.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
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 { appRouter } from '../../router.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-domains': OpsViewDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-domains')
|
||||
export class OpsViewDomains extends DeesElement {
|
||||
@state()
|
||||
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||
this.domainsState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.domainsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sourceBadge.dcrouter {
|
||||
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||
}
|
||||
|
||||
.sourceBadge.provider {
|
||||
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
||||
color: ${cssManager.bdTheme('#92400e', '#fde047')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const domains = this.domainsState.domains;
|
||||
const providersById = new Map(this.domainsState.providers.map((p) => [p.id, p]));
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Domains</dees-heading>
|
||||
<div class="domainsContainer">
|
||||
<dees-table
|
||||
.heading1=${'Domains'}
|
||||
.heading2=${'Domains under management — served by dcrouter (authoritative) or imported from a provider'}
|
||||
.data=${domains}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(d: interfaces.data.IDomain) => ({
|
||||
Name: d.name,
|
||||
Source: this.renderSourceBadge(d, providersById),
|
||||
Authoritative: d.authoritative ? 'yes' : 'no',
|
||||
Nameservers: d.nameservers?.join(', ') || '-',
|
||||
'Last Synced': d.lastSyncedAt
|
||||
? new Date(d.lastSyncedAt).toLocaleString()
|
||||
: '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Add DcRouter Domain',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.showCreateDcrouterDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Import from Provider',
|
||||
iconName: 'lucide:download',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.showImportDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:rotateCw',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.fetchDomainsAndProvidersAction,
|
||||
null,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Records',
|
||||
iconName: 'lucide:list',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const domain = actionData.item as interfaces.data.IDomain;
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.fetchDnsRecordsForDomainAction,
|
||||
{ domainId: domain.id },
|
||||
);
|
||||
appRouter.navigateToView('domains', 'dns');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sync Now',
|
||||
iconName: 'lucide:rotateCw',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const domain = actionData.item as interfaces.data.IDomain;
|
||||
if (domain.source !== 'provider') {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({
|
||||
message: 'Sync only applies to provider-managed domains',
|
||||
type: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.syncDomainAction, {
|
||||
id: domain.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
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',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const domain = actionData.item as interfaces.data.IDomain;
|
||||
await this.deleteDomain(domain);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSourceBadge(
|
||||
d: interfaces.data.IDomain,
|
||||
providersById: Map<string, interfaces.data.IDnsProviderPublic>,
|
||||
): TemplateResult {
|
||||
if (d.source === 'dcrouter') {
|
||||
return html`<span class="sourceBadge dcrouter">DcRouter</span>`;
|
||||
}
|
||||
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
|
||||
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
|
||||
}
|
||||
|
||||
private async showCreateDcrouterDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Add DcRouter Domain',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'FQDN'} .description=${'e.g. example.com'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
</dees-form>
|
||||
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
dcrouter will become the authoritative DNS server for this domain. You'll need to
|
||||
delegate the domain's nameservers to dcrouter to make this effective.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot
|
||||
?.querySelector('.content')
|
||||
?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.createDcrouterDomainAction, {
|
||||
name: String(data.name),
|
||||
description: data.description ? String(data.description) : undefined,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showImportDialog() {
|
||||
const providers = this.domainsState.providers;
|
||||
if (providers.length === 0) {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesToast.show({
|
||||
message: 'Add a DNS provider first (Domains > Providers)',
|
||||
type: 'warning',
|
||||
duration: 3500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Import Domains from Provider',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
.key=${'providerId'}
|
||||
.label=${'Provider'}
|
||||
.options=${providers.map((p) => ({ option: p.name, key: p.id }))}
|
||||
.required=${true}
|
||||
></dees-input-dropdown>
|
||||
<dees-input-text
|
||||
.key=${'domainNames'}
|
||||
.label=${'Domain Names'}
|
||||
.description=${'Comma-separated FQDNs, e.g. example.com, foo.com'}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
Tip: use "List Provider Domains" to see what's available before typing.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'List Provider Domains',
|
||||
action: async (_modalArg: any) => {
|
||||
const form = _modalArg.shadowRoot
|
||||
?.querySelector('.content')
|
||||
?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const providerKey = data.providerId?.key ?? data.providerId;
|
||||
if (!providerKey) {
|
||||
DeesToast.show({ message: 'Pick a provider first', type: 'warning', duration: 2500 });
|
||||
return;
|
||||
}
|
||||
const result = await appstate.fetchProviderDomains(String(providerKey));
|
||||
if (!result.success) {
|
||||
DeesToast.show({
|
||||
message: result.message || 'Failed to fetch domains',
|
||||
type: 'error',
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const list = (result.domains ?? []).map((d) => d.name).join(', ');
|
||||
DeesToast.show({
|
||||
message: `Provider has: ${list || '(none)'}`,
|
||||
type: 'info',
|
||||
duration: 8000,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Import',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot
|
||||
?.querySelector('.content')
|
||||
?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const providerKey = data.providerId?.key ?? data.providerId;
|
||||
if (!providerKey) {
|
||||
DeesToast.show({ message: 'Pick a provider', type: 'warning', duration: 2500 });
|
||||
return;
|
||||
}
|
||||
const names = String(data.domainNames || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (names.length === 0) {
|
||||
DeesToast.show({ message: 'Enter at least one FQDN', type: 'warning', duration: 2500 });
|
||||
return;
|
||||
}
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.importDomainsFromProviderAction,
|
||||
{ providerId: String(providerKey), domainNames: names },
|
||||
);
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
DeesModal.createAndShow({
|
||||
heading: `Delete domain ${domain.name}?`,
|
||||
content: html`
|
||||
<p>
|
||||
${domain.source === 'provider'
|
||||
? 'This removes the domain and its cached records from dcrouter only. The zone at the provider is NOT touched.'
|
||||
: 'This removes the domain and all of its DNS records from dcrouter. dcrouter will no longer answer queries for this domain after the next restart.'}
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Delete',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.deleteDomainAction, {
|
||||
id: domain.id,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
298
ts_web/elements/domains/ops-view-providers.ts
Normal file
298
ts_web/elements/domains/ops-view-providers.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
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 './dns-provider-form.js';
|
||||
import type { DnsProviderForm } from './dns-provider-form.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-providers': OpsViewProviders;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-providers')
|
||||
export class OpsViewProviders extends DeesElement {
|
||||
@state()
|
||||
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||
this.domainsState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.providersContainer {
|
||||
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;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statusBadge.ok {
|
||||
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||
}
|
||||
|
||||
.statusBadge.error {
|
||||
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.statusBadge.untested {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
|
||||
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
|
||||
}
|
||||
|
||||
.statusBadge.builtin {
|
||||
background: ${cssManager.bdTheme('#e0e7ff', '#1e1b4b')};
|
||||
color: ${cssManager.bdTheme('#3730a3', '#a5b4fc')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const providers = this.domainsState.providers;
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">DNS Providers</dees-heading>
|
||||
<div class="providersContainer">
|
||||
<dees-table
|
||||
.heading1=${'Providers'}
|
||||
.heading2=${'Built-in dcrouter + external DNS provider accounts'}
|
||||
.data=${providers}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
|
||||
Name: p.name,
|
||||
Type: this.providerTypeLabel(p.type),
|
||||
Status: p.builtIn
|
||||
? html`<span class="statusBadge builtin">built-in</span>`
|
||||
: this.renderStatusBadge(p.status),
|
||||
'Last Tested': p.builtIn
|
||||
? '—'
|
||||
: p.lastTestedAt
|
||||
? new Date(p.lastTestedAt).toLocaleString()
|
||||
: 'never',
|
||||
Error: p.builtIn ? '—' : p.lastError || '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Add Provider',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.showCreateDialog();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Refresh',
|
||||
iconName: 'lucide:rotateCw',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await appstate.domainsStatePart.dispatchAction(
|
||||
appstate.fetchDomainsAndProvidersAction,
|
||||
null,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Test Connection',
|
||||
iconName: 'lucide:plug',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
|
||||
await this.testProvider(provider);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
|
||||
await this.showEditDialog(provider);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (p: interfaces.data.IDnsProviderPublic) => !p.builtIn,
|
||||
actionFunc: async (actionData: any) => {
|
||||
const provider = actionData.item as interfaces.data.IDnsProviderPublic;
|
||||
await this.deleteProvider(provider);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(status: interfaces.data.TDnsProviderStatus): TemplateResult {
|
||||
return html`<span class="statusBadge ${status}">${status}</span>`;
|
||||
}
|
||||
|
||||
private providerTypeLabel(type: interfaces.data.TDnsProviderType): string {
|
||||
return interfaces.data.getDnsProviderTypeDescriptor(type)?.displayName ?? type;
|
||||
}
|
||||
|
||||
private async showCreateDialog() {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const formEl = document.createElement('dns-provider-form') as DnsProviderForm;
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Add DNS Provider',
|
||||
content: html`${formEl}`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
action: async (modalArg: any) => {
|
||||
const data = await formEl.collectData();
|
||||
if (!data) return;
|
||||
if (!data.name) {
|
||||
DeesToast.show({ message: 'Name is required', type: 'warning', duration: 2500 });
|
||||
return;
|
||||
}
|
||||
if (!data.credentialsTouched) {
|
||||
DeesToast.show({
|
||||
message: 'Fill in the provider credentials',
|
||||
type: 'warning',
|
||||
duration: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
credentials: data.credentials,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const formEl = document.createElement('dns-provider-form') as DnsProviderForm;
|
||||
formEl.providerName = provider.name;
|
||||
formEl.selectedType = provider.type;
|
||||
formEl.lockType = true;
|
||||
formEl.credentialsHint =
|
||||
'Leave credential fields blank to keep the current values. Fill them to rotate.';
|
||||
DeesModal.createAndShow({
|
||||
heading: `Edit Provider: ${provider.name}`,
|
||||
content: html`${formEl}`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (modalArg: any) => {
|
||||
const data = await formEl.collectData();
|
||||
if (!data) return;
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, {
|
||||
id: provider.id,
|
||||
name: data.name || provider.name,
|
||||
// Only send credentials if the user actually entered something —
|
||||
// otherwise we keep the current secret untouched.
|
||||
credentials: data.credentialsTouched ? data.credentials : undefined,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async testProvider(provider: interfaces.data.IDnsProviderPublic) {
|
||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.testDnsProviderAction, {
|
||||
id: provider.id,
|
||||
});
|
||||
const updated = appstate.domainsStatePart
|
||||
.getState()!
|
||||
.providers.find((p) => p.id === provider.id);
|
||||
if (updated?.status === 'ok') {
|
||||
DeesToast.show({
|
||||
message: `${provider.name}: connection OK`,
|
||||
type: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
} else {
|
||||
DeesToast.show({
|
||||
message: `${provider.name}: ${updated?.lastError || 'connection failed'}`,
|
||||
type: 'error',
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteProvider(provider: interfaces.data.IDnsProviderPublic) {
|
||||
const linkedDomains = this.domainsState.domains.filter((d) => d.providerId === provider.id);
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
const doDelete = async (force: boolean) => {
|
||||
await appstate.domainsStatePart.dispatchAction(appstate.deleteDnsProviderAction, {
|
||||
id: provider.id,
|
||||
force,
|
||||
});
|
||||
};
|
||||
|
||||
if (linkedDomains.length > 0) {
|
||||
DeesModal.createAndShow({
|
||||
heading: `Provider in use`,
|
||||
content: html`
|
||||
<p>
|
||||
Provider <strong>${provider.name}</strong> is referenced by ${linkedDomains.length}
|
||||
domain(s). Deleting will also remove the imported domain(s) and their cached
|
||||
records (the records at ${provider.type} are NOT touched).
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{
|
||||
name: 'Force Delete',
|
||||
action: async (modalArg: any) => {
|
||||
await doDelete(true);
|
||||
modalArg.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await doDelete(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
ts_web/elements/email/index.ts
Normal file
3
ts_web/elements/email/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ops-view-emails.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() },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
141
ts_web/elements/email/ops-view-email-security.ts
Normal file
141
ts_web/elements/email/ops-view-email-security.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-email-security': OpsViewEmailSecurity;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-email-security')
|
||||
export class OpsViewEmailSecurity extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.securityContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'malware',
|
||||
title: 'Malware Detection',
|
||||
value: metrics.malwareDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:BugOff',
|
||||
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Malware detected',
|
||||
},
|
||||
{
|
||||
id: 'phishing',
|
||||
title: 'Phishing Detection',
|
||||
value: metrics.phishingDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Fish',
|
||||
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Phishing attempts detected',
|
||||
},
|
||||
{
|
||||
id: 'suspicious',
|
||||
title: 'Suspicious Activities',
|
||||
value: metrics.suspiciousActivities,
|
||||
type: 'number',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Suspicious activities detected',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
title: 'Spam Detection',
|
||||
value: metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Ban',
|
||||
color: '#f59e0b',
|
||||
description: 'Spam emails blocked',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Email Security</dees-heading>
|
||||
|
||||
<div class="securityContainer">
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-settings
|
||||
.heading=${'Security Configuration'}
|
||||
.settingsFields=${[
|
||||
{ key: 'spf', label: 'SPF checking', value: 'enabled' },
|
||||
{ key: 'dkim', label: 'DKIM validation', value: 'enabled' },
|
||||
{ key: 'dmarc', label: 'DMARC policy', value: 'enabled' },
|
||||
{ key: 'spam', label: 'Spam filtering', value: 'enabled' },
|
||||
]}
|
||||
.actions=${[{ name: 'Edit', action: () => this.showEditSecurityDialog() }]}
|
||||
></dees-settings>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async showEditSecurityDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
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() },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Email Operations</dees-heading>
|
||||
<dees-heading level="3">Email Log</dees-heading>
|
||||
<div class="viewContainer">
|
||||
${this.currentView === 'detail' && this.selectedEmail
|
||||
? html`
|
||||
@@ -1,16 +1,9 @@
|
||||
export * from './ops-dashboard.js';
|
||||
export * from './ops-view-overview.js';
|
||||
export * from './ops-view-network.js';
|
||||
export * from './ops-view-emails.js';
|
||||
export * from './overview/index.js';
|
||||
export * from './network/index.js';
|
||||
export * from './email/index.js';
|
||||
export * from './ops-view-logs.js';
|
||||
export * from './ops-view-config.js';
|
||||
export * from './ops-view-routes.js';
|
||||
export * from './ops-view-apitokens.js';
|
||||
export * from './ops-view-security.js';
|
||||
export * from './ops-view-certificates.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
export * from './ops-view-sourceprofiles.js';
|
||||
export * from './ops-view-networktargets.js';
|
||||
export * from './ops-view-targetprofiles.js';
|
||||
export * from './shared/index.js';
|
||||
export * from './access/index.js';
|
||||
export * from './security/index.js';
|
||||
export * from './domains/index.js';
|
||||
export * from './shared/index.js';
|
||||
|
||||
7
ts_web/elements/network/index.ts
Normal file
7
ts_web/elements/network/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './ops-view-network-activity.js';
|
||||
export * from './ops-view-routes.js';
|
||||
export * from './ops-view-sourceprofiles.js';
|
||||
export * from './ops-view-networktargets.js';
|
||||
export * from './ops-view-targetprofiles.js';
|
||||
export * from './ops-view-remoteingress.js';
|
||||
export * from './ops-view-vpn.js';
|
||||
@@ -1,39 +1,23 @@
|
||||
import { DeesElement, property, 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 * 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-network': OpsViewNetwork;
|
||||
'ops-view-network-activity': OpsViewNetworkActivity;
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
export class OpsViewNetwork extends DeesElement {
|
||||
@customElement('ops-view-network-activity')
|
||||
export class OpsViewNetworkActivity extends DeesElement {
|
||||
/** How far back the traffic chart shows */
|
||||
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** How often a new data point is added */
|
||||
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
|
||||
/** Derived: max data points the buffer holds */
|
||||
private static readonly MAX_DATA_POINTS = OpsViewNetwork.CHART_WINDOW_MS / OpsViewNetwork.UPDATE_INTERVAL_MS;
|
||||
private static readonly MAX_DATA_POINTS = OpsViewNetworkActivity.CHART_WINDOW_MS / OpsViewNetworkActivity.UPDATE_INTERVAL_MS;
|
||||
|
||||
@state()
|
||||
accessor statsState = appstate.statsStatePart.getState()!;
|
||||
@@ -42,18 +26,15 @@ export class OpsViewNetwork extends DeesElement {
|
||||
accessor networkState = appstate.networkStatePart.getState()!;
|
||||
|
||||
|
||||
@state()
|
||||
accessor networkRequests: INetworkRequest[] = [];
|
||||
|
||||
@state()
|
||||
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
|
||||
|
||||
@state()
|
||||
accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
|
||||
|
||||
|
||||
// Track if we need to update the chart to avoid unnecessary re-renders
|
||||
private lastChartUpdate = 0;
|
||||
private chartUpdateThreshold = OpsViewNetwork.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
|
||||
private chartUpdateThreshold = OpsViewNetworkActivity.UPDATE_INTERVAL_MS; // Minimum ms between chart updates
|
||||
|
||||
private trafficUpdateTimer: any = null;
|
||||
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
|
||||
@@ -101,17 +82,17 @@ export class OpsViewNetwork extends DeesElement {
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(statsUnsubscribe);
|
||||
|
||||
|
||||
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
|
||||
this.networkState = state;
|
||||
this.updateNetworkData();
|
||||
});
|
||||
this.rxSubscriptions.push(networkUnsubscribe);
|
||||
}
|
||||
|
||||
|
||||
private initializeTrafficData() {
|
||||
const now = Date.now();
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
|
||||
|
||||
// Initialize with empty data points for both in and out
|
||||
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
|
||||
@@ -148,7 +129,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
||||
}));
|
||||
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetwork;
|
||||
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
|
||||
|
||||
// Use history as the chart data, keeping the most recent points within the window
|
||||
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
|
||||
@@ -285,8 +266,8 @@ export class OpsViewNetwork extends DeesElement {
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Network Activity</dees-heading>
|
||||
|
||||
<dees-heading level="3">Network Activity</dees-heading>
|
||||
|
||||
<div class="networkContainer">
|
||||
<!-- Stats Grid -->
|
||||
${this.renderNetworkStats()}
|
||||
@@ -307,112 +288,28 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}
|
||||
]}
|
||||
.realtimeMode=${true}
|
||||
.rollingWindow=${OpsViewNetwork.CHART_WINDOW_MS}
|
||||
.rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
|
||||
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
||||
></dees-chart-area>
|
||||
|
||||
<!-- Protocol Distribution Charts -->
|
||||
${this.renderProtocolCharts()}
|
||||
|
||||
<!-- Top IPs Section -->
|
||||
<!-- Top IPs by Connection Count -->
|
||||
${this.renderTopIPs()}
|
||||
|
||||
<!-- Top IPs by Bandwidth -->
|
||||
${this.renderTopIPsByBandwidth()}
|
||||
|
||||
<!-- Domain Activity -->
|
||||
${this.renderDomainActivity()}
|
||||
|
||||
<!-- Backend Protocols Section -->
|
||||
${this.renderBackendProtocols()}
|
||||
|
||||
<!-- Requests Table -->
|
||||
<dees-table
|
||||
.data=${this.networkRequests}
|
||||
.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
|
||||
.pagination=${true}
|
||||
.paginationSize=${50}
|
||||
dataName="request"
|
||||
></dees-table>
|
||||
</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 {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
@@ -426,26 +323,26 @@ export class OpsViewNetwork extends DeesElement {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
|
||||
private formatBitsPerSecond(bytesPerSecond: number): string {
|
||||
const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits
|
||||
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
|
||||
let size = bitsPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000; // Use 1000 for bits (not 1024)
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
@@ -520,18 +417,9 @@ export class OpsViewNetwork extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
.gridActions=${[
|
||||
{
|
||||
name: 'Export Data',
|
||||
iconName: 'lucide:FileOutput',
|
||||
action: async () => {
|
||||
console.log('Export feature coming soon');
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
`;
|
||||
}
|
||||
@@ -603,6 +491,8 @@ export class OpsViewNetwork extends DeesElement {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${this.networkState.topIPs}
|
||||
.rowKey=${'ip'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.displayFunction=${(ipData: { ip: string; count: number }) => {
|
||||
const bw = bandwidthByIP.get(ipData.ip);
|
||||
return {
|
||||
@@ -615,12 +505,75 @@ export class OpsViewNetwork extends DeesElement {
|
||||
}}
|
||||
heading1="Top Connected IPs"
|
||||
heading2="IPs with most active connections and bandwidth"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
dataName="ip"
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
const backends = this.networkState.backends;
|
||||
if (!backends || backends.length === 0) {
|
||||
@@ -630,6 +583,8 @@ export class OpsViewNetwork extends DeesElement {
|
||||
return html`
|
||||
<dees-table
|
||||
.data=${backends}
|
||||
.rowKey=${'backend'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.displayFunction=${(item: interfaces.data.IBackendInfo) => {
|
||||
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
|
||||
const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
@@ -665,6 +620,7 @@ export class OpsViewNetwork extends DeesElement {
|
||||
heading1="Backend Protocols"
|
||||
heading2="Auto-detected backend protocols and connection pool health"
|
||||
searchable
|
||||
.showColumnFilters=${true}
|
||||
.pagination=${false}
|
||||
dataName="backend"
|
||||
></dees-table>
|
||||
@@ -729,93 +685,61 @@ export class OpsViewNetwork extends DeesElement {
|
||||
this.requestsPerSecHistory.shift();
|
||||
}
|
||||
|
||||
// Only update if connections changed significantly
|
||||
const newConnectionCount = this.networkState.connections.length;
|
||||
const oldConnectionCount = this.networkRequests.length;
|
||||
|
||||
// Check if we need to update the network requests array
|
||||
const shouldUpdate = newConnectionCount !== oldConnectionCount ||
|
||||
newConnectionCount === 0 ||
|
||||
(newConnectionCount > 0 && this.networkRequests.length === 0);
|
||||
|
||||
if (shouldUpdate) {
|
||||
// Convert connection data to network requests format
|
||||
if (newConnectionCount > 0) {
|
||||
this.networkRequests = this.networkState.connections.map((conn, index) => ({
|
||||
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',
|
||||
}));
|
||||
} else {
|
||||
this.networkRequests = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Load server-side throughput history into chart (once)
|
||||
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
|
||||
this.loadThroughputHistory();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private startTrafficUpdateTimer() {
|
||||
this.stopTrafficUpdateTimer(); // Clear any existing timer
|
||||
this.trafficUpdateTimer = setInterval(() => {
|
||||
this.addTrafficDataPoint();
|
||||
}, OpsViewNetwork.UPDATE_INTERVAL_MS);
|
||||
}, OpsViewNetworkActivity.UPDATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
|
||||
private addTrafficDataPoint() {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Throttle chart updates to avoid excessive re-renders
|
||||
if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const throughput = this.calculateThroughput();
|
||||
|
||||
|
||||
// Convert to Mbps (bytes * 8 / 1,000,000)
|
||||
const throughputInMbps = (throughput.in * 8) / 1000000;
|
||||
const throughputOutMbps = (throughput.out * 8) / 1000000;
|
||||
|
||||
|
||||
// Add new data points
|
||||
const timestamp = new Date(now).toISOString();
|
||||
|
||||
|
||||
const newDataPointIn = {
|
||||
x: timestamp,
|
||||
y: Math.round(throughputInMbps * 10) / 10
|
||||
};
|
||||
|
||||
|
||||
const newDataPointOut = {
|
||||
x: timestamp,
|
||||
y: Math.round(throughputOutMbps * 10) / 10
|
||||
};
|
||||
|
||||
|
||||
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays)
|
||||
if (this.trafficDataIn.length >= OpsViewNetwork.MAX_DATA_POINTS) {
|
||||
if (this.trafficDataIn.length >= OpsViewNetworkActivity.MAX_DATA_POINTS) {
|
||||
this.trafficDataIn.shift();
|
||||
this.trafficDataOut.shift();
|
||||
}
|
||||
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
|
||||
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
|
||||
|
||||
|
||||
this.lastChartUpdate = now;
|
||||
}
|
||||
|
||||
|
||||
private stopTrafficUpdateTimer() {
|
||||
if (this.trafficUpdateTimer) {
|
||||
clearInterval(this.trafficUpdateTimer);
|
||||
this.trafficUpdateTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
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 * 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 {
|
||||
@@ -64,13 +64,14 @@ export class OpsViewNetworkTargets extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Network Targets</dees-heading>
|
||||
<dees-heading level="3">Network Targets</dees-heading>
|
||||
<div class="targetsContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Network Targets'}
|
||||
.heading2=${'Reusable host:port destinations for routes'}
|
||||
.data=${targets}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({
|
||||
Name: target.name,
|
||||
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
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 * 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 {
|
||||
@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Remote Ingress</dees-heading>
|
||||
<dees-heading level="3">Remote Ingress</dees-heading>
|
||||
|
||||
${this.riState.newEdgeId ? html`
|
||||
<div class="secretDialog">
|
||||
@@ -220,6 +220,9 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
.heading1=${'Edge Nodes'}
|
||||
.heading2=${'Manage remote ingress edge registrations'}
|
||||
.data=${this.riState.edges}
|
||||
.rowKey=${'id'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
|
||||
name: edge.name,
|
||||
status: this.getEdgeStatusHtml(edge),
|
||||
@@ -240,9 +243,9 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Additional Manual Ports (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated, optional)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -317,9 +320,9 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports (comma-separated)'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
|
||||
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags (comma-separated)'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
import {
|
||||
@@ -49,6 +49,8 @@ function setupTlsVisibility(formEl: any) {
|
||||
|
||||
@customElement('ops-view-routes')
|
||||
export class OpsViewRoutes extends DeesElement {
|
||||
@state() accessor routeFilter: 'User Routes' | 'System Routes' = 'User Routes';
|
||||
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
@@ -140,9 +142,9 @@ export class OpsViewRoutes extends DeesElement {
|
||||
public render(): TemplateResult {
|
||||
const { mergedRoutes, warnings } = this.routeState;
|
||||
|
||||
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
||||
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
||||
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||
const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
|
||||
const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
|
||||
|
||||
const statsTiles: IStatsTile[] = [
|
||||
{
|
||||
@@ -155,21 +157,21 @@ export class OpsViewRoutes extends DeesElement {
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
id: 'hardcoded',
|
||||
title: 'Hardcoded',
|
||||
id: 'configRoutes',
|
||||
title: 'System Routes',
|
||||
type: 'number',
|
||||
value: hardcodedCount,
|
||||
icon: 'lucide:lock',
|
||||
description: 'Routes from constructor config',
|
||||
value: configCount,
|
||||
icon: 'lucide:settings',
|
||||
description: 'From config, email, and DNS',
|
||||
color: '#8b5cf6',
|
||||
},
|
||||
{
|
||||
id: 'programmatic',
|
||||
title: 'Programmatic',
|
||||
id: 'apiRoutes',
|
||||
title: 'User Routes',
|
||||
type: 'number',
|
||||
value: programmaticCount,
|
||||
value: apiCount,
|
||||
icon: 'lucide:code',
|
||||
description: 'Routes added via API',
|
||||
description: 'Created via API',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
@@ -183,24 +185,29 @@ export class OpsViewRoutes extends DeesElement {
|
||||
},
|
||||
];
|
||||
|
||||
// Map merged routes to sz-route-list-view format
|
||||
const szRoutes = mergedRoutes.map((mr) => {
|
||||
// Filter routes based on selected tab
|
||||
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 || [])];
|
||||
tags.push(mr.source);
|
||||
tags.push(mr.origin);
|
||||
if (!mr.enabled) tags.push('disabled');
|
||||
if (mr.overridden) tags.push('overridden');
|
||||
|
||||
return {
|
||||
...mr.route,
|
||||
enabled: mr.enabled,
|
||||
tags,
|
||||
id: mr.storedRouteId || mr.route.name || undefined,
|
||||
id: mr.id || mr.route.name || undefined,
|
||||
metadata: mr.metadata,
|
||||
};
|
||||
});
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Route Management</dees-heading>
|
||||
<dees-heading level="3">Route Management</dees-heading>
|
||||
|
||||
<div class="routesContainer">
|
||||
<dees-statsgrid
|
||||
@@ -219,6 +226,13 @@ export class OpsViewRoutes extends DeesElement {
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-input-multitoggle
|
||||
class="routeFilterToggle"
|
||||
.type=${'single'}
|
||||
.options=${['User Routes', 'System Routes']}
|
||||
.selectedOption=${this.routeFilter}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
${warnings.length > 0
|
||||
? html`
|
||||
<div class="warnings-bar">
|
||||
@@ -238,7 +252,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
? html`
|
||||
<sz-route-list-view
|
||||
.routes=${szRoutes}
|
||||
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
|
||||
.showActionsFilter=${isUserRoutes ? () => true : () => false}
|
||||
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
||||
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
||||
@@ -246,8 +260,8 @@ export class OpsViewRoutes extends DeesElement {
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<p>No routes configured</p>
|
||||
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||
<p>No ${isUserRoutes ? 'user' : 'system'} routes</p>
|
||||
<p>${isUserRoutes ? 'Add a route to get started.' : 'System routes are generated from config, email, and DNS settings.'}</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
@@ -266,112 +280,56 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
|
||||
if (merged.source === 'hardcoded') {
|
||||
const menuOptions = merged.enabled
|
||||
? [
|
||||
{
|
||||
name: 'Disable Route',
|
||||
iconName: 'lucide:pause',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: false },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Enable Route',
|
||||
iconName: 'lucide:play',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.setRouteOverrideAction,
|
||||
{ routeName: merged.route.name!, enabled: true },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Remove Override',
|
||||
iconName: 'lucide:undo',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.removeRouteOverrideAction,
|
||||
merged.route.name!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
];
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
||||
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
||||
</div>
|
||||
`,
|
||||
menuOptions,
|
||||
});
|
||||
} else {
|
||||
// Programmatic route
|
||||
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();
|
||||
},
|
||||
const meta = merged.metadata;
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
<div style="color: #ccc; padding: 8px 0;">
|
||||
<p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
|
||||
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
<p>ID: <code style="color: #888;">${merged.id}</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.id, enabled: !merged.enabled },
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Edit',
|
||||
iconName: 'lucide:pencil',
|
||||
action: async (modalArg: any) => {
|
||||
await modalArg.destroy();
|
||||
this.showEditRouteDialog(merged);
|
||||
},
|
||||
{
|
||||
name: 'Close',
|
||||
iconName: 'lucide:x',
|
||||
action: async (modalArg: any) => await modalArg.destroy(),
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash-2',
|
||||
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) {
|
||||
@@ -381,7 +339,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged || !merged.storedRouteId) return;
|
||||
if (!merged) return;
|
||||
|
||||
this.showEditRouteDialog(merged);
|
||||
}
|
||||
@@ -393,7 +351,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const merged = this.routeState.mergedRoutes.find(
|
||||
(mr) => mr.route.name === clickedRoute.name,
|
||||
);
|
||||
if (!merged || !merged.storedRouteId) return;
|
||||
if (!merged) return;
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
@@ -415,7 +373,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
action: async (modalArg: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.deleteRouteAction,
|
||||
merged.storedRouteId!,
|
||||
merged.id,
|
||||
);
|
||||
await modalArg.destroy();
|
||||
},
|
||||
@@ -473,19 +431,19 @@ export class OpsViewRoutes extends DeesElement {
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .value=${route.name || ''} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: ${needsCert && isCustom ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'} .value=${currentCustomKey}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'} .value=${currentCustomCert}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
@@ -563,7 +521,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
await appstate.routeManagementStatePart.dispatchAction(
|
||||
appstate.updateRouteAction,
|
||||
{
|
||||
id: merged.storedRouteId!,
|
||||
id: merged.id,
|
||||
route: updatedRoute,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
},
|
||||
@@ -603,23 +561,23 @@ export class OpsViewRoutes extends DeesElement {
|
||||
];
|
||||
|
||||
const createModal = await DeesModal.createAndShow({
|
||||
heading: 'Add Programmatic Route',
|
||||
heading: 'Add Route',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
|
||||
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
|
||||
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
|
||||
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
|
||||
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'}></dees-input-text>
|
||||
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'}></dees-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</dees-form>
|
||||
@@ -719,5 +677,13 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
async firstUpdated() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
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 * 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 {
|
||||
@@ -64,13 +64,14 @@ export class OpsViewSourceProfiles extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Source Profiles</dees-heading>
|
||||
<dees-heading level="3">Source Profiles</dees-heading>
|
||||
<div class="profilesContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Source Profiles'}
|
||||
.heading2=${'Reusable source configurations for routes'}
|
||||
.data=${profiles}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
|
||||
Name: profile.name,
|
||||
Description: profile.description || '-',
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
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 {
|
||||
@@ -77,13 +77,14 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">Target Profiles</dees-heading>
|
||||
<dees-heading level="3">Target Profiles</dees-heading>
|
||||
<div class="profilesContainer">
|
||||
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
||||
<dees-table
|
||||
.heading1=${'Target Profiles'}
|
||||
.heading2=${'Define what resources VPN clients can access'}
|
||||
.data=${profiles}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
|
||||
Name: profile.name,
|
||||
Description: profile.description || '-',
|
||||
@@ -94,7 +95,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
|
||||
: '-',
|
||||
'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(),
|
||||
})}
|
||||
@@ -148,12 +149,57 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private getRouteCandidates() {
|
||||
private getRouteChoices() {
|
||||
const routeState = appstate.routeManagementStatePart.getState();
|
||||
const routes = routeState?.mergedRoutes || [];
|
||||
return routes
|
||||
.filter((mr) => mr.route.name)
|
||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
||||
.filter((mr) => mr.route.name && mr.id)
|
||||
.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() {
|
||||
@@ -175,7 +221,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -202,7 +248,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
};
|
||||
})
|
||||
.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, {
|
||||
name: String(data.name),
|
||||
@@ -221,7 +269,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const currentDomains = profile.domains || [];
|
||||
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');
|
||||
await this.ensureRoutesLoaded();
|
||||
@@ -234,7 +282,7 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -260,7 +308,9 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
};
|
||||
})
|
||||
.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, {
|
||||
id: profile.id,
|
||||
@@ -335,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: 14px; margin-top: 4px;">
|
||||
${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>
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
state,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from './shared/css.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
|
||||
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
|
||||
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') 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 (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
|
||||
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
|
||||
@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="2">VPN</dees-heading>
|
||||
<dees-heading level="3">VPN</dees-heading>
|
||||
<div class="vpnContainer">
|
||||
|
||||
${this.vpnState.newClientConfig ? html`
|
||||
@@ -305,6 +305,9 @@ export class OpsViewVpn extends DeesElement {
|
||||
.heading1=${'VPN Clients'}
|
||||
.heading2=${'Manage WireGuard and SmartVPN client registrations'}
|
||||
.data=${clients}
|
||||
.rowKey=${'clientId'}
|
||||
.highlightUpdates=${'flash'}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(client: interfaces.data.IVpnClient) => {
|
||||
const conn = this.getConnectedInfo(client);
|
||||
let statusHtml;
|
||||
@@ -368,8 +371,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
</div>
|
||||
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox>
|
||||
<div class="aclGroup" style="display: none; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -387,7 +390,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
if (!data.clientId) return;
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
@@ -411,10 +414,10 @@ export class OpsViewVpn extends DeesElement {
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
useHostIp,
|
||||
useDhcp,
|
||||
staticIp,
|
||||
forceVlan: forceVlan || undefined,
|
||||
forceVlan,
|
||||
vlanId,
|
||||
destinationAllowList,
|
||||
destinationBlockList,
|
||||
@@ -482,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">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>
|
||||
${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>
|
||||
@@ -646,7 +649,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const client = actionData.item as interfaces.data.IVpnClient;
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const currentDescription = client.description ?? '';
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
|
||||
const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
|
||||
const profileCandidates = this.getTargetProfileCandidates();
|
||||
const currentUseHostIp = client.useHostIp ?? false;
|
||||
const currentUseDhcp = client.useDhcp ?? false;
|
||||
@@ -678,8 +681,8 @@ export class OpsViewVpn extends DeesElement {
|
||||
</div>
|
||||
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox>
|
||||
<div class="aclGroup" style="display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'} .value=${currentAllowList}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentAllowList}></dees-input-text>
|
||||
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentBlockList}></dees-input-text>
|
||||
</div>
|
||||
</dees-form>
|
||||
`,
|
||||
@@ -692,7 +695,7 @@ export class OpsViewVpn extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const targetProfileIds = this.resolveProfileNamesToIds(
|
||||
const targetProfileIds = this.resolveProfileLabelsToIds(
|
||||
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
|
||||
);
|
||||
|
||||
@@ -716,10 +719,10 @@ export class OpsViewVpn extends DeesElement {
|
||||
description: data.description || undefined,
|
||||
targetProfileIds,
|
||||
|
||||
useHostIp: useHostIp || undefined,
|
||||
useDhcp: useDhcp || undefined,
|
||||
useHostIp,
|
||||
useDhcp,
|
||||
staticIp,
|
||||
forceVlan: forceVlan || undefined,
|
||||
forceVlan,
|
||||
vlanId,
|
||||
destinationAllowList,
|
||||
destinationBlockList,
|
||||
@@ -808,41 +811,52 @@ export class OpsViewVpn extends DeesElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build autocomplete candidates from loaded target profiles.
|
||||
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
|
||||
* Build stable profile labels for list inputs.
|
||||
*/
|
||||
private getTargetProfileCandidates() {
|
||||
private getTargetProfileChoices() {
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
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;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
const choices = this.getTargetProfileChoices();
|
||||
const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
|
||||
return ids.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
return profile?.name || id;
|
||||
return labelsById.get(id) || id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert profile names back to IDs (for saving form data).
|
||||
* Uses the dees-input-list candidates' payload when available.
|
||||
* Convert profile form labels back to IDs.
|
||||
*/
|
||||
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
|
||||
if (!names.length) return undefined;
|
||||
const profileState = appstate.targetProfilesStatePart.getState();
|
||||
const profiles = profileState?.profiles || [];
|
||||
return names
|
||||
.map((name) => {
|
||||
const profile = profiles.find((p) => p.name === name);
|
||||
return profile?.id;
|
||||
})
|
||||
private resolveProfileLabelsToIds(labels: string[]): string[] {
|
||||
if (!labels.length) return [];
|
||||
|
||||
const labelsToIds = new Map(
|
||||
this.getTargetProfileChoices().map((profile) => [profile.label, profile.id]),
|
||||
);
|
||||
return labels
|
||||
.map((label) => labelsToIds.get(label))
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
}
|
||||
@@ -11,22 +11,52 @@ import {
|
||||
state,
|
||||
type TemplateResult
|
||||
} from '@design.estate/dees-element';
|
||||
import type { IView } from '@design.estate/dees-catalog';
|
||||
|
||||
// Import view components
|
||||
import { OpsViewOverview } from './ops-view-overview.js';
|
||||
import { OpsViewNetwork } from './ops-view-network.js';
|
||||
import { OpsViewEmails } from './ops-view-emails.js';
|
||||
// Top-level / flat views
|
||||
import { OpsViewLogs } from './ops-view-logs.js';
|
||||
import { OpsViewConfig } from './ops-view-config.js';
|
||||
import { OpsViewRoutes } from './ops-view-routes.js';
|
||||
import { OpsViewApiTokens } from './ops-view-apitokens.js';
|
||||
import { OpsViewSecurity } from './ops-view-security.js';
|
||||
import { OpsViewCertificates } from './ops-view-certificates.js';
|
||||
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './ops-view-vpn.js';
|
||||
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
|
||||
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
|
||||
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js';
|
||||
|
||||
// Overview group
|
||||
import { OpsViewOverview } from './overview/ops-view-overview.js';
|
||||
import { OpsViewConfig } from './overview/ops-view-config.js';
|
||||
|
||||
// Network group
|
||||
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
|
||||
import { OpsViewRoutes } from './network/ops-view-routes.js';
|
||||
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
|
||||
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
|
||||
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
|
||||
import { OpsViewRemoteIngress } from './network/ops-view-remoteingress.js';
|
||||
import { OpsViewVpn } from './network/ops-view-vpn.js';
|
||||
|
||||
// Email group
|
||||
import { OpsViewEmails } from './email/ops-view-emails.js';
|
||||
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
||||
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
|
||||
|
||||
// Access group
|
||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||
import { OpsViewUsers } from './access/ops-view-users.js';
|
||||
|
||||
// Security group
|
||||
import { OpsViewSecurityOverview } from './security/ops-view-security-overview.js';
|
||||
import { OpsViewSecurityBlocked } from './security/ops-view-security-blocked.js';
|
||||
import { OpsViewSecurityAuthentication } from './security/ops-view-security-authentication.js';
|
||||
|
||||
// Domains group
|
||||
import { OpsViewProviders } from './domains/ops-view-providers.js';
|
||||
import { OpsViewDomains } from './domains/ops-view-domains.js';
|
||||
import { OpsViewDns } from './domains/ops-view-dns.js';
|
||||
import { OpsViewCertificates } from './domains/ops-view-certificates.js';
|
||||
|
||||
/**
|
||||
* Extended IView with explicit URL slug. Without an explicit `slug`, the URL
|
||||
* slug is derived from `name.toLowerCase().replace(/\s+/g, '')`.
|
||||
*/
|
||||
interface ITabbedView extends IView {
|
||||
slug?: string;
|
||||
subViews?: ITabbedView[];
|
||||
}
|
||||
|
||||
@customElement('ops-dashboard')
|
||||
export class OpsDashboard extends DeesElement {
|
||||
@@ -37,6 +67,7 @@ export class OpsDashboard extends DeesElement {
|
||||
|
||||
@state() accessor uiState: appstate.IUiState = {
|
||||
activeView: 'overview',
|
||||
activeSubview: null,
|
||||
sidebarCollapsed: false,
|
||||
autoRefresh: true,
|
||||
refreshInterval: 1000,
|
||||
@@ -49,27 +80,37 @@ export class OpsDashboard extends DeesElement {
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Store viewTabs as a property to maintain object references
|
||||
private viewTabs = [
|
||||
// Store viewTabs as a property to maintain object references (used for === selectedView identity)
|
||||
private viewTabs: ITabbedView[] = [
|
||||
{
|
||||
name: 'Overview',
|
||||
iconName: 'lucide:layoutDashboard',
|
||||
element: OpsViewOverview,
|
||||
},
|
||||
{
|
||||
name: 'Configuration',
|
||||
iconName: 'lucide:settings',
|
||||
element: OpsViewConfig,
|
||||
subViews: [
|
||||
{ slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview },
|
||||
{ slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Network',
|
||||
iconName: 'lucide:network',
|
||||
element: OpsViewNetwork,
|
||||
subViews: [
|
||||
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
|
||||
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
|
||||
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
|
||||
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
|
||||
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
|
||||
{ slug: 'remoteingress', name: 'Remote Ingress', iconName: 'lucide:globe', element: OpsViewRemoteIngress },
|
||||
{ slug: 'vpn', name: 'VPN', iconName: 'lucide:shield', element: OpsViewVpn },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
name: 'Email',
|
||||
iconName: 'lucide:mail',
|
||||
element: OpsViewEmails,
|
||||
subViews: [
|
||||
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
|
||||
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
|
||||
{ slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
@@ -77,52 +118,54 @@ export class OpsDashboard extends DeesElement {
|
||||
element: OpsViewLogs,
|
||||
},
|
||||
{
|
||||
name: 'Routes',
|
||||
iconName: 'lucide:route',
|
||||
element: OpsViewRoutes,
|
||||
},
|
||||
{
|
||||
name: 'SourceProfiles',
|
||||
iconName: 'lucide:shieldCheck',
|
||||
element: OpsViewSourceProfiles,
|
||||
},
|
||||
{
|
||||
name: 'NetworkTargets',
|
||||
iconName: 'lucide:server',
|
||||
element: OpsViewNetworkTargets,
|
||||
},
|
||||
{
|
||||
name: 'TargetProfiles',
|
||||
iconName: 'lucide:target',
|
||||
element: OpsViewTargetProfiles,
|
||||
},
|
||||
{
|
||||
name: 'ApiTokens',
|
||||
iconName: 'lucide:key',
|
||||
element: OpsViewApiTokens,
|
||||
name: 'Access',
|
||||
iconName: 'lucide:keyRound',
|
||||
subViews: [
|
||||
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
|
||||
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Security',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewSecurity,
|
||||
subViews: [
|
||||
{ slug: 'overview', name: 'Overview', iconName: 'lucide:eye', element: OpsViewSecurityOverview },
|
||||
{ slug: 'blocked', name: 'Blocked IPs', iconName: 'lucide:shieldBan', element: OpsViewSecurityBlocked },
|
||||
{ slug: 'authentication', name: 'Authentication', iconName: 'lucide:lock', element: OpsViewSecurityAuthentication },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Certificates',
|
||||
iconName: 'lucide:badgeCheck',
|
||||
element: OpsViewCertificates,
|
||||
},
|
||||
{
|
||||
name: 'RemoteIngress',
|
||||
name: 'Domains',
|
||||
iconName: 'lucide:globe',
|
||||
element: OpsViewRemoteIngress,
|
||||
},
|
||||
{
|
||||
name: 'VPN',
|
||||
iconName: 'lucide:shield',
|
||||
element: OpsViewVpn,
|
||||
subViews: [
|
||||
{ slug: 'providers', name: 'Providers', iconName: 'lucide:plug', element: OpsViewProviders },
|
||||
{ slug: 'domains', name: 'Domains', iconName: 'lucide:globe', element: OpsViewDomains },
|
||||
{ slug: 'dns', name: 'DNS', iconName: 'lucide:list', element: OpsViewDns },
|
||||
{ slug: 'certificates', name: 'Certificates', iconName: 'lucide:badgeCheck', element: OpsViewCertificates },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** URL slug for a view (explicit `slug` field, or lowercased name with spaces stripped). */
|
||||
private slugFor(view: ITabbedView): string {
|
||||
return view.slug ?? view.name.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/** Find the parent group of a subview, or undefined for top-level views. */
|
||||
private findParent(view: ITabbedView): ITabbedView | undefined {
|
||||
return this.viewTabs.find((v) => v.subViews?.includes(view));
|
||||
}
|
||||
|
||||
/** Look up a view (or subview) by its URL slug pair. */
|
||||
private findViewBySlug(viewSlug: string, subSlug: string | null): ITabbedView | undefined {
|
||||
const top = this.viewTabs.find((v) => this.slugFor(v) === viewSlug);
|
||||
if (!top) return undefined;
|
||||
if (subSlug && top.subViews) {
|
||||
return top.subViews.find((sv) => this.slugFor(sv) === subSlug) ?? top;
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
private get globalMessages() {
|
||||
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
|
||||
const config = this.configState.config;
|
||||
@@ -138,17 +181,19 @@ export class OpsDashboard extends DeesElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current view tab based on the UI state's activeView.
|
||||
* Get the current view tab based on the UI state's activeView/activeSubview.
|
||||
* Used to pass the correct selectedView to dees-simple-appdash on initial render.
|
||||
*/
|
||||
private get currentViewTab() {
|
||||
return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0];
|
||||
private get currentViewTab(): ITabbedView {
|
||||
return (
|
||||
this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0]
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
document.title = 'DCRouter OpsServer';
|
||||
|
||||
|
||||
// Subscribe to login state
|
||||
const loginSubscription = appstate.loginStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
@@ -161,7 +206,7 @@ export class OpsDashboard extends DeesElement {
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
|
||||
|
||||
// Subscribe to config state (for global warnings)
|
||||
const configSubscription = appstate.configStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
@@ -176,38 +221,27 @@ export class OpsDashboard extends DeesElement {
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
// Sync appdash view when state changes (e.g., from URL navigation)
|
||||
this.syncAppdashView(uiState.activeView);
|
||||
this.syncAppdashView(uiState.activeView, uiState.activeSubview);
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the dees-simple-appdash view selection with the current state.
|
||||
* This is needed when the URL changes and we need to update the UI.
|
||||
* This is needed when the URL changes externally (back/forward, deep link).
|
||||
*/
|
||||
private syncAppdashView(viewName: string): void {
|
||||
private syncAppdashView(viewSlug: string, subviewSlug: string | null): void {
|
||||
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||
if (!appDash) return;
|
||||
|
||||
const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName);
|
||||
if (!targetTab) return;
|
||||
const targetView = this.findViewBySlug(viewSlug, subviewSlug);
|
||||
if (!targetView) return;
|
||||
|
||||
// Check if we need to switch (avoid unnecessary updates)
|
||||
if (appDash.selectedView === targetTab) return;
|
||||
if (appDash.selectedView === targetView) return;
|
||||
|
||||
// Update the selected view programmatically
|
||||
appDash.selectedView = targetTab;
|
||||
|
||||
// Update the displayed content
|
||||
const content = appDash.shadowRoot?.querySelector('.appcontent');
|
||||
if (content) {
|
||||
if (appDash.currentView) {
|
||||
appDash.currentView.remove();
|
||||
}
|
||||
const view = new targetTab.element();
|
||||
content.appendChild(view);
|
||||
appDash.currentView = view;
|
||||
}
|
||||
// Use loadView to update both selectedView and the mounted element.
|
||||
// It will dispatch view-select; our handler skips when state already matches.
|
||||
appDash.loadView(targetView);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -249,7 +283,7 @@ export class OpsDashboard extends DeesElement {
|
||||
public async firstUpdated() {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
simpleLogin.addEventListener('login', (e: Event) => {
|
||||
// Handle logout event
|
||||
// Handle login event
|
||||
const detail = (e as CustomEvent).detail;
|
||||
this.login(detail.data.username, detail.data.password);
|
||||
});
|
||||
@@ -258,9 +292,24 @@ export class OpsDashboard extends DeesElement {
|
||||
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: Event) => {
|
||||
const viewName = (e as CustomEvent).detail.view.name.toLowerCase();
|
||||
// Use router for navigation instead of direct state update
|
||||
appRouter.navigateToView(viewName);
|
||||
const view = (e as CustomEvent).detail.view as ITabbedView;
|
||||
const parent = this.findParent(view);
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
if (parent) {
|
||||
const parentSlug = this.slugFor(parent);
|
||||
const subSlug = this.slugFor(view);
|
||||
// Skip if already on this exact subview — preserves URL on initial mount
|
||||
if (currentState?.activeView === parentSlug && currentState?.activeSubview === subSlug) {
|
||||
return;
|
||||
}
|
||||
appRouter.navigateToView(parentSlug, subSlug);
|
||||
} else {
|
||||
const slug = this.slugFor(view);
|
||||
if (currentState?.activeView === slug && !currentState?.activeSubview) {
|
||||
return;
|
||||
}
|
||||
appRouter.navigateToView(slug);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle logout event
|
||||
@@ -306,12 +355,12 @@ export class OpsDashboard extends DeesElement {
|
||||
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
|
||||
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
|
||||
form.setStatus('pending', 'Logging in...');
|
||||
|
||||
|
||||
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
|
||||
if (state.identity) {
|
||||
console.log('Login successful');
|
||||
this.loginState = state;
|
||||
@@ -325,4 +374,4 @@ export class OpsDashboard extends DeesElement {
|
||||
form!.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Logs</dees-heading>
|
||||
<dees-heading level="3">Logs</dees-heading>
|
||||
|
||||
<dees-chart-log
|
||||
.label=${'Application Logs'}
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
@customElement('ops-view-security')
|
||||
export class OpsViewSecurity extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = {
|
||||
serverStats: null,
|
||||
emailStats: null,
|
||||
dnsStats: null,
|
||||
securityMetrics: null,
|
||||
radiusStats: null,
|
||||
vpnStats: null,
|
||||
lastUpdated: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor selectedTab: 'overview' | 'blocked' | 'authentication' | 'email-security' = 'overview';
|
||||
|
||||
private tabLabelMap: Record<string, string> = {
|
||||
'overview': 'Overview',
|
||||
'blocked': 'Blocked IPs',
|
||||
'authentication': 'Authentication',
|
||||
'email-security': 'Email Security',
|
||||
};
|
||||
|
||||
private labelToTab: Record<string, 'overview' | 'blocked' | 'authentication' | 'email-security'> = {
|
||||
'Overview': 'overview',
|
||||
'Blocked IPs': 'blocked',
|
||||
'Authentication': 'authentication',
|
||||
'Email Security': 'email-security',
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.statsStatePart
|
||||
.select((stateArg) => stateArg)
|
||||
.subscribe((statsState) => {
|
||||
this.statsState = statsState;
|
||||
});
|
||||
this.rxSubscriptions.push(subscription);
|
||||
}
|
||||
|
||||
async firstUpdated() {
|
||||
const toggle = this.shadowRoot!.querySelector('dees-input-multitoggle') as any;
|
||||
if (toggle) {
|
||||
const sub = toggle.changeSubject.subscribe(() => {
|
||||
const tab = this.labelToTab[toggle.selectedOption];
|
||||
if (tab) this.selectedTab = tab;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
dees-input-multitoggle {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
`,
|
||||
];
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Security</dees-heading>
|
||||
|
||||
<dees-input-multitoggle
|
||||
.type=${'single'}
|
||||
.options=${['Overview', 'Blocked IPs', 'Authentication', 'Email Security']}
|
||||
.selectedOption=${this.tabLabelMap[this.selectedTab]}
|
||||
></dees-input-multitoggle>
|
||||
|
||||
${this.renderTabContent()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTabContent() {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
switch(this.selectedTab) {
|
||||
case 'overview':
|
||||
return this.renderOverview(metrics);
|
||||
case 'blocked':
|
||||
return this.renderBlockedIPs(metrics);
|
||||
case 'authentication':
|
||||
return this.renderAuthentication(metrics);
|
||||
case 'email-security':
|
||||
return this.renderEmailSecurity(metrics);
|
||||
}
|
||||
}
|
||||
|
||||
private renderOverview(metrics: any) {
|
||||
const threatLevel = this.calculateThreatLevel(metrics);
|
||||
const threatScore = this.getThreatScore(metrics);
|
||||
|
||||
// Derive active sessions from recent successful auth events (last hour)
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const oneHourAgo = Date.now() - 3600000;
|
||||
const recentAuthSuccesses = allEvents.filter(
|
||||
(evt: any) => evt.type === 'authentication' && evt.success === true && evt.timestamp >= oneHourAgo
|
||||
).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'threatLevel',
|
||||
title: 'Threat Level',
|
||||
value: threatScore,
|
||||
type: 'gauge',
|
||||
icon: 'lucide:Shield',
|
||||
gaugeOptions: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
thresholds: [
|
||||
{ value: 0, color: '#ef4444' },
|
||||
{ value: 30, color: '#f59e0b' },
|
||||
{ value: 70, color: '#22c55e' },
|
||||
],
|
||||
},
|
||||
description: `Status: ${threatLevel.toUpperCase()}`,
|
||||
},
|
||||
{
|
||||
id: 'blockedThreats',
|
||||
title: 'Blocked Threats',
|
||||
value: (metrics.blockedIPs?.length || 0) + metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldCheck',
|
||||
color: '#ef4444',
|
||||
description: 'Total threats blocked today',
|
||||
},
|
||||
{
|
||||
id: 'activeSessions',
|
||||
title: 'Active Sessions',
|
||||
value: recentAuthSuccesses,
|
||||
type: 'number',
|
||||
icon: 'lucide:Users',
|
||||
color: '#22c55e',
|
||||
description: 'Authenticated in last hour',
|
||||
},
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Auth Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed login attempts today',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Security Events</h2>
|
||||
<dees-table
|
||||
.heading1=${'Security Events'}
|
||||
.heading2=${'Last 24 hours'}
|
||||
.data=${this.getSecurityEvents(metrics)}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleTimeString(),
|
||||
'Event': item.event,
|
||||
'Severity': item.severity,
|
||||
'Details': item.details,
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBlockedIPs(metrics: any) {
|
||||
const blockedIPs: string[] = metrics.blockedIPs || [];
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalBlocked',
|
||||
title: 'Blocked IPs',
|
||||
value: blockedIPs.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldBan',
|
||||
color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Currently blocked addresses',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-table
|
||||
.heading1=${'Blocked IP Addresses'}
|
||||
.heading2=${'IPs blocked due to suspicious activity'}
|
||||
.data=${blockedIPs.map((ip) => ({ ip }))}
|
||||
.displayFunction=${(item) => ({
|
||||
'IP Address': item.ip,
|
||||
'Reason': 'Suspicious activity',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Unblock',
|
||||
iconName: 'lucide:shield-off',
|
||||
type: ['contextmenu' as const],
|
||||
actionFunc: async (item) => {
|
||||
await this.unblockIP(item.ip);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clear All',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.clearBlockedIPs();
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAuthentication(metrics: any) {
|
||||
// Derive auth events from recentEvents
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Authentication Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed authentication attempts today',
|
||||
},
|
||||
{
|
||||
id: 'successfulLogins',
|
||||
title: 'Successful Logins',
|
||||
value: successfulLogins,
|
||||
type: 'number',
|
||||
icon: 'lucide:Lock',
|
||||
color: '#22c55e',
|
||||
description: 'Successful logins today',
|
||||
},
|
||||
];
|
||||
|
||||
// Map auth events to login history table data
|
||||
const loginHistory = authEvents.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
username: evt.details?.username || 'unknown',
|
||||
ipAddress: evt.ipAddress || 'unknown',
|
||||
success: evt.success ?? false,
|
||||
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||
}));
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Login Attempts</h2>
|
||||
<dees-table
|
||||
.heading1=${'Login History'}
|
||||
.heading2=${'Recent authentication attempts'}
|
||||
.data=${loginHistory}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleString(),
|
||||
'Username': item.username,
|
||||
'IP Address': item.ipAddress,
|
||||
'Status': item.success ? 'Success' : 'Failed',
|
||||
'Reason': item.reason || '-',
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderEmailSecurity(metrics: any) {
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'malware',
|
||||
title: 'Malware Detection',
|
||||
value: metrics.malwareDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:BugOff',
|
||||
color: metrics.malwareDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Malware detected',
|
||||
},
|
||||
{
|
||||
id: 'phishing',
|
||||
title: 'Phishing Detection',
|
||||
value: metrics.phishingDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Fish',
|
||||
color: metrics.phishingDetected > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Phishing attempts detected',
|
||||
},
|
||||
{
|
||||
id: 'suspicious',
|
||||
title: 'Suspicious Activities',
|
||||
value: metrics.suspiciousActivities,
|
||||
type: 'number',
|
||||
icon: 'lucide:TriangleAlert',
|
||||
color: metrics.suspiciousActivities > 5 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Suspicious activities detected',
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
title: 'Spam Detection',
|
||||
value: metrics.spamDetected,
|
||||
type: 'number',
|
||||
icon: 'lucide:Ban',
|
||||
color: '#f59e0b',
|
||||
description: 'Spam emails blocked',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Email Security Configuration</h2>
|
||||
<div class="securityCard">
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableSPF'}
|
||||
.label=${'Enable SPF checking'}
|
||||
.value=${true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableDKIM'}
|
||||
.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>
|
||||
`;
|
||||
}
|
||||
|
||||
private calculateThreatLevel(metrics: any): string {
|
||||
const score = this.getThreatScore(metrics);
|
||||
if (score < 30) return 'alert';
|
||||
if (score < 70) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
private getThreatScore(metrics: any): number {
|
||||
// Simple scoring algorithm
|
||||
let score = 100;
|
||||
const blockedCount = Array.isArray(metrics.blockedIPs) ? metrics.blockedIPs.length : (metrics.blockedIPs || 0);
|
||||
score -= blockedCount * 2;
|
||||
score -= (metrics.authenticationFailures || 0) * 1;
|
||||
score -= (metrics.spamDetected || 0) * 0.5;
|
||||
score -= (metrics.malwareDetected || 0) * 3;
|
||||
score -= (metrics.phishingDetected || 0) * 3;
|
||||
score -= (metrics.suspiciousActivities || 0) * 2;
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
private getSecurityEvents(metrics: any): any[] {
|
||||
const events: any[] = metrics.recentEvents || [];
|
||||
return events.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
event: evt.message,
|
||||
severity: evt.level === 'critical' ? 'critical' : evt.level === 'error' ? 'high' : evt.level === 'warn' ? 'warning' : 'info',
|
||||
details: evt.ipAddress ? `IP: ${evt.ipAddress}` : evt.domain ? `Domain: ${evt.domain}` : evt.type,
|
||||
}));
|
||||
}
|
||||
|
||||
private async clearBlockedIPs() {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||
}
|
||||
|
||||
private async unblockIP(ip: string) {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||
}
|
||||
|
||||
private async saveEmailSecuritySettings() {
|
||||
// Config is read-only from the UI for now
|
||||
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
|
||||
}
|
||||
}
|
||||
2
ts_web/elements/overview/index.ts
Normal file
2
ts_web/elements/overview/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './ops-view-overview.js';
|
||||
export * from './ops-view-config.js';
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { appRouter } from '../../router.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Configuration</dees-heading>
|
||||
<dees-heading level="3">Configuration</dees-heading>
|
||||
|
||||
${this.configState.isLoading
|
||||
? html`
|
||||
@@ -86,7 +86,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
|
||||
@navigate=${(e: CustomEvent) => {
|
||||
if (e.detail?.view) {
|
||||
appRouter.navigateToView(e.detail.view);
|
||||
appRouter.navigateToView(e.detail.view, e.detail.subview);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -149,7 +149,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'routes' } },
|
||||
{ label: 'View Routes', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'routes' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -181,7 +181,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
}
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'emails' } },
|
||||
{ label: 'View Emails', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'email', subview: 'log' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -227,7 +227,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
|
||||
const status = tls.source === 'none' ? 'not-configured' : 'enabled';
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'certificates' } },
|
||||
{ label: 'View Certificates', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'domains', subview: 'certificates' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -305,7 +305,7 @@ export class OpsViewConfig extends DeesElement {
|
||||
];
|
||||
|
||||
const actions: IConfigSectionAction[] = [
|
||||
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'remoteingress' } },
|
||||
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
|
||||
];
|
||||
|
||||
return html`
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as shared from './shared/index.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import * as shared from '../shared/index.js';
|
||||
import * as appstate from '../../appstate.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
|
||||
|
||||
public render() {
|
||||
return html`
|
||||
<dees-heading level="2">Overview</dees-heading>
|
||||
<dees-heading level="3">Stats</dees-heading>
|
||||
|
||||
${this.statsState.isLoading ? html`
|
||||
<div class="loadingMessage">
|
||||
3
ts_web/elements/security/index.ts
Normal file
3
ts_web/elements/security/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './ops-view-security-overview.js';
|
||||
export * from './ops-view-security-blocked.js';
|
||||
export * from './ops-view-security-authentication.js';
|
||||
121
ts_web/elements/security/ops-view-security-authentication.ts
Normal file
121
ts_web/elements/security/ops-view-security-authentication.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-security-authentication': OpsViewSecurityAuthentication;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-security-authentication')
|
||||
export class OpsViewSecurityAuthentication extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
h2 {
|
||||
margin: 32px 0 16px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#333', '#ccc')};
|
||||
}
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Derive auth events from recentEvents
|
||||
const allEvents: any[] = metrics.recentEvents || [];
|
||||
const authEvents = allEvents.filter((evt: any) => evt.type === 'authentication');
|
||||
const successfulLogins = authEvents.filter((evt: any) => evt.success === true).length;
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'authFailures',
|
||||
title: 'Authentication Failures',
|
||||
value: metrics.authenticationFailures,
|
||||
type: 'number',
|
||||
icon: 'lucide:LockOpen',
|
||||
color: metrics.authenticationFailures > 10 ? '#ef4444' : '#f59e0b',
|
||||
description: 'Failed authentication attempts today',
|
||||
},
|
||||
{
|
||||
id: 'successfulLogins',
|
||||
title: 'Successful Logins',
|
||||
value: successfulLogins,
|
||||
type: 'number',
|
||||
icon: 'lucide:Lock',
|
||||
color: '#22c55e',
|
||||
description: 'Successful logins today',
|
||||
},
|
||||
];
|
||||
|
||||
// Map auth events to login history table data
|
||||
const loginHistory = authEvents.map((evt: any) => ({
|
||||
timestamp: evt.timestamp,
|
||||
username: evt.details?.username || 'unknown',
|
||||
ipAddress: evt.ipAddress || 'unknown',
|
||||
success: evt.success ?? false,
|
||||
reason: evt.success ? '' : evt.message || 'Authentication failed',
|
||||
}));
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Authentication</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<h2>Recent Login Attempts</h2>
|
||||
<dees-table
|
||||
.heading1=${'Login History'}
|
||||
.heading2=${'Recent authentication attempts'}
|
||||
.data=${loginHistory}
|
||||
.displayFunction=${(item) => ({
|
||||
'Time': new Date(item.timestamp).toLocaleString(),
|
||||
'Username': item.username,
|
||||
'IP Address': item.ipAddress,
|
||||
'Status': item.success ? 'Success' : 'Failed',
|
||||
'Reason': item.reason || '-',
|
||||
})}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
118
ts_web/elements/security/ops-view-security-blocked.ts
Normal file
118
ts_web/elements/security/ops-view-security-blocked.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'ops-view-security-blocked': OpsViewSecurityBlocked;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('ops-view-security-blocked')
|
||||
export class OpsViewSecurityBlocked extends DeesElement {
|
||||
@state()
|
||||
accessor statsState: appstate.IStatsState = appstate.statsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.statsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.statsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
dees-statsgrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const metrics = this.statsState.securityMetrics;
|
||||
|
||||
if (!metrics) {
|
||||
return html`
|
||||
<div class="loadingMessage">
|
||||
<p>Loading security metrics...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const blockedIPs: string[] = metrics.blockedIPs || [];
|
||||
|
||||
const tiles: IStatsTile[] = [
|
||||
{
|
||||
id: 'totalBlocked',
|
||||
title: 'Blocked IPs',
|
||||
value: blockedIPs.length,
|
||||
type: 'number',
|
||||
icon: 'lucide:ShieldBan',
|
||||
color: blockedIPs.length > 0 ? '#ef4444' : '#22c55e',
|
||||
description: 'Currently blocked addresses',
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-heading level="3">Blocked IPs</dees-heading>
|
||||
|
||||
<dees-statsgrid
|
||||
.tiles=${tiles}
|
||||
.minTileWidth=${200}
|
||||
></dees-statsgrid>
|
||||
|
||||
<dees-table
|
||||
.heading1=${'Blocked IP Addresses'}
|
||||
.heading2=${'IPs blocked due to suspicious activity'}
|
||||
.data=${blockedIPs.map((ip) => ({ ip }))}
|
||||
.displayFunction=${(item) => ({
|
||||
'IP Address': item.ip,
|
||||
'Reason': 'Suspicious activity',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Unblock',
|
||||
iconName: 'lucide:shield-off',
|
||||
type: ['contextmenu' as const],
|
||||
actionFunc: async (item) => {
|
||||
await this.unblockIP(item.ip);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Clear All',
|
||||
iconName: 'lucide:trash-2',
|
||||
type: ['header' as const],
|
||||
actionFunc: async () => {
|
||||
await this.clearBlockedIPs();
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private async clearBlockedIPs() {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert('Clearing blocked IPs is not yet supported from the UI.');
|
||||
}
|
||||
|
||||
private async unblockIP(ip: string) {
|
||||
// SmartProxy manages IP blocking — not yet exposed via API
|
||||
alert(`Unblocking IP ${ip} is not yet supported from the UI.`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user