Compare commits

...

56 Commits

Author SHA1 Message Date
0a39d50d20 v13.16.2
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 18:51:41 +00:00
de7b9f7ec5 fix(deps): bump @push.rocks/smartproxy to ^27.6.0 2026-04-13 18:51:41 +00:00
bd959464c7 v13.16.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 18:08:36 +00:00
36b629676f fix(migrations): use exact smartdata collection names in route unification migration 2026-04-13 18:08:36 +00:00
19398ea836 v13.16.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 17:38:23 +00:00
4aba8cc353 feat(routes): unify route storage and management across config, email, dns, and API origins 2026-04-13 17:38:23 +00:00
5fd036eeb6 v13.15.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:15:11 +00:00
cfcb66f1ee fix(monitoring): improve domain activity aggregation for multi-domain and wildcard routes 2026-04-13 12:15:11 +00:00
501f4f9de6 v13.15.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:07:13 +00:00
fa926eb10b feat(stats): add typed network stats response fields for bandwidth, domain activity, and protocol distribution 2026-04-13 12:07:13 +00:00
f2d0a9ec1b v13.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 11:04:15 +00:00
035173702d feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring 2026-04-13 11:04:15 +00:00
07a3365496 v13.13.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 09:47:19 +00:00
1c4f7dbb11 feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling 2026-04-13 09:47:19 +00:00
1fdff79dd0 v13.12.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 23:46:31 +00:00
59b52d08fa feat(email-domains): support creating email domains on optional subdomains 2026-04-12 23:46:31 +00:00
2cdc392a40 v13.11.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 22:09:20 +00:00
433047bbf1 feat(email-domains): add email domain management with DNS provisioning, validation, and ops dashboard support 2026-04-12 22:09:20 +00:00
0b81c95de2 v13.10.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 20:43:57 +00:00
196e5dfc1b feat(web-ui): standardize settings views for ACME and email security panels 2026-04-12 20:43:57 +00:00
60d095cd78 v13.9.2
Some checks failed
Docker (tags) / security (push) Failing after 2m58s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 19:42:07 +00:00
2861511d20 fix(web-ui): improve form field descriptions and align certificate settings with tile components 2026-04-12 19:42:07 +00:00
b582d44502 v13.9.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 15:26:12 +00:00
36a2ebc94e fix(network-ui): enable flashing table updates for network activity, remote ingress, and VPN views 2026-04-08 15:26:12 +00:00
ed52a3188d v13.9.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 14:54:49 +00:00
93cc5c7b06 feat(dns): add built-in dcrouter DNS provider support and rename manual domains to dcrouter-hosted/local 2026-04-08 14:54:49 +00:00
5689e93665 v13.8.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 13:12:20 +00:00
c224028495 feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI 2026-04-08 13:12:20 +00:00
4fbe01823b v13.7.1
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 12:06:08 +00:00
34ba2c9f02 fix(repo): no changes to commit 2026-04-08 12:06:08 +00:00
52aed0e96e v13.7.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 11:11:53 +00:00
ea2e618990 feat(dns-providers): add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows 2026-04-08 11:11:53 +00:00
140637a307 v13.6.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 11:08:18 +00:00
21c80e173d feat(dns): add db-backed DNS provider, domain, and record management with ops UI support 2026-04-08 11:08:18 +00:00
e77fe9451e v13.5.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 09:01:08 +00:00
7971bd249e feat(opsserver-access): add admin user listing to the access dashboard 2026-04-08 09:01:08 +00:00
6099563acd v13.4.2
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 08:29:30 +00:00
bf4c181026 fix(repo): no changes to commit 2026-04-08 08:29:30 +00:00
d9d12427d3 v13.4.1
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 08:28:00 +00:00
91aa9a7228 fix(repo): no changes to commit 2026-04-08 08:28:00 +00:00
877356b247 v13.4.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 08:24:55 +00:00
2325f01cde feat(web-ui): reorganize dashboard views into grouped navigation with new email, access, and network subviews 2026-04-08 08:24:55 +00:00
00fdadb088 v13.3.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:45:26 +00:00
2b76e05a40 feat(web-ui): reorganize network and security views into tabbed subviews with route-aware navigation 2026-04-08 07:45:26 +00:00
1b37944aab v13.2.2
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:13:01 +00:00
35a01a6981 fix(project): no changes to commit 2026-04-08 07:13:01 +00:00
3058706d2a v13.2.1
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:12:16 +00:00
0e4d6a3c0c fix(project): no changes to commit 2026-04-08 07:12:16 +00:00
2bc2475878 v13.2.0
Some checks failed
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 07:11:21 +00:00
37eab7c7b1 feat(ops-ui): add column filters to operations tables across admin views 2026-04-08 07:11:21 +00:00
8ab7343606 v13.1.3
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-08 00:56:02 +00:00
f04feec273 fix(certificate-handler): preserve wildcard coverage during forced certificate renewals and propagate renewed certs to sibling domains 2026-04-08 00:56:02 +00:00
d320590ce2 v13.1.2
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 22:46:22 +00:00
0ee57f433b fix(deps): bump @serve.zone/catalog to ^2.12.3 2026-04-07 22:46:22 +00:00
b28b5eea84 v13.1.1
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-07 22:28:22 +00:00
27d7489af9 fix(deps): bump catalog-related dependencies to newer patch and minor releases 2026-04-07 22:28:22 +00:00
100 changed files with 9050 additions and 1732 deletions

17
AGENTS.md Normal file
View 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.

View File

@@ -1,5 +1,186 @@
# Changelog # Changelog
## 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
- add deriveCertDomainName helper to match shared ACME certificate identities across wildcard and subdomain routes
- pass includeWildcard when force-renewing certificates so renewed certs keep wildcard SAN coverage for sibling subdomains
- persist renewed certificate data to all sibling route domains that share the same cert identity and clear cached certificate status entries
- add regression tests for certificate domain derivation and force-renew wildcard handling
## 2026-04-07 - 13.1.2 - fix(deps)
bump @serve.zone/catalog to ^2.12.3
- Updates @serve.zone/catalog from ^2.12.0 to ^2.12.3 in package.json
## 2026-04-07 - 13.1.1 - fix(deps)
bump catalog-related dependencies to newer patch and minor releases
- update @design.estate/dees-catalog from ^3.66.0 to ^3.67.1
- update @serve.zone/catalog from ^2.11.2 to ^2.12.0
## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations) ## 2026-04-07 - 13.1.0 - feat(vpn,target-profiles,migrations)
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "13.1.0", "version": "13.16.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -27,7 +27,7 @@
"@git.zone/tsrun": "^2.0.2", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3", "@git.zone/tstest": "^3.6.3",
"@git.zone/tswatch": "^3.3.2", "@git.zone/tswatch": "^3.3.2",
"@types/node": "^25.5.2" "@types/node": "^25.6.0"
}, },
"dependencies": { "dependencies": {
"@api.global/typedrequest": "^3.3.0", "@api.global/typedrequest": "^3.3.0",
@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6", "@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2", "@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.66.0", "@design.estate/dees-catalog": "^3.78.2",
"@design.estate/dees-element": "^2.2.4", "@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0", "@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0", "@push.rocks/projectinfo": "^5.1.0",
@@ -49,12 +49,12 @@
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.2", "@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmetrics": "^3.0.3",
"@push.rocks/smartmigration": "1.1.1", "@push.rocks/smartmigration": "1.2.0",
"@push.rocks/smartmta": "^5.3.1", "@push.rocks/smartmta": "^5.3.1",
"@push.rocks/smartnetwork": "^4.5.2", "@push.rocks/smartnetwork": "^4.5.2",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartproxy": "^27.5.0", "@push.rocks/smartproxy": "^27.6.0",
"@push.rocks/smartradius": "^1.1.1", "@push.rocks/smartradius": "^1.1.1",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10", "@push.rocks/smartrx": "^3.0.10",
@@ -62,12 +62,12 @@
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.2", "@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.11.2", "@serve.zone/catalog": "^2.12.3",
"@serve.zone/interfaces": "^5.3.0", "@serve.zone/interfaces": "^5.3.0",
"@serve.zone/remoteingress": "^4.15.3", "@serve.zone/remoteingress": "^4.15.3",
"@tsclass/tsclass": "^9.5.0", "@tsclass/tsclass": "^9.5.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"lru-cache": "^11.3.2", "lru-cache": "^11.3.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },

154
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
'@design.estate/dees-catalog': '@design.estate/dees-catalog':
specifier: ^3.66.0 specifier: ^3.78.2
version: 3.66.0(@tiptap/pm@2.27.2) version: 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-element': '@design.estate/dees-element':
specifier: ^2.2.4 specifier: ^2.2.4
version: 2.2.4 version: 2.2.4
@@ -66,8 +66,8 @@ importers:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
'@push.rocks/smartmigration': '@push.rocks/smartmigration':
specifier: 1.1.1 specifier: 1.2.0
version: 1.1.1(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7)) version: 1.2.0(@push.rocks/smartbucket@4.6.0)(@push.rocks/smartdata@7.1.7(socks@2.8.7))
'@push.rocks/smartmta': '@push.rocks/smartmta':
specifier: ^5.3.1 specifier: ^5.3.1
version: 5.3.1 version: 5.3.1
@@ -81,8 +81,8 @@ importers:
specifier: ^4.2.3 specifier: ^4.2.3
version: 4.2.3 version: 4.2.3
'@push.rocks/smartproxy': '@push.rocks/smartproxy':
specifier: ^27.5.0 specifier: ^27.6.0
version: 27.5.0 version: 27.6.0
'@push.rocks/smartradius': '@push.rocks/smartradius':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
@@ -105,8 +105,8 @@ importers:
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
'@serve.zone/catalog': '@serve.zone/catalog':
specifier: ^2.11.2 specifier: ^2.12.3
version: 2.11.2(@tiptap/pm@2.27.2) version: 2.12.3(@tiptap/pm@2.27.2)
'@serve.zone/interfaces': '@serve.zone/interfaces':
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
@@ -120,8 +120,8 @@ importers:
specifier: ^1.5.6 specifier: ^1.5.6
version: 1.5.6 version: 1.5.6
lru-cache: lru-cache:
specifier: ^11.3.2 specifier: ^11.3.3
version: 11.3.2 version: 11.3.3
qrcode: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
@@ -145,8 +145,8 @@ importers:
specifier: ^3.3.2 specifier: ^3.3.2
version: 3.3.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@tiptap/pm@2.27.2) version: 3.3.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@tiptap/pm@2.27.2)
'@types/node': '@types/node':
specifier: ^25.5.2 specifier: ^25.6.0
version: 25.5.2 version: 25.6.0
packages: packages:
@@ -353,8 +353,8 @@ packages:
'@configvault.io/interfaces@1.0.17': '@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==} resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.66.0': '@design.estate/dees-catalog@3.78.2':
resolution: {integrity: sha512-jF6/cmCFjIuDJsjstrcwR0gOaqtxAPIen0SFew2APw071qGdzC2eIYmJF1SltF6OV/nMhMi8sIApH40Ak0rMJQ==} resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
'@design.estate/dees-comms@1.0.30': '@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==} resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -368,6 +368,9 @@ packages:
'@design.estate/dees-wcctools@3.8.0': '@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==} resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.9.0':
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -1231,8 +1234,8 @@ packages:
'@push.rocks/smartmetrics@3.0.3': '@push.rocks/smartmetrics@3.0.3':
resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==} resolution: {integrity: sha512-RYY4NOla3kraZYVF9TBHgIz4/hSkqVDVNP7tLwhLK5mGBPBy8I/9NWXX6txZKQw6QihP85YD8mWUuUu2xS4D6Q==}
'@push.rocks/smartmigration@1.1.1': '@push.rocks/smartmigration@1.2.0':
resolution: {integrity: sha512-K/eLN9cNy+CLOT73rhI93vOy/vGwpV46iJpjRUyPwHsMcQcV6po2idk5+XZQzeuq2x7KpKuUPtZ6gXMtf5Y/ig==} resolution: {integrity: sha512-H2diE1UbZm4cXjxgxkt2YQW3aUQ3QVVU/e8Ws30hzIep0xIqL1BH6//WawA5ZBQhnAOBssZpVOuWOd3GIeBq+Q==}
peerDependencies: peerDependencies:
'@push.rocks/smartbucket': ^4.6.0 '@push.rocks/smartbucket': ^4.6.0
'@push.rocks/smartdata': ^7.1.7 '@push.rocks/smartdata': ^7.1.7
@@ -1284,8 +1287,8 @@ packages:
'@push.rocks/smartpromise@4.2.3': '@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartproxy@27.5.0': '@push.rocks/smartproxy@27.6.0':
resolution: {integrity: sha512-QIXrVQtAoqBCv+9ScLOdGcizN55svJuGCfMDsDaBVtwS3Tva30IxuEL3usNTHABveuI8slaWzSxTabmTULDOwA==} resolution: {integrity: sha512-1mPzabUKhlC0EdeI7Hjee/aiptTsOLftbq8oWBTlIg9JhCQwkIs5UNGTJV/VvlEflJKnay8TbzLzlr95gUr/1w==}
'@push.rocks/smartpuppeteer@2.0.5': '@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1588,8 +1591,8 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@serve.zone/catalog@2.11.2': '@serve.zone/catalog@2.12.3':
resolution: {integrity: sha512-dxrIEAx79h+V0h7IEiHnc4g0Ks3yMcldCZFw8QXarFLNbqjiNzbR5aryumiCjEBHjdjgFJItO59LqWUU+3ML2g==} resolution: {integrity: sha512-/QLFjFcy/ig6cdr4517smSc/VCutW/qF/8lCM3v7tpQ5yLApjqiL314Dyvk9zzSwHpw69IeuM9EmPOeTuCY0iQ==}
'@serve.zone/interfaces@5.3.0': '@serve.zone/interfaces@5.3.0':
resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==} resolution: {integrity: sha512-venO7wtDR9ixzD9NhdERBGjNKbFA5LL0yHw4eqGh0UpmvtXVc3SFG0uuHDilOKMZqZ8bttV88qVsFy1aSTJrtA==}
@@ -1994,6 +1997,12 @@ packages:
'@types/debug@4.1.13': '@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/fs-extra@11.0.4': '@types/fs-extra@11.0.4':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
@@ -2054,8 +2063,8 @@ packages:
'@types/node@22.19.17': '@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@25.5.2': '@types/node@25.6.0':
resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -2858,8 +2867,8 @@ packages:
humanize-ms@1.2.1: humanize-ms@1.2.1:
resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=} resolution: {integrity: sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=}
ibantools@4.5.2: ibantools@4.5.4:
resolution: {integrity: sha512-is+8TgZcKS/AMv/z9nW1zz0bhjhoyjpA1p0nc3A6GkW/InOdcQiUZpkufADzh/aO/LY/TOD/P3oPWncNRn5QMA==} resolution: {integrity: sha512-6jX1gh4aH6XH+o0ey+wtkMTzkcvsEta7DakIOZSng9voZYpMw3U+gK1+tZChk3aRcPcloEt0NOzksjaRZiqXbw==}
iconv-lite@0.4.24: iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
@@ -3076,16 +3085,16 @@ packages:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lru-cache@11.3.2: lru-cache@11.3.3:
resolution: {integrity: sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==} resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lru-cache@7.18.3: lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'} engines: {node: '>=12'}
lucide@0.577.0: lucide@1.8.0:
resolution: {integrity: sha512-PpC/m5eOItp/WU/GlQPFBXDOhq6HibL73KzYP37OX3LM7VmzWQF8voEj8QRWUFvy9FIKfeDQkWYoyS1D/MdWFA==} resolution: {integrity: sha512-JjV/QnadgFLj1Pyu9IKl0lknrolFEzo04B64QcYLLeRzZl/iEHpdbSrRRKbyXcv45SZNv+WGjIUCT33e7xHO6Q==}
mailparser@3.9.6: mailparser@3.9.6:
resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==} resolution: {integrity: sha512-EJYTDWMrOS1kddK1mTsRkrx2Ngh2nYsg54SRMWVVWGVEGbHH4tod8tqqU9hIRPgGQVboSjFubDn9cboSitbM3Q==}
@@ -3163,6 +3172,9 @@ packages:
mdurl@2.0.0: mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mediabunny@1.40.1:
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
memory-pager@1.5.0: memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -4098,8 +4110,8 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.18.2: undici-types@7.19.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
unified@11.0.5: unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -4315,7 +4327,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3) '@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260405.1 '@cloudflare/workers-types': 4.20260405.1
'@design.estate/dees-catalog': 3.66.0(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30 '@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0 '@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -4844,11 +4856,11 @@ snapshots:
dependencies: dependencies:
'@api.global/typedrequest-interfaces': 3.0.19 '@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.66.0(@tiptap/pm@2.27.2)': '@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0 '@design.estate/dees-wcctools': 3.9.0
'@fortawesome/fontawesome-svg-core': 7.2.0 '@fortawesome/fontawesome-svg-core': 7.2.0
'@fortawesome/free-brands-svg-icons': 7.2.0 '@fortawesome/free-brands-svg-icons': 7.2.0
'@fortawesome/free-regular-svg-icons': 7.2.0 '@fortawesome/free-regular-svg-icons': 7.2.0
@@ -4866,9 +4878,9 @@ snapshots:
'@tsclass/tsclass': 9.5.0 '@tsclass/tsclass': 9.5.0
echarts: 5.6.0 echarts: 5.6.0
highlight.js: 11.11.1 highlight.js: 11.11.1
ibantools: 4.5.2 ibantools: 4.5.4
lightweight-charts: 5.1.0 lightweight-charts: 5.1.0
lucide: 0.577.0 lucide: 1.8.0
monaco-editor: 0.55.1 monaco-editor: 0.55.1
pdfjs-dist: 4.10.38 pdfjs-dist: 4.10.38
xterm: 5.3.0 xterm: 5.3.0
@@ -4937,6 +4949,19 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@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
- supports-color
- vue
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@@ -6354,7 +6379,7 @@ snapshots:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.2.2 '@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: dependencies:
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
'@push.rocks/smartversion': 3.1.0 '@push.rocks/smartversion': 3.1.0
@@ -6404,7 +6429,7 @@ snapshots:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrust': 1.3.2 '@push.rocks/smartrust': 1.3.2
'@tsclass/tsclass': 9.5.0 '@tsclass/tsclass': 9.5.0
lru-cache: 11.3.2 lru-cache: 11.3.3
mailparser: 3.9.6 mailparser: 3.9.6
uuid: 13.0.0 uuid: 13.0.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -6496,7 +6521,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartproxy@27.5.0': '@push.rocks/smartproxy@27.6.0':
dependencies: dependencies:
'@push.rocks/smartcrypto': 2.0.4 '@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2 '@push.rocks/smartlog': 3.2.2
@@ -6898,9 +6923,9 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 selderee: 0.11.0
'@serve.zone/catalog@2.11.2(@tiptap/pm@2.27.2)': '@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
dependencies: dependencies:
'@design.estate/dees-catalog': 3.66.0(@tiptap/pm@2.27.2) '@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.4 '@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4 '@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0 '@design.estate/dees-wcctools': 3.8.0
@@ -7442,17 +7467,23 @@ snapshots:
'@types/clean-css@4.2.11': '@types/clean-css@4.2.11':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
source-map: 0.6.1 source-map: 0.6.1
'@types/debug@4.1.13': '@types/debug@4.1.13':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/fs-extra@11.0.4': '@types/fs-extra@11.0.4':
dependencies: dependencies:
'@types/jsonfile': 6.1.4 '@types/jsonfile': 6.1.4
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/hast@3.0.4': '@types/hast@3.0.4':
dependencies: dependencies:
@@ -7472,12 +7503,12 @@ snapshots:
'@types/jsonfile@6.1.4': '@types/jsonfile@6.1.4':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/jsonwebtoken@9.0.10': '@types/jsonwebtoken@9.0.10':
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
@@ -7498,16 +7529,16 @@ snapshots:
'@types/mute-stream@0.0.4': '@types/mute-stream@0.0.4':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/node-fetch@2.6.13': '@types/node-fetch@2.6.13':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
form-data: 4.0.5 form-data: 4.0.5
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/node@16.9.1': {} '@types/node@16.9.1': {}
@@ -7519,13 +7550,13 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/node@25.5.2': '@types/node@25.6.0':
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.19.2
'@types/qrcode@1.5.6': '@types/qrcode@1.5.6':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/randomatic@3.1.5': {} '@types/randomatic@3.1.5': {}
@@ -7535,11 +7566,11 @@ snapshots:
'@types/tar-stream@3.1.4': '@types/tar-stream@3.1.4':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/through2@2.0.41': '@types/through2@2.0.41':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
@@ -7569,11 +7600,11 @@ snapshots:
'@types/ws@8.18.1': '@types/ws@8.18.1':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 25.5.2 '@types/node': 25.6.0
optional: true optional: true
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
@@ -8390,7 +8421,7 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
ibantools@4.5.2: {} ibantools@4.5.4: {}
iconv-lite@0.4.24: iconv-lite@0.4.24:
dependencies: dependencies:
@@ -8628,11 +8659,11 @@ snapshots:
lowercase-keys@3.0.0: {} lowercase-keys@3.0.0: {}
lru-cache@11.3.2: {} lru-cache@11.3.3: {}
lru-cache@7.18.3: {} lru-cache@7.18.3: {}
lucide@0.577.0: {} lucide@1.8.0: {}
mailparser@3.9.6: mailparser@3.9.6:
dependencies: dependencies:
@@ -8804,6 +8835,11 @@ snapshots:
mdurl@2.0.0: {} mdurl@2.0.0: {}
mediabunny@1.40.1:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memory-pager@1.5.0: {} memory-pager@1.5.0: {}
micromark-core-commonmark@2.0.3: micromark-core-commonmark@2.0.3:
@@ -9268,7 +9304,7 @@ snapshots:
path-scurry@2.0.2: path-scurry@2.0.2:
dependencies: dependencies:
lru-cache: 11.3.2 lru-cache: 11.3.3
minipass: 7.1.3 minipass: 7.1.3
path-to-regexp@8.4.2: {} path-to-regexp@8.4.2: {}
@@ -9942,7 +9978,7 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.18.2: {} undici-types@7.19.2: {}
unified@11.0.5: unified@11.0.5:
dependencies: dependencies:

View File

@@ -174,62 +174,20 @@ tap.test('Route - should hydrate from IMergedRoute data', async () => {
match: { ports: 443, domains: 'example.com' }, match: { ports: 443, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] }, action: { type: 'forward', targets: [{ host: 'backend', port: 8080 }] },
}, },
source: 'programmatic', id: 'route-123',
enabled: true, enabled: true,
overridden: false, origin: 'api',
storedRouteId: 'route-123',
createdAt: 1000, createdAt: 1000,
updatedAt: 2000, updatedAt: 2000,
}); });
expect(route.name).toEqual('test-route'); expect(route.name).toEqual('test-route');
expect(route.source).toEqual('programmatic'); expect(route.id).toEqual('route-123');
expect(route.enabled).toEqual(true); expect(route.enabled).toEqual(true);
expect(route.overridden).toEqual(false); expect(route.origin).toEqual('api');
expect(route.storedRouteId).toEqual('route-123');
expect(route.routeConfig.match.ports).toEqual(443); expect(route.routeConfig.match.ports).toEqual(443);
}); });
tap.test('Route - should throw on update/delete/toggle for hardcoded routes', async () => {
const client = new DcRouterApiClient({ baseUrl: 'https://localhost:3000' });
const route = new Route(client, {
route: {
name: 'hardcoded-route',
match: { ports: 80 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
},
source: 'hardcoded',
enabled: true,
overridden: false,
// No storedRouteId for hardcoded routes
});
let updateError: Error | undefined;
try {
await route.update({ name: 'new-name' });
} catch (e) {
updateError = e as Error;
}
expect(updateError).toBeTruthy();
expect(updateError!.message).toInclude('hardcoded');
let deleteError: Error | undefined;
try {
await route.delete();
} catch (e) {
deleteError = e as Error;
}
expect(deleteError).toBeTruthy();
let toggleError: Error | undefined;
try {
await route.toggle(false);
} catch (e) {
toggleError = e as Error;
}
expect(toggleError).toBeTruthy();
});
// ============================================================================= // =============================================================================
// Certificate resource class // Certificate resource class
// ============================================================================= // =============================================================================

196
test/test.cert-renewal.ts Normal file
View File

@@ -0,0 +1,196 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { deriveCertDomainName } from '../ts/opsserver/handlers/certificate.handler.js';
// ──────────────────────────────────────────────────────────────────────────────
// deriveCertDomainName — pure helper that mirrors smartacme's certmatcher.
// Used by the force-renew sibling-propagation logic to identify which routes
// share a single underlying ACME certificate.
// ──────────────────────────────────────────────────────────────────────────────
tap.test('deriveCertDomainName collapses 3-level subdomain to base', async () => {
expect(deriveCertDomainName('outline.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('pr.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('mtd.task.vc')).toEqual('task.vc');
});
tap.test('deriveCertDomainName returns base domain unchanged for 2-level domain', async () => {
expect(deriveCertDomainName('task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('example.com')).toEqual('example.com');
});
tap.test('deriveCertDomainName strips wildcard prefix', async () => {
expect(deriveCertDomainName('*.task.vc')).toEqual('task.vc');
expect(deriveCertDomainName('*.example.com')).toEqual('example.com');
});
tap.test('deriveCertDomainName collapses subdomain and wildcard to same identity', async () => {
// This is the core property: outline.task.vc and *.task.vc must yield
// the same cert identity, otherwise sibling propagation cannot work.
const subdomain = deriveCertDomainName('outline.task.vc');
const wildcard = deriveCertDomainName('*.task.vc');
expect(subdomain).toEqual(wildcard);
});
tap.test('deriveCertDomainName returns undefined for 4+ level domains', async () => {
// Matches smartacme's "deeper domains not supported" behavior.
expect(deriveCertDomainName('a.b.task.vc')).toBeUndefined();
expect(deriveCertDomainName('one.two.three.example.com')).toBeUndefined();
});
tap.test('deriveCertDomainName returns undefined for malformed inputs', async () => {
expect(deriveCertDomainName('vc')).toBeUndefined();
expect(deriveCertDomainName('')).toBeUndefined();
});
// ──────────────────────────────────────────────────────────────────────────────
// CertificateHandler.reprovisionCertificateDomain — verify the includeWildcard
// option is forwarded to smartAcme.getCertificateForDomain on force renew.
//
// This is the regression test for Bug 1: previously the call passed only
// `{ forceRenew: true }`, causing the re-issued cert to drop the wildcard SAN
// and break every sibling subdomain.
// ──────────────────────────────────────────────────────────────────────────────
import { CertificateHandler } from '../ts/opsserver/handlers/certificate.handler.js';
// Build a minimal stub of OpsServer + DcRouter that satisfies CertificateHandler.
// We only need: viewRouter.addTypedHandler / adminRouter.addTypedHandler (no-op),
// dcRouterRef.smartProxy.routeManager.getRoutes(), dcRouterRef.smartAcme,
// dcRouterRef.findRouteNamesForDomain, dcRouterRef.certificateStatusMap.
function makeStubOpsServer(opts: {
routes: Array<{ name: string; domains: string[] }>;
smartAcmeStub: { getCertificateForDomain: (domain: string, options: any) => Promise<any> };
}) {
const captured: { typedHandlers: any[] } = { typedHandlers: [] };
const router = {
addTypedHandler(handler: any) { captured.typedHandlers.push(handler); },
};
const routes = opts.routes.map((r) => ({
name: r.name,
match: { domains: r.domains, ports: 443 },
action: { type: 'forward', tls: { certificate: 'auto' } },
}));
const dcRouterRef: any = {
smartProxy: {
routeManager: { getRoutes: () => routes },
},
smartAcme: opts.smartAcmeStub,
findRouteNamesForDomain: (domain: string) =>
routes.filter((r) => r.match.domains.includes(domain)).map((r) => r.name),
certificateStatusMap: new Map<string, any>(),
certProvisionScheduler: null,
routeConfigManager: null,
};
const opsServerRef: any = {
viewRouter: router,
adminRouter: router,
dcRouterRef,
};
return { opsServerRef, dcRouterRef, captured };
}
tap.test('reprovisionCertificateDomain passes includeWildcard=true for non-wildcard domain', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [
{ name: 'outline-route', domains: ['outline.task.vc'] },
{ name: 'pr-route', domains: ['pr.task.vc'] },
{ name: 'mtd-route', domains: ['mtd.task.vc'] },
],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
// Return a cert object shaped like SmartacmeCert
return {
id: 'test-id',
domainName: 'task.vc',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
};
},
},
});
// Override updateRoutes/applyRoutes to no-op so the test doesn't try to talk to a real proxy
dcRouterRef.smartProxy.updateRoutes = async () => {};
// Construct handler — registerHandlers will run and register typed handlers on our stub router.
const handler = new CertificateHandler(opsServerRef);
// Invoke the private reprovision method directly. The Bug 1 fix is verified
// by inspecting the captured smartAcme call options regardless of whether
// sibling propagation succeeds (it relies on a real DB for ProxyCertDoc).
await (handler as any).reprovisionCertificateDomain('outline.task.vc', true);
// Sibling propagation may fail because ProxyCertDoc.findByDomain needs a real DB.
// The Bug 1 fix is verified by the captured smartAcme call regardless.
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls[0].domain).toEqual('outline.task.vc');
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: true });
});
tap.test('reprovisionCertificateDomain passes includeWildcard=false for wildcard domain', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [
{ name: 'wildcard-route', domains: ['*.task.vc'] },
],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
return {
id: 'test-id',
domainName: 'task.vc',
created: Date.now(),
validUntil: Date.now() + 90 * 24 * 60 * 60 * 1000,
privateKey: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
publicKey: '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----',
csr: '',
};
},
},
});
dcRouterRef.smartProxy.updateRoutes = async () => {};
const handler = new CertificateHandler(opsServerRef);
await (handler as any).reprovisionCertificateDomain('*.task.vc', true);
expect(calls.length).toBeGreaterThanOrEqual(1);
expect(calls[0].domain).toEqual('*.task.vc');
expect(calls[0].options).toEqual({ forceRenew: true, includeWildcard: false });
});
tap.test('reprovisionCertificateDomain does not call smartAcme when forceRenew is false', async () => {
const calls: Array<{ domain: string; options: any }> = [];
const { opsServerRef, dcRouterRef } = makeStubOpsServer({
routes: [{ name: 'outline-route', domains: ['outline.task.vc'] }],
smartAcmeStub: {
getCertificateForDomain: async (domain: string, options: any) => {
calls.push({ domain, options });
return {} as any;
},
},
});
dcRouterRef.smartProxy.updateRoutes = async () => {};
const handler = new CertificateHandler(opsServerRef);
await (handler as any).reprovisionCertificateDomain('outline.task.vc', false);
// forceRenew=false should NOT call getCertificateForDomain — it just triggers
// applyRoutes and lets the cert provisioning pipeline handle it.
expect(calls.length).toEqual(0);
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.1.0', version: '13.16.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

1
ts/acme/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './manager.acme-config.js';

View 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,
};
}
}

View File

@@ -27,6 +27,9 @@ import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js'; import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js'; import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/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 { export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */ /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -116,13 +119,6 @@ export interface IDcRouterOptions {
useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true) 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. * Unified database configuration.
* All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata. * All persistent data (config, certs, VPN, cache, etc.) is stored via smartdata.
@@ -279,6 +275,13 @@ export class DcRouter {
public referenceResolver?: ReferenceResolver; public referenceResolver?: ReferenceResolver;
public targetProfileManager?: TargetProfileManager; 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) // Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null; public detectedPublicIp: string | null = null;
@@ -309,8 +312,10 @@ export class DcRouter {
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
// Cached constructor routes (computed once during setupSmartProxy, used by RouteConfigManager) // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private constructorRoutes: plugins.smartproxy.IRouteConfig[] = []; private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Environment access // Environment access
private qenv = new plugins.qenv.Qenv('./', '.nogit/'); private qenv = new plugins.qenv.Qenv('./', '.nogit/');
@@ -393,10 +398,72 @@ export class DcRouter {
.withRetry({ maxRetries: 1, baseDelayMs: 1000 }), .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[] = []; const smartProxyDeps: string[] = [];
if (this.options.dbConfig?.enabled !== false) { if (this.options.dbConfig?.enabled !== false) {
smartProxyDeps.push('DcRouterDb'); smartProxyDeps.push('DcRouterDb');
smartProxyDeps.push('DnsManager');
smartProxyDeps.push('AcmeConfigManager');
} }
this.serviceManager.addService( this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy') new plugins.taskbuffer.Service('SmartProxy')
@@ -415,9 +482,11 @@ export class DcRouter {
.withRetry({ maxRetries: 0 }), .withRetry({ maxRetries: 0 }),
); );
// SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits // SmartAcme: optional, depends on SmartProxy — aggressive retry for rate limits.
// Only registered if DNS challenge is configured // Always registered when the DB is enabled; setupSmartProxy() decides whether
if (this.options.dnsChallenge?.cloudflareApiKey) { // 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( this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartAcme') new plugins.taskbuffer.Service('SmartAcme')
.optional() .optional()
@@ -482,7 +551,6 @@ export class DcRouter {
await this.targetProfileManager.initialize(); await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.getConstructorRoutes(),
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.options.vpnConfig?.enabled
@@ -497,7 +565,7 @@ export class DcRouter {
} }
: undefined, : undefined,
this.referenceResolver, this.referenceResolver,
// Sync merged routes to RemoteIngressManager whenever routes change, // Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary // then push updated derived ports to the Rust hub binary
(routes) => { (routes) => {
if (this.remoteIngressManager) { if (this.remoteIngressManager) {
@@ -510,7 +578,11 @@ export class DcRouter {
); );
this.apiTokenManager = new ApiTokenManager(); this.apiTokenManager = new ApiTokenManager();
await this.apiTokenManager.initialize(); await this.apiTokenManager.initialize();
await this.routeConfigManager.initialize(); await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
// Seed default profiles/targets if DB is empty and seeding is enabled // Seed default profiles/targets if DB is empty and seeding is enabled
const seeder = new DbSeeder(this.referenceResolver); const seeder = new DbSeeder(this.referenceResolver);
@@ -814,47 +886,66 @@ export class DcRouter {
this.smartProxy = undefined; this.smartProxy = undefined;
} }
let routes: plugins.smartproxy.IRouteConfig[] = []; // Assemble seed routes from constructor config — these will be seeded into DB
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined; // by RouteConfigManager.initialize() when the ConfigManagers service starts.
this.seedConfigRoutes = (this.options.smartProxyConfig?.routes || []) as plugins.smartproxy.IRouteConfig[];
logger.log('info', `Found ${this.seedConfigRoutes.length} routes in config`);
// If user provides full SmartProxy config, use it directly this.seedEmailRoutes = [];
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
if (this.options.emailConfig) { if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig); this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) }); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
} }
// If DNS is configured, add DNS routes this.seedDnsRoutes = [];
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes(); this.seedDnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) }); logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(this.seedDnsRoutes) });
routes = [...routes, ...dnsRoutes];
} }
// Merge TLS/ACME configuration if provided at root level // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
if (this.options.tls && !acmeConfig) { let routes: plugins.smartproxy.IRouteConfig[] = [
acmeConfig = { ...this.seedConfigRoutes,
accountEmail: this.options.tls.contactEmail, ...this.seedEmailRoutes,
...this.seedDnsRoutes,
];
// 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, enabled: true,
useProduction: true, useProduction: dbAcme.useProduction,
autoRenew: true, autoRenew: dbAcme.autoRenew,
renewThresholdDays: 30 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[] = []; let challengeHandlers: any[] = [];
if (this.options.dnsChallenge?.cloudflareApiKey) { if (
logger.log('info', 'Configuring Cloudflare DNS challenge for ACME'); acmeConfig &&
const cloudflareAccount = new plugins.cloudflare.CloudflareAccount(this.options.dnsChallenge.cloudflareApiKey); this.dnsManager &&
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cloudflareAccount); (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); challengeHandlers.push(dns01Handler);
} }
@@ -865,10 +956,6 @@ export class DcRouter {
logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration'); logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
} }
// Cache constructor routes for RouteConfigManager (without VPN security baked in —
// applyRoutes() injects VPN security dynamically so it stays current with client changes)
this.constructorRoutes = [...routes];
// If we have routes or need a basic SmartProxy instance, create it // If we have routes or need a basic SmartProxy instance, create it
if (routes.length > 0 || this.options.smartProxyConfig) { if (routes.length > 0 || this.options.smartProxyConfig) {
logger.log('info', 'Setting up SmartProxy with combined configuration'); logger.log('info', 'Setting up SmartProxy with combined configuration');
@@ -953,10 +1040,12 @@ export class DcRouter {
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) }) 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({ this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com', accountEmail: dbAcme!.accountEmail,
certManager: new StorageBackedCertManager(), certManager: new StorageBackedCertManager(),
environment: 'production', environment: dbAcme!.useProduction ? 'production' : 'integration',
challengeHandlers: challengeHandlers, challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'], challengePriority: ['dns-01'],
}); });
@@ -1317,14 +1406,6 @@ export class DcRouter {
return names; return names;
} }
/**
* Get the routes derived from constructor config (smartProxy + email + DNS).
* Used by RouteConfigManager as the "hardcoded" base.
*/
public getConstructorRoutes(): plugins.smartproxy.IRouteConfig[] {
return this.constructorRoutes;
}
public async stop() { public async stop() {
logger.log('info', 'Stopping DcRouter services...'); logger.log('info', 'Stopping DcRouter services...');
@@ -1368,17 +1449,16 @@ export class DcRouter {
// Update configuration // Update configuration
this.options.smartProxyConfig = config; this.options.smartProxyConfig = config;
// Update routes on RemoteIngressManager so derived ports stay in sync // Start new SmartProxy with updated configuration (rebuilds seed routes)
if (this.remoteIngressManager && config.routes) {
this.remoteIngressManager.setRoutes(config.routes as any[]);
}
// Start new SmartProxy with updated configuration (will include email routes if configured)
await this.setupSmartProxy(); await this.setupSmartProxy();
// Re-apply programmatic routes and overrides after SmartProxy restart // Re-seed and re-apply all routes after SmartProxy restart
if (this.routeConfigManager) { if (this.routeConfigManager) {
await this.routeConfigManager.initialize(); await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
} }
logger.log('info', 'SmartProxy configuration updated'); logger.log('info', 'SmartProxy configuration updated');
@@ -1720,6 +1800,12 @@ export class DcRouter {
this.registerDnsRecords(allRecords); 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)`); 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);
}
} }
/** /**
@@ -2090,13 +2176,14 @@ export class DcRouter {
this.remoteIngressManager = new RemoteIngressManager(); this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize(); await this.remoteIngressManager.initialize();
// Pass current routes so the manager can derive edge ports from remoteIngress-tagged routes // Pass current bootstrap routes so the manager can derive edge ports initially.
const currentRoutes = this.constructorRoutes; // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
this.remoteIngressManager.setRoutes(currentRoutes as any[]); // will push the complete merged routes here.
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.seedDnsRoutes];
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
// Race-condition fix: if ConfigManagers finished before us, re-apply routes // If ConfigManagers finished before us, re-apply routes
// so the callback delivers the full merged set (including DB-stored routes) // so the callback delivers the full DB set to our newly-created remoteIngressManager.
// to our newly-created remoteIngressManager.
if (this.routeConfigManager) { if (this.routeConfigManager) {
await this.routeConfigManager.applyRoutes(); await this.routeConfigManager.applyRoutes();
} }
@@ -2183,11 +2270,10 @@ export class DcRouter {
if (!this.targetProfileManager) return [...ips]; if (!this.targetProfileManager) return [...ips];
const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[]; const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec( const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds, routes, storedRoutes, targetProfileIds, allRoutes,
); );
// Add target IPs directly // Add target IPs directly
@@ -2210,9 +2296,8 @@ export class DcRouter {
await this.vpnManager.start(); await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures hardcoded routes // Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists (not possible during setupSmartProxy since // get correct profile-based ipAllowLists
// VPN server wasn't ready yet)
await this.routeConfigManager?.applyRoutes(); await this.routeConfigManager?.applyRoutes();
} }

View File

@@ -1,11 +1,11 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; import { SourceProfileDoc, NetworkTargetDoc, RouteDoc } from '../db/index.js';
import type { import type {
ISourceProfile, ISourceProfile,
INetworkTarget, INetworkTarget,
IRouteMetadata, IRouteMetadata,
IStoredRoute, IRoute,
IRouteSecurity, IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js'; } from '../../ts_interfaces/data/route-management.js';
@@ -81,7 +81,7 @@ export class ReferenceResolver {
public async deleteProfile( public async deleteProfile(
id: string, id: string,
force: boolean, force: boolean,
storedRoutes?: Map<string, IStoredRoute>, storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id); const profile = this.profiles.get(id);
if (!profile) { if (!profile) {
@@ -131,7 +131,7 @@ export class ReferenceResolver {
return [...this.profiles.values()]; return [...this.profiles.values()];
} }
public getProfileUsage(storedRoutes: Map<string, IStoredRoute>): Map<string, Array<{ id: string; routeName: string }>> { public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>(); const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) { for (const profile of this.profiles.values()) {
usage.set(profile.id, []); usage.set(profile.id, []);
@@ -147,7 +147,7 @@ export class ReferenceResolver {
public getProfileUsageForId( public getProfileUsageForId(
profileId: string, profileId: string,
storedRoutes: Map<string, IStoredRoute>, storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> { ): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = []; const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
@@ -214,7 +214,7 @@ export class ReferenceResolver {
public async deleteTarget( public async deleteTarget(
id: string, id: string,
force: boolean, force: boolean,
storedRoutes?: Map<string, IStoredRoute>, storedRoutes?: Map<string, IRoute>,
): Promise<{ success: boolean; message?: string }> { ): Promise<{ success: boolean; message?: string }> {
const target = this.targets.get(id); const target = this.targets.get(id);
if (!target) { if (!target) {
@@ -263,7 +263,7 @@ export class ReferenceResolver {
public getTargetUsageForId( public getTargetUsageForId(
targetId: string, targetId: string,
storedRoutes: Map<string, IStoredRoute>, storedRoutes: Map<string, IRoute>,
): Array<{ id: string; routeName: string }> { ): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = []; const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
@@ -334,20 +334,20 @@ export class ReferenceResolver {
// ========================================================================= // =========================================================================
public async findRoutesByProfileRef(profileId: string): Promise<string[]> { public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.sourceProfileRef === profileId) .filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
public async findRoutesByTargetRef(targetId: string): Promise<string[]> { public async findRoutesByTargetRef(targetId: string): Promise<string[]> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.networkTargetRef === targetId) .filter((doc) => doc.metadata?.networkTargetRef === targetId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) { if (stored.metadata?.sourceProfileRef === profileId) {
@@ -357,7 +357,7 @@ export class ReferenceResolver {
return ids; return ids;
} }
public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByTargetRefSync(targetId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.networkTargetRef === targetId) { if (stored.metadata?.networkTargetRef === targetId) {
@@ -547,7 +547,7 @@ export class ReferenceResolver {
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> { private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) { for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId); const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,
@@ -562,7 +562,7 @@ export class ReferenceResolver {
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> { private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) { for (const routeId of routeIds) {
const doc = await StoredRouteDoc.findById(routeId); const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,

View File

@@ -1,9 +1,8 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { StoredRouteDoc, RouteOverrideDoc } from '../db/index.js'; import { RouteDoc } from '../db/index.js';
import type { import type {
IStoredRoute, IRoute,
IRouteOverride,
IMergedRoute, IMergedRoute,
IRouteWarning, IRouteWarning,
IRouteMetadata, IRouteMetadata,
@@ -46,13 +45,11 @@ class RouteUpdateMutex {
} }
export class RouteConfigManager { export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>(); private routes = new Map<string, IRoute>();
private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = []; private warnings: IRouteWarning[] = [];
private routeUpdateMutex = new RouteUpdateMutex(); private routeUpdateMutex = new RouteUpdateMutex();
constructor( constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[],
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined, private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined, private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[], private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
@@ -60,52 +57,44 @@ export class RouteConfigManager {
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {} ) {}
/** Expose stored routes map for reference resolution lookups. */ /** Expose routes map for reference resolution lookups. */
public getStoredRoutes(): Map<string, IStoredRoute> { public getRoutes(): Map<string, IRoute> {
return this.storedRoutes; return this.routes;
} }
/** /**
* Load persisted routes and overrides, compute warnings, apply to SmartProxy. * Load persisted routes, seed config/email/dns routes, compute warnings, apply to SmartProxy.
*/ */
public async initialize(): Promise<void> { public async initialize(
await this.loadStoredRoutes(); configRoutes: IDcRouterRouteConfig[] = [],
await this.loadOverrides(); emailRoutes: IDcRouterRouteConfig[] = [],
dnsRoutes: IDcRouterRouteConfig[] = [],
): Promise<void> {
await this.loadRoutes();
await this.seedRoutes(configRoutes, 'config');
await this.seedRoutes(emailRoutes, 'email');
await this.seedRoutes(dnsRoutes, 'dns');
this.computeWarnings(); this.computeWarnings();
this.logWarnings(); this.logWarnings();
await this.applyRoutes(); await this.applyRoutes();
} }
// ========================================================================= // =========================================================================
// Merged view // Route listing
// ========================================================================= // =========================================================================
public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } { public getMergedRoutes(): { routes: IMergedRoute[]; warnings: IRouteWarning[] } {
const merged: IMergedRoute[] = []; const merged: IMergedRoute[] = [];
// Hardcoded routes for (const route of this.routes.values()) {
for (const route of this.getHardcodedRoutes()) {
const name = route.name || '';
const override = this.overrides.get(name);
merged.push({ merged.push({
route, route: route.route,
source: 'hardcoded', id: route.id,
enabled: override ? override.enabled : true, enabled: route.enabled,
overridden: !!override, origin: route.origin,
}); createdAt: route.createdAt,
} updatedAt: route.updatedAt,
metadata: route.metadata,
// 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,
}); });
} }
@@ -113,7 +102,7 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Programmatic route CRUD // Route CRUD
// ========================================================================= // =========================================================================
public async createRoute( public async createRoute(
@@ -127,7 +116,7 @@ export class RouteConfigManager {
// Ensure route has a name // Ensure route has a name
if (!route.name) { if (!route.name) {
route.name = `programmatic-${id.slice(0, 8)}`; route.name = `route-${id.slice(0, 8)}`;
} }
// Resolve references if metadata has refs and resolver is available // Resolve references if metadata has refs and resolver is available
@@ -138,17 +127,18 @@ export class RouteConfigManager {
resolvedMetadata = resolved.metadata; resolvedMetadata = resolved.metadata;
} }
const stored: IStoredRoute = { const stored: IRoute = {
id, id,
route, route,
enabled, enabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy, createdBy,
origin: 'api',
metadata: resolvedMetadata, metadata: resolvedMetadata,
}; };
this.storedRoutes.set(id, stored); this.routes.set(id, stored);
await this.persistRoute(stored); await this.persistRoute(stored);
await this.applyRoutes(); await this.applyRoutes();
return id; return id;
@@ -162,7 +152,7 @@ export class RouteConfigManager {
metadata?: Partial<IRouteMetadata>; metadata?: Partial<IRouteMetadata>;
}, },
): Promise<boolean> { ): Promise<boolean> {
const stored = this.storedRoutes.get(id); const stored = this.routes.get(id);
if (!stored) return false; if (!stored) return false;
if (patch.route) { if (patch.route) {
@@ -201,9 +191,9 @@ export class RouteConfigManager {
} }
public async deleteRoute(id: string): Promise<boolean> { public async deleteRoute(id: string): Promise<boolean> {
if (!this.storedRoutes.has(id)) return false; if (!this.routes.has(id)) return false;
this.storedRoutes.delete(id); this.routes.delete(id);
const doc = await StoredRouteDoc.findById(id); const doc = await RouteDoc.findById(id);
if (doc) await doc.delete(); if (doc) await doc.delete();
await this.applyRoutes(); await this.applyRoutes();
return true; return true;
@@ -214,103 +204,124 @@ 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 = { * Upsert seed routes by name+origin. Preserves user's `enabled` state.
routeName, * Deletes stale DB routes whose origin matches but name is not in the seed set.
enabled, */
updatedAt: Date.now(), private async seedRoutes(
updatedBy, seedRoutes: IDcRouterRouteConfig[],
}; origin: 'config' | 'email' | 'dns',
this.overrides.set(routeName, override); ): Promise<void> {
const existingDoc = await RouteOverrideDoc.findByRouteName(routeName); if (seedRoutes.length === 0) return;
if (existingDoc) {
existingDoc.enabled = override.enabled; const seedNames = new Set<string>();
existingDoc.updatedAt = override.updatedAt; let seeded = 0;
existingDoc.updatedBy = override.updatedBy; let updated = 0;
await existingDoc.save();
} else { for (const route of seedRoutes) {
const doc = new RouteOverrideDoc(); const name = route.name || '';
doc.routeName = override.routeName; seedNames.add(name);
doc.enabled = override.enabled;
doc.updatedAt = override.updatedAt; // Check if a route with this name+origin already exists in memory
doc.updatedBy = override.updatedBy; let existingId: string | undefined;
await doc.save(); for (const [id, r] of this.routes) {
if (r.origin === origin && r.route.name === name) {
existingId = id;
break;
} }
this.computeWarnings();
await this.applyRoutes();
} }
public async removeOverride(routeName: string): Promise<boolean> { if (existingId) {
if (!this.overrides.has(routeName)) return false; // Update route config but preserve enabled state
this.overrides.delete(routeName); const existing = this.routes.get(existingId)!;
const doc = await RouteOverrideDoc.findByRouteName(routeName); 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 (doc) await doc.delete();
this.computeWarnings(); }
await this.applyRoutes();
return true; if (seeded > 0 || updated > 0 || staleIds.length > 0) {
logger.log('info', `Seed routes (${origin}): ${seeded} new, ${updated} updated, ${staleIds.length} stale removed`);
}
} }
// ========================================================================= // =========================================================================
// Private: persistence // Private: persistence
// ========================================================================= // =========================================================================
private async loadStoredRoutes(): Promise<void> { private async loadRoutes(): Promise<void> {
const docs = await StoredRouteDoc.findAll(); const docs = await RouteDoc.findAll();
for (const doc of docs) { for (const doc of docs) {
if (doc.id) { if (doc.id) {
this.storedRoutes.set(doc.id, { this.routes.set(doc.id, {
id: doc.id, id: doc.id,
route: doc.route, route: doc.route,
enabled: doc.enabled, enabled: doc.enabled,
createdAt: doc.createdAt, createdAt: doc.createdAt,
updatedAt: doc.updatedAt, updatedAt: doc.updatedAt,
createdBy: doc.createdBy, createdBy: doc.createdBy,
origin: doc.origin || 'api',
metadata: doc.metadata, metadata: doc.metadata,
}); });
} }
} }
if (this.storedRoutes.size > 0) { if (this.routes.size > 0) {
logger.log('info', `Loaded ${this.storedRoutes.size} programmatic route(s) from storage`); logger.log('info', `Loaded ${this.routes.size} route(s) from database`);
} }
} }
private async loadOverrides(): Promise<void> { private async persistRoute(stored: IRoute): Promise<void> {
const docs = await RouteOverrideDoc.findAll(); const existingDoc = await RouteDoc.findById(stored.id);
for (const doc of docs) {
if (doc.routeName) {
this.overrides.set(doc.routeName, {
routeName: doc.routeName,
enabled: doc.enabled,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
});
}
}
if (this.overrides.size > 0) {
logger.log('info', `Loaded ${this.overrides.size} route override(s) from storage`);
}
}
private async persistRoute(stored: IStoredRoute): Promise<void> {
const existingDoc = await StoredRouteDoc.findById(stored.id);
if (existingDoc) { if (existingDoc) {
existingDoc.route = stored.route; existingDoc.route = stored.route;
existingDoc.enabled = stored.enabled; existingDoc.enabled = stored.enabled;
existingDoc.updatedAt = stored.updatedAt; existingDoc.updatedAt = stored.updatedAt;
existingDoc.createdBy = stored.createdBy; existingDoc.createdBy = stored.createdBy;
existingDoc.origin = stored.origin;
existingDoc.metadata = stored.metadata; existingDoc.metadata = stored.metadata;
await existingDoc.save(); await existingDoc.save();
} else { } else {
const doc = new StoredRouteDoc(); const doc = new RouteDoc();
doc.id = stored.id; doc.id = stored.id;
doc.route = stored.route; doc.route = stored.route;
doc.enabled = stored.enabled; doc.enabled = stored.enabled;
doc.createdAt = stored.createdAt; doc.createdAt = stored.createdAt;
doc.updatedAt = stored.updatedAt; doc.updatedAt = stored.updatedAt;
doc.createdBy = stored.createdBy; doc.createdBy = stored.createdBy;
doc.origin = stored.origin;
doc.metadata = stored.metadata; doc.metadata = stored.metadata;
await doc.save(); await doc.save();
} }
@@ -322,33 +333,14 @@ export class RouteConfigManager {
private computeWarnings(): void { private computeWarnings(): void {
this.warnings = []; this.warnings = [];
const hardcodedNames = new Set(this.getHardcodedRoutes().map((r) => r.name || ''));
// Check overrides for (const route of this.routes.values()) {
for (const [routeName, override] of this.overrides) { if (!route.enabled) {
if (!hardcodedNames.has(routeName)) { const name = route.route.name || route.id;
this.warnings.push({ this.warnings.push({
type: 'orphaned-override', type: 'disabled-route',
routeName,
message: `Orphaned override for route '${routeName}' — hardcoded route no longer exists`,
});
} else if (!override.enabled) {
this.warnings.push({
type: 'disabled-hardcoded',
routeName,
message: `Route '${routeName}' is disabled via API override`,
});
}
}
// Check disabled programmatic routes
for (const stored of this.storedRoutes.values()) {
if (!stored.enabled) {
const name = stored.route.name || stored.id;
this.warnings.push({
type: 'disabled-programmatic',
routeName: name, routeName: name,
message: `Programmatic route '${name}' (id: ${stored.id}) is disabled`, message: `Route '${name}' (id: ${route.id}) is disabled`,
}); });
} }
} }
@@ -372,7 +364,7 @@ export class RouteConfigManager {
if (!this.referenceResolver || routeIds.length === 0) return; if (!this.referenceResolver || routeIds.length === 0) return;
for (const routeId of routeIds) { for (const routeId of routeIds) {
const stored = this.storedRoutes.get(routeId); const stored = this.routes.get(routeId);
if (!stored?.metadata) continue; if (!stored?.metadata) continue;
const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata); const resolved = this.referenceResolver.resolveRoute(stored.route, stored.metadata);
@@ -387,7 +379,7 @@ export class RouteConfigManager {
} }
// ========================================================================= // =========================================================================
// Private: apply merged routes to SmartProxy // Apply routes to SmartProxy
// ========================================================================= // =========================================================================
public async applyRoutes(): Promise<void> { public async applyRoutes(): Promise<void> {
@@ -416,35 +408,25 @@ export class RouteConfigManager {
}; };
}; };
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection) // Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.getHardcodedRoutes()) { for (const route of this.routes.values()) {
const name = route.name || ''; if (route.enabled) {
const override = this.overrides.get(name); let r = route.route;
if (override && !override.enabled) {
continue; // Skip disabled hardcoded route
}
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) { if (http3Config?.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config }); r = augmentRouteWithHttp3(r, { enabled: true, ...http3Config });
} }
enabledRoutes.push(injectVpn(route, stored.id)); enabledRoutes.push(injectVpn(r, route.id));
} }
} }
await smartProxy.updateRoutes(enabledRoutes); await smartProxy.updateRoutes(enabledRoutes);
// Notify listeners (e.g. RemoteIngressManager) of the merged route set // Notify listeners (e.g. RemoteIngressManager) of the route set
if (this.onRoutesApplied) { if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes); this.onRoutesApplied(enabledRoutes);
} }
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.routes.size} total)`);
}); });
} }
} }

View File

@@ -3,7 +3,7 @@ import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js'; import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js'; import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js'; import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js'; import type { IRoute } from '../../ts_interfaces/data/route-management.js';
/** /**
* Manages TargetProfiles (target-side: what can be accessed). * Manages TargetProfiles (target-side: what can be accessed).
@@ -220,8 +220,7 @@ export class TargetProfileManager {
*/ */
public getClientAccessSpec( public getClientAccessSpec(
targetProfileIds: string[], targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[], allRoutes: Map<string, IRoute>,
storedRoutes: Map<string, IStoredRoute>,
): { domains: string[]; targetIps: string[] } { ): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>(); const domains = new Set<string>();
const targetIps = new Set<string>(); const targetIps = new Set<string>();
@@ -245,23 +244,11 @@ export class TargetProfileManager {
} }
} }
// Route references: scan constructor routes // Route references: scan all routes
for (const route of allRoutes) { for (const [routeId, route] of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) { if (!route.enabled) continue;
const routeDomains = (route.match as any)?.domains; if (this.routeMatchesProfile(route.route as IDcRouterRouteConfig, routeId, profile)) {
if (Array.isArray(routeDomains)) { const routeDomains = (route.route.match as any)?.domains;
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;
if (Array.isArray(routeDomains)) { if (Array.isArray(routeDomains)) {
for (const d of routeDomains) { for (const d of routeDomains) {
domains.add(d); domains.add(d);

View 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' });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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({});
}
}

View File

@@ -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({});
}
}

View File

@@ -6,7 +6,7 @@ import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteing
const getDb = () => DcRouterDb.getInstance().getDb(); const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => 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.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public id!: string; public id!: string;
@@ -26,6 +26,9 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public createdBy!: string; public createdBy!: string;
@plugins.smartdata.svDb()
public origin!: 'config' | 'email' | 'dns' | 'api';
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public metadata?: IRouteMetadata; public metadata?: IRouteMetadata;
@@ -33,11 +36,19 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
super(); super();
} }
public static async findById(id: string): Promise<StoredRouteDoc | null> { public static async findById(id: string): Promise<RouteDoc | null> {
return await StoredRouteDoc.getInstance({ id }); return await RouteDoc.getInstance({ id });
} }
public static async findAll(): Promise<StoredRouteDoc[]> { public static async findAll(): Promise<RouteDoc[]> {
return await StoredRouteDoc.getInstances({}); 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 });
} }
} }

View File

@@ -3,8 +3,7 @@ export * from './classes.cached.email.js';
export * from './classes.cached.ip.reputation.js'; export * from './classes.cached.ip.reputation.js';
// Config document classes // Config document classes
export * from './classes.stored-route.doc.js'; export * from './classes.route.doc.js';
export * from './classes.route-override.doc.js';
export * from './classes.api-token.doc.js'; export * from './classes.api-token.doc.js';
export * from './classes.source-profile.doc.js'; export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js'; export * from './classes.target-profile.doc.js';
@@ -25,3 +24,14 @@ export * from './classes.remote-ingress-edge.doc.js';
// RADIUS document classes // RADIUS document classes
export * from './classes.vlan-mappings.doc.js'; export * from './classes.vlan-mappings.doc.js';
export * from './classes.accounting-session.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
View File

@@ -0,0 +1,2 @@
export * from './manager.dns.js';
export * from './providers/index.js';

1064
ts/dns/manager.dns.ts Normal file

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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}`);
}
}
}

View File

@@ -0,0 +1,3 @@
export * from './interfaces.js';
export * from './cloudflare.provider.js';
export * from './factory.js';

View 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>;
}

View 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
View File

@@ -0,0 +1 @@
export * from './classes.email-domain.manager.js';

View File

@@ -553,12 +553,14 @@ export class MetricsManager {
connectionsByIP: new Map<string, number>(), connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>, topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>, throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(), throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [] as Array<any>, backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
}; };
} }
@@ -572,7 +574,7 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out bytesOutPerSecond: instantThroughput.out
}; };
// Get top IPs // Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10); const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred // Get total data transferred
@@ -699,10 +701,156 @@ 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 from per-route metrics
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Map route name → ALL its domains (not just the first one)
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);
}
}
}
// Use protocol cache to discover actual active domains (resolves wildcards)
const activeDomains = new Set<string>();
const domainToBackend = new Map<string, string>(); // domain → host:port
for (const entry of protocolCache) {
if (entry.domain) {
activeDomains.add(entry.domain);
domainToBackend.set(entry.domain, `${entry.host}:${entry.port}`);
}
}
// Build reverse map: domain → route name(s) that handle it
// For concrete domains: direct lookup from route config
// For wildcard patterns: match active domains from protocol cache
const domainToRoutes = new Map<string, string[]>();
for (const [routeName, domains] of routeDomains) {
for (const pattern of domains) {
if (pattern.includes('*')) {
// Wildcard pattern — match against active domains from protocol cache
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
for (const activeDomain of activeDomains) {
if (regex.test(activeDomain)) {
const existing = domainToRoutes.get(activeDomain);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(activeDomain, [routeName]); }
}
}
} else {
// Concrete domain
const existing = domainToRoutes.get(pattern);
if (existing) { existing.push(routeName); }
else { domainToRoutes.set(pattern, [routeName]); }
}
}
}
// Aggregate metrics per domain
// For each domain, sum metrics from all routes that serve it,
// divided by the number of domains each route serves
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
}>();
// Track which routes are accounted for
const accountedRoutes = new Set<string>();
for (const [domain, routeNames] of domainToRoutes) {
let totalConns = 0;
let totalIn = 0;
let totalOut = 0;
for (const routeName of routeNames) {
accountedRoutes.add(routeName);
const conns = connectionsByRoute.get(routeName) || 0;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
// Count how many resolved domains share this route
let domainsInRoute = 0;
for (const [, routes] of domainToRoutes) {
if (routes.includes(routeName)) domainsInRoute++;
}
const share = Math.max(domainsInRoute, 1);
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,
});
}
// Include routes with no domain config (fallback: use route name)
for (const [routeName, activeConns] of connectionsByRoute) {
if (accountedRoutes.has(routeName)) continue;
if (routeDomains.has(routeName)) continue; // has domains but no traffic matched
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
if (activeConns === 0 && tp.in === 0 && tp.out === 0) continue;
const existing = domainAgg.get(routeName);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(routeName, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
bytesInPerSecond: data.bytesInPerSec,
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
return { return {
connectionsByIP, connectionsByIP,
throughputRate, throughputRate,
topIPs, topIPs,
topIPsByBandwidth,
totalDataTransferred, totalDataTransferred,
throughputHistory, throughputHistory,
throughputByIP, throughputByIP,
@@ -711,6 +859,7 @@ export class MetricsManager {
backends, backends,
frontendProtocols, frontendProtocols,
backendProtocols, backendProtocols,
domainActivity,
}; };
}, 1000); // 1s cache — matches typical dashboard poll interval }, 1000); // 1s cache — matches typical dashboard poll interval
} }

View File

@@ -32,6 +32,12 @@ export class OpsServer {
private sourceProfileHandler!: handlers.SourceProfileHandler; private sourceProfileHandler!: handlers.SourceProfileHandler;
private targetProfileHandler!: handlers.TargetProfileHandler; private targetProfileHandler!: handlers.TargetProfileHandler;
private networkTargetHandler!: handlers.NetworkTargetHandler; 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) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
@@ -94,6 +100,12 @@ export class OpsServer {
this.sourceProfileHandler = new handlers.SourceProfileHandler(this); this.sourceProfileHandler = new handlers.SourceProfileHandler(this);
this.targetProfileHandler = new handlers.TargetProfileHandler(this); this.targetProfileHandler = new handlers.TargetProfileHandler(this);
this.networkTargetHandler = new handlers.NetworkTargetHandler(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'); console.log('✅ OpsServer TypedRequest handlers initialized');
} }

View 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 };
}
},
),
);
}
}

View File

@@ -53,6 +53,18 @@ export class AdminHandler {
}); });
} }
/**
* 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 { private registerHandlers(): void {
// Admin Login Handler // Admin Login Handler
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(

View File

@@ -2,6 +2,28 @@ import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js'; import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js'; import * as interfaces from '../../../ts_interfaces/index.js';
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js'; import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
import { logger } from '../../logger.js';
/**
* Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
* @push.rocks/smartacme. Inlined here because the original is `private` on
* SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
* '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
* the same identity share the same underlying ACME cert.
*
* Returns undefined for domains with 4+ levels (matching smartacme's
* "deeper domains not supported" behavior) and for malformed inputs.
*
* Exported for unit testing.
*/
export function deriveCertDomainName(domain: string): string | undefined {
if (domain.startsWith('*.')) {
return domain.slice(2);
}
const parts = domain.split('.');
if (parts.length < 2 || parts.length > 3) return undefined;
return parts.slice(-2).join('.');
}
export class CertificateHandler { export class CertificateHandler {
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
@@ -363,12 +385,34 @@ export class CertificateHandler {
// If forceRenew, order a fresh cert from ACME now so it's already in // If forceRenew, order a fresh cert from ACME now so it's already in
// AcmeCertDoc by the time certProvisionFunction is invoked below. // AcmeCertDoc by the time certProvisionFunction is invoked below.
//
// includeWildcard: when forcing a non-wildcard subdomain renewal, we still
// want the wildcard SAN in the order so the new cert keeps covering every
// sibling. Without this, smartacme defaults to includeWildcard: false and
// the re-issued cert would have only the base domain as SAN, breaking every
// sibling subdomain that was previously covered by the same wildcard cert.
if (forceRenew && dcRouter.smartAcme) { if (forceRenew && dcRouter.smartAcme) {
let newCert: plugins.smartacme.Cert;
try { try {
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true }); newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
forceRenew: true,
includeWildcard: !domain.startsWith('*.'),
});
} catch (err: unknown) { } catch (err: unknown) {
return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` }; return { success: false, message: `Failed to renew certificate for ${domain}: ${(err as Error).message}` };
} }
// Propagate the freshly-issued cert PEM to every sibling route domain that
// shares the same cert identity. Without this, the rust hot-swap (keyed by
// exact domain in `loaded_certs`) only fires for the clicked route via the
// fire-and-forget cert provisioning path, leaving siblings serving the
// stale in-memory cert until the next background reload completes.
try {
await this.propagateCertToSiblings(domain, newCert);
} catch (err: unknown) {
// Best-effort: failure here doesn't undo the cert issuance, just log.
logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${(err as Error).message}`);
}
} }
// Clear status map entry so it gets refreshed by the certificate-issued event // Clear status map entry so it gets refreshed by the certificate-issued event
@@ -392,6 +436,86 @@ export class CertificateHandler {
} }
} }
/**
* After a force-renew, walk every route in the smartproxy that resolves to
* the same cert identity as `forcedDomain` and write the freshly-issued cert
* PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
* → provisionCertificatesViaCallback iteration will hot-swap every sibling's
* rust loaded_certs entry with the new (correct) PEM, rather than relying on
* the in-memory cert returned by smartacme's per-domain cache.
*
* Why this is necessary:
* Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
* bridge.loadCertificate(domain, ...) only swaps that one entry. The
* fire-and-forget cert provisioning path triggered by updateRoutes does
* eventually iterate every auto-cert route, but it returns the cached
* (broken pre-fix) cert from smartacme's per-domain mutex. With this
* helper, ProxyCertDoc is updated synchronously to the correct PEM before
* applyRoutes runs, so even the transient window stays consistent.
*/
private async propagateCertToSiblings(
forcedDomain: string,
newCert: plugins.smartacme.Cert,
): Promise<void> {
const dcRouter = this.opsServerRef.dcRouterRef;
const smartProxy = dcRouter.smartProxy;
if (!smartProxy) return;
const certIdentity = deriveCertDomainName(forcedDomain);
if (!certIdentity) return;
// Collect every route domain whose cert identity matches.
const affected = new Set<string>();
for (const route of smartProxy.routeManager.getRoutes()) {
if (!route.match.domains) continue;
const routeDomains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
for (const routeDomain of routeDomains) {
if (deriveCertDomainName(routeDomain) === certIdentity) {
affected.add(routeDomain);
}
}
}
if (affected.size === 0) return;
// Parse expiry from PEM (defense-in-depth — same pattern as
// ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
let validUntil = newCert.validUntil;
let validFrom: number | undefined;
if (newCert.publicKey) {
try {
const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
validUntil = new Date(x509.validTo).getTime();
validFrom = new Date(x509.validFrom).getTime();
} catch { /* fall back to smartacme's value */ }
}
// Persist new cert PEM under each affected route domain
for (const routeDomain of affected) {
let doc = await ProxyCertDoc.findByDomain(routeDomain);
if (!doc) {
doc = new ProxyCertDoc();
doc.domain = routeDomain;
}
doc.publicKey = newCert.publicKey;
doc.privateKey = newCert.privateKey;
doc.ca = '';
doc.validUntil = validUntil || 0;
doc.validFrom = validFrom || 0;
await doc.save();
// Clear status so the next event refresh shows the new cert
dcRouter.certificateStatusMap.delete(routeDomain);
}
logger.log(
'info',
`Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`,
);
}
/** /**
* Delete certificate data for a domain from storage * Delete certificate data for a domain from storage
*/ */

View File

@@ -123,6 +123,15 @@ export class ConfigHandler {
ttl: r.ttl, 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'] = { const dns: interfaces.requests.IConfigData['dns'] = {
enabled: !!dcRouter.dnsServer, enabled: !!dcRouter.dnsServer,
port: 53, port: 53,
@@ -130,7 +139,7 @@ export class ConfigHandler {
scopes: opts.dnsScopes || [], scopes: opts.dnsScopes || [],
recordCount: dnsRecords.length, recordCount: dnsRecords.length,
records: dnsRecords, records: dnsRecords,
dnsChallenge: !!opts.dnsChallenge?.cloudflareApiKey, dnsChallenge: dnsChallengeEnabled,
}; };
// --- TLS --- // --- TLS ---

View 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 };
}
},
),
);
}
}

View 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);
},
),
);
}
}

View 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,
});
},
),
);
}
}

View 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 };
}
},
),
);
}
}

View File

@@ -13,3 +13,9 @@ export * from './vpn.handler.js';
export * from './source-profile.handler.js'; export * from './source-profile.handler.js';
export * from './target-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';

View File

@@ -135,7 +135,7 @@ export class NetworkTargetHandler {
const result = await resolver.deleteTarget( const result = await resolver.deleteTarget(
dataArg.id, dataArg.id,
dataArg.force ?? false, dataArg.force ?? false,
manager.getStoredRoutes(), manager.getRoutes(),
); );
if (result.success && dataArg.force) { if (result.success && dataArg.force) {
@@ -158,7 +158,7 @@ export class NetworkTargetHandler {
if (!resolver || !manager) { if (!resolver || !manager) {
return { routes: [] }; return { routes: [] };
} }
const usage = resolver.getTargetUsageForId(dataArg.id, manager.getStoredRoutes()); const usage = resolver.getTargetUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
}, },
), ),

View File

@@ -72,7 +72,7 @@ export class RouteManagementHandler {
return { success: false, message: 'Route management not initialized' }; return { success: false, message: 'Route management not initialized' };
} }
const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata); const id = await manager.createRoute(dataArg.route, userId, dataArg.enabled ?? true, dataArg.metadata);
return { success: true, storedRouteId: id }; return { success: true, routeId: id };
}, },
), ),
); );
@@ -113,39 +113,7 @@ export class RouteManagementHandler {
), ),
); );
// Set override on a hardcoded route // Toggle route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
await manager.setOverride(dataArg.routeName, dataArg.enabled, userId);
return { success: true };
},
),
);
// Remove override from a hardcoded route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:write');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { success: false, message: 'Route management not initialized' };
}
const ok = await manager.removeOverride(dataArg.routeName);
return { success: ok, message: ok ? undefined : 'Override not found' };
},
),
);
// Toggle programmatic route
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute', 'toggleRoute',

View File

@@ -51,8 +51,8 @@ export class SecurityHandler {
startTime: conn.startTime, startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any, protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any, state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2), bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: Math.floor(conn.bytesTransferred / 2), bytesSent: (conn as any)._throughputOut || 0,
})); }));
const summary = { const summary = {
@@ -96,9 +96,11 @@ export class SecurityHandler {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })), connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate, throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs, topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
totalDataTransferred: networkStats.totalDataTransferred, totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [], throughputHistory: networkStats.throughputHistory || [],
throughputByIP, throughputByIP,
domainActivity: networkStats.domainActivity || [],
requestsPerSecond: networkStats.requestsPerSecond || 0, requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0, requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [], backends: networkStats.backends || [],
@@ -110,9 +112,11 @@ export class SecurityHandler {
connectionsByIP: [], connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 }, throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [], topIPs: [],
topIPsByBandwidth: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 }, totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [], throughputHistory: [],
throughputByIP: [], throughputByIP: [],
domainActivity: [],
requestsPerSecond: 0, requestsPerSecond: 0,
requestsTotal: 0, requestsTotal: 0,
backends: [], backends: [],
@@ -251,31 +255,31 @@ export class SecurityHandler {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo(); const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats(); const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API // One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) { if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0; let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server'; const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) { for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection const tp = networkStats.throughputByIP?.get(ip);
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
connections.push({ connections.push({
id: `conn-${connIndex++}`, id: `ip-${connIndex++}`,
type: 'http', type: 'http',
source: { source: {
ip: ip, ip: ip,
port: Math.floor(Math.random() * 50000) + 10000, // High port range port: 0,
}, },
destination: { destination: {
ip: publicIp, ip: publicIp,
port: 443, port: 443,
service: 'proxy', service: 'proxy',
}, },
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour startTime: 0,
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size), bytesTransferred: count, // Store connection count here
status: 'active', status: 'active',
}); // Attach real throughput for the handler mapping
} ...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
} }
} else if (connectionInfo.length > 0) { } else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available // Fallback to route-based connection info if no IP data available

View File

@@ -136,7 +136,7 @@ export class SourceProfileHandler {
const result = await resolver.deleteProfile( const result = await resolver.deleteProfile(
dataArg.id, dataArg.id,
dataArg.force ?? false, dataArg.force ?? false,
manager.getStoredRoutes(), manager.getRoutes(),
); );
// If force-deleted with affected routes, re-apply // If force-deleted with affected routes, re-apply
@@ -160,7 +160,7 @@ export class SourceProfileHandler {
if (!resolver || !manager) { if (!resolver || !manager) {
return { routes: [] }; return { routes: [] };
} }
const usage = resolver.getProfileUsageForId(dataArg.id, manager.getStoredRoutes()); const usage = resolver.getProfileUsageForId(dataArg.id, manager.getRoutes());
return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) }; return { routes: usage.map((u) => ({ id: u.id, name: u.routeName })) };
}, },
), ),

View File

@@ -291,6 +291,20 @@ export class StatsHandler {
} }
} }
// Build connectionDetails from real per-IP data
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
for (const [ip, count] of stats.connectionsByIP) {
const tp = stats.throughputByIP?.get(ip);
connectionDetails.push({
remoteAddress: ip,
protocol: 'https',
state: 'connected',
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
});
}
metrics.network = { metrics.network = {
totalBandwidth: { totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond, in: stats.throughputRate.bytesInPerSecond,
@@ -301,12 +315,18 @@ export class StatsHandler {
out: stats.totalDataTransferred.bytesOut, out: stats.totalDataTransferred.bytesOut,
}, },
activeConnections: serverStats.activeConnections, activeConnections: serverStats.activeConnections,
connectionDetails: [], connectionDetails,
topEndpoints: stats.topIPs.map(ip => ({ topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip, endpoint: ip.ip,
requests: ip.count, connections: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 }, bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})), })),
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
endpoint: ip.ip,
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [], throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0, requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0, requestsTotal: stats.requestsTotal || 0,

View 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 };
},
),
);
}
}

View File

@@ -7,10 +7,9 @@ export class Route {
// Data from IMergedRoute // Data from IMergedRoute
public routeConfig: IRouteConfig; public routeConfig: IRouteConfig;
public source: 'hardcoded' | 'programmatic'; public id: string;
public enabled: boolean; public enabled: boolean;
public overridden: boolean; public origin: 'config' | 'email' | 'dns' | 'api';
public storedRouteId?: string;
public createdAt?: number; public createdAt?: number;
public updatedAt?: number; public updatedAt?: number;
@@ -22,21 +21,17 @@ export class Route {
constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) { constructor(clientRef: DcRouterApiClient, data: interfaces.data.IMergedRoute) {
this.clientRef = clientRef; this.clientRef = clientRef;
this.routeConfig = data.route; this.routeConfig = data.route;
this.source = data.source; this.id = data.id;
this.enabled = data.enabled; this.enabled = data.enabled;
this.overridden = data.overridden; this.origin = data.origin;
this.storedRouteId = data.storedRouteId;
this.createdAt = data.createdAt; this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt; this.updatedAt = data.updatedAt;
} }
public async update(changes: Partial<IRouteConfig>): Promise<void> { public async update(changes: Partial<IRouteConfig>): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot update a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRoute>(
'updateRoute', 'updateRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, route: changes }) as any, this.clientRef.buildRequestPayload({ id: this.id, route: changes }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to update route'); throw new Error(response.message || 'Failed to update route');
@@ -44,12 +39,9 @@ export class Route {
} }
public async delete(): Promise<void> { public async delete(): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot delete a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_DeleteRoute>(
'deleteRoute', 'deleteRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId }) as any, this.clientRef.buildRequestPayload({ id: this.id }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to delete route'); throw new Error(response.message || 'Failed to delete route');
@@ -57,41 +49,15 @@ export class Route {
} }
public async toggle(enabled: boolean): Promise<void> { public async toggle(enabled: boolean): Promise<void> {
if (!this.storedRouteId) {
throw new Error('Cannot toggle a hardcoded route. Use setOverride() instead.');
}
const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>( const response = await this.clientRef.request<interfaces.requests.IReq_ToggleRoute>(
'toggleRoute', 'toggleRoute',
this.clientRef.buildRequestPayload({ id: this.storedRouteId, enabled }) as any, this.clientRef.buildRequestPayload({ id: this.id, enabled }) as any,
); );
if (!response.success) { if (!response.success) {
throw new Error(response.message || 'Failed to toggle route'); throw new Error(response.message || 'Failed to toggle route');
} }
this.enabled = enabled; this.enabled = enabled;
} }
public async setOverride(enabled: boolean): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_SetRouteOverride>(
'setRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name, enabled }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to set route override');
}
this.overridden = true;
this.enabled = enabled;
}
public async removeOverride(): Promise<void> {
const response = await this.clientRef.request<interfaces.requests.IReq_RemoveRouteOverride>(
'removeRouteOverride',
this.clientRef.buildRequestPayload({ routeName: this.name }) as any,
);
if (!response.success) {
throw new Error(response.message || 'Failed to remove route override');
}
this.overridden = false;
}
} }
export class RouteBuilder { export class RouteBuilder {
@@ -144,9 +110,8 @@ export class RouteBuilder {
} }
// Return a Route instance by re-fetching the list // Return a Route instance by re-fetching the list
// The created route is programmatic, so we find it by storedRouteId
const { routes } = await new RouteManager(this.clientRef).list(); const { routes } = await new RouteManager(this.clientRef).list();
const created = routes.find((r) => r.storedRouteId === response.storedRouteId); const created = routes.find((r) => r.id === response.routeId);
if (created) { if (created) {
return created; return created;
} }
@@ -154,10 +119,9 @@ export class RouteBuilder {
// Fallback: construct from known data // Fallback: construct from known data
return new Route(this.clientRef, { return new Route(this.clientRef, {
route: this.routeConfig as IRouteConfig, route: this.routeConfig as IRouteConfig,
source: 'programmatic', id: response.routeId || '',
enabled: this.isEnabled, enabled: this.isEnabled,
overridden: false, origin: 'api',
storedRouteId: response.storedRouteId,
}); });
} }
} }
@@ -190,10 +154,9 @@ export class RouteManager {
} }
return new Route(this.clientRef, { return new Route(this.clientRef, {
route: routeConfig, route: routeConfig,
source: 'programmatic', id: response.routeId || '',
enabled: enabled ?? true, enabled: enabled ?? true,
overridden: false, origin: 'api',
storedRouteId: response.storedRouteId,
}); });
} }

View 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;
}

View 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);
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -4,3 +4,8 @@ export * from './remoteingress.js';
export * from './route-management.js'; export * from './route-management.js';
export * from './target-profile.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';

View File

@@ -14,7 +14,11 @@ export type TApiTokenScope =
| 'tokens:read' | 'tokens:manage' | 'tokens:read' | 'tokens:manage'
| 'source-profiles:read' | 'source-profiles:write' | 'source-profiles:read' | 'source-profiles:write'
| 'target-profiles:read' | 'target-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) // 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 { export interface IMergedRoute {
route: IDcRouterRouteConfig; route: IDcRouterRouteConfig;
source: 'hardcoded' | 'programmatic'; id: string;
enabled: boolean; enabled: boolean;
overridden: boolean; origin: 'config' | 'email' | 'dns' | 'api';
storedRouteId?: string;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }
/** /**
* A warning generated during route merge/startup. * A warning generated during route startup/apply.
*/ */
export interface IRouteWarning { export interface IRouteWarning {
type: 'disabled-hardcoded' | 'disabled-programmatic' | 'orphaned-override'; type: 'disabled-route';
routeName: string; routeName: string;
message: string; message: string;
} }
@@ -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; id: string;
route: IDcRouterRouteConfig; route: IDcRouterRouteConfig;
enabled: boolean; enabled: boolean;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
createdBy: string; createdBy: string;
origin: 'config' | 'email' | 'dns' | 'api';
metadata?: IRouteMetadata; metadata?: IRouteMetadata;
} }
/**
* An override for a hardcoded route, stored in /config-api/overrides/{routeName}.json
*/
export interface IRouteOverride {
routeName: string;
enabled: boolean;
updatedAt: number;
updatedBy: string;
}
/** /**
* A stored API token, stored in /config-api/tokens/{id}.json * A stored API token, stored in /config-api/tokens/{id}.json
*/ */

View File

@@ -143,6 +143,14 @@ export interface IHealthStatus {
version?: string; version?: string;
} }
export interface IDomainActivity {
domain: string;
bytesInPerSecond: number;
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
}
export interface INetworkMetrics { export interface INetworkMetrics {
totalBandwidth: { totalBandwidth: {
in: number; in: number;
@@ -156,12 +164,21 @@ export interface INetworkMetrics {
connectionDetails: IConnectionDetails[]; connectionDetails: IConnectionDetails[];
topEndpoints: Array<{ topEndpoints: Array<{
endpoint: string; endpoint: string;
requests: number; connections: number;
bandwidth: { bandwidth: {
in: number; in: number;
out: number; out: number;
}; };
}>; }>;
topEndpointsByBandwidth: Array<{
endpoint: string;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>; throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number; requestsPerSecond?: number;
requestsTotal?: number; requestsTotal?: number;

View 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;
};
}

View 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;
};
}

View 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;
};
}

View 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;
};
}

View 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;
};
}

View File

@@ -13,3 +13,9 @@ export * from './vpn.js';
export * from './source-profiles.js'; export * from './source-profiles.js';
export * from './target-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';

View File

@@ -9,7 +9,7 @@ import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
// ============================================================================ // ============================================================================
/** /**
* Get all merged routes (hardcoded + programmatic) with warnings. * Get all routes with warnings.
*/ */
export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -27,7 +27,7 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
} }
/** /**
* Create a new programmatic route. * Create a new route.
*/ */
export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -43,13 +43,13 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
}; };
response: { response: {
success: boolean; success: boolean;
storedRouteId?: string; routeId?: string;
message?: string; message?: string;
}; };
} }
/** /**
* Update a programmatic route. * Update a route.
*/ */
export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -71,7 +71,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Delete a programmatic route. * Delete a route.
*/ */
export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
@@ -90,46 +90,7 @@ export interface IReq_DeleteRoute extends plugins.typedrequestInterfaces.impleme
} }
/** /**
* Set an override on a hardcoded route (disable/enable by name). * Toggle a route on/off by id.
*/
export interface IReq_SetRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_SetRouteOverride
> {
method: 'setRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
enabled: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove an override from a hardcoded route (restore default behavior).
*/
export interface IReq_RemoveRouteOverride extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_RemoveRouteOverride
> {
method: 'removeRouteOverride';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
routeName: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Toggle a programmatic route on/off by id.
*/ */
export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_ToggleRoute extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,

View File

@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
requestsPerSecond: number; requestsPerSecond: number;
requestsTotal: number; requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[]; backends?: statsInterfaces.IBackendInfo[];
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
domainActivity: statsInterfaces.IDomainActivity[];
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
}; };
} }

View 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;
}>;
};
}

View File

@@ -64,6 +64,62 @@ export async function createMigrationRunner(
migrated++; migrated++;
} }
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`); ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
})
.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('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');
}
// 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');
}
}); });
return migration; return migration;

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.1.0', version: '13.16.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
export * from './ops-view-apitokens.js';
export * from './ops-view-users.js';

View File

@@ -1,6 +1,6 @@
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { import {
DeesElement, DeesElement,
@@ -100,7 +100,7 @@ export class OpsViewApiTokens extends DeesElement {
const { apiTokens } = this.routeState; const { apiTokens } = this.routeState;
return html` return html`
<dees-heading level="2">API Tokens</dees-heading> <dees-heading level="3">API Tokens</dees-heading>
<div class="apiTokensContainer"> <div class="apiTokensContainer">
<dees-table <dees-table
@@ -109,6 +109,7 @@ export class OpsViewApiTokens extends DeesElement {
.data=${apiTokens} .data=${apiTokens}
.dataName=${'token'} .dataName=${'token'}
.searchable=${true} .searchable=${true}
.showColumnFilters=${true}
.displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({ .displayFunction=${(token: interfaces.data.IApiTokenInfo) => ({
name: token.name, name: token.name,
scopes: this.renderScopePills(token.scopes), scopes: this.renderScopePills(token.scopes),
@@ -221,7 +222,7 @@ export class OpsViewApiTokens extends DeesElement {
.suggestions=${allScopes} .suggestions=${allScopes}
.required=${true} .required=${true}
></dees-input-tags> ></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> </dees-form>
`, `,
menuOptions: [ menuOptions: [

View 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);
}
}
}

View 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,
};
}
}

View 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';

View File

@@ -7,9 +7,9 @@ import {
state, state,
cssManager, cssManager,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
declare global { declare global {
@@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement {
@state() @state()
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!; accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
@state()
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
constructor() { constructor() {
super(); super();
const sub = appstate.certificateStatePart.select().subscribe((newState) => { const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
this.certState = 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() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null); await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
} }
public static styles = [ public static styles = [
@@ -159,15 +167,121 @@ export class OpsViewCertificates extends DeesElement {
const { summary } = this.certState; const { summary } = this.certState;
return html` return html`
<dees-heading level="2">Certificates</dees-heading> <dees-heading level="3">Certificates</dees-heading>
<div class="certificatesContainer"> <div class="certificatesContainer">
${this.renderStatsTiles(summary)} ${this.renderStatsTiles(summary)}
${this.renderAcmeSettingsTile()}
${this.renderCertificateTable()} ${this.renderCertificateTable()}
</div> </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 { private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
const tiles: IStatsTile[] = [ const tiles: IStatsTile[] = [
{ {
@@ -228,6 +342,7 @@ export class OpsViewCertificates extends DeesElement {
return html` return html`
<dees-table <dees-table
.data=${this.certState.certificates} .data=${this.certState.certificates}
.showColumnFilters=${true}
.displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({ .displayFunction=${(cert: interfaces.requests.ICertificateInfo) => ({
Domain: cert.domain, Domain: cert.domain,
Routes: this.renderRoutePills(cert.routeNames), Routes: this.renderRoutePills(cert.routeNames),
@@ -252,11 +367,12 @@ export class OpsViewCertificates extends DeesElement {
content: html` content: html`
<dees-form> <dees-form>
<dees-input-fileupload <dees-input-fileupload
key="certJsonFile" .key=${'certJsonFile'}
label="Certificate JSON (.tsclass.cert.json)" .label=${'Certificate JSON'}
accept=".json" .description=${'Upload a .tsclass.cert.json file'}
.accept=${'.json'}
.multiple=${false} .multiple=${false}
required .required=${true}
></dees-input-fileupload> ></dees-input-fileupload>
</dees-form> </dees-form>
`, `,

View 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();
},
},
],
});
}
}

View 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();
},
},
],
});
}
}

View 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);
}
}
}

View 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';

View 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() },
],
});
}
}

View 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() },
],
});
}
}

View File

@@ -1,8 +1,8 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as shared from './shared/index.js'; import * as shared from '../shared/index.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -60,7 +60,7 @@ export class OpsViewEmails extends DeesElement {
public render() { public render() {
return html` return html`
<dees-heading level="2">Email Operations</dees-heading> <dees-heading level="3">Email Log</dees-heading>
<div class="viewContainer"> <div class="viewContainer">
${this.currentView === 'detail' && this.selectedEmail ${this.currentView === 'detail' && this.selectedEmail
? html` ? html`

View File

@@ -1,16 +1,9 @@
export * from './ops-dashboard.js'; export * from './ops-dashboard.js';
export * from './ops-view-overview.js'; export * from './overview/index.js';
export * from './ops-view-network.js'; export * from './network/index.js';
export * from './ops-view-emails.js'; export * from './email/index.js';
export * from './ops-view-logs.js'; export * from './ops-view-logs.js';
export * from './ops-view-config.js'; export * from './access/index.js';
export * from './ops-view-routes.js'; export * from './security/index.js';
export * from './ops-view-apitokens.js'; export * from './domains/index.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 './shared/index.js';

View 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';

View File

@@ -1,39 +1,23 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element'; import { DeesElement, property, html, customElement, type TemplateResult, css, state, cssManager } from '@design.estate/dees-element';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'ops-view-network': OpsViewNetwork; 'ops-view-network-activity': OpsViewNetworkActivity;
} }
} }
interface INetworkRequest { @customElement('ops-view-network-activity')
id: string; export class OpsViewNetworkActivity extends DeesElement {
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 {
/** How far back the traffic chart shows */ /** How far back the traffic chart shows */
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
/** How often a new data point is added */ /** How often a new data point is added */
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
/** Derived: max data points the buffer holds */ /** 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() @state()
accessor statsState = appstate.statsStatePart.getState()!; accessor statsState = appstate.statsStatePart.getState()!;
@@ -42,9 +26,6 @@ export class OpsViewNetwork extends DeesElement {
accessor networkState = appstate.networkStatePart.getState()!; accessor networkState = appstate.networkStatePart.getState()!;
@state()
accessor networkRequests: INetworkRequest[] = [];
@state() @state()
accessor trafficDataIn: Array<{ x: string | number; y: number }> = []; accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
@@ -53,7 +34,7 @@ export class OpsViewNetwork extends DeesElement {
// Track if we need to update the chart to avoid unnecessary re-renders // Track if we need to update the chart to avoid unnecessary re-renders
private lastChartUpdate = 0; 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 trafficUpdateTimer: any = null;
private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend private requestsPerSecHistory: number[] = []; // Track requests/sec over time for trend
@@ -111,7 +92,7 @@ export class OpsViewNetwork extends DeesElement {
private initializeTrafficData() { private initializeTrafficData() {
const now = Date.now(); 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 // Initialize with empty data points for both in and out
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => { 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, 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 // Use history as the chart data, keeping the most recent points within the window
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS); const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
@@ -285,7 +266,7 @@ export class OpsViewNetwork extends DeesElement {
public render() { public render() {
return html` return html`
<dees-heading level="2">Network Activity</dees-heading> <dees-heading level="3">Network Activity</dees-heading>
<div class="networkContainer"> <div class="networkContainer">
<!-- Stats Grid --> <!-- Stats Grid -->
@@ -307,112 +288,28 @@ export class OpsViewNetwork extends DeesElement {
} }
]} ]}
.realtimeMode=${true} .realtimeMode=${true}
.rollingWindow=${OpsViewNetwork.CHART_WINDOW_MS} .rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
.yAxisFormatter=${(val: number) => `${val} Mbit/s`} .yAxisFormatter=${(val: number) => `${val} Mbit/s`}
></dees-chart-area> ></dees-chart-area>
<!-- Protocol Distribution Charts --> <!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()} ${this.renderProtocolCharts()}
<!-- Top IPs Section --> <!-- Top IPs by Connection Count -->
${this.renderTopIPs()} ${this.renderTopIPs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
<!-- Domain Activity -->
${this.renderDomainActivity()}
<!-- Backend Protocols Section --> <!-- Backend Protocols Section -->
${this.renderBackendProtocols()} ${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.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> </div>
`; `;
} }
private async showRequestDetails(request: INetworkRequest) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Request Details',
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Request Information'}
progLang="json"
.codeToDisplay=${JSON.stringify({
id: request.id,
timestamp: new Date(request.timestamp).toISOString(),
protocol: request.protocol,
method: request.method,
url: request.url,
hostname: request.hostname,
port: request.port,
statusCode: request.statusCode,
duration: `${request.duration}ms`,
bytesIn: request.bytesIn,
bytesOut: request.bytesOut,
remoteIp: request.remoteIp,
route: request.route,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Request ID',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(request.id);
}
}
]
});
}
private renderStatus(statusCode?: number): TemplateResult {
if (!statusCode) {
return html`<span class="statusBadge warning">N/A</span>`;
}
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
}
private truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength - 3) + '...';
}
private formatNumber(num: number): string { private formatNumber(num: number): string {
if (num >= 1000000) { if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'; return (num / 1000000).toFixed(1) + 'M';
@@ -523,15 +420,6 @@ export class OpsViewNetwork extends DeesElement {
<dees-statsgrid <dees-statsgrid
.tiles=${tiles} .tiles=${tiles}
.minTileWidth=${200} .minTileWidth=${200}
.gridActions=${[
{
name: 'Export Data',
iconName: 'lucide:FileOutput',
action: async () => {
console.log('Export feature coming soon');
},
},
]}
></dees-statsgrid> ></dees-statsgrid>
`; `;
} }
@@ -603,6 +491,8 @@ export class OpsViewNetwork extends DeesElement {
return html` return html`
<dees-table <dees-table
.data=${this.networkState.topIPs} .data=${this.networkState.topIPs}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.displayFunction=${(ipData: { ip: string; count: number }) => { .displayFunction=${(ipData: { ip: string; count: number }) => {
const bw = bandwidthByIP.get(ipData.ip); const bw = bandwidthByIP.get(ipData.ip);
return { return {
@@ -615,12 +505,74 @@ export class OpsViewNetwork extends DeesElement {
}} }}
heading1="Top Connected IPs" heading1="Top Connected IPs"
heading2="IPs with most active connections and bandwidth" heading2="IPs with most active connections and bandwidth"
searchable
.showColumnFilters=${true}
.pagination=${false} .pagination=${false}
dataName="ip" dataName="ip"
></dees-table> ></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,
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity aggregated from route metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="domain"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult { private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends; const backends = this.networkState.backends;
if (!backends || backends.length === 0) { if (!backends || backends.length === 0) {
@@ -630,6 +582,8 @@ export class OpsViewNetwork extends DeesElement {
return html` return html`
<dees-table <dees-table
.data=${backends} .data=${backends}
.rowKey=${'backend'}
.highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IBackendInfo) => { .displayFunction=${(item: interfaces.data.IBackendInfo) => {
const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors; const totalErrors = item.connectErrors + item.handshakeErrors + item.requestErrors;
const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, ''); const protocolClass = item.protocol.toLowerCase().replace(/[^a-z0-9]/g, '');
@@ -665,6 +619,7 @@ export class OpsViewNetwork extends DeesElement {
heading1="Backend Protocols" heading1="Backend Protocols"
heading2="Auto-detected backend protocols and connection pool health" heading2="Auto-detected backend protocols and connection pool health"
searchable searchable
.showColumnFilters=${true}
.pagination=${false} .pagination=${false}
dataName="backend" dataName="backend"
></dees-table> ></dees-table>
@@ -729,38 +684,6 @@ export class OpsViewNetwork extends DeesElement {
this.requestsPerSecHistory.shift(); 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) // Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) { if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory(); this.loadThroughputHistory();
@@ -771,7 +694,7 @@ export class OpsViewNetwork extends DeesElement {
this.stopTrafficUpdateTimer(); // Clear any existing timer this.stopTrafficUpdateTimer(); // Clear any existing timer
this.trafficUpdateTimer = setInterval(() => { this.trafficUpdateTimer = setInterval(() => {
this.addTrafficDataPoint(); this.addTrafficDataPoint();
}, OpsViewNetwork.UPDATE_INTERVAL_MS); }, OpsViewNetworkActivity.UPDATE_INTERVAL_MS);
} }
private addTrafficDataPoint() { private addTrafficDataPoint() {
@@ -802,7 +725,7 @@ export class OpsViewNetwork extends DeesElement {
}; };
// In-place mutation then reassign for Lit reactivity (avoids 4 intermediate arrays) // 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.trafficDataIn.shift();
this.trafficDataOut.shift(); this.trafficDataOut.shift();
} }

View File

@@ -7,9 +7,9 @@ import {
state, state,
cssManager, cssManager,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
declare global { declare global {
@@ -64,13 +64,14 @@ export class OpsViewNetworkTargets extends DeesElement {
]; ];
return html` return html`
<dees-heading level="2">Network Targets</dees-heading> <dees-heading level="3">Network Targets</dees-heading>
<div class="targetsContainer"> <div class="targetsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid> <dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table <dees-table
.heading1=${'Network Targets'} .heading1=${'Network Targets'}
.heading2=${'Reusable host:port destinations for routes'} .heading2=${'Reusable host:port destinations for routes'}
.data=${targets} .data=${targets}
.showColumnFilters=${true}
.displayFunction=${(target: interfaces.data.INetworkTarget) => ({ .displayFunction=${(target: interfaces.data.INetworkTarget) => ({
Name: target.name, Name: target.name,
Host: Array.isArray(target.host) ? target.host.join(', ') : target.host, Host: Array.isArray(target.host) ? target.host.join(', ') : target.host,

View File

@@ -7,9 +7,9 @@ import {
state, state,
cssManager, cssManager,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
declare global { declare global {
@@ -174,7 +174,7 @@ export class OpsViewRemoteIngress extends DeesElement {
]; ];
return html` return html`
<dees-heading level="2">Remote Ingress</dees-heading> <dees-heading level="3">Remote Ingress</dees-heading>
${this.riState.newEdgeId ? html` ${this.riState.newEdgeId ? html`
<div class="secretDialog"> <div class="secretDialog">
@@ -220,6 +220,9 @@ export class OpsViewRemoteIngress extends DeesElement {
.heading1=${'Edge Nodes'} .heading1=${'Edge Nodes'}
.heading2=${'Manage remote ingress edge registrations'} .heading2=${'Manage remote ingress edge registrations'}
.data=${this.riState.edges} .data=${this.riState.edges}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.showColumnFilters=${true}
.displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({ .displayFunction=${(edge: interfaces.data.IRemoteIngress) => ({
name: edge.name, name: edge.name,
status: this.getEdgeStatusHtml(edge), status: this.getEdgeStatusHtml(edge),
@@ -240,9 +243,9 @@ export class OpsViewRemoteIngress extends DeesElement {
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text> <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-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> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -317,9 +320,9 @@ export class OpsViewRemoteIngress extends DeesElement {
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text> <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-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> </dees-form>
`, `,
menuOptions: [ menuOptions: [

View File

@@ -1,6 +1,6 @@
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
import { import {
@@ -140,9 +140,9 @@ export class OpsViewRoutes extends DeesElement {
public render(): TemplateResult { public render(): TemplateResult {
const { mergedRoutes, warnings } = this.routeState; const { mergedRoutes, warnings } = this.routeState;
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length; const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
const configCount = mergedRoutes.filter((mr) => mr.origin !== 'api').length;
const apiCount = mergedRoutes.filter((mr) => mr.origin === 'api').length;
const statsTiles: IStatsTile[] = [ const statsTiles: IStatsTile[] = [
{ {
@@ -155,19 +155,19 @@ export class OpsViewRoutes extends DeesElement {
color: '#3b82f6', color: '#3b82f6',
}, },
{ {
id: 'hardcoded', id: 'configRoutes',
title: 'Hardcoded', title: 'From Config',
type: 'number', type: 'number',
value: hardcodedCount, value: configCount,
icon: 'lucide:lock', icon: 'lucide:settings',
description: 'Routes from constructor config', description: 'Seeded from config/email/DNS',
color: '#8b5cf6', color: '#8b5cf6',
}, },
{ {
id: 'programmatic', id: 'apiRoutes',
title: 'Programmatic', title: 'API Created',
type: 'number', type: 'number',
value: programmaticCount, value: apiCount,
icon: 'lucide:code', icon: 'lucide:code',
description: 'Routes added via API', description: 'Routes added via API',
color: '#0ea5e9', color: '#0ea5e9',
@@ -186,21 +186,20 @@ export class OpsViewRoutes extends DeesElement {
// Map merged routes to sz-route-list-view format // Map merged routes to sz-route-list-view format
const szRoutes = mergedRoutes.map((mr) => { const szRoutes = mergedRoutes.map((mr) => {
const tags = [...(mr.route.tags || [])]; const tags = [...(mr.route.tags || [])];
tags.push(mr.source); tags.push(mr.origin);
if (!mr.enabled) tags.push('disabled'); if (!mr.enabled) tags.push('disabled');
if (mr.overridden) tags.push('overridden');
return { return {
...mr.route, ...mr.route,
enabled: mr.enabled, enabled: mr.enabled,
tags, tags,
id: mr.storedRouteId || mr.route.name || undefined, id: mr.id || mr.route.name || undefined,
metadata: mr.metadata, metadata: mr.metadata,
}; };
}); });
return html` return html`
<dees-heading level="2">Route Management</dees-heading> <dees-heading level="3">Route Management</dees-heading>
<div class="routesContainer"> <div class="routesContainer">
<dees-statsgrid <dees-statsgrid
@@ -238,7 +237,6 @@ export class OpsViewRoutes extends DeesElement {
? html` ? html`
<sz-route-list-view <sz-route-list-view
.routes=${szRoutes} .routes=${szRoutes}
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)} @route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)} @route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)} @route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
@@ -247,7 +245,7 @@ export class OpsViewRoutes extends DeesElement {
: html` : html`
<div class="empty-state"> <div class="empty-state">
<p>No routes configured</p> <p>No routes configured</p>
<p>Add a programmatic route or check your constructor configuration.</p> <p>Add a route to get started.</p>
</div> </div>
`} `}
</div> </div>
@@ -266,77 +264,14 @@ export class OpsViewRoutes extends DeesElement {
const { DeesModal } = await import('@design.estate/dees-catalog'); 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; const meta = merged.metadata;
await DeesModal.createAndShow({ await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`, heading: `Route: ${merged.route.name}`,
content: html` content: html`
<div style="color: #ccc; padding: 8px 0;"> <div style="color: #ccc; padding: 8px 0;">
<p>Source: <strong style="color: #0af;">programmatic</strong></p> <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p> <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></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?.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>` : ''} ${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div> </div>
@@ -348,18 +283,26 @@ export class OpsViewRoutes extends DeesElement {
action: async (modalArg: any) => { action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.toggleRouteAction, appstate.toggleRouteAction,
{ id: merged.storedRouteId!, enabled: !merged.enabled }, { id: merged.id, enabled: !merged.enabled },
); );
await modalArg.destroy(); await modalArg.destroy();
}, },
}, },
{
name: 'Edit',
iconName: 'lucide:pencil',
action: async (modalArg: any) => {
await modalArg.destroy();
this.showEditRouteDialog(merged);
},
},
{ {
name: 'Delete', name: 'Delete',
iconName: 'lucide:trash-2', iconName: 'lucide:trash-2',
action: async (modalArg: any) => { action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction, appstate.deleteRouteAction,
merged.storedRouteId!, merged.id,
); );
await modalArg.destroy(); await modalArg.destroy();
}, },
@@ -372,7 +315,6 @@ export class OpsViewRoutes extends DeesElement {
], ],
}); });
} }
}
private async handleRouteEdit(e: CustomEvent) { private async handleRouteEdit(e: CustomEvent) {
const clickedRoute = e.detail; const clickedRoute = e.detail;
@@ -381,7 +323,7 @@ export class OpsViewRoutes extends DeesElement {
const merged = this.routeState.mergedRoutes.find( const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name, (mr) => mr.route.name === clickedRoute.name,
); );
if (!merged || !merged.storedRouteId) return; if (!merged) return;
this.showEditRouteDialog(merged); this.showEditRouteDialog(merged);
} }
@@ -393,7 +335,7 @@ export class OpsViewRoutes extends DeesElement {
const merged = this.routeState.mergedRoutes.find( const merged = this.routeState.mergedRoutes.find(
(mr) => mr.route.name === clickedRoute.name, (mr) => mr.route.name === clickedRoute.name,
); );
if (!merged || !merged.storedRouteId) return; if (!merged) return;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
@@ -415,7 +357,7 @@ export class OpsViewRoutes extends DeesElement {
action: async (modalArg: any) => { action: async (modalArg: any) => {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.deleteRouteAction, appstate.deleteRouteAction,
merged.storedRouteId!, merged.id,
); );
await modalArg.destroy(); await modalArg.destroy();
}, },
@@ -473,19 +415,19 @@ export class OpsViewRoutes extends DeesElement {
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .value=${route.name || ''} .required=${true}></dees-input-text> <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-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=${'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-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=${'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 (if no target selected)'} .value=${currentTargetPort}></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> <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;"> <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> <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;"> <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=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'} .value=${currentCustomKey}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text> <dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'} .value=${currentCustomCert}></dees-input-text>
</div> </div>
</div> </div>
</dees-form> </dees-form>
@@ -563,7 +505,7 @@ export class OpsViewRoutes extends DeesElement {
await appstate.routeManagementStatePart.dispatchAction( await appstate.routeManagementStatePart.dispatchAction(
appstate.updateRouteAction, appstate.updateRouteAction,
{ {
id: merged.storedRouteId!, id: merged.id,
route: updatedRoute, route: updatedRoute,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined, metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
}, },
@@ -603,23 +545,23 @@ export class OpsViewRoutes extends DeesElement {
]; ];
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
heading: 'Add Programmatic Route', heading: 'Add Route',
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text> <dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
<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-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=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></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=${'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 (if no target selected)'}></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> <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;"> <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> <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;"> <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=${'tlsCertKey'} .label=${'Private Key'} .description=${'PEM-encoded private key'}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text> <dees-input-text .key=${'tlsCertCert'} .label=${'Certificate'} .description=${'PEM-encoded certificate'}></dees-input-text>
</div> </div>
</div> </div>
</dees-form> </dees-form>

View File

@@ -7,9 +7,9 @@ import {
state, state,
cssManager, cssManager,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
declare global { declare global {
@@ -64,13 +64,14 @@ export class OpsViewSourceProfiles extends DeesElement {
]; ];
return html` return html`
<dees-heading level="2">Source Profiles</dees-heading> <dees-heading level="3">Source Profiles</dees-heading>
<div class="profilesContainer"> <div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid> <dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table <dees-table
.heading1=${'Source Profiles'} .heading1=${'Source Profiles'}
.heading2=${'Reusable source configurations for routes'} .heading2=${'Reusable source configurations for routes'}
.data=${profiles} .data=${profiles}
.showColumnFilters=${true}
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({ .displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
Name: profile.name, Name: profile.name,
Description: profile.description || '-', Description: profile.description || '-',

View File

@@ -7,10 +7,10 @@ import {
state, state,
cssManager, cssManager,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
declare global { declare global {
@@ -77,13 +77,14 @@ export class OpsViewTargetProfiles extends DeesElement {
]; ];
return html` return html`
<dees-heading level="2">Target Profiles</dees-heading> <dees-heading level="3">Target Profiles</dees-heading>
<div class="profilesContainer"> <div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid> <dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table <dees-table
.heading1=${'Target Profiles'} .heading1=${'Target Profiles'}
.heading2=${'Define what resources VPN clients can access'} .heading2=${'Define what resources VPN clients can access'}
.data=${profiles} .data=${profiles}
.showColumnFilters=${true}
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({ .displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
Name: profile.name, Name: profile.name,
Description: profile.description || '-', Description: profile.description || '-',
@@ -175,7 +176,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text> <dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></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=${'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-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form> </dees-form>
`, `,
@@ -234,7 +235,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text> <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-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=${'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-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form> </dees-form>
`, `,

View File

@@ -7,10 +7,10 @@ import {
state, state,
cssManager, cssManager,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from './shared/css.js'; import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog'; import { type IStatsTile } from '@design.estate/dees-catalog';
/** /**
@@ -223,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
]; ];
return html` return html`
<dees-heading level="2">VPN</dees-heading> <dees-heading level="3">VPN</dees-heading>
<div class="vpnContainer"> <div class="vpnContainer">
${this.vpnState.newClientConfig ? html` ${this.vpnState.newClientConfig ? html`
@@ -305,6 +305,9 @@ export class OpsViewVpn extends DeesElement {
.heading1=${'VPN Clients'} .heading1=${'VPN Clients'}
.heading2=${'Manage WireGuard and SmartVPN client registrations'} .heading2=${'Manage WireGuard and SmartVPN client registrations'}
.data=${clients} .data=${clients}
.rowKey=${'clientId'}
.highlightUpdates=${'flash'}
.showColumnFilters=${true}
.displayFunction=${(client: interfaces.data.IVpnClient) => { .displayFunction=${(client: interfaces.data.IVpnClient) => {
const conn = this.getConnectedInfo(client); const conn = this.getConnectedInfo(client);
let statusHtml; let statusHtml;
@@ -368,8 +371,8 @@ export class OpsViewVpn extends DeesElement {
</div> </div>
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox> <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;"> <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=${'destinationAllowList'} .label=${'Destination Allow List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text> <dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'}></dees-input-text>
</div> </div>
</dees-form> </dees-form>
`, `,
@@ -678,8 +681,8 @@ export class OpsViewVpn extends DeesElement {
</div> </div>
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox> <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;"> <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=${'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 (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text> <dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List'} .description=${'Comma-separated IPs or CIDRs'} .value=${currentBlockList}></dees-input-text>
</div> </div>
</dees-form> </dees-form>
`, `,

View File

@@ -11,22 +11,52 @@ import {
state, state,
type TemplateResult type TemplateResult
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import type { IView } from '@design.estate/dees-catalog';
// Import view components // Top-level / flat views
import { OpsViewOverview } from './ops-view-overview.js';
import { OpsViewNetwork } from './ops-view-network.js';
import { OpsViewEmails } from './ops-view-emails.js';
import { OpsViewLogs } from './ops-view-logs.js'; import { OpsViewLogs } from './ops-view-logs.js';
import { OpsViewConfig } from './ops-view-config.js';
import { OpsViewRoutes } from './ops-view-routes.js'; // Overview group
import { OpsViewApiTokens } from './ops-view-apitokens.js'; import { OpsViewOverview } from './overview/ops-view-overview.js';
import { OpsViewSecurity } from './ops-view-security.js'; import { OpsViewConfig } from './overview/ops-view-config.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js'; // Network group
import { OpsViewVpn } from './ops-view-vpn.js'; import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js'; import { OpsViewRoutes } from './network/ops-view-routes.js';
import { OpsViewNetworkTargets } from './ops-view-networktargets.js'; import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.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') @customElement('ops-dashboard')
export class OpsDashboard extends DeesElement { export class OpsDashboard extends DeesElement {
@@ -37,6 +67,7 @@ export class OpsDashboard extends DeesElement {
@state() accessor uiState: appstate.IUiState = { @state() accessor uiState: appstate.IUiState = {
activeView: 'overview', activeView: 'overview',
activeSubview: null,
sidebarCollapsed: false, sidebarCollapsed: false,
autoRefresh: true, autoRefresh: true,
refreshInterval: 1000, refreshInterval: 1000,
@@ -49,27 +80,37 @@ export class OpsDashboard extends DeesElement {
error: null, error: null,
}; };
// Store viewTabs as a property to maintain object references // Store viewTabs as a property to maintain object references (used for === selectedView identity)
private viewTabs = [ private viewTabs: ITabbedView[] = [
{ {
name: 'Overview', name: 'Overview',
iconName: 'lucide:layoutDashboard', iconName: 'lucide:layoutDashboard',
element: OpsViewOverview, subViews: [
}, { slug: 'stats', name: 'Stats', iconName: 'lucide:activity', element: OpsViewOverview },
{ { slug: 'configuration', name: 'Configuration', iconName: 'lucide:settings', element: OpsViewConfig },
name: 'Configuration', ],
iconName: 'lucide:settings',
element: OpsViewConfig,
}, },
{ {
name: 'Network', name: 'Network',
iconName: 'lucide: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', 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', name: 'Logs',
@@ -77,52 +118,54 @@ export class OpsDashboard extends DeesElement {
element: OpsViewLogs, element: OpsViewLogs,
}, },
{ {
name: 'Routes', name: 'Access',
iconName: 'lucide:route', iconName: 'lucide:keyRound',
element: OpsViewRoutes, subViews: [
}, { slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
{ { slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
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: 'Security', name: 'Security',
iconName: 'lucide:shield', 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', name: 'Domains',
iconName: 'lucide:badgeCheck',
element: OpsViewCertificates,
},
{
name: 'RemoteIngress',
iconName: 'lucide:globe', iconName: 'lucide:globe',
element: OpsViewRemoteIngress, subViews: [
}, { slug: 'providers', name: 'Providers', iconName: 'lucide:plug', element: OpsViewProviders },
{ { slug: 'domains', name: 'Domains', iconName: 'lucide:globe', element: OpsViewDomains },
name: 'VPN', { slug: 'dns', name: 'DNS', iconName: 'lucide:list', element: OpsViewDns },
iconName: 'lucide:shield', { slug: 'certificates', name: 'Certificates', iconName: 'lucide:badgeCheck', element: OpsViewCertificates },
element: OpsViewVpn, ],
}, },
]; ];
/** 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() { private get globalMessages() {
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = []; const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
const config = this.configState.config; const config = this.configState.config;
@@ -138,11 +181,13 @@ 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. * Used to pass the correct selectedView to dees-simple-appdash on initial render.
*/ */
private get currentViewTab() { private get currentViewTab(): ITabbedView {
return this.viewTabs.find(t => t.name.toLowerCase() === this.uiState.activeView) || this.viewTabs[0]; return (
this.findViewBySlug(this.uiState.activeView, this.uiState.activeSubview) ?? this.viewTabs[0]
);
} }
constructor() { constructor() {
@@ -176,38 +221,27 @@ export class OpsDashboard extends DeesElement {
.subscribe((uiState) => { .subscribe((uiState) => {
this.uiState = uiState; this.uiState = uiState;
// Sync appdash view when state changes (e.g., from URL navigation) // 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); this.rxSubscriptions.push(uiSubscription);
} }
/** /**
* Sync the dees-simple-appdash view selection with the current state. * 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; const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
if (!appDash) return; if (!appDash) return;
const targetTab = this.viewTabs.find(t => t.name.toLowerCase() === viewName); const targetView = this.findViewBySlug(viewSlug, subviewSlug);
if (!targetTab) return; if (!targetView) return;
// Check if we need to switch (avoid unnecessary updates) if (appDash.selectedView === targetView) return;
if (appDash.selectedView === targetTab) return;
// Update the selected view programmatically // Use loadView to update both selectedView and the mounted element.
appDash.selectedView = targetTab; // It will dispatch view-select; our handler skips when state already matches.
appDash.loadView(targetView);
// 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;
}
} }
public static styles = [ public static styles = [
@@ -249,7 +283,7 @@ export class OpsDashboard extends DeesElement {
public async firstUpdated() { public async firstUpdated() {
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
simpleLogin.addEventListener('login', (e: Event) => { simpleLogin.addEventListener('login', (e: Event) => {
// Handle logout event // Handle login event
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
this.login(detail.data.username, detail.data.password); 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'); const appDash = this.shadowRoot!.querySelector('dees-simple-appdash');
if (appDash) { if (appDash) {
appDash.addEventListener('view-select', (e: Event) => { appDash.addEventListener('view-select', (e: Event) => {
const viewName = (e as CustomEvent).detail.view.name.toLowerCase(); const view = (e as CustomEvent).detail.view as ITabbedView;
// Use router for navigation instead of direct state update const parent = this.findParent(view);
appRouter.navigateToView(viewName); 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 // Handle logout event

View File

@@ -39,7 +39,7 @@ export class OpsViewLogs extends DeesElement {
public render() { public render() {
return html` return html`
<dees-heading level="2">Logs</dees-heading> <dees-heading level="3">Logs</dees-heading>
<dees-chart-log <dees-chart-log
.label=${'Application Logs'} .label=${'Application Logs'}

View File

@@ -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.');
}
}

View File

@@ -0,0 +1,2 @@
export * from './ops-view-overview.js';
export * from './ops-view-config.js';

View File

@@ -1,7 +1,7 @@
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import * as shared from './shared/index.js'; import * as shared from '../shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import { appRouter } from '../router.js'; import { appRouter } from '../../router.js';
import { import {
DeesElement, DeesElement,
@@ -57,7 +57,7 @@ export class OpsViewConfig extends DeesElement {
public render() { public render() {
return html` return html`
<dees-heading level="2">Configuration</dees-heading> <dees-heading level="3">Configuration</dees-heading>
${this.configState.isLoading ${this.configState.isLoading
? html` ? 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." infoText="This view displays the current running configuration. DcRouter is configured through code or remote management."
@navigate=${(e: CustomEvent) => { @navigate=${(e: CustomEvent) => {
if (e.detail?.view) { 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[] = [ 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` return html`
@@ -181,7 +181,7 @@ export class OpsViewConfig extends DeesElement {
} }
const actions: IConfigSectionAction[] = [ 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` return html`
@@ -227,7 +227,7 @@ export class OpsViewConfig extends DeesElement {
const status = tls.source === 'none' ? 'not-configured' : 'enabled'; const status = tls.source === 'none' ? 'not-configured' : 'enabled';
const actions: IConfigSectionAction[] = [ 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` return html`
@@ -305,7 +305,7 @@ export class OpsViewConfig extends DeesElement {
]; ];
const actions: IConfigSectionAction[] = [ 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` return html`

View File

@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js'; import * as plugins from '../../plugins.js';
import * as shared from './shared/index.js'; import * as shared from '../shared/index.js';
import * as appstate from '../appstate.js'; import * as appstate from '../../appstate.js';
import { import {
DeesElement, DeesElement,
@@ -94,7 +94,7 @@ export class OpsViewOverview extends DeesElement {
public render() { public render() {
return html` return html`
<dees-heading level="2">Overview</dees-heading> <dees-heading level="3">Stats</dees-heading>
${this.statsState.isLoading ? html` ${this.statsState.isLoading ? html`
<div class="loadingMessage"> <div class="loadingMessage">

View 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';

View 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>
`;
}
}

View 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.`);
}
}

View File

@@ -0,0 +1,172 @@
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-overview': OpsViewSecurityOverview;
}
}
@customElement('ops-view-security-overview')
export class OpsViewSecurityOverview 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>
`;
}
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-heading level="3">Overview</dees-heading>
<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 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,
}));
}
}

View File

@@ -3,9 +3,39 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter; const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const; // Flat top-level views (no subviews)
const flatViews = ['logs'] as const;
export type TValidView = typeof validViews[number]; // Tabbed views and their valid subviews
const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security', 'domains'] as const,
access: ['apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,
domains: ['providers', 'domains', 'dns', 'certificates'] as const,
};
// Default subview when user visits the bare parent URL
const defaultSubview: Record<string, string> = {
overview: 'stats',
network: 'activity',
email: 'log',
access: 'apitokens',
security: 'overview',
domains: 'domains',
};
export const validTopLevelViews = [...flatViews, ...Object.keys(subviewMap)] as const;
export type TValidView = typeof validTopLevelViews[number];
export function isValidView(view: string): boolean {
return (validTopLevelViews as readonly string[]).includes(view);
}
export function isValidSubview(view: string, subview: string): boolean {
return subviewMap[view]?.includes(subview) ?? false;
}
class AppRouter { class AppRouter {
private router: InstanceType<typeof SmartRouter>; private router: InstanceType<typeof SmartRouter>;
@@ -25,12 +55,27 @@ class AppRouter {
} }
private setupRoutes(): void { private setupRoutes(): void {
for (const view of validViews) { // Flat views
for (const view of flatViews) {
this.router.on(`/${view}`, async () => { this.router.on(`/${view}`, async () => {
this.updateViewState(view); this.updateViewState(view, null);
}); });
} }
// Tabbed views
for (const view of Object.keys(subviewMap)) {
// Bare parent → redirect to default subview
this.router.on(`/${view}`, async () => {
this.navigateTo(`/${view}/${defaultSubview[view]}`);
});
// Each valid subview
for (const sub of subviewMap[view]) {
this.router.on(`/${view}/${sub}`, async () => {
this.updateViewState(view, sub);
});
}
}
// Root redirect // Root redirect
this.router.on('/', async () => { this.router.on('/', async () => {
this.navigateTo('/overview'); this.navigateTo('/overview');
@@ -42,7 +87,9 @@ class AppRouter {
if (this.suppressStateUpdate) return; if (this.suppressStateUpdate) return;
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const expectedPath = `/${uiState.activeView}`; const expectedPath = uiState.activeSubview
? `/${uiState.activeView}/${uiState.activeSubview}`
: `/${uiState.activeView}`;
if (currentPath !== expectedPath) { if (currentPath !== expectedPath) {
this.suppressStateUpdate = true; this.suppressStateUpdate = true;
@@ -57,25 +104,38 @@ class AppRouter {
if (!path || path === '/') { if (!path || path === '/') {
this.router.pushUrl('/overview'); this.router.pushUrl('/overview');
} else { return;
}
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const view = segments[0]; const view = segments[0];
const sub = segments[1];
if (validViews.includes(view as TValidView)) { if (!isValidView(view)) {
this.updateViewState(view as TValidView);
} else {
this.router.pushUrl('/overview'); this.router.pushUrl('/overview');
return;
} }
if (subviewMap[view]) {
if (sub && isValidSubview(view, sub)) {
this.updateViewState(view, sub);
} else {
// Bare parent or invalid sub → default subview
this.router.pushUrl(`/${view}/${defaultSubview[view]}`);
}
} else {
this.updateViewState(view, null);
} }
} }
private updateViewState(view: string): void { private updateViewState(view: string, subview: string | null): void {
this.suppressStateUpdate = true; this.suppressStateUpdate = true;
const currentState = appstate.uiStatePart.getState()!; const currentState = appstate.uiStatePart.getState()!;
if (currentState.activeView !== view) { if (currentState.activeView !== view || currentState.activeSubview !== subview) {
appstate.uiStatePart.setState({ appstate.uiStatePart.setState({
...currentState, ...currentState,
activeView: view, activeView: view,
activeSubview: subview,
} as appstate.IUiState); } as appstate.IUiState);
} }
this.suppressStateUpdate = false; this.suppressStateUpdate = false;
@@ -85,11 +145,17 @@ class AppRouter {
this.router.pushUrl(path); this.router.pushUrl(path);
} }
public navigateToView(view: string): void { public navigateToView(view: string, subview?: string): void {
if (validViews.includes(view as TValidView)) { if (!isValidView(view)) {
this.navigateTo(`/${view}`);
} else {
this.navigateTo('/overview'); this.navigateTo('/overview');
return;
}
if (subview && isValidSubview(view, subview)) {
this.navigateTo(`/${view}/${subview}`);
} else if (subviewMap[view]) {
this.navigateTo(`/${view}/${defaultSubview[view]}`);
} else {
this.navigateTo(`/${view}`);
} }
} }