Compare commits

..

64 Commits

Author SHA1 Message Date
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
940c7dc92e v13.1.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-07 21:02:37 +00:00
7fa6d82e58 feat(vpn,target-profiles,migrations): add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips 2026-04-07 21:02:37 +00:00
f29ed9757e fix(target-profile-manager): enhance domain matching to support bidirectional checks 2026-04-06 11:56:55 +00:00
ad45d1b8b9 v13.0.11
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-06 10:23:19 +00:00
68473f8550 fix(routing): serialize route updates and correct VPN-gated route application 2026-04-06 10:23:18 +00:00
07cfe76cac v13.0.10
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-06 08:08:23 +00:00
3775957bf2 fix(repo): no changes to commit 2026-04-06 08:08:23 +00:00
31ce18a025 v13.0.9
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-06 08:07:25 +00:00
0cccec5526 fix(repo): no changes to commit 2026-04-06 08:07:25 +00:00
0373f02f86 v13.0.8
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-06 08:05:07 +00:00
52dac0339f fix(ops-view-vpn): show target profile names in VPN forms and load profile candidates for autocomplete 2026-04-06 08:05:07 +00:00
b6f7f5f63f v13.0.7
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-06 07:51:25 +00:00
6271bb1079 fix(vpn,target-profiles): refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists 2026-04-06 07:51:25 +00:00
0fa65f31c3 fix(ops-view-targetprofiles): ensure routes are loaded before showing profile dialogs 2026-04-05 13:48:08 +00:00
93d6c7d341 v13.0.6
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-05 11:29:47 +00:00
b2ccd54079 fix(certificates): resolve base-domain certificate lookups and route profile list inputs 2026-04-05 11:29:47 +00:00
4e9b09616d v13.0.5
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-05 10:13:09 +00:00
ddb420835e fix(ts_web): replace custom section heading component with dees-heading across ops views 2026-04-05 10:13:09 +00:00
505fd044c0 v13.0.4
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-05 03:54:39 +00:00
7711204fef fix(deps): bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases 2026-04-05 03:54:39 +00:00
d7b6fbb241 v13.0.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-05 03:28:40 +00:00
a670b27a1c fix(deps): bump @push.rocks/smartdb to ^2.5.2 2026-04-05 03:28:40 +00:00
c2f57b086f v13.0.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-05 02:50:56 +00:00
083f16d7b4 fix(deps): bump smartdata, smartdb, and catalog dependencies 2026-04-05 02:50:56 +00:00
2994b6e686 v13.0.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-05 01:33:27 +00:00
ba15c169d7 fix(deps): bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies 2026-04-05 01:33:27 +00:00
bbd5707711 v13.0.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-05 00:37:37 +00:00
1ddf83b28d BREAKING CHANGE(vpn): replace tag-based VPN access control with source and target profiles 2026-04-05 00:37:37 +00:00
25365678e0 v12.10.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-04 21:23:16 +00:00
96d215fc66 feat(routes): add TLS configuration controls for route create and edit flows 2026-04-04 21:23:16 +00:00
648ba9e61d v12.9.4
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-04 20:18:34 +00:00
fcc1d9fede fix(deps): bump @push.rocks/smartdb to ^2.3.1 2026-04-04 20:18:34 +00:00
102 changed files with 9948 additions and 2632 deletions

View File

@@ -1,5 +1,201 @@
# Changelog # Changelog
## 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)
add startup data migrations, support scoped VPN route allow entries, and rename target profile hosts to ips
- runs smartmigration at startup before configuration is loaded and adds a migration for target profile targets from host to ip
- changes VPN client routing to always force traffic through SmartProxy while allowing direct target bypasses from target profiles
- supports domain-scoped VPN ipAllowList entries for vpnOnly routes based on matching target profile domains
- updates certificate reprovisioning to reapply routes so renewed certificates are loaded into the running proxy
- removes the forceDestinationSmartproxy VPN client option from API, persistence, manager, and web UI
## 2026-04-06 - 13.0.11 - fix(routing)
serialize route updates and correct VPN-gated route application
- RouteConfigManager now serializes concurrent applyRoutes calls to prevent overlapping SmartProxy updates and stale route overwrites.
- VPN-only routes deny access until VPN state is ready, then re-apply routes after VPN clients load or change to refresh ipAllowLists safely.
- Certificate provisioning retries now go through RouteConfigManager when available so the full merged route set is reapplied consistently.
- Reference resolution now expands network targets with multiple hosts into multiple route targets.
- Adds rollback when VPN client persistence fails, enforces unique target profile names, and fixes maxConnections parsing in the source profiles UI.
## 2026-04-06 - 13.0.10 - fix(repo)
no changes to commit
## 2026-04-06 - 13.0.9 - fix(repo)
no changes to commit
## 2026-04-06 - 13.0.8 - fix(ops-view-vpn)
show target profile names in VPN forms and load profile candidates for autocomplete
- fetch target profiles when the VPN operations view connects so profile data is available in the UI
- replace comma-separated target profile ID inputs with a restricted autocomplete list based on available target profiles
- map stored target profile IDs to profile names for table and detail displays, while resolving selected names back to IDs on save
## 2026-04-06 - 13.0.7 - fix(vpn,target-profiles)
refresh VPN client security when target profiles change and include profile target IPs in direct destination allow-lists
- Adds direct target IP resolution from target profiles so forced SmartProxy clients can bypass rewriting for explicit profile targets.
- Refreshes running VPN client security policies after target profile updates or deletions to keep destination access rules in sync.
## 2026-04-05 - 13.0.6 - fix(certificates)
resolve base-domain certificate lookups and route profile list inputs
- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
## 2026-04-05 - 13.0.5 - fix(ts_web)
replace custom section heading component with dees-heading across ops views
- updates all operations dashboard views to use <dees-heading level="2"> for section titles
- removes the unused shared ops-sectionheading component export and source file
- bumps UI and data layer dependencies to compatible patch/minor releases
## 2026-04-05 - 13.0.4 - fix(deps)
bump @push.rocks/smartdata and @push.rocks/smartdb to the latest patch releases
- Updates @push.rocks/smartdata from ^7.1.4 to ^7.1.5
- Updates @push.rocks/smartdb from ^2.5.2 to ^2.5.4
## 2026-04-05 - 13.0.3 - fix(deps)
bump @push.rocks/smartdb to ^2.5.2
- Updates @push.rocks/smartdb from ^2.5.1 to ^2.5.2 in package.json.
## 2026-04-05 - 13.0.2 - fix(deps)
bump smartdata, smartdb, and catalog dependencies
- updates @push.rocks/smartdata from ^7.1.3 to ^7.1.4
- updates @push.rocks/smartdb from ^2.4.1 to ^2.5.1
- updates @serve.zone/catalog from ^2.11.1 to ^2.11.2
## 2026-04-05 - 13.0.1 - fix(deps)
bump @design.estate/dees-catalog and @push.rocks/smartdb dependencies
- updates @design.estate/dees-catalog from ^3.55.6 to ^3.59.1
- updates @push.rocks/smartdb from ^2.3.1 to ^2.4.1
## 2026-04-05 - 13.0.0 - BREAKING CHANGE(vpn)
replace tag-based VPN access control with source and target profiles
- Renames Security Profiles to Source Profiles across APIs, persistence, route metadata, tests, and UI.
- Adds TargetProfile management, storage, API handlers, and dashboard views to define VPN-accessible domains, targets, and route references.
- Replaces route-level vpn configuration with vpnOnly and switches VPN clients from serverDefinedClientTags to targetProfileIds for access resolution.
- Updates route application and VPN AllowedIPs generation to derive client access from matching target profiles instead of tags.
## 2026-04-04 - 12.10.0 - feat(routes)
add TLS configuration controls for route create and edit flows
- Adds TLS mode and certificate selection to the route create and edit dialogs, including support for custom PEM key/certificate input.
- Allows route updates to explicitly remove nested TLS settings by treating null action properties as deletions during route patch merging.
- Bumps @design.estate/dees-catalog to ^3.55.6 and @serve.zone/catalog to ^2.11.1.
## 2026-04-04 - 12.9.4 - fix(deps)
bump @push.rocks/smartdb to ^2.3.1
- updates the @push.rocks/smartdb dependency from ^2.1.1 to ^2.3.1
## 2026-04-04 - 12.9.3 - fix(route-management) ## 2026-04-04 - 12.9.3 - fix(route-management)
include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs

View File

@@ -1,7 +1,7 @@
{ {
"name": "@serve.zone/dcrouter", "name": "@serve.zone/dcrouter",
"private": false, "private": false,
"version": "12.9.3", "version": "13.9.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -35,38 +35,39 @@
"@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.55.5", "@design.estate/dees-catalog": "^3.70.0",
"@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",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartacme": "^9.4.0", "@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.3", "@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.1.1", "@push.rocks/smartdb": "^2.6.2",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartfs": "^1.5.0", "@push.rocks/smartfs": "^1.5.0",
"@push.rocks/smartguard": "^3.1.0", "@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.1", "@push.rocks/smartjwt": "^2.2.1",
"@push.rocks/smartlog": "^3.2.1", "@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartmetrics": "^3.0.3", "@push.rocks/smartmetrics": "^3.0.3",
"@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.4.0", "@push.rocks/smartproxy": "^27.5.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",
"@push.rocks/smartstate": "^2.3.0", "@push.rocks/smartstate": "^2.3.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.1", "@push.rocks/smartvpn": "1.19.2",
"@push.rocks/taskbuffer": "^8.0.2", "@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.11.0", "@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.2.7", "lru-cache": "^11.3.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },

2476
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@@ -1,13 +1,13 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js'; import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import type { ISecurityProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js'; import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy'; import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================ // ============================================================================
// Helpers: access private maps for direct unit testing without DB // Helpers: access private maps for direct unit testing without DB
// ============================================================================ // ============================================================================
function injectProfile(resolver: ReferenceResolver, profile: ISecurityProfile): void { function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile); (resolver as any).profiles.set(profile.id, profile);
} }
@@ -15,7 +15,7 @@ function injectTarget(resolver: ReferenceResolver, target: INetworkTarget): void
(resolver as any).targets.set(target.id, target); (resolver as any).targets.set(target.id, target);
} }
function makeProfile(overrides: Partial<ISecurityProfile> = {}): ISecurityProfile { function makeProfile(overrides: Partial<ISourceProfile> = {}): ISourceProfile {
return { return {
id: 'profile-1', id: 'profile-1',
name: 'STANDARD', name: 'STANDARD',
@@ -72,14 +72,14 @@ tap.test('should list empty profiles and targets initially', async () => {
expect(resolver.listTargets().length).toEqual(0); expect(resolver.listTargets().length).toEqual(0);
}); });
// ---- Security profile resolution ---- // ---- Source profile resolution ----
tap.test('should resolve security profile onto a route', async () => { tap.test('should resolve source profile onto a route', async () => {
const profile = makeProfile(); const profile = makeProfile();
injectProfile(resolver, profile); injectProfile(resolver, profile);
const route = makeRoute(); const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' }; const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata); const result = resolver.resolveRoute(route, metadata);
@@ -87,7 +87,7 @@ tap.test('should resolve security profile onto a route', async () => {
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16'); expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8'); expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.maxConnections).toEqual(1000); expect(result.route.security!.maxConnections).toEqual(1000);
expect(result.metadata.securityProfileName).toEqual('STANDARD'); expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy(); expect(result.metadata.lastResolvedAt).toBeTruthy();
}); });
@@ -98,7 +98,7 @@ tap.test('should merge inline route security with profile security', async () =>
maxConnections: 5000, maxConnections: 5000,
}, },
}); });
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' }; const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata); const result = resolver.resolveRoute(route, metadata);
@@ -117,7 +117,7 @@ tap.test('should deduplicate IP lists during merge', async () => {
ipAllowList: ['192.168.0.0/16', '127.0.0.1'], ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
}, },
}); });
const metadata: IRouteMetadata = { securityProfileRef: 'profile-1' }; const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata); const result = resolver.resolveRoute(route, metadata);
@@ -128,13 +128,13 @@ tap.test('should deduplicate IP lists during merge', async () => {
tap.test('should handle missing profile gracefully', async () => { tap.test('should handle missing profile gracefully', async () => {
const route = makeRoute(); const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'nonexistent-profile' }; const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
const result = resolver.resolveRoute(route, metadata); const result = resolver.resolveRoute(route, metadata);
// Route should be unchanged // Route should be unchanged
expect(result.route.security).toBeUndefined(); expect(result.route.security).toBeUndefined();
expect(result.metadata.securityProfileName).toBeUndefined(); expect(result.metadata.sourceProfileName).toBeUndefined();
}); });
// ---- Profile inheritance ---- // ---- Profile inheritance ----
@@ -161,7 +161,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
injectProfile(resolver, extendedProfile); injectProfile(resolver, extendedProfile);
const route = makeRoute(); const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'extended-profile' }; const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
const result = resolver.resolveRoute(route, metadata); const result = resolver.resolveRoute(route, metadata);
@@ -170,7 +170,7 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21'); expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
// maxConnections from base (extended doesn't override) // maxConnections from base (extended doesn't override)
expect(result.route.security!.maxConnections).toEqual(500); expect(result.route.security!.maxConnections).toEqual(500);
expect(result.metadata.securityProfileName).toEqual('EXTENDED'); expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
}); });
tap.test('should detect circular profile inheritance', async () => { tap.test('should detect circular profile inheritance', async () => {
@@ -190,7 +190,7 @@ tap.test('should detect circular profile inheritance', async () => {
injectProfile(resolver, profileB); injectProfile(resolver, profileB);
const route = makeRoute(); const route = makeRoute();
const metadata: IRouteMetadata = { securityProfileRef: 'circular-a' }; const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
// Should not infinite loop — resolves what it can // Should not infinite loop — resolves what it can
const result = resolver.resolveRoute(route, metadata); const result = resolver.resolveRoute(route, metadata);
@@ -232,7 +232,7 @@ tap.test('should handle missing target gracefully', async () => {
tap.test('should resolve both profile and target simultaneously', async () => { tap.test('should resolve both profile and target simultaneously', async () => {
const route = makeRoute(); const route = makeRoute();
const metadata: IRouteMetadata = { const metadata: IRouteMetadata = {
securityProfileRef: 'profile-1', sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1', networkTargetRef: 'target-1',
}; };
@@ -247,7 +247,7 @@ tap.test('should resolve both profile and target simultaneously', async () => {
expect(result.route.action.targets![0].port).toEqual(443); expect(result.route.action.targets![0].port).toEqual(443);
// Both names recorded // Both names recorded
expect(result.metadata.securityProfileName).toEqual('STANDARD'); expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.networkTargetName).toEqual('INFRA'); expect(result.metadata.networkTargetName).toEqual('INFRA');
}); });
@@ -268,7 +268,7 @@ tap.test('should skip resolution when no metadata refs', async () => {
tap.test('should be idempotent — resolving twice gives same result', async () => { tap.test('should be idempotent — resolving twice gives same result', async () => {
const route = makeRoute(); const route = makeRoute();
const metadata: IRouteMetadata = { const metadata: IRouteMetadata = {
securityProfileRef: 'profile-1', sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1', networkTargetRef: 'target-1',
}; };
@@ -288,7 +288,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-a', id: 'route-a',
route: makeRoute({ name: 'route-a' }), route: makeRoute({ name: 'route-a' }),
enabled: true, enabled: true,
metadata: { securityProfileRef: 'profile-1' }, metadata: { sourceProfileRef: 'profile-1' },
}); });
storedRoutes.set('route-b', { storedRoutes.set('route-b', {
id: 'route-b', id: 'route-b',
@@ -300,7 +300,7 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-c', id: 'route-c',
route: makeRoute({ name: 'route-c' }), route: makeRoute({ name: 'route-c' }),
enabled: true, enabled: true,
metadata: { securityProfileRef: 'profile-1', networkTargetRef: 'target-1' }, metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
}); });
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes); const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
@@ -320,7 +320,7 @@ tap.test('should get profile usage for a specific profile ID', async () => {
id: 'route-x', id: 'route-x',
route: makeRoute({ name: 'my-route' }), route: makeRoute({ name: 'my-route' }),
enabled: true, enabled: true,
metadata: { securityProfileRef: 'profile-1' }, metadata: { sourceProfileRef: 'profile-1' },
}); });
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes); const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);

View File

@@ -39,13 +39,13 @@ tap.test('should login as admin', async () => {
}); });
// ============================================================================ // ============================================================================
// Security Profile endpoints (graceful fallbacks when resolver unavailable) // Source Profile endpoints (graceful fallbacks when resolver unavailable)
// ============================================================================ // ============================================================================
tap.test('should return empty profiles list when resolver not initialized', async () => { tap.test('should return empty profiles list when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>( const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
TEST_URL, TEST_URL,
'getSecurityProfiles' 'getSourceProfiles'
); );
const response = await req.fire({ const response = await req.fire({
@@ -57,9 +57,9 @@ tap.test('should return empty profiles list when resolver not initialized', asyn
}); });
tap.test('should return null for single profile when resolver not initialized', async () => { tap.test('should return null for single profile when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfile>( const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfile>(
TEST_URL, TEST_URL,
'getSecurityProfile' 'getSourceProfile'
); );
const response = await req.fire({ const response = await req.fire({
@@ -71,9 +71,9 @@ tap.test('should return null for single profile when resolver not initialized',
}); });
tap.test('should return failure for create profile when resolver not initialized', async () => { tap.test('should return failure for create profile when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_CreateSecurityProfile>( const req = new TypedRequest<interfaces.requests.IReq_CreateSourceProfile>(
TEST_URL, TEST_URL,
'createSecurityProfile' 'createSourceProfile'
); );
const response = await req.fire({ const response = await req.fire({
@@ -87,9 +87,9 @@ tap.test('should return failure for create profile when resolver not initialized
}); });
tap.test('should return empty profile usage when resolver not initialized', async () => { tap.test('should return empty profile usage when resolver not initialized', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfileUsage>( const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfileUsage>(
TEST_URL, TEST_URL,
'getSecurityProfileUsage' 'getSourceProfileUsage'
); );
const response = await req.fire({ const response = await req.fire({
@@ -170,9 +170,9 @@ tap.test('should return empty target usage when resolver not initialized', async
// ============================================================================ // ============================================================================
tap.test('should reject unauthenticated profile requests', async () => { tap.test('should reject unauthenticated profile requests', async () => {
const req = new TypedRequest<interfaces.requests.IReq_GetSecurityProfiles>( const req = new TypedRequest<interfaces.requests.IReq_GetSourceProfiles>(
TEST_URL, TEST_URL,
'getSecurityProfiles' 'getSourceProfiles'
); );
try { try {

View File

@@ -29,13 +29,13 @@ const devRouter = new DcRouter({
name: 'vpn-internal-app', name: 'vpn-internal-app',
match: { ports: [18080], domains: ['internal.example.com'] }, match: { ports: [18080], domains: ['internal.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] }, action: { type: 'forward', targets: [{ host: 'localhost', port: 5000 }] },
vpn: { enabled: true }, vpnOnly: true,
}, },
{ {
name: 'vpn-eng-dashboard', name: 'vpn-eng-dashboard',
match: { ports: [18080], domains: ['eng.example.com'] }, match: { ports: [18080], domains: ['eng.example.com'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] }, action: { type: 'forward', targets: [{ host: 'localhost', port: 5001 }] },
vpn: { enabled: true, allowedServerDefinedClientTags: ['engineering'] }, vpnOnly: true,
}, },
] as any[], ] as any[],
}, },
@@ -44,9 +44,9 @@ const devRouter = new DcRouter({
enabled: true, enabled: true,
serverEndpoint: 'vpn.dev.local', serverEndpoint: 'vpn.dev.local',
clients: [ clients: [
{ clientId: 'dev-laptop', serverDefinedClientTags: ['engineering', 'dev'], description: 'Developer laptop' }, { clientId: 'dev-laptop', description: 'Developer laptop' },
{ clientId: 'ci-runner', serverDefinedClientTags: ['engineering', 'ci'], description: 'CI/CD pipeline' }, { clientId: 'ci-runner', description: 'CI/CD pipeline' },
{ clientId: 'admin-desktop', serverDefinedClientTags: ['admin'], description: 'Admin workstation' }, { clientId: 'admin-desktop', description: 'Admin workstation' },
], ],
}, },
dbConfig: { enabled: true }, dbConfig: { enabled: true },

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '12.9.3', version: '13.9.0',
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

@@ -15,15 +15,20 @@ import { StorageBackedCertManager } from './classes.storage-cert-manager.js';
import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js'; import { CertProvisionScheduler } from './classes.cert-provision-scheduler.js';
// Import unified database // Import unified database
import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js'; import { DcRouterDb, type IDcRouterDbConfig, CacheCleaner, ProxyCertDoc, AcmeCertDoc } from './db/index.js';
// Import migration runner and app version
import { createMigrationRunner } from '../ts_migrations/index.js';
import { commitinfo } from './00_commitinfo_data.js';
import { OpsServer } from './opsserver/index.js'; import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js'; import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js'; import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js'; import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js'; import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder } 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';
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 */
@@ -113,13 +118,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.
@@ -180,8 +178,8 @@ export interface IDcRouterOptions {
/** /**
* VPN server configuration. * VPN server configuration.
* Enables VPN-based access control: routes with vpn.enabled are only * Enables VPN-based access control: routes with vpnOnly are only
* accessible from VPN clients. Supports WireGuard + native (WS/QUIC) transports. * accessible from VPN clients whose TargetProfile matches. Supports WireGuard + native (WS/QUIC) transports.
*/ */
vpnConfig?: { vpnConfig?: {
/** Enable VPN server (default: false) */ /** Enable VPN server (default: false) */
@@ -197,7 +195,7 @@ export interface IDcRouterOptions {
/** Pre-defined VPN clients created on startup */ /** Pre-defined VPN clients created on startup */
clients?: Array<{ clients?: Array<{
clientId: string; clientId: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
description?: string; description?: string;
}>; }>;
/** Destination routing policy for VPN client traffic. /** Destination routing policy for VPN client traffic.
@@ -274,6 +272,13 @@ export class DcRouter {
public routeConfigManager?: RouteConfigManager; public routeConfigManager?: RouteConfigManager;
public apiTokenManager?: ApiTokenManager; public apiTokenManager?: ApiTokenManager;
public referenceResolver?: ReferenceResolver; public referenceResolver?: ReferenceResolver;
public targetProfileManager?: TargetProfileManager;
// Domain / DNS management (DB-backed providers, domains, records)
public dnsManager?: DnsManager;
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
public acmeConfigManager?: AcmeConfigManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords) // Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null; public detectedPublicIp: string | null = null;
@@ -389,10 +394,57 @@ 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 }),
);
}
// 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')
@@ -411,9 +463,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()
@@ -430,7 +484,15 @@ export class DcRouter {
// failed silently (SmartProxy doesn't emit certificate-failed for this path). // failed silently (SmartProxy doesn't emit certificate-failed for this path).
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally, // Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
// which calls certProvisionFunction again — now with smartAcmeReady === true. // which calls certProvisionFunction again — now with smartAcmeReady === true.
if (this.smartProxy) { if (this.routeConfigManager) {
// Go through RouteConfigManager to get the full merged route set
// and serialize via the route-update mutex (prevents stale overwrites)
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
this.routeConfigManager.applyRoutes().catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
} else if (this.smartProxy) {
// No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning
if (this.certProvisionScheduler) { if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear(); this.certProvisionScheduler.clear();
} }
@@ -465,16 +527,23 @@ export class DcRouter {
this.referenceResolver = new ReferenceResolver(); this.referenceResolver = new ReferenceResolver();
await this.referenceResolver.initialize(); await this.referenceResolver.initialize();
// Initialize target profile manager
this.targetProfileManager = new TargetProfileManager();
await this.targetProfileManager.initialize();
this.routeConfigManager = new RouteConfigManager( this.routeConfigManager = new RouteConfigManager(
() => this.getConstructorRoutes(), () => this.getConstructorRoutes(),
() => this.smartProxy, () => this.smartProxy,
() => this.options.http3, () => this.options.http3,
this.options.vpnConfig?.enabled this.options.vpnConfig?.enabled
? (tags?: string[]) => { ? (route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig, routeId?: string) => {
if (tags?.length && this.vpnManager) { if (!this.vpnManager || !this.targetProfileManager) {
return this.vpnManager.getClientIpsForServerDefinedTags(tags); // VPN not ready yet — deny all until re-apply after VPN starts
return [];
} }
return [this.options.vpnConfig?.subnet || '10.8.0.0/24']; return this.targetProfileManager.getMatchingClientIps(
route, routeId, this.vpnManager.listClients(),
);
} }
: undefined, : undefined,
this.referenceResolver, this.referenceResolver,
@@ -504,6 +573,7 @@ export class DcRouter {
this.routeConfigManager = undefined; this.routeConfigManager = undefined;
this.apiTokenManager = undefined; this.apiTokenManager = undefined;
this.referenceResolver = undefined; this.referenceResolver = undefined;
this.targetProfileManager = undefined;
}) })
.withRetry({ maxRetries: 2, baseDelayMs: 1000 }), .withRetry({ maxRetries: 2, baseDelayMs: 1000 }),
); );
@@ -758,6 +828,19 @@ export class DcRouter {
await this.dcRouterDb.start(); await this.dcRouterDb.start();
// Run any pending data migrations before anything else reads from the DB.
// This must complete before ConfigManagers loads profiles.
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version);
const migrationResult = await migration.run();
if (migrationResult.stepsApplied.length > 0) {
logger.log('info',
`smartmigration: ${migrationResult.currentVersionBefore ?? 'fresh'}${migrationResult.currentVersionAfter} ` +
`(${migrationResult.stepsApplied.length} step(s) applied in ${migrationResult.totalDurationMs}ms)`,
);
} else if (migrationResult.wasFreshInstall) {
logger.log('info', `smartmigration: fresh install stamped to ${migrationResult.currentVersionAfter}`);
}
// Start the cache cleaner for TTL-based document cleanup // Start the cache cleaner for TTL-based document cleanup
const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000; const cleanupIntervalMs = (dbConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
this.cacheCleaner = new CacheCleaner(this.dcRouterDb, { this.cacheCleaner = new CacheCleaner(this.dcRouterDb, {
@@ -782,46 +865,65 @@ export class DcRouter {
} }
let routes: plugins.smartproxy.IRouteConfig[] = []; let routes: plugins.smartproxy.IRouteConfig[] = [];
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
// If user provides full SmartProxy config, use its routes.
// If user provides full SmartProxy config, use it directly // NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
// AcmeConfigManager on first boot. The live ACME config always comes
// from the DB via `this.acmeConfigManager.getConfig()`.
if (this.options.smartProxyConfig) { if (this.options.smartProxyConfig) {
routes = this.options.smartProxyConfig.routes || []; routes = this.options.smartProxyConfig.routes || [];
acmeConfig = this.options.smartProxyConfig.acme; logger.log('info', `Found ${routes.length} routes in config`);
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
} }
// If email config exists, automatically add email routes // If email config exists, automatically add email routes
if (this.options.emailConfig) { if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig); const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) }); logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
} }
// If DNS is configured, add DNS routes // If DNS is configured, add DNS routes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes(); const dnsRoutes = 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(dnsRoutes) });
routes = [...routes, ...dnsRoutes]; routes = [...routes, ...dnsRoutes];
} }
// Merge TLS/ACME configuration if provided at root level // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
if (this.options.tls && !acmeConfig) { // If no config exists or it's disabled, SmartProxy's own ACME is turned off
acmeConfig = { // and dcrouter's SmartAcme / certProvisionFunction are not wired.
accountEmail: this.options.tls.contactEmail, const dbAcme = this.acmeConfigManager?.getConfig();
enabled: true, const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
useProduction: true, dbAcme && dbAcme.enabled
autoRenew: true, ? {
renewThresholdDays: 30 accountEmail: dbAcme.accountEmail,
}; enabled: true,
useProduction: dbAcme.useProduction,
autoRenew: dbAcme.autoRenew,
renewThresholdDays: dbAcme.renewThresholdDays,
}
: undefined;
if (acmeConfig) {
logger.log(
'info',
`ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`,
);
} else {
logger.log('info', 'ACME config: disabled or not yet configured in DB');
} }
// Configure DNS challenge if available // Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
// ACME is enabled. The DnsManager dispatches each challenge to the right
// provider client based on the FQDN being certificated.
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.hasAcmeCapableProvider())
) {
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
challengeHandlers.push(dns01Handler); challengeHandlers.push(dns01Handler);
} }
@@ -920,10 +1022,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'],
}); });
@@ -1025,15 +1129,9 @@ export class DcRouter {
}); });
}); });
this.smartProxy.on('certificate-renewed', (event: plugins.smartproxy.ICertificateIssuedEvent) => { // Note: smartproxy v27.5.0 emits only 'certificate-issued' and 'certificate-failed'.
logger.log('info', `Certificate renewed for ${event.domain} via ${event.source}, expires ${event.expiryDate}`); // Renewals come through 'certificate-issued' (with optional isRenewal? in the payload).
const routeNames = this.findRouteNamesForDomain(event.domain); // The vestigial 'certificate-renewed' event from common-types.ts is never emitted.
this.certificateStatusMap.set(event.domain, {
status: 'valid', routeNames,
expiryDate: event.expiryDate, issuedAt: new Date().toISOString(),
source: event.source,
});
});
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => { this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`); logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
@@ -1068,7 +1166,10 @@ export class DcRouter {
if (!expiryDate) { if (!expiryDate) {
try { try {
const cleanDomain = entry.domain.replace(/^\*\.?/, ''); const cleanDomain = entry.domain.replace(/^\*\.?/, '');
const certDoc = await AcmeCertDoc.findByDomain(cleanDomain); const domParts = cleanDomain.split('.');
const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain;
const certDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (certDoc?.validUntil) { if (certDoc?.validUntil) {
expiryDate = new Date(certDoc.validUntil).toISOString(); expiryDate = new Date(certDoc.validUntil).toISOString();
} }
@@ -1690,8 +1791,14 @@ 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);
}
} }
/** /**
* Create DNS socket handler for DoH * Create DNS socket handler for DoH
*/ */
@@ -2137,56 +2244,38 @@ export class DcRouter {
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart, bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd, bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
onClientChanged: () => { onClientChanged: () => {
// Re-apply routes so tag-based ipAllowLists get updated // Re-apply routes so profile-based ipAllowLists get updated
this.routeConfigManager?.applyRoutes(); // (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
});
}, },
getClientAllowedIPs: async (clientTags: string[]) => { getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
},
getClientAllowedIPs: async (targetProfileIds: string[]) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24'; const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]); const ips = new Set<string>([subnet]);
// Check routes for VPN-gated tag match and collect domains if (!this.targetProfileManager) return [...ips];
const routes = this.options.smartProxyConfig?.routes || [];
const domainsToResolve = new Set<string>();
for (const route of routes) {
const dcRoute = route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (!dcRoute.vpn?.enabled) continue;
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags; const routes = (this.options.smartProxyConfig?.routes || []) as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[];
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) { const storedRoutes = this.routeConfigManager?.getStoredRoutes() || new Map();
// Collect domains from this route
const domains = (route.match as any)?.domains;
if (Array.isArray(domains)) {
for (const d of domains) {
// Strip wildcard prefix for DNS resolution (*.example.com → example.com)
domainsToResolve.add(d.replace(/^\*\./, ''));
}
}
}
}
// Also scan stored/programmatic routes const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
const storedRoutes = this.routeConfigManager?.getStoredRoutes(); targetProfileIds, routes, storedRoutes,
if (storedRoutes) { );
for (const [, stored] of storedRoutes) {
if (!stored.enabled) continue;
const dcRoute = stored.route as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig;
if (!dcRoute.vpn?.enabled) continue;
const routeTags = dcRoute.vpn.allowedServerDefinedClientTags; // Add target IPs directly
if (!routeTags?.length || clientTags.some(t => routeTags.includes(t))) { for (const ip of targetIps) {
const domains = (stored.route.match as any)?.domains; ips.add(`${ip}/32`);
if (Array.isArray(domains)) {
for (const d of domains) {
domainsToResolve.add(d.replace(/^\*\./, ''));
}
}
}
}
} }
// Resolve DNS A records for matched domains (with caching) // Resolve DNS A records for matched domains (with caching)
for (const domain of domainsToResolve) { for (const domain of domains) {
const resolvedIps = await this.resolveVpnDomainIPs(domain); const stripped = domain.replace(/^\*\./, '');
const resolvedIps = await this.resolveVpnDomainIPs(stripped);
for (const ip of resolvedIps) { for (const ip of resolvedIps) {
ips.add(`${ip}/32`); ips.add(`${ip}/32`);
} }
@@ -2199,9 +2288,9 @@ 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 hardcoded routes
// get correct tag-based ipAllowLists (not possible during setupSmartProxy since // get correct profile-based ipAllowLists (not possible during setupSmartProxy since
// VPN server wasn't ready yet) // VPN server wasn't ready yet)
this.routeConfigManager?.applyRoutes(); await this.routeConfigManager?.applyRoutes();
} }
/** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */ /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
@@ -2219,6 +2308,11 @@ export class DcRouter {
const { promises: dnsPromises } = await import('dns'); const { promises: dnsPromises } = await import('dns');
const ips = await dnsPromises.resolve4(domain); const ips = await dnsPromises.resolve4(domain);
this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 }); this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
// Evict oldest entries if cache exceeds 1000 entries
if (this.vpnDomainIpCache.size > 1000) {
const firstKey = this.vpnDomainIpCache.keys().next().value;
if (firstKey) this.vpnDomainIpCache.delete(firstKey);
}
return ips; return ips;
} catch (err) { } catch (err) {
logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`); logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);

View File

@@ -1,8 +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 { SecurityProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js'; import { SourceProfileDoc, NetworkTargetDoc, StoredRouteDoc } from '../db/index.js';
import type { import type {
ISecurityProfile, ISourceProfile,
INetworkTarget, INetworkTarget,
IRouteMetadata, IRouteMetadata,
IStoredRoute, IStoredRoute,
@@ -12,7 +12,7 @@ import type {
const MAX_INHERITANCE_DEPTH = 5; const MAX_INHERITANCE_DEPTH = 5;
export class ReferenceResolver { export class ReferenceResolver {
private profiles = new Map<string, ISecurityProfile>(); private profiles = new Map<string, ISourceProfile>();
private targets = new Map<string, INetworkTarget>(); private targets = new Map<string, INetworkTarget>();
// ========================================================================= // =========================================================================
@@ -38,7 +38,7 @@ export class ReferenceResolver {
const id = plugins.uuid.v4(); const id = plugins.uuid.v4();
const now = Date.now(); const now = Date.now();
const profile: ISecurityProfile = { const profile: ISourceProfile = {
id, id,
name: data.name, name: data.name,
description: data.description, description: data.description,
@@ -51,17 +51,17 @@ export class ReferenceResolver {
this.profiles.set(id, profile); this.profiles.set(id, profile);
await this.persistProfile(profile); await this.persistProfile(profile);
logger.log('info', `Created security profile '${profile.name}' (${id})`); logger.log('info', `Created source profile '${profile.name}' (${id})`);
return id; return id;
} }
public async updateProfile( public async updateProfile(
id: string, id: string,
patch: Partial<Omit<ISecurityProfile, 'id' | 'createdAt' | 'createdBy'>>, patch: Partial<Omit<ISourceProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<{ affectedRouteIds: string[] }> { ): Promise<{ affectedRouteIds: string[] }> {
const profile = this.profiles.get(id); const profile = this.profiles.get(id);
if (!profile) { if (!profile) {
throw new Error(`Security profile '${id}' not found`); throw new Error(`Source profile '${id}' not found`);
} }
if (patch.name !== undefined) profile.name = patch.name; if (patch.name !== undefined) profile.name = patch.name;
@@ -71,7 +71,7 @@ export class ReferenceResolver {
profile.updatedAt = Date.now(); profile.updatedAt = Date.now();
await this.persistProfile(profile); await this.persistProfile(profile);
logger.log('info', `Updated security profile '${profile.name}' (${id})`); logger.log('info', `Updated source profile '${profile.name}' (${id})`);
// Find routes referencing this profile // Find routes referencing this profile
const affectedRouteIds = await this.findRoutesByProfileRef(id); const affectedRouteIds = await this.findRoutesByProfileRef(id);
@@ -85,7 +85,7 @@ export class ReferenceResolver {
): 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) {
return { success: false, message: `Security profile '${id}' not found` }; return { success: false, message: `Source profile '${id}' not found` };
} }
// Check usage // Check usage
@@ -101,7 +101,7 @@ export class ReferenceResolver {
} }
// Delete from DB // Delete from DB
const doc = await SecurityProfileDoc.findById(id); const doc = await SourceProfileDoc.findById(id);
if (doc) await doc.delete(); if (doc) await doc.delete();
this.profiles.delete(id); this.profiles.delete(id);
@@ -110,24 +110,24 @@ export class ReferenceResolver {
await this.clearProfileRefsOnRoutes(affectedIds); await this.clearProfileRefsOnRoutes(affectedIds);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`); logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else { } else {
logger.log('info', `Deleted security profile '${profile.name}' (${id})`); logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
} }
return { success: true }; return { success: true };
} }
public getProfile(id: string): ISecurityProfile | undefined { public getProfile(id: string): ISourceProfile | undefined {
return this.profiles.get(id); return this.profiles.get(id);
} }
public getProfileByName(name: string): ISecurityProfile | undefined { public getProfileByName(name: string): ISourceProfile | undefined {
for (const profile of this.profiles.values()) { for (const profile of this.profiles.values()) {
if (profile.name === name) return profile; if (profile.name === name) return profile;
} }
return undefined; return undefined;
} }
public listProfiles(): ISecurityProfile[] { public listProfiles(): ISourceProfile[] {
return [...this.profiles.values()]; return [...this.profiles.values()];
} }
@@ -137,7 +137,7 @@ export class ReferenceResolver {
usage.set(profile.id, []); usage.set(profile.id, []);
} }
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.securityProfileRef; const ref = stored.metadata?.sourceProfileRef;
if (ref && usage.has(ref)) { if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId }); usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
} }
@@ -151,7 +151,7 @@ export class ReferenceResolver {
): 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) {
if (stored.metadata?.securityProfileRef === profileId) { if (stored.metadata?.sourceProfileRef === profileId) {
routes.push({ id: routeId, routeName: stored.route.name || routeId }); routes.push({ id: routeId, routeName: stored.route.name || routeId });
} }
} }
@@ -280,7 +280,7 @@ export class ReferenceResolver {
/** /**
* Resolve references for a single route. * Resolve references for a single route.
* Materializes security profile and/or network target into the route's fields. * Materializes source profile and/or network target into the route's fields.
* Returns the resolved route and updated metadata. * Returns the resolved route and updated metadata.
*/ */
public resolveRoute( public resolveRoute(
@@ -289,33 +289,34 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } { ): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata }; const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.securityProfileRef) { if (resolvedMetadata.sourceProfileRef) {
const resolvedSecurity = this.resolveSecurityProfile(resolvedMetadata.securityProfileRef); const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) { if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.securityProfileRef); const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
// Merge: profile provides base, route's inline values override // Merge: profile provides base, route's inline values override
route = { route = {
...route, ...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security), security: this.mergeSecurityFields(resolvedSecurity, route.security),
}; };
resolvedMetadata.securityProfileName = profile?.name; resolvedMetadata.sourceProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now(); resolvedMetadata.lastResolvedAt = Date.now();
} else { } else {
logger.log('warn', `Security profile '${resolvedMetadata.securityProfileRef}' not found during resolution`); logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
} }
} }
if (resolvedMetadata.networkTargetRef) { if (resolvedMetadata.networkTargetRef) {
const target = this.targets.get(resolvedMetadata.networkTargetRef); const target = this.targets.get(resolvedMetadata.networkTargetRef);
if (target) { if (target) {
const hosts = Array.isArray(target.host) ? target.host : [target.host];
route = { route = {
...route, ...route,
action: { action: {
...route.action, ...route.action,
targets: [{ targets: hosts.map((h) => ({
host: target.host as string, host: h,
port: target.port, port: target.port,
}], })),
}, },
}; };
resolvedMetadata.networkTargetName = target.name; resolvedMetadata.networkTargetName = target.name;
@@ -335,7 +336,7 @@ 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 StoredRouteDoc.findAll();
return docs return docs
.filter((doc) => doc.metadata?.securityProfileRef === profileId) .filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.map((doc) => doc.id); .map((doc) => doc.id);
} }
@@ -349,7 +350,7 @@ export class ReferenceResolver {
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] { public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IStoredRoute>): string[] {
const ids: string[] = []; const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) { for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.securityProfileRef === profileId) { if (stored.metadata?.sourceProfileRef === profileId) {
ids.push(routeId); ids.push(routeId);
} }
} }
@@ -367,10 +368,10 @@ export class ReferenceResolver {
} }
// ========================================================================= // =========================================================================
// Private: security profile resolution with inheritance // Private: source profile resolution with inheritance
// ========================================================================= // =========================================================================
private resolveSecurityProfile( private resolveSourceProfile(
profileId: string, profileId: string,
visited: Set<string> = new Set(), visited: Set<string> = new Set(),
depth: number = 0, depth: number = 0,
@@ -396,7 +397,7 @@ export class ReferenceResolver {
// Resolve parent profiles first (top-down, later overrides earlier) // Resolve parent profiles first (top-down, later overrides earlier)
if (profile.extendsProfiles?.length) { if (profile.extendsProfiles?.length) {
for (const parentId of profile.extendsProfiles) { for (const parentId of profile.extendsProfiles) {
const parentSecurity = this.resolveSecurityProfile(parentId, new Set(visited), depth + 1); const parentSecurity = this.resolveSourceProfile(parentId, new Set(visited), depth + 1);
if (parentSecurity) { if (parentSecurity) {
baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity); baseSecurity = this.mergeSecurityFields(baseSecurity, parentSecurity);
} }
@@ -453,7 +454,7 @@ export class ReferenceResolver {
// ========================================================================= // =========================================================================
private async loadProfiles(): Promise<void> { private async loadProfiles(): Promise<void> {
const docs = await SecurityProfileDoc.findAll(); const docs = await SourceProfileDoc.findAll();
for (const doc of docs) { for (const doc of docs) {
if (doc.id) { if (doc.id) {
this.profiles.set(doc.id, { this.profiles.set(doc.id, {
@@ -469,7 +470,7 @@ export class ReferenceResolver {
} }
} }
if (this.profiles.size > 0) { if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} security profile(s) from storage`); logger.log('info', `Loaded ${this.profiles.size} source profile(s) from storage`);
} }
} }
@@ -494,8 +495,8 @@ export class ReferenceResolver {
} }
} }
private async persistProfile(profile: ISecurityProfile): Promise<void> { private async persistProfile(profile: ISourceProfile): Promise<void> {
const existingDoc = await SecurityProfileDoc.findById(profile.id); const existingDoc = await SourceProfileDoc.findById(profile.id);
if (existingDoc) { if (existingDoc) {
existingDoc.name = profile.name; existingDoc.name = profile.name;
existingDoc.description = profile.description; existingDoc.description = profile.description;
@@ -504,7 +505,7 @@ export class ReferenceResolver {
existingDoc.updatedAt = profile.updatedAt; existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save(); await existingDoc.save();
} else { } else {
const doc = new SecurityProfileDoc(); const doc = new SourceProfileDoc();
doc.id = profile.id; doc.id = profile.id;
doc.name = profile.name; doc.name = profile.name;
doc.description = profile.description; doc.description = profile.description;
@@ -550,8 +551,8 @@ export class ReferenceResolver {
if (doc?.metadata) { if (doc?.metadata) {
doc.metadata = { doc.metadata = {
...doc.metadata, ...doc.metadata,
securityProfileRef: undefined, sourceProfileRef: undefined,
securityProfileName: undefined, sourceProfileName: undefined,
}; };
doc.updatedAt = Date.now(); doc.updatedAt = Date.now();
await doc.save(); await doc.save();

View File

@@ -12,16 +12,50 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js'; import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js'; import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
/**
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
*/
class RouteUpdateMutex {
private locked = false;
private queue: Array<() => void> = [];
async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
await new Promise<void>((resolve) => {
if (!this.locked) {
this.locked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
try {
return await fn();
} finally {
this.locked = false;
const next = this.queue.shift();
if (next) {
this.locked = true;
next();
}
}
}
}
export class RouteConfigManager { export class RouteConfigManager {
private storedRoutes = new Map<string, IStoredRoute>(); private storedRoutes = new Map<string, IStoredRoute>();
private overrides = new Map<string, IRouteOverride>(); private overrides = new Map<string, IRouteOverride>();
private warnings: IRouteWarning[] = []; private warnings: IRouteWarning[] = [];
private routeUpdateMutex = new RouteUpdateMutex();
constructor( constructor(
private getHardcodedRoutes: () => plugins.smartproxy.IRouteConfig[], 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 getVpnAllowList?: (tags?: string[]) => string[], private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private referenceResolver?: ReferenceResolver, private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void, private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void,
) {} ) {}
@@ -132,7 +166,18 @@ export class RouteConfigManager {
if (!stored) return false; if (!stored) return false;
if (patch.route) { if (patch.route) {
stored.route = { ...stored.route, ...patch.route } as IDcRouterRouteConfig; const mergedAction = patch.route.action
? { ...stored.route.action, ...patch.route.action }
: stored.route.action;
// Handle explicit null to remove nested action properties (e.g., tls: null)
if (patch.route.action) {
for (const [key, val] of Object.entries(patch.route.action)) {
if (val === null) {
delete (mergedAction as any)[key];
}
}
}
stored.route = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
} }
if (patch.enabled !== undefined) { if (patch.enabled !== undefined) {
stored.enabled = patch.enabled; stored.enabled = patch.enabled;
@@ -346,60 +391,60 @@ export class RouteConfigManager {
// ========================================================================= // =========================================================================
public async applyRoutes(): Promise<void> { public async applyRoutes(): Promise<void> {
const smartProxy = this.getSmartProxy(); await this.routeUpdateMutex.runExclusive(async () => {
if (!smartProxy) return; const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = []; const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const http3Config = this.getHttp3Config?.(); const http3Config = this.getHttp3Config?.();
const vpnAllowList = this.getVpnAllowList; const vpnCallback = this.getVpnClientIpsForRoute;
// Helper: inject VPN security into a route if vpn.enabled is set // Helper: inject VPN security into a vpnOnly route
const injectVpn = (route: plugins.smartproxy.IRouteConfig): plugins.smartproxy.IRouteConfig => { const injectVpn = (route: plugins.smartproxy.IRouteConfig, routeId?: string): plugins.smartproxy.IRouteConfig => {
if (!vpnAllowList) return route; if (!vpnCallback) return route;
const dcRoute = route as IDcRouterRouteConfig; const dcRoute = route as IDcRouterRouteConfig;
if (!dcRoute.vpn?.enabled) return route; if (!dcRoute.vpnOnly) return route;
const allowList = vpnAllowList(dcRoute.vpn.allowedServerDefinedClientTags); const vpnEntries = vpnCallback(dcRoute, routeId);
const mandatory = dcRoute.vpn.mandatory === true; // defaults to false const existingEntries = route.security?.ipAllowList || [];
return { return {
...route, ...route,
security: { security: {
...route.security, ...route.security,
ipAllowList: mandatory ipAllowList: [...existingEntries, ...vpnEntries],
? allowList },
: [...(route.security?.ipAllowList || []), ...allowList], };
},
}; };
};
// Add enabled hardcoded routes (respecting overrides, with fresh VPN injection) // Add enabled hardcoded routes (respecting overrides, with fresh VPN injection)
for (const route of this.getHardcodedRoutes()) { for (const route of this.getHardcodedRoutes()) {
const name = route.name || ''; const name = route.name || '';
const override = this.overrides.get(name); const override = this.overrides.get(name);
if (override && !override.enabled) { if (override && !override.enabled) {
continue; // Skip disabled hardcoded route 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) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
} }
enabledRoutes.push(injectVpn(route)); enabledRoutes.push(injectVpn(route));
} }
}
await smartProxy.updateRoutes(enabledRoutes); // Add enabled programmatic routes (with HTTP/3 and VPN augmentation)
for (const stored of this.storedRoutes.values()) {
if (stored.enabled) {
let route = stored.route;
if (http3Config?.enabled !== false) {
route = augmentRouteWithHttp3(route, { enabled: true, ...http3Config });
}
enabledRoutes.push(injectVpn(route, stored.id));
}
}
// Notify listeners (e.g. RemoteIngressManager) of the merged route set await smartProxy.updateRoutes(enabledRoutes);
if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes);
}
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`); // Notify listeners (e.g. RemoteIngressManager) of the merged route set
if (this.onRoutesApplied) {
this.onRoutesApplied(enabledRoutes);
}
logger.log('info', `Applied ${enabledRoutes.length} routes to SmartProxy (${this.storedRoutes.size} programmatic, ${this.overrides.size} overrides)`);
});
} }
} }

View File

@@ -0,0 +1,428 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { TargetProfileDoc, VpnClientDoc } from '../db/index.js';
import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/data/target-profile.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IStoredRoute } from '../../ts_interfaces/data/route-management.js';
/**
* Manages TargetProfiles (target-side: what can be accessed).
* TargetProfiles define what resources a VPN client can reach:
* domains, specific IP:port targets, and/or direct route references.
*/
export class TargetProfileManager {
private profiles = new Map<string, ITargetProfile>();
// =========================================================================
// Lifecycle
// =========================================================================
public async initialize(): Promise<void> {
await this.loadProfiles();
}
// =========================================================================
// CRUD
// =========================================================================
public async createProfile(data: {
name: string;
description?: string;
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
createdBy: string;
}): Promise<string> {
// Enforce unique profile names
for (const existing of this.profiles.values()) {
if (existing.name === data.name) {
throw new Error(`Target profile with name '${data.name}' already exists (id: ${existing.id})`);
}
}
const id = plugins.uuid.v4();
const now = Date.now();
const profile: ITargetProfile = {
id,
name: data.name,
description: data.description,
domains: data.domains,
targets: data.targets,
routeRefs: data.routeRefs,
createdAt: now,
updatedAt: now,
createdBy: data.createdBy,
};
this.profiles.set(id, profile);
await this.persistProfile(profile);
logger.log('info', `Created target profile '${profile.name}' (${id})`);
return id;
}
public async updateProfile(
id: string,
patch: Partial<Omit<ITargetProfile, 'id' | 'createdAt' | 'createdBy'>>,
): Promise<void> {
const profile = this.profiles.get(id);
if (!profile) {
throw new Error(`Target profile '${id}' not found`);
}
if (patch.name !== undefined) profile.name = patch.name;
if (patch.description !== undefined) profile.description = patch.description;
if (patch.domains !== undefined) profile.domains = patch.domains;
if (patch.targets !== undefined) profile.targets = patch.targets;
if (patch.routeRefs !== undefined) profile.routeRefs = patch.routeRefs;
profile.updatedAt = Date.now();
await this.persistProfile(profile);
logger.log('info', `Updated target profile '${profile.name}' (${id})`);
}
public async deleteProfile(
id: string,
force?: boolean,
): Promise<{ success: boolean; message?: string }> {
const profile = this.profiles.get(id);
if (!profile) {
return { success: false, message: `Target profile '${id}' not found` };
}
// Check if any VPN clients reference this profile
const clients = await VpnClientDoc.findAll();
const referencingClients = clients.filter(
(c) => c.targetProfileIds?.includes(id),
);
if (referencingClients.length > 0 && !force) {
return {
success: false,
message: `Profile '${profile.name}' is in use by ${referencingClients.length} VPN client(s). Use force=true to delete.`,
};
}
// Delete from DB
const doc = await TargetProfileDoc.findById(id);
if (doc) await doc.delete();
this.profiles.delete(id);
if (referencingClients.length > 0) {
// Remove profile ref from clients
for (const client of referencingClients) {
client.targetProfileIds = client.targetProfileIds?.filter((pid) => pid !== id);
client.updatedAt = Date.now();
await client.save();
}
logger.log('warn', `Force-deleted target profile '${profile.name}'; removed refs from ${referencingClients.length} client(s)`);
} else {
logger.log('info', `Deleted target profile '${profile.name}' (${id})`);
}
return { success: true };
}
public getProfile(id: string): ITargetProfile | undefined {
return this.profiles.get(id);
}
public listProfiles(): ITargetProfile[] {
return [...this.profiles.values()];
}
/**
* Get which VPN clients reference a target profile.
*/
public async getProfileUsage(profileId: string): Promise<Array<{ clientId: string; description?: string }>> {
const clients = await VpnClientDoc.findAll();
return clients
.filter((c) => c.targetProfileIds?.includes(profileId))
.map((c) => ({ clientId: c.clientId, description: c.description }));
}
// =========================================================================
// Direct target IPs (bypass SmartProxy)
// =========================================================================
/**
* For a set of target profile IDs, collect all explicit target IPs.
* These IPs bypass the SmartProxy forceTarget rewrite — VPN clients can
* connect to them directly through the tunnel.
*/
public getDirectTargetIps(targetProfileIds: string[]): string[] {
const ips = new Set<string>();
for (const profileId of targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile?.targets?.length) continue;
for (const t of profile.targets) {
ips.add(t.ip);
}
}
return [...ips];
}
// =========================================================================
// Core matching: route → client IPs
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns IP allow entries for injection into ipAllowList.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains.
*/
public getMatchingClientIps(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
const routeDomains: string[] = (route.match as any)?.domains || [];
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.targetProfileIds?.length) continue;
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
const matchResult = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
if (matchResult === 'full') {
fullAccess = true;
break; // No need to check more profiles
}
if (matchResult !== 'none') {
for (const d of matchResult.domains) scopedDomains.add(d);
}
}
if (fullAccess) {
entries.push(client.assignedIp);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
}
}
return entries;
}
/**
* For a given client (by its targetProfileIds), compute the set of
* domains and target IPs it can access. Used for WireGuard AllowedIPs.
*/
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: IDcRouterRouteConfig[],
storedRoutes: Map<string, IStoredRoute>,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
// Collect all access specifiers from assigned profiles
for (const profileId of targetProfileIds) {
const profile = this.profiles.get(profileId);
if (!profile) continue;
// Direct domain entries
if (profile.domains?.length) {
for (const d of profile.domains) {
domains.add(d);
}
}
// Direct target IP entries
if (profile.targets?.length) {
for (const t of profile.targets) {
targetIps.add(t.ip);
}
}
// Route references: scan constructor routes
for (const route of allRoutes) {
if (this.routeMatchesProfile(route as IDcRouterRouteConfig, undefined, profile)) {
const routeDomains = (route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
// Route references: scan stored routes
for (const [storedId, stored] of storedRoutes) {
if (!stored.enabled) continue;
if (this.routeMatchesProfile(stored.route as IDcRouterRouteConfig, storedId, profile)) {
const routeDomains = (stored.route.match as any)?.domains;
if (Array.isArray(routeDomains)) {
for (const d of routeDomains) {
domains.add(d);
}
}
}
}
}
return {
domains: [...domains],
targetIps: [...targetIps],
};
}
// =========================================================================
// Private: matching logic
// =========================================================================
/**
* Check if a route matches a profile (boolean convenience wrapper).
*/
private routeMatchesProfile(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
): boolean {
const routeDomains: string[] = (route.match as any)?.domains || [];
const result = this.routeMatchesProfileDetailed(route, routeId, profile, routeDomains);
return result !== 'none';
}
/**
* Detailed match: returns 'full' (plain IP, entire route), 'scoped' (domain-limited),
* or 'none' (no match).
*
* - routeRefs / target matches → 'full' (explicit reference = full access)
* - domain match where profile domains are a subset of route wildcard → 'scoped'
* - domain match where domains are identical or profile is a wildcard → 'full'
*/
private routeMatchesProfileDetailed(
route: IDcRouterRouteConfig,
routeId: string | undefined,
profile: ITargetProfile,
routeDomains: string[],
): 'full' | { type: 'scoped'; domains: string[] } | 'none' {
// 1. Route reference match → full access
if (profile.routeRefs?.length) {
if (routeId && profile.routeRefs.includes(routeId)) return 'full';
if (route.name && profile.routeRefs.includes(route.name)) return 'full';
}
// 2. Domain match
if (profile.domains?.length && routeDomains.length) {
const matchedProfileDomains: string[] = [];
for (const profileDomain of profile.domains) {
for (const routeDomain of routeDomains) {
if (this.domainMatchesPattern(routeDomain, profileDomain) ||
this.domainMatchesPattern(profileDomain, routeDomain)) {
matchedProfileDomains.push(profileDomain);
break; // This profileDomain matched, move to the next
}
}
}
if (matchedProfileDomains.length > 0) {
// Check if profile domains cover the route entirely (same wildcards = full access)
const isFullCoverage = routeDomains.every((rd) =>
matchedProfileDomains.some((pd) =>
rd === pd || this.domainMatchesPattern(rd, pd),
),
);
if (isFullCoverage) return 'full';
// Profile domains are a subset → scoped access to those specific domains
return { type: 'scoped', domains: matchedProfileDomains };
}
}
// 3. Target match (host + port) → full access (precise by nature)
if (profile.targets?.length) {
const routeTargets = (route.action as any)?.targets;
if (Array.isArray(routeTargets)) {
for (const profileTarget of profile.targets) {
for (const routeTarget of routeTargets) {
const routeHost = routeTarget.host;
const routePort = routeTarget.port;
if (routeHost === profileTarget.ip && routePort === profileTarget.port) {
return 'full';
}
}
}
}
}
return 'none';
}
/**
* Check if a domain matches a pattern.
* - '*.example.com' matches 'sub.example.com', 'a.b.example.com'
* - 'example.com' matches only 'example.com'
*/
private domainMatchesPattern(domain: string, pattern: string): boolean {
if (pattern === domain) return true;
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1); // '.example.com'
return domain.endsWith(suffix) && domain.length > suffix.length;
}
return false;
}
// =========================================================================
// Private: persistence
// =========================================================================
private async loadProfiles(): Promise<void> {
const docs = await TargetProfileDoc.findAll();
for (const doc of docs) {
if (doc.id) {
this.profiles.set(doc.id, {
id: doc.id,
name: doc.name,
description: doc.description,
domains: doc.domains,
targets: doc.targets,
routeRefs: doc.routeRefs,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
});
}
}
if (this.profiles.size > 0) {
logger.log('info', `Loaded ${this.profiles.size} target profile(s) from storage`);
}
}
private async persistProfile(profile: ITargetProfile): Promise<void> {
const existingDoc = await TargetProfileDoc.findById(profile.id);
if (existingDoc) {
existingDoc.name = profile.name;
existingDoc.description = profile.description;
existingDoc.domains = profile.domains;
existingDoc.targets = profile.targets;
existingDoc.routeRefs = profile.routeRefs;
existingDoc.updatedAt = profile.updatedAt;
await existingDoc.save();
} else {
const doc = new TargetProfileDoc();
doc.id = profile.id;
doc.name = profile.name;
doc.description = profile.description;
doc.domains = profile.domains;
doc.targets = profile.targets;
doc.routeRefs = profile.routeRefs;
doc.createdAt = profile.createdAt;
doc.updatedAt = profile.updatedAt;
doc.createdBy = profile.createdBy;
await doc.save();
}
}
}

View File

@@ -3,4 +3,5 @@ export * from './validator.js';
export { RouteConfigManager } from './classes.route-config-manager.js'; export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js'; export { ApiTokenManager } from './classes.api-token-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js'; export { ReferenceResolver } from './classes.reference-resolver.js';
export { DbSeeder } from './classes.db-seeder.js'; export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';

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

@@ -5,7 +5,7 @@ import type { IRouteSecurity } from '../../../ts_interfaces/data/route-managemen
const getDb = () => DcRouterDb.getInstance().getDb(); const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<SecurityProfileDoc, SecurityProfileDoc> { export class SourceProfileDoc extends plugins.smartdata.SmartDataDbDoc<SourceProfileDoc, SourceProfileDoc> {
@plugins.smartdata.unI() @plugins.smartdata.unI()
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public id!: string; public id!: string;
@@ -35,15 +35,11 @@ export class SecurityProfileDoc extends plugins.smartdata.SmartDataDbDoc<Securit
super(); super();
} }
public static async findById(id: string): Promise<SecurityProfileDoc | null> { public static async findById(id: string): Promise<SourceProfileDoc | null> {
return await SecurityProfileDoc.getInstance({ id }); return await SourceProfileDoc.getInstance({ id });
} }
public static async findByName(name: string): Promise<SecurityProfileDoc | null> { public static async findAll(): Promise<SourceProfileDoc[]> {
return await SecurityProfileDoc.getInstance({ name }); return await SourceProfileDoc.getInstances({});
}
public static async findAll(): Promise<SecurityProfileDoc[]> {
return await SecurityProfileDoc.getInstances({});
} }
} }

View File

@@ -0,0 +1,48 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { ITargetProfileTarget } from '../../../ts_interfaces/data/target-profile.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class TargetProfileDoc extends plugins.smartdata.SmartDataDbDoc<TargetProfileDoc, TargetProfileDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public name: string = '';
@plugins.smartdata.svDb()
public description?: string;
@plugins.smartdata.svDb()
public domains?: string[];
@plugins.smartdata.svDb()
public targets?: ITargetProfileTarget[];
@plugins.smartdata.svDb()
public routeRefs?: 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<TargetProfileDoc | null> {
return await TargetProfileDoc.getInstance({ id });
}
public static async findAll(): Promise<TargetProfileDoc[]> {
return await TargetProfileDoc.getInstances({});
}
}

View File

@@ -13,7 +13,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
public enabled!: boolean; public enabled!: boolean;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public serverDefinedClientTags?: string[]; public targetProfileIds?: string[];
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public description?: string; public description?: string;
@@ -39,9 +39,6 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public expiresAt?: string; public expiresAt?: string;
@plugins.smartdata.svDb()
public forceDestinationSmartproxy: boolean = true;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public destinationAllowList?: string[]; public destinationAllowList?: string[];
@@ -67,15 +64,7 @@ export class VpnClientDoc extends plugins.smartdata.SmartDataDbDoc<VpnClientDoc,
super(); super();
} }
public static async findByClientId(clientId: string): Promise<VpnClientDoc | null> {
return await VpnClientDoc.getInstance({ clientId });
}
public static async findAll(): Promise<VpnClientDoc[]> { public static async findAll(): Promise<VpnClientDoc[]> {
return await VpnClientDoc.getInstances({}); return await VpnClientDoc.getInstances({});
} }
public static async findEnabled(): Promise<VpnClientDoc[]> {
return await VpnClientDoc.getInstances({ enabled: true });
}
} }

View File

@@ -6,7 +6,8 @@ export * from './classes.cached.ip.reputation.js';
export * from './classes.stored-route.doc.js'; export * from './classes.stored-route.doc.js';
export * from './classes.route-override.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.security-profile.doc.js'; export * from './classes.source-profile.doc.js';
export * from './classes.target-profile.doc.js';
export * from './classes.network-target.doc.js'; export * from './classes.network-target.doc.js';
// VPN document classes // VPN document classes
@@ -24,3 +25,11 @@ 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';

2
ts/dns/index.ts Normal file
View File

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

880
ts/dns/manager.dns.ts Normal file
View File

@@ -0,0 +1,880 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import {
DnsProviderDoc,
DomainDoc,
DnsRecordDoc,
} from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IDnsProviderClient, IProviderRecord } from './providers/interfaces.js';
import { createDnsProvider } from './providers/factory.js';
import type {
TDnsRecordType,
TDnsRecordSource,
} from '../../ts_interfaces/data/dns-record.js';
import type {
TDnsProviderType,
TDnsProviderCredentials,
IDnsProviderPublic,
IProviderDomainListing,
} from '../../ts_interfaces/data/dns-provider.js';
/**
* DnsManager — owns runtime DNS state on top of the embedded DnsServer.
*
* Responsibilities:
* - Load Domain/DnsRecord docs from the DB on start
* - First-boot seeding from legacy constructor config (dnsScopes/dnsRecords/dnsNsDomains)
* - Register dcrouter-hosted domain records with smartdns.DnsServer at startup
* - Provide CRUD methods used by OpsServer handlers (dcrouter-hosted domains hit
* smartdns, provider domains hit the provider API)
* - Expose a provider lookup used by the ACME DNS-01 wiring in setupSmartProxy()
*
* Provider-managed domains are NEVER served from the embedded DnsServer — the
* provider stays authoritative. We only mirror their records locally for the UI
* and to track providerRecordIds for updates / deletes.
*/
export class DnsManager {
/**
* Reference to the active smartdns DnsServer (set by DcRouter once it exists).
* May be undefined if dnsScopes/dnsNsDomains aren't configured.
*/
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
/**
* Cached provider clients, keyed by DnsProviderDoc.id.
* Created lazily when a provider is first needed.
*/
private providerClients = new Map<string, IDnsProviderClient>();
constructor(private options: IDcRouterOptions) {}
// ==========================================================================
// Lifecycle
// ==========================================================================
/**
* Called from DcRouter after DcRouterDb is up. Performs first-boot seeding
* from legacy constructor config if (and only if) the DB is empty.
*/
public async start(): Promise<void> {
logger.log('info', 'DnsManager: starting');
await this.seedFromConstructorConfigIfEmpty();
}
public async stop(): Promise<void> {
this.providerClients.clear();
this.dnsServer = undefined;
}
/**
* Wire the embedded DnsServer instance after it has been created by
* DcRouter.setupDnsWithSocketHandler(). After this, local records on
* dcrouter-hosted domains loaded from the DB are registered with the server.
*/
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
this.dnsServer = dnsServer;
await this.applyDcrouterDomainsToDnsServer();
}
// ==========================================================================
// First-boot seeding
// ==========================================================================
/**
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
* seed them as dcrouter-hosted (`domain.source: 'dcrouter'`) zones with
* local (`record.source: 'local'`) records. On subsequent boots (DB has
* entries), constructor config is ignored with a warning.
*/
private async seedFromConstructorConfigIfEmpty(): Promise<void> {
const existingDomains = await DomainDoc.findAll();
const hasLegacyConfig =
(this.options.dnsScopes && this.options.dnsScopes.length > 0) ||
(this.options.dnsRecords && this.options.dnsRecords.length > 0);
if (existingDomains.length > 0) {
if (hasLegacyConfig) {
logger.log(
'warn',
'DnsManager: DB has DomainDoc entries — ignoring legacy dnsScopes/dnsRecords/dnsNsDomains constructor config. ' +
'Manage DNS via the Domains UI instead.',
);
}
return;
}
if (!hasLegacyConfig) {
return;
}
logger.log('info', 'DnsManager: seeding DB from legacy constructor DNS config');
const now = Date.now();
const seededDomains = new Map<string, DomainDoc>();
// Create one DomainDoc per dnsScope (these are the authoritative zones)
for (const scope of this.options.dnsScopes ?? []) {
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = scope.toLowerCase();
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'seed';
await domain.save();
seededDomains.set(domain.name, domain);
logger.log('info', `DnsManager: seeded DomainDoc for ${domain.name}`);
}
// Map each legacy dnsRecord to its parent DomainDoc
for (const rec of this.options.dnsRecords ?? []) {
const parent = this.findParentDomain(rec.name, seededDomains);
if (!parent) {
logger.log(
'warn',
`DnsManager: legacy dnsRecord '${rec.name}' has no matching dnsScope — skipping seed`,
);
continue;
}
const record = new DnsRecordDoc();
record.id = plugins.uuid.v4();
record.domainId = parent.id;
record.name = rec.name.toLowerCase();
record.type = rec.type as TDnsRecordType;
record.value = rec.value;
record.ttl = rec.ttl ?? 300;
record.source = 'local';
record.createdAt = now;
record.updatedAt = now;
record.createdBy = 'seed';
await record.save();
}
logger.log(
'info',
`DnsManager: seeded ${seededDomains.size} domain(s) and ${this.options.dnsRecords?.length ?? 0} record(s) from legacy config`,
);
}
private findParentDomain(
recordName: string,
domains: Map<string, DomainDoc>,
): DomainDoc | null {
const lower = recordName.toLowerCase().replace(/^\*\./, '');
let candidate: DomainDoc | null = null;
for (const [name, doc] of domains) {
if (lower === name || lower.endsWith(`.${name}`)) {
if (!candidate || name.length > candidate.name.length) {
candidate = doc;
}
}
}
return candidate;
}
// ==========================================================================
// DcRouter-hosted domain DnsServer wiring
// ==========================================================================
/**
* Register all records from dcrouter-hosted domains in the DB with the
* embedded DnsServer. Called once after attachDnsServer().
*/
private async applyDcrouterDomainsToDnsServer(): Promise<void> {
if (!this.dnsServer) {
return;
}
const allDomains = await DomainDoc.findAll();
const dcrouterDomains = allDomains.filter((d) => d.source === 'dcrouter');
let registered = 0;
for (const domain of dcrouterDomains) {
const records = await DnsRecordDoc.findByDomainId(domain.id);
for (const rec of records) {
this.registerRecordWithDnsServer(rec);
registered++;
}
}
logger.log(
'info',
`DnsManager: registered ${registered} dcrouter-hosted DNS record(s) from DB`,
);
}
/**
* Register a single record with the embedded DnsServer. The handler closure
* captures the record fields, so updates require a re-register cycle.
*/
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
if (question.name === rec.name && question.type === rec.type) {
return {
name: rec.name,
type: rec.type,
class: 'IN',
ttl: rec.ttl,
data: this.parseRecordData(rec.type, rec.value),
};
}
return null;
});
}
private parseRecordData(type: TDnsRecordType, value: string): any {
switch (type) {
case 'A':
case 'AAAA':
case 'CNAME':
case 'TXT':
case 'NS':
case 'CAA':
return value;
case 'MX': {
const [priorityStr, exchange] = value.split(' ');
return { priority: parseInt(priorityStr, 10), exchange };
}
case 'SOA': {
const parts = value.split(' ');
return {
mname: parts[0],
rname: parts[1],
serial: parseInt(parts[2], 10),
refresh: parseInt(parts[3], 10),
retry: parseInt(parts[4], 10),
expire: parseInt(parts[5], 10),
minimum: parseInt(parts[6], 10),
};
}
default:
return value;
}
}
// ==========================================================================
// Provider lookup (used by ACME DNS-01 + record CRUD)
// ==========================================================================
/**
* Get the provider client for a given DnsProviderDoc id, instantiating
* (and caching) it on first use.
*/
public async getProviderClientById(providerId: string): Promise<IDnsProviderClient | null> {
const cached = this.providerClients.get(providerId);
if (cached) return cached;
const doc = await DnsProviderDoc.findById(providerId);
if (!doc) return null;
const client = createDnsProvider(doc.type, doc.credentials);
this.providerClients.set(providerId, client);
return client;
}
/**
* Find the IDnsProviderClient that owns the given FQDN (by walking up its
* labels to find a matching DomainDoc with `source === 'provider'`).
* Returns null if no provider claims this FQDN.
*
* Used by:
* - SmartAcme DNS-01 wiring in setupSmartProxy()
* - DnsRecordHandler when creating provider records
*/
public async getProviderClientForDomain(fqdn: string): Promise<IDnsProviderClient | null> {
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
const allDomains = await DomainDoc.findAll();
const providerDomains = allDomains
.filter((d) => d.source === 'provider' && d.providerId)
// longest-match wins
.sort((a, b) => b.name.length - a.name.length);
for (const domain of providerDomains) {
if (lower === domain.name || lower.endsWith(`.${domain.name}`)) {
return this.getProviderClientById(domain.providerId!);
}
}
return null;
}
/**
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
* to decide whether to wire SmartAcme with a DNS-01 handler.
*/
public async hasAcmeCapableProvider(): Promise<boolean> {
const providers = await DnsProviderDoc.findAll();
return providers.length > 0;
}
/**
* Build an IConvenientDnsProvider that dispatches each ACME challenge to
* the right provider client (whichever provider type owns the parent zone),
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
* interface, so any registered provider implementation works.
* Returned object plugs directly into smartacme's Dns01Handler.
*/
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {
const self = this;
const adapter = {
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
throw new Error(
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
'Add one in the Domains > Providers UI before issuing certificates.',
);
}
// Clean any leftover challenge records first to avoid duplicates.
try {
const existing = await client.listRecords(dnsChallenge.hostName);
for (const r of existing) {
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId).catch(() => {});
}
}
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
await client.createRecord(dnsChallenge.hostName, {
name: dnsChallenge.hostName,
type: 'TXT',
value: dnsChallenge.challenge,
ttl: 120,
});
},
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
// The domain may have been removed; nothing to clean up.
return;
}
try {
const existing = await client.listRecords(dnsChallenge.hostName);
for (const r of existing) {
if (r.type === 'TXT' && r.name === dnsChallenge.hostName) {
await client.deleteRecord(dnsChallenge.hostName, r.providerRecordId);
}
}
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
},
async isDomainSupported(domain: string): Promise<boolean> {
const client = await self.getProviderClientForDomain(domain);
return !!client;
},
};
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
}
// ==========================================================================
// Provider CRUD (used by DnsProviderHandler)
// ==========================================================================
public async listProviders(): Promise<IDnsProviderPublic[]> {
const docs = await DnsProviderDoc.findAll();
return docs.map((d) => this.toPublicProvider(d));
}
public async getProvider(id: string): Promise<IDnsProviderPublic | null> {
const doc = await DnsProviderDoc.findById(id);
return doc ? this.toPublicProvider(doc) : null;
}
public async createProvider(args: {
name: string;
type: TDnsProviderType;
credentials: TDnsProviderCredentials;
createdBy: string;
}): Promise<string> {
if (args.type === 'dcrouter') {
throw new Error(
'createProvider: cannot create a DnsProviderDoc with type "dcrouter" — ' +
'that type is reserved for the built-in pseudo-provider surfaced at read time.',
);
}
const now = Date.now();
const doc = new DnsProviderDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name;
doc.type = args.type;
doc.credentials = args.credentials;
doc.status = 'untested';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
await doc.save();
return doc.id;
}
public async updateProvider(
id: string,
args: { name?: string; credentials?: TDnsProviderCredentials },
): Promise<boolean> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) return false;
if (args.name !== undefined) doc.name = args.name;
if (args.credentials !== undefined) {
doc.credentials = args.credentials;
doc.status = 'untested';
doc.lastError = undefined;
// Invalidate cached client so the next use re-instantiates with the new credentials.
this.providerClients.delete(id);
}
doc.updatedAt = Date.now();
await doc.save();
return true;
}
public async deleteProvider(id: string, force: boolean): Promise<{ success: boolean; message?: string }> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) return { success: false, message: 'Provider not found' };
const linkedDomains = await DomainDoc.findByProviderId(id);
if (linkedDomains.length > 0 && !force) {
return {
success: false,
message: `Provider is referenced by ${linkedDomains.length} domain(s). Pass force: true to delete anyway.`,
};
}
// If forcing, also delete the linked domains and their records.
if (force) {
for (const domain of linkedDomains) {
await this.deleteDomain(domain.id);
}
}
await doc.delete();
this.providerClients.delete(id);
return { success: true };
}
public async testProvider(id: string): Promise<{ ok: boolean; error?: string; testedAt: number }> {
const doc = await DnsProviderDoc.findById(id);
if (!doc) {
return { ok: false, error: 'Provider not found', testedAt: Date.now() };
}
const client = createDnsProvider(doc.type, doc.credentials);
const result = await client.testConnection();
doc.status = result.ok ? 'ok' : 'error';
doc.lastTestedAt = Date.now();
doc.lastError = result.ok ? undefined : result.error;
await doc.save();
if (result.ok) {
this.providerClients.set(id, client); // cache the working client
}
return { ok: result.ok, error: result.error, testedAt: doc.lastTestedAt };
}
public async listProviderDomains(providerId: string): Promise<IProviderDomainListing[]> {
const client = await this.getProviderClientById(providerId);
if (!client) {
throw new Error('Provider not found');
}
return await client.listDomains();
}
// ==========================================================================
// Domain CRUD (used by DomainHandler)
// ==========================================================================
public async listDomains(): Promise<DomainDoc[]> {
return await DomainDoc.findAll();
}
public async getDomain(id: string): Promise<DomainDoc | null> {
return await DomainDoc.findById(id);
}
/**
* Create a dcrouter-hosted (authoritative) domain. dcrouter will serve
* DNS records for this domain via the embedded smartdns.DnsServer.
*/
public async createDcrouterDomain(args: {
name: string;
description?: string;
createdBy: string;
}): Promise<string> {
const now = Date.now();
const doc = new DomainDoc();
doc.id = plugins.uuid.v4();
doc.name = args.name.toLowerCase();
doc.source = 'dcrouter';
doc.authoritative = true;
doc.description = args.description;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
await doc.save();
return doc.id;
}
/**
* Import one or more domains from a provider, pulling all of their DNS
* records into local DnsRecordDocs.
*/
public async importDomainsFromProvider(args: {
providerId: string;
domainNames: string[];
createdBy: string;
}): Promise<string[]> {
const provider = await DnsProviderDoc.findById(args.providerId);
if (!provider) {
throw new Error('Provider not found');
}
const client = await this.getProviderClientById(args.providerId);
if (!client) {
throw new Error('Failed to instantiate provider client');
}
const allProviderDomains = await client.listDomains();
const importedIds: string[] = [];
const now = Date.now();
for (const wantedName of args.domainNames) {
const lower = wantedName.toLowerCase();
const listing = allProviderDomains.find((d) => d.name.toLowerCase() === lower);
if (!listing) {
logger.log('warn', `DnsManager: import skipped — provider does not list domain ${wantedName}`);
continue;
}
// Skip if already imported
const existing = await DomainDoc.findByName(lower);
if (existing) {
logger.log('warn', `DnsManager: domain ${wantedName} already imported — skipping`);
continue;
}
const domain = new DomainDoc();
domain.id = plugins.uuid.v4();
domain.name = lower;
domain.source = 'provider';
domain.providerId = args.providerId;
domain.authoritative = false;
domain.nameservers = listing.nameservers;
domain.externalZoneId = listing.externalId;
domain.lastSyncedAt = now;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = args.createdBy;
await domain.save();
importedIds.push(domain.id);
// Pull records for the imported domain
try {
const providerRecords = await client.listRecords(lower);
for (const pr of providerRecords) {
await this.createSyncedRecord(domain.id, pr, args.createdBy);
}
logger.log('info', `DnsManager: imported ${providerRecords.length} record(s) for ${lower}`);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to import records for ${lower}: ${(err as Error).message}`);
}
}
return importedIds;
}
public async updateDomain(id: string, args: { description?: string }): Promise<boolean> {
const doc = await DomainDoc.findById(id);
if (!doc) return false;
if (args.description !== undefined) doc.description = args.description;
doc.updatedAt = Date.now();
await doc.save();
return true;
}
/**
* Delete a domain and all of its DNS records. For provider domains, only
* removes the local mirror — does NOT touch the provider.
* For dcrouter-hosted domains, also unregisters records from the embedded
* DnsServer.
*
* Note: smartdns has no public unregister-by-name API in the version pinned
* here, so local record deletes only take effect after a restart. The DB
* is the source of truth and the next start will not register the deleted
* record.
*/
public async deleteDomain(id: string): Promise<boolean> {
const doc = await DomainDoc.findById(id);
if (!doc) return false;
const records = await DnsRecordDoc.findByDomainId(id);
for (const r of records) {
await r.delete();
}
await doc.delete();
return true;
}
/**
* Force-resync a provider-managed domain: re-pull all records from the
* provider API, replacing the cached DnsRecordDocs.
*/
public async syncDomain(id: string): Promise<{ success: boolean; recordCount?: number; message?: string }> {
const doc = await DomainDoc.findById(id);
if (!doc) return { success: false, message: 'Domain not found' };
if (doc.source !== 'provider' || !doc.providerId) {
return { success: false, message: 'Domain is not provider-managed' };
}
const client = await this.getProviderClientById(doc.providerId);
if (!client) {
return { success: false, message: 'Provider client unavailable' };
}
const providerRecords = await client.listRecords(doc.name);
// Drop existing records and replace
const existing = await DnsRecordDoc.findByDomainId(id);
for (const r of existing) {
await r.delete();
}
for (const pr of providerRecords) {
await this.createSyncedRecord(id, pr, doc.createdBy);
}
doc.lastSyncedAt = Date.now();
doc.updatedAt = doc.lastSyncedAt;
await doc.save();
return { success: true, recordCount: providerRecords.length };
}
// ==========================================================================
// Record CRUD (used by DnsRecordHandler)
// ==========================================================================
public async listRecordsForDomain(domainId: string): Promise<DnsRecordDoc[]> {
return await DnsRecordDoc.findByDomainId(domainId);
}
public async getRecord(id: string): Promise<DnsRecordDoc | null> {
return await DnsRecordDoc.findById(id);
}
public async createRecord(args: {
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
createdBy: string;
}): Promise<{ success: boolean; id?: string; message?: string }> {
const domain = await DomainDoc.findById(args.domainId);
if (!domain) return { success: false, message: 'Domain not found' };
const now = Date.now();
const doc = new DnsRecordDoc();
doc.id = plugins.uuid.v4();
doc.domainId = args.domainId;
doc.name = args.name.toLowerCase();
doc.type = args.type;
doc.value = args.value;
doc.ttl = args.ttl ?? 300;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.source = 'local';
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = args.createdBy;
if (domain.source === 'provider') {
// Push to provider first; only persist locally on success
if (!domain.providerId) {
return { success: false, message: 'Provider domain has no providerId' };
}
const client = await this.getProviderClientById(domain.providerId);
if (!client) return { success: false, message: 'Provider client unavailable' };
try {
const created = await client.createRecord(domain.name, {
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
});
doc.providerRecordId = created.providerRecordId;
doc.source = 'synced';
} catch (err: unknown) {
return { success: false, message: `Provider rejected record: ${(err as Error).message}` };
}
} else {
// dcrouter-hosted / authoritative — register with embedded DnsServer immediately
this.registerRecordWithDnsServer(doc);
}
await doc.save();
return { success: true, id: doc.id };
}
public async updateRecord(args: {
id: string;
name?: string;
value?: string;
ttl?: number;
proxied?: boolean;
}): Promise<{ success: boolean; message?: string }> {
const doc = await DnsRecordDoc.findById(args.id);
if (!doc) return { success: false, message: 'Record not found' };
const domain = await DomainDoc.findById(doc.domainId);
if (!domain) return { success: false, message: 'Parent domain not found' };
if (args.name !== undefined) doc.name = args.name.toLowerCase();
if (args.value !== undefined) doc.value = args.value;
if (args.ttl !== undefined) doc.ttl = args.ttl;
if (args.proxied !== undefined) doc.proxied = args.proxied;
doc.updatedAt = Date.now();
if (domain.source === 'provider') {
if (!domain.providerId || !doc.providerRecordId) {
return { success: false, message: 'Provider record metadata missing' };
}
const client = await this.getProviderClientById(domain.providerId);
if (!client) return { success: false, message: 'Provider client unavailable' };
try {
await client.updateRecord(domain.name, doc.providerRecordId, {
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
});
} catch (err: unknown) {
return { success: false, message: `Provider rejected update: ${(err as Error).message}` };
}
} else {
// Re-register the local record so the new closure picks up the updated fields
this.registerRecordWithDnsServer(doc);
}
await doc.save();
return { success: true };
}
public async deleteRecord(id: string): Promise<{ success: boolean; message?: string }> {
const doc = await DnsRecordDoc.findById(id);
if (!doc) return { success: false, message: 'Record not found' };
const domain = await DomainDoc.findById(doc.domainId);
if (!domain) return { success: false, message: 'Parent domain not found' };
if (domain.source === 'provider') {
if (domain.providerId && doc.providerRecordId) {
const client = await this.getProviderClientById(domain.providerId);
if (client) {
try {
await client.deleteRecord(domain.name, doc.providerRecordId);
} catch (err: unknown) {
return { success: false, message: `Provider rejected delete: ${(err as Error).message}` };
}
}
}
}
// For local records: smartdns has no unregister API in the pinned version,
// so the record stays served until the next restart. The DB delete still
// takes effect — on restart, the record will not be re-registered.
await doc.delete();
return { success: true };
}
// ==========================================================================
// Internal helpers
// ==========================================================================
private async createSyncedRecord(
domainId: string,
pr: IProviderRecord,
createdBy: string,
): Promise<void> {
const now = Date.now();
const doc = new DnsRecordDoc();
doc.id = plugins.uuid.v4();
doc.domainId = domainId;
doc.name = pr.name.toLowerCase();
doc.type = pr.type;
doc.value = pr.value;
doc.ttl = pr.ttl;
if (pr.proxied !== undefined) doc.proxied = pr.proxied;
doc.source = 'synced';
doc.providerRecordId = pr.providerRecordId;
doc.createdAt = now;
doc.updatedAt = now;
doc.createdBy = createdBy;
await doc.save();
}
/**
* Convert a DnsProviderDoc to its public, secret-stripped representation
* for the OpsServer API.
*/
public toPublicProvider(doc: DnsProviderDoc): IDnsProviderPublic {
return {
id: doc.id,
name: doc.name,
type: doc.type,
status: doc.status,
lastTestedAt: doc.lastTestedAt,
lastError: doc.lastError,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
hasCredentials: !!doc.credentials,
};
}
/**
* Convert a DomainDoc to its plain interface representation.
*/
public toPublicDomain(doc: DomainDoc): {
id: string;
name: string;
source: 'dcrouter' | 'provider';
providerId?: string;
authoritative: boolean;
nameservers?: string[];
externalZoneId?: string;
lastSyncedAt?: number;
description?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
} {
return {
id: doc.id,
name: doc.name,
source: doc.source,
providerId: doc.providerId,
authoritative: doc.authoritative,
nameservers: doc.nameservers,
externalZoneId: doc.externalZoneId,
lastSyncedAt: doc.lastSyncedAt,
description: doc.description,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
/**
* Convert a DnsRecordDoc to its plain interface representation.
*/
public toPublicRecord(doc: DnsRecordDoc): {
id: string;
domainId: string;
name: string;
type: TDnsRecordType;
value: string;
ttl: number;
proxied?: boolean;
source: TDnsRecordSource;
providerRecordId?: string;
createdAt: number;
updatedAt: number;
createdBy: string;
} {
return {
id: doc.id,
domainId: doc.domainId,
name: doc.name,
type: doc.type,
value: doc.value,
ttl: doc.ttl,
proxied: doc.proxied,
source: doc.source,
providerRecordId: doc.providerRecordId,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
createdBy: doc.createdBy,
};
}
}

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

@@ -29,8 +29,14 @@ export class OpsServer {
private routeManagementHandler!: handlers.RouteManagementHandler; private routeManagementHandler!: handlers.RouteManagementHandler;
private apiTokenHandler!: handlers.ApiTokenHandler; private apiTokenHandler!: handlers.ApiTokenHandler;
private vpnHandler!: handlers.VpnHandler; private vpnHandler!: handlers.VpnHandler;
private securityProfileHandler!: handlers.SecurityProfileHandler; private sourceProfileHandler!: handlers.SourceProfileHandler;
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;
constructor(dcRouterRefArg: DcRouter) { constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg; this.dcRouterRef = dcRouterRefArg;
@@ -90,8 +96,14 @@ export class OpsServer {
this.routeManagementHandler = new handlers.RouteManagementHandler(this); this.routeManagementHandler = new handlers.RouteManagementHandler(this);
this.apiTokenHandler = new handlers.ApiTokenHandler(this); this.apiTokenHandler = new handlers.ApiTokenHandler(this);
this.vpnHandler = new handlers.VpnHandler(this); this.vpnHandler = new handlers.VpnHandler(this);
this.securityProfileHandler = new handlers.SecurityProfileHandler(this); this.sourceProfileHandler = new handlers.SourceProfileHandler(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);
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

@@ -52,6 +52,18 @@ export class AdminHandler {
role: 'admin', role: 'admin',
}); });
} }
/**
* Return a safe projection of the users Map — excludes password fields.
* Used by UsersHandler to serve the admin-only listUsers endpoint.
*/
public listUsers(): Array<{ id: string; username: string; role: string }> {
return Array.from(this.users.values()).map((user) => ({
id: user.id,
username: user.username,
role: user.role,
}));
}
private registerHandlers(): void { private registerHandlers(): void {
// Admin Login Handler // Admin Login Handler

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) {
@@ -191,7 +213,11 @@ export class CertificateHandler {
// Check persisted cert data from smartdata document classes // Check persisted cert data from smartdata document classes
if (status === 'unknown') { if (status === 'unknown') {
const cleanDomain = domain.replace(/^\*\.?/, ''); const cleanDomain = domain.replace(/^\*\.?/, '');
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain); // SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null; const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
if (acmeDoc?.validUntil) { if (acmeDoc?.validUntil) {
@@ -291,7 +317,12 @@ export class CertificateHandler {
} }
/** /**
* Legacy route-based reprovisioning * Legacy route-based reprovisioning. Kept for backward compatibility with
* older clients that send `reprovisionCertificate` typed-requests.
*
* Like reprovisionCertificateDomain, this triggers the full route apply
* pipeline rather than smartProxy.provisionCertificate(routeName) — which
* is a no-op when certProvisionFunction is set (Rust ACME disabled).
*/ */
private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> { private async reprovisionCertificateByRoute(routeName: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
@@ -301,13 +332,19 @@ export class CertificateHandler {
return { success: false, message: 'SmartProxy is not running' }; return { success: false, message: 'SmartProxy is not running' };
} }
// Clear event-based status for domains in this route so the
// certificate-issued event can refresh them
for (const [domain, entry] of dcRouter.certificateStatusMap) {
if (entry.routeNames.includes(routeName)) {
dcRouter.certificateStatusMap.delete(domain);
}
}
try { try {
await smartProxy.provisionCertificate(routeName); if (dcRouter.routeConfigManager) {
// Clear event-based status for domains in this route await dcRouter.routeConfigManager.applyRoutes();
for (const [domain, entry] of dcRouter.certificateStatusMap) { } else {
if (entry.routeNames.includes(routeName)) { await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
dcRouter.certificateStatusMap.delete(domain);
}
} }
return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` }; return { success: true, message: `Certificate reprovisioning triggered for route '${routeName}'` };
} catch (err: unknown) { } catch (err: unknown) {
@@ -316,7 +353,16 @@ export class CertificateHandler {
} }
/** /**
* Domain-based reprovisioning — clears backoff first, then triggers provision * Domain-based reprovisioning — clears backoff first, refreshes the smartacme
* cert (when forceRenew is set), then re-applies routes so the running Rust
* proxy actually picks up the new cert.
*
* Why applyRoutes (not smartProxy.provisionCertificate)?
* smartProxy.provisionCertificate(routeName) routes through the Rust ACME
* path, which is forcibly disabled whenever certProvisionFunction is set
* (smart-proxy.ts:168-171). The only path that re-invokes
* certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
* we trigger via routeConfigManager.applyRoutes().
*/ */
private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> { private async reprovisionCertificateDomain(domain: string, forceRenew?: boolean): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
@@ -331,31 +377,143 @@ export class CertificateHandler {
await dcRouter.certProvisionScheduler.clearBackoff(domain); await dcRouter.certProvisionScheduler.clearBackoff(domain);
} }
// Clear status map entry so it gets refreshed // Find routes matching this domain — fail early if none exist
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length === 0) {
return { success: false, message: `No routes found for domain '${domain}'` };
}
// If forceRenew, order a fresh cert from ACME now so it's already in
// 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) {
let newCert: plugins.smartacme.Cert;
try {
newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
forceRenew: true,
includeWildcard: !domain.startsWith('*.'),
});
} catch (err: unknown) {
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
dcRouter.certificateStatusMap.delete(domain); dcRouter.certificateStatusMap.delete(domain);
// Try to provision via SmartAcme directly // Trigger the full route apply pipeline:
if (dcRouter.smartAcme) { // applyRoutes → updateRoutes → provisionCertificatesViaCallback →
try { // certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false }); // bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` }; // certificate-issued event → certificateStatusMap updated
} catch (err: unknown) { try {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` }; if (dcRouter.routeConfigManager) {
await dcRouter.routeConfigManager.applyRoutes();
} else {
// Fallback when DB is disabled and there is no RouteConfigManager
await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
}
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
} catch (err: unknown) {
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
}
}
/**
* 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);
}
} }
} }
// Fallback: try provisioning via the first matching route if (affected.size === 0) return;
const routeNames = dcRouter.findRouteNamesForDomain(domain);
if (routeNames.length > 0) { // 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 { try {
await smartProxy.provisionCertificate(routeNames[0]); const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` }; validUntil = new Date(x509.validTo).getTime();
} catch (err: unknown) { validFrom = new Date(x509.validFrom).getTime();
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` }; } catch { /* fall back to smartacme's value */ }
}
} }
return { success: false, message: `No routes found for domain '${domain}'` }; // 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(', ')}`,
);
} }
/** /**
@@ -364,9 +522,12 @@ export class CertificateHandler {
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> { private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
const dcRouter = this.opsServerRef.dcRouterRef; const dcRouter = this.opsServerRef.dcRouterRef;
const cleanDomain = domain.replace(/^\*\.?/, ''); const cleanDomain = domain.replace(/^\*\.?/, '');
const parts = cleanDomain.split('.');
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
// Delete from smartdata document classes // Delete from smartdata document classes (try base domain first, then exact)
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain); const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
if (acmeDoc) { if (acmeDoc) {
await acmeDoc.delete(); await acmeDoc.delete();
} }

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?.hasAcmeCapableProvider()) ?? 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,161 @@
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);
},
),
);
}
}

View File

@@ -10,5 +10,11 @@ export * from './remoteingress.handler.js';
export * from './route-management.handler.js'; export * from './route-management.handler.js';
export * from './api-token.handler.js'; export * from './api-token.handler.js';
export * from './vpn.handler.js'; export * from './vpn.handler.js';
export * from './security-profile.handler.js'; export * from './source-profile.handler.js';
export * from './network-target.handler.js'; export * from './target-profile.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';

View File

@@ -2,7 +2,7 @@ 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';
export class SecurityProfileHandler { export class SourceProfileHandler {
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) { constructor(private opsServerRef: OpsServer) {
@@ -40,12 +40,12 @@ export class SecurityProfileHandler {
} }
private registerHandlers(): void { private registerHandlers(): void {
// Get all security profiles // Get all source profiles
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfiles>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfiles>(
'getSecurityProfiles', 'getSourceProfiles',
async (dataArg) => { async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read'); await this.requireAuth(dataArg, 'source-profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) { if (!resolver) {
return { profiles: [] }; return { profiles: [] };
@@ -55,12 +55,12 @@ export class SecurityProfileHandler {
), ),
); );
// Get a single security profile // Get a single source profile
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfile>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfile>(
'getSecurityProfile', 'getSourceProfile',
async (dataArg) => { async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read'); await this.requireAuth(dataArg, 'source-profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) { if (!resolver) {
return { profile: null }; return { profile: null };
@@ -70,12 +70,12 @@ export class SecurityProfileHandler {
), ),
); );
// Create a security profile // Create a source profile
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSecurityProfile>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateSourceProfile>(
'createSecurityProfile', 'createSourceProfile',
async (dataArg) => { async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'profiles:write'); const userId = await this.requireAuth(dataArg, 'source-profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
if (!resolver) { if (!resolver) {
return { success: false, message: 'Reference resolver not initialized' }; return { success: false, message: 'Reference resolver not initialized' };
@@ -92,12 +92,12 @@ export class SecurityProfileHandler {
), ),
); );
// Update a security profile // Update a source profile
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSecurityProfile>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateSourceProfile>(
'updateSecurityProfile', 'updateSourceProfile',
async (dataArg) => { async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write'); await this.requireAuth(dataArg, 'source-profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager; const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) { if (!resolver || !manager) {
@@ -121,12 +121,12 @@ export class SecurityProfileHandler {
), ),
); );
// Delete a security profile // Delete a source profile
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSecurityProfile>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteSourceProfile>(
'deleteSecurityProfile', 'deleteSourceProfile',
async (dataArg) => { async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:write'); await this.requireAuth(dataArg, 'source-profiles:write');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager; const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) { if (!resolver || !manager) {
@@ -149,12 +149,12 @@ export class SecurityProfileHandler {
), ),
); );
// Get routes using a security profile // Get routes using a source profile
this.typedrouter.addTypedHandler( this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSecurityProfileUsage>( new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetSourceProfileUsage>(
'getSecurityProfileUsage', 'getSourceProfileUsage',
async (dataArg) => { async (dataArg) => {
await this.requireAuth(dataArg, 'profiles:read'); await this.requireAuth(dataArg, 'source-profiles:read');
const resolver = this.opsServerRef.dcRouterRef.referenceResolver; const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const manager = this.opsServerRef.dcRouterRef.routeConfigManager; const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!resolver || !manager) { if (!resolver || !manager) {

View File

@@ -0,0 +1,157 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class TargetProfileHandler {
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 target profiles
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfiles>(
'getTargetProfiles',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:read');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { profiles: [] };
}
return { profiles: manager.listProfiles() };
},
),
);
// Get a single target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfile>(
'getTargetProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:read');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { profile: null };
}
return { profile: manager.getProfile(dataArg.id) || null };
},
),
);
// Create a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateTargetProfile>(
'createTargetProfile',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'target-profiles:write');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { success: false, message: 'Target profile manager not initialized' };
}
const id = await manager.createProfile({
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
createdBy: userId,
});
return { success: true, id };
},
),
);
// Update a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateTargetProfile>(
'updateTargetProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:write');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { success: false, message: 'Not initialized' };
}
await manager.updateProfile(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
return { success: true };
},
),
);
// Delete a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteTargetProfile>(
'deleteTargetProfile',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:write');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { success: false, message: 'Not initialized' };
}
const result = await manager.deleteProfile(dataArg.id, dataArg.force);
if (result.success) {
// Re-apply routes and refresh VPN client security to update access
await this.opsServerRef.dcRouterRef.routeConfigManager?.applyRoutes();
await this.opsServerRef.dcRouterRef.vpnManager?.refreshAllClientSecurity();
}
return result;
},
),
);
// Get VPN clients using a target profile
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetTargetProfileUsage>(
'getTargetProfileUsage',
async (dataArg) => {
await this.requireAuth(dataArg, 'target-profiles:read');
const manager = this.opsServerRef.dcRouterRef.targetProfileManager;
if (!manager) {
return { clients: [] };
}
return { clients: await manager.getProfileUsage(dataArg.id) };
},
),
);
}
}

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

@@ -25,13 +25,12 @@ export class VpnHandler {
const clients = manager.listClients().map((c) => ({ const clients = manager.listClients().map((c) => ({
clientId: c.clientId, clientId: c.clientId,
enabled: c.enabled, enabled: c.enabled,
serverDefinedClientTags: c.serverDefinedClientTags, targetProfileIds: c.targetProfileIds,
description: c.description, description: c.description,
assignedIp: c.assignedIp, assignedIp: c.assignedIp,
createdAt: c.createdAt, createdAt: c.createdAt,
updatedAt: c.updatedAt, updatedAt: c.updatedAt,
expiresAt: c.expiresAt, expiresAt: c.expiresAt,
forceDestinationSmartproxy: c.forceDestinationSmartproxy ?? true,
destinationAllowList: c.destinationAllowList, destinationAllowList: c.destinationAllowList,
destinationBlockList: c.destinationBlockList, destinationBlockList: c.destinationBlockList,
useHostIp: c.useHostIp, useHostIp: c.useHostIp,
@@ -120,9 +119,8 @@ export class VpnHandler {
try { try {
const bundle = await manager.createClient({ const bundle = await manager.createClient({
clientId: dataArg.clientId, clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags, targetProfileIds: dataArg.targetProfileIds,
description: dataArg.description, description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList, destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList, destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp, useHostIp: dataArg.useHostIp,
@@ -142,13 +140,12 @@ export class VpnHandler {
client: { client: {
clientId: bundle.entry.clientId, clientId: bundle.entry.clientId,
enabled: bundle.entry.enabled ?? true, enabled: bundle.entry.enabled ?? true,
serverDefinedClientTags: bundle.entry.serverDefinedClientTags, targetProfileIds: persistedClient?.targetProfileIds,
description: bundle.entry.description, description: bundle.entry.description,
assignedIp: bundle.entry.assignedIp, assignedIp: bundle.entry.assignedIp,
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
expiresAt: bundle.entry.expiresAt, expiresAt: bundle.entry.expiresAt,
forceDestinationSmartproxy: persistedClient?.forceDestinationSmartproxy ?? true,
destinationAllowList: persistedClient?.destinationAllowList, destinationAllowList: persistedClient?.destinationAllowList,
destinationBlockList: persistedClient?.destinationBlockList, destinationBlockList: persistedClient?.destinationBlockList,
useHostIp: persistedClient?.useHostIp, useHostIp: persistedClient?.useHostIp,
@@ -179,8 +176,7 @@ export class VpnHandler {
try { try {
await manager.updateClient(dataArg.clientId, { await manager.updateClient(dataArg.clientId, {
description: dataArg.description, description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags, targetProfileIds: dataArg.targetProfileIds,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList, destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList, destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp, useHostIp: dataArg.useHostIp,

View File

@@ -1,3 +1,3 @@
{ {
"order": 2 "order": 3
} }

View File

@@ -14,7 +14,7 @@ export interface IVpnManagerConfig {
/** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */ /** Pre-defined VPN clients created on startup (idempotent — skips already-persisted clients) */
initialClients?: Array<{ initialClients?: Array<{
clientId: string; clientId: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
description?: string; description?: string;
}>; }>;
/** Called when clients are created/deleted/toggled — triggers route re-application */ /** Called when clients are created/deleted/toggled — triggers route re-application */
@@ -26,10 +26,13 @@ export interface IVpnManagerConfig {
allowList?: string[]; allowList?: string[];
blockList?: string[]; blockList?: string[];
}; };
/** Compute per-client AllowedIPs based on the client's server-defined tags. /** Compute per-client AllowedIPs based on the client's target profile IDs.
* Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs. * Called at config generation time (create/export). Returns CIDRs for WireGuard AllowedIPs.
* When not set, defaults to [subnet]. */ * When not set, defaults to [subnet]. */
getClientAllowedIPs?: (clientTags: string[]) => Promise<string[]>; getClientAllowedIPs?: (targetProfileIds: string[]) => Promise<string[]>;
/** Resolve per-client destination allow-list IPs from target profile IDs.
* Returns IP strings that should bypass forceTarget and go direct to the real destination. */
getClientDirectTargets?: (targetProfileIds: string[]) => string[];
/** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN), /** Forwarding mode: 'socket' (default, userspace NAT), 'bridge' (L2 bridge to host LAN),
* or 'hybrid' (socket default, bridge for clients with useHostIp=true) */ * or 'hybrid' (socket default, bridge for clients with useHostIp=true) */
forwardingMode?: 'socket' | 'bridge' | 'hybrid'; forwardingMode?: 'socket' | 'bridge' | 'hybrid';
@@ -90,7 +93,6 @@ export class VpnManager {
publicKey: client.noisePublicKey, publicKey: client.noisePublicKey,
wgPublicKey: client.wgPublicKey, wgPublicKey: client.wgPublicKey,
enabled: client.enabled, enabled: client.enabled,
serverDefinedClientTags: client.serverDefinedClientTags,
description: client.description, description: client.description,
assignedIp: client.assignedIp, assignedIp: client.assignedIp,
expiresAt: client.expiresAt, expiresAt: client.expiresAt,
@@ -163,7 +165,7 @@ export class VpnManager {
if (!this.clients.has(initial.clientId)) { if (!this.clients.has(initial.clientId)) {
const bundle = await this.createClient({ const bundle = await this.createClient({
clientId: initial.clientId, clientId: initial.clientId,
serverDefinedClientTags: initial.serverDefinedClientTags, targetProfileIds: initial.targetProfileIds,
description: initial.description, description: initial.description,
}); });
logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`); logger.log('info', `VPN: Created initial client '${initial.clientId}' (IP: ${bundle.entry.assignedIp})`);
@@ -197,9 +199,8 @@ export class VpnManager {
*/ */
public async createClient(opts: { public async createClient(opts: {
clientId: string; clientId: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
description?: string; description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;
@@ -214,13 +215,12 @@ export class VpnManager {
const bundle = await this.vpnServer.createClient({ const bundle = await this.vpnServer.createClient({
clientId: opts.clientId, clientId: opts.clientId,
serverDefinedClientTags: opts.serverDefinedClientTags,
description: opts.description, description: opts.description,
}); });
// Override AllowedIPs with per-client values based on tag-matched routes // Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs && bundle.wireguardConfig) { if (this.config.getClientAllowedIPs && bundle.wireguardConfig) {
const allowedIPs = await this.config.getClientAllowedIPs(opts.serverDefinedClientTags || []); const allowedIPs = await this.config.getClientAllowedIPs(opts.targetProfileIds || []);
bundle.wireguardConfig = bundle.wireguardConfig.replace( bundle.wireguardConfig = bundle.wireguardConfig.replace(
/AllowedIPs\s*=\s*.+/, /AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`, `AllowedIPs = ${allowedIPs.join(', ')}`,
@@ -231,7 +231,7 @@ export class VpnManager {
const doc = new VpnClientDoc(); const doc = new VpnClientDoc();
doc.clientId = bundle.entry.clientId; doc.clientId = bundle.entry.clientId;
doc.enabled = bundle.entry.enabled ?? true; doc.enabled = bundle.entry.enabled ?? true;
doc.serverDefinedClientTags = bundle.entry.serverDefinedClientTags; doc.targetProfileIds = opts.targetProfileIds;
doc.description = bundle.entry.description; doc.description = bundle.entry.description;
doc.assignedIp = bundle.entry.assignedIp; doc.assignedIp = bundle.entry.assignedIp;
doc.noisePublicKey = bundle.entry.publicKey; doc.noisePublicKey = bundle.entry.publicKey;
@@ -241,9 +241,6 @@ export class VpnManager {
doc.createdAt = Date.now(); doc.createdAt = Date.now();
doc.updatedAt = Date.now(); doc.updatedAt = Date.now();
doc.expiresAt = bundle.entry.expiresAt; doc.expiresAt = bundle.entry.expiresAt;
if (opts.forceDestinationSmartproxy !== undefined) {
doc.forceDestinationSmartproxy = opts.forceDestinationSmartproxy;
}
if (opts.destinationAllowList !== undefined) { if (opts.destinationAllowList !== undefined) {
doc.destinationAllowList = opts.destinationAllowList; doc.destinationAllowList = opts.destinationAllowList;
} }
@@ -266,7 +263,18 @@ export class VpnManager {
doc.vlanId = opts.vlanId; doc.vlanId = opts.vlanId;
} }
this.clients.set(doc.clientId, doc); this.clients.set(doc.clientId, doc);
await this.persistClient(doc); try {
await this.persistClient(doc);
} catch (err) {
// Rollback: remove from in-memory map and daemon to stay consistent with DB
this.clients.delete(doc.clientId);
try {
await this.vpnServer!.removeClient(doc.clientId);
} catch {
// best-effort daemon cleanup
}
throw err;
}
// Sync per-client security to the running daemon // Sync per-client security to the running daemon
const security = this.buildClientSecurity(doc); const security = this.buildClientSecurity(doc);
@@ -332,12 +340,11 @@ export class VpnManager {
} }
/** /**
* Update a client's metadata (description, tags) without rotating keys. * Update a client's metadata (description, target profiles) without rotating keys.
*/ */
public async updateClient(clientId: string, update: { public async updateClient(clientId: string, update: {
description?: string; description?: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;
@@ -349,8 +356,7 @@ export class VpnManager {
const client = this.clients.get(clientId); const client = this.clients.get(clientId);
if (!client) throw new Error(`Client not found: ${clientId}`); if (!client) throw new Error(`Client not found: ${clientId}`);
if (update.description !== undefined) client.description = update.description; if (update.description !== undefined) client.description = update.description;
if (update.serverDefinedClientTags !== undefined) client.serverDefinedClientTags = update.serverDefinedClientTags; if (update.targetProfileIds !== undefined) client.targetProfileIds = update.targetProfileIds;
if (update.forceDestinationSmartproxy !== undefined) client.forceDestinationSmartproxy = update.forceDestinationSmartproxy;
if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList; if (update.destinationAllowList !== undefined) client.destinationAllowList = update.destinationAllowList;
if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList; if (update.destinationBlockList !== undefined) client.destinationBlockList = update.destinationBlockList;
if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp; if (update.useHostIp !== undefined) client.useHostIp = update.useHostIp;
@@ -409,10 +415,10 @@ export class VpnManager {
); );
} }
// Override AllowedIPs with per-client values based on tag-matched routes // Override AllowedIPs with per-client values based on target profiles
if (this.config.getClientAllowedIPs) { if (this.config.getClientAllowedIPs) {
const clientTags = persisted?.serverDefinedClientTags || []; const profileIds = persisted?.targetProfileIds || [];
const allowedIPs = await this.config.getClientAllowedIPs(clientTags); const allowedIPs = await this.config.getClientAllowedIPs(profileIds);
config = config.replace( config = config.replace(
/AllowedIPs\s*=\s*.+/, /AllowedIPs\s*=\s*.+/,
`AllowedIPs = ${allowedIPs.join(', ')}`, `AllowedIPs = ${allowedIPs.join(', ')}`,
@@ -423,22 +429,6 @@ export class VpnManager {
return config; return config;
} }
// ── Tag-based access control ───────────────────────────────────────────
/**
* Get assigned IPs for all enabled clients matching any of the given server-defined tags.
*/
public getClientIpsForServerDefinedTags(tags: string[]): string[] {
const ips: string[] = [];
for (const client of this.clients.values()) {
if (!client.enabled || !client.assignedIp) continue;
if (client.serverDefinedClientTags?.some(t => tags.includes(t))) {
ips.push(client.assignedIp);
}
}
return ips;
}
// ── Status and telemetry ─────────────────────────────────────────────── // ── Status and telemetry ───────────────────────────────────────────────
/** /**
@@ -488,33 +478,45 @@ export class VpnManager {
/** /**
* Build per-client security settings for the smartvpn daemon. * Build per-client security settings for the smartvpn daemon.
* Maps dcrouter-level fields (forceDestinationSmartproxy, allow/block lists) * All VPN traffic is forced through SmartProxy (forceTarget to 127.0.0.1).
* to smartvpn's IClientSecurity with a destinationPolicy. * TargetProfile direct IP:port targets bypass SmartProxy via allowList.
*/ */
private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity { private buildClientSecurity(client: VpnClientDoc): plugins.smartvpn.IClientSecurity {
const security: plugins.smartvpn.IClientSecurity = {}; const security: plugins.smartvpn.IClientSecurity = {};
const forceSmartproxy = client.forceDestinationSmartproxy ?? true;
if (!forceSmartproxy) { // Collect direct targets from assigned TargetProfiles (bypass forceTarget for these IPs)
// Client traffic goes directly — not forced to SmartProxy const profileDirectTargets = this.config.getClientDirectTargets?.(client.targetProfileIds || []) || [];
security.destinationPolicy = {
default: 'allow' as const, // Merge with per-client explicit allow list
blockList: client.destinationBlockList, const mergedAllowList = [
}; ...(client.destinationAllowList || []),
} else if (client.destinationAllowList?.length || client.destinationBlockList?.length) { ...profileDirectTargets,
// Client is forced to SmartProxy, but with per-client allow/block overrides ];
security.destinationPolicy = {
default: 'forceTarget' as const, security.destinationPolicy = {
target: '127.0.0.1', default: 'forceTarget' as const,
allowList: client.destinationAllowList, target: '127.0.0.1',
blockList: client.destinationBlockList, allowList: mergedAllowList.length ? mergedAllowList : undefined,
}; blockList: client.destinationBlockList,
} };
// else: no per-client policy, server-wide applies
return security; return security;
} }
/**
* Refresh all client security policies against the running daemon.
* Call this when TargetProfiles change so destination allow-lists stay in sync.
*/
public async refreshAllClientSecurity(): Promise<void> {
if (!this.vpnServer) return;
for (const client of this.clients.values()) {
const security = this.buildClientSecurity(client);
if (security.destinationPolicy) {
await this.vpnServer.updateClient(client.clientId, { security });
}
}
}
// ── Private helpers ──────────────────────────────────────────────────── // ── Private helpers ────────────────────────────────────────────────────
private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> { private async loadOrGenerateServerKeys(): Promise<VpnServerKeysDoc> {
@@ -548,12 +550,6 @@ export class VpnManager {
private async loadPersistedClients(): Promise<void> { private async loadPersistedClients(): Promise<void> {
const docs = await VpnClientDoc.findAll(); const docs = await VpnClientDoc.findAll();
for (const doc of docs) { for (const doc of docs) {
// Migrate legacy `tags` → `serverDefinedClientTags`
if (!doc.serverDefinedClientTags && (doc as any).tags) {
doc.serverDefinedClientTags = (doc as any).tags;
(doc as any).tags = undefined;
await doc.save();
}
this.clients.set(doc.clientId, doc); this.clients.set(doc.clientId, doc);
} }
if (this.clients.size > 0) { if (this.clients.size > 0) {

View File

@@ -1,3 +1,3 @@
{ {
"order": 4 "order": 5
} }

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:
'Manages records via the Cloudflare API. Provider stays authoritative; dcrouter pushes record changes.',
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

@@ -2,4 +2,9 @@ export * from './auth.js';
export * from './stats.js'; export * from './stats.js';
export * from './remoteingress.js'; export * from './remoteingress.js';
export * from './route-management.js'; export * from './route-management.js';
export * from './vpn.js'; export * from './target-profile.js';
export * from './vpn.js';
export * from './dns-provider.js';
export * from './domain.js';
export * from './dns-record.js';
export * from './acme-config.js';

View File

@@ -51,26 +51,14 @@ export interface IRouteRemoteIngress {
edgeFilter?: string[]; edgeFilter?: string[];
} }
/**
* Route-level VPN access configuration.
* When attached to a route, controls VPN client access.
*/
export interface IRouteVpn {
/** Enable VPN client access for this route */
enabled: boolean;
/** When true (default), ONLY VPN clients can access this route (replaces ipAllowList).
* When false, VPN client IPs are added alongside the existing allowlist. */
mandatory?: boolean;
/** Only allow VPN clients with these server-defined tags. Omitted = all VPN clients. */
allowedServerDefinedClientTags?: string[];
}
/** /**
* Extended route config used within dcrouter. * Extended route config used within dcrouter.
* Adds optional `remoteIngress` and `vpn` properties to SmartProxy's IRouteConfig. * Adds optional `remoteIngress` and `vpnOnly` properties to SmartProxy's IRouteConfig.
* SmartProxy ignores unknown properties at runtime. * SmartProxy ignores unknown properties at runtime.
*/ */
export type IDcRouterRouteConfig = IRouteConfig & { export type IDcRouterRouteConfig = IRouteConfig & {
remoteIngress?: IRouteRemoteIngress; remoteIngress?: IRouteRemoteIngress;
vpn?: IRouteVpn; /** When true, only VPN clients whose TargetProfile matches this route get access.
* Matching is determined by domain overlap, target overlap, or direct routeRef. */
vpnOnly?: boolean;
}; };

View File

@@ -12,18 +12,26 @@ export type TApiTokenScope =
| 'routes:read' | 'routes:write' | 'routes:read' | 'routes:write'
| 'config:read' | 'config:read'
| 'tokens:read' | 'tokens:manage' | 'tokens:read' | 'tokens:manage'
| 'profiles:read' | 'profiles:write' | 'source-profiles:read' | 'source-profiles:write'
| 'targets:read' | 'targets:write'; | 'target-profiles:read' | 'target-profiles: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';
// ============================================================================ // ============================================================================
// Security Profile Types // Source Profile Types (source-side: who can access)
// ============================================================================ // ============================================================================
/** /**
* A reusable, named security profile that can be referenced by routes. * A reusable, named source profile that can be referenced by routes.
* Stores the full IRouteSecurity shape from SmartProxy. * Stores the full IRouteSecurity shape from SmartProxy.
*
* SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth)
* TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs)
*/ */
export interface ISecurityProfile { export interface ISourceProfile {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
@@ -62,12 +70,12 @@ export interface INetworkTarget {
* Metadata on a stored route tracking where its resolved values came from. * Metadata on a stored route tracking where its resolved values came from.
*/ */
export interface IRouteMetadata { export interface IRouteMetadata {
/** ID of the SecurityProfileDoc used to resolve this route's security. */ /** ID of the SourceProfileDoc used to resolve this route's security. */
securityProfileRef?: string; sourceProfileRef?: string;
/** ID of the NetworkTargetDoc used to resolve this route's targets. */ /** ID of the NetworkTargetDoc used to resolve this route's targets. */
networkTargetRef?: string; networkTargetRef?: string;
/** Snapshot of the profile name at resolution time, for display. */ /** Snapshot of the profile name at resolution time, for display. */
securityProfileName?: string; sourceProfileName?: string;
/** Snapshot of the target name at resolution time, for display. */ /** Snapshot of the target name at resolution time, for display. */
networkTargetName?: string; networkTargetName?: string;
/** Timestamp of last reference resolution. */ /** Timestamp of last reference resolution. */

View File

@@ -0,0 +1,29 @@
/**
* A specific IP:port target within a TargetProfile.
*/
export interface ITargetProfileTarget {
ip: string;
port: number;
}
/**
* A reusable, named target profile that defines what resources a VPN client can reach.
* Assigned to VPN clients via targetProfileIds.
*
* SourceProfile = source-side (who can access: ipAllowList, rateLimit, auth)
* TargetProfile = target-side (what can be accessed: domains, IP:port targets, route refs)
*/
export interface ITargetProfile {
id: string;
name: string;
description?: string;
/** Domain patterns this profile grants access to (supports wildcards: '*.example.com') */
domains?: string[];
/** Specific IP:port targets this profile grants access to */
targets?: ITargetProfileTarget[];
/** Route references by stored route ID or route name */
routeRefs?: string[];
createdAt: number;
updatedAt: number;
createdBy: string;
}

View File

@@ -4,13 +4,13 @@
export interface IVpnClient { export interface IVpnClient {
clientId: string; clientId: string;
enabled: boolean; enabled: boolean;
serverDefinedClientTags?: string[]; /** IDs of TargetProfiles assigned to this client */
targetProfileIds?: string[];
description?: string; description?: string;
assignedIp?: string; assignedIp?: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
expiresAt?: string; expiresAt?: string;
forceDestinationSmartproxy: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;

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,150 @@
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;
};
}

View File

@@ -10,5 +10,11 @@ export * from './remoteingress.js';
export * from './route-management.js'; export * from './route-management.js';
export * from './api-tokens.js'; export * from './api-tokens.js';
export * from './vpn.js'; export * from './vpn.js';
export * from './security-profiles.js'; export * from './source-profiles.js';
export * from './network-targets.js'; export * from './target-profiles.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';

View File

@@ -1,54 +1,54 @@
import * as plugins from '../plugins.js'; import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js'; import type * as authInterfaces from '../data/auth.js';
import type { ISecurityProfile, IRouteSecurity } from '../data/route-management.js'; import type { ISourceProfile, IRouteSecurity } from '../data/route-management.js';
// ============================================================================ // ============================================================================
// Security Profile Endpoints // Source Profile Endpoints (source-side: who can access)
// ============================================================================ // ============================================================================
/** /**
* Get all security profiles. * Get all source profiles.
*/ */
export interface IReq_GetSecurityProfiles extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetSourceProfiles extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityProfiles IReq_GetSourceProfiles
> { > {
method: 'getSecurityProfiles'; method: 'getSourceProfiles';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
apiToken?: string; apiToken?: string;
}; };
response: { response: {
profiles: ISecurityProfile[]; profiles: ISourceProfile[];
}; };
} }
/** /**
* Get a single security profile by ID. * Get a single source profile by ID.
*/ */
export interface IReq_GetSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityProfile IReq_GetSourceProfile
> { > {
method: 'getSecurityProfile'; method: 'getSourceProfile';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
apiToken?: string; apiToken?: string;
id: string; id: string;
}; };
response: { response: {
profile: ISecurityProfile | null; profile: ISourceProfile | null;
}; };
} }
/** /**
* Create a new security profile. * Create a new source profile.
*/ */
export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_CreateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateSecurityProfile IReq_CreateSourceProfile
> { > {
method: 'createSecurityProfile'; method: 'createSourceProfile';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
apiToken?: string; apiToken?: string;
@@ -65,13 +65,13 @@ export interface IReq_CreateSecurityProfile extends plugins.typedrequestInterfac
} }
/** /**
* Update a security profile. * Update a source profile.
*/ */
export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_UpdateSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateSecurityProfile IReq_UpdateSourceProfile
> { > {
method: 'updateSecurityProfile'; method: 'updateSourceProfile';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
apiToken?: string; apiToken?: string;
@@ -89,13 +89,13 @@ export interface IReq_UpdateSecurityProfile extends plugins.typedrequestInterfac
} }
/** /**
* Delete a security profile. * Delete a source profile.
*/ */
export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_DeleteSourceProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteSecurityProfile IReq_DeleteSourceProfile
> { > {
method: 'deleteSecurityProfile'; method: 'deleteSourceProfile';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
apiToken?: string; apiToken?: string;
@@ -109,13 +109,13 @@ export interface IReq_DeleteSecurityProfile extends plugins.typedrequestInterfac
} }
/** /**
* Get which routes reference a security profile. * Get which routes reference a source profile.
*/ */
export interface IReq_GetSecurityProfileUsage extends plugins.typedrequestInterfaces.implementsTR< export interface IReq_GetSourceProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest, plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetSecurityProfileUsage IReq_GetSourceProfileUsage
> { > {
method: 'getSecurityProfileUsage'; method: 'getSourceProfileUsage';
request: { request: {
identity?: authInterfaces.IIdentity; identity?: authInterfaces.IIdentity;
apiToken?: string; apiToken?: string;

View File

@@ -0,0 +1,128 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { ITargetProfile, ITargetProfileTarget } from '../data/target-profile.js';
// ============================================================================
// Target Profile Endpoints (target-side: what can be accessed)
// ============================================================================
/**
* Get all target profiles.
*/
export interface IReq_GetTargetProfiles extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetTargetProfiles
> {
method: 'getTargetProfiles';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
profiles: ITargetProfile[];
};
}
/**
* Get a single target profile by ID.
*/
export interface IReq_GetTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetTargetProfile
> {
method: 'getTargetProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
profile: ITargetProfile | null;
};
}
/**
* Create a new target profile.
*/
export interface IReq_CreateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateTargetProfile
> {
method: 'createTargetProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
name: string;
description?: string;
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
};
response: {
success: boolean;
id?: string;
message?: string;
};
}
/**
* Update a target profile.
*/
export interface IReq_UpdateTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateTargetProfile
> {
method: 'updateTargetProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
name?: string;
description?: string;
domains?: string[];
targets?: ITargetProfileTarget[];
routeRefs?: string[];
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete a target profile.
*/
export interface IReq_DeleteTargetProfile extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteTargetProfile
> {
method: 'deleteTargetProfile';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
force?: boolean;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Get which VPN clients reference a target profile.
*/
export interface IReq_GetTargetProfileUsage extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetTargetProfileUsage
> {
method: 'getTargetProfileUsage';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
clients: Array<{ clientId: string; description?: string }>;
};
}

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

@@ -49,9 +49,9 @@ export interface IReq_CreateVpnClient extends plugins.typedrequestInterfaces.imp
request: { request: {
identity: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
clientId: string; clientId: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
description?: string; description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;
@@ -81,8 +81,8 @@ export interface IReq_UpdateVpnClient extends plugins.typedrequestInterfaces.imp
identity: authInterfaces.IIdentity; identity: authInterfaces.IIdentity;
clientId: string; clientId: string;
description?: string; description?: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;

98
ts_migrations/index.ts Normal file
View File

@@ -0,0 +1,98 @@
/// <reference types="node" />
/**
* dcrouter migration runner.
*
* Uses @push.rocks/smartmigration via dynamic import so smartmigration's type
* chain (which pulls in mongodb 7.x and related types) doesn't leak into
* compile-time type checking for this folder.
*/
/** Matches the subset of IMigrationRunResult we actually log. */
export interface IMigrationRunResult {
stepsApplied: Array<unknown>;
wasFreshInstall: boolean;
currentVersionBefore: string | null;
currentVersionAfter: string;
totalDurationMs: number;
}
export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>;
}
/**
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
*
* Call `.run()` on the returned instance at startup (after DcRouterDb is ready,
* before any service that reads migrated collections).
*
* @param db - The initialized SmartdataDb instance from DcRouterDb.getDb()
* @param targetVersion - The current app version (from commitinfo.version)
*/
export async function createMigrationRunner(
db: unknown,
targetVersion: string,
): Promise<IMigrationRunner> {
const sm = await import('@push.rocks/smartmigration');
const migration = new sm.SmartMigration({
targetVersion,
db: db as any,
// Brand-new installs skip all migrations and stamp directly to the current version.
freshInstallVersion: targetVersion,
});
// Register steps in execution order. Each step's .from() must match the
// previous step's .to() to form a contiguous chain.
migration
.step('rename-target-profile-host-to-ip')
.from('13.0.11').to('13.1.0')
.description('Rename ITargetProfileTarget.host → ip on all target profiles')
.up(async (ctx) => {
const collection = ctx.mongo!.collection('targetprofiledoc');
const cursor = collection.find({ 'targets.host': { $exists: true } });
let migrated = 0;
for await (const doc of cursor) {
const targets = ((doc as any).targets || []).map((t: any) => {
if (t && typeof t === 'object' && 'host' in t && !('ip' in t)) {
const { host, ...rest } = t;
return { ...rest, ip: host };
}
return t;
});
await collection.updateOne({ _id: (doc as any)._id }, { $set: { targets } });
migrated++;
}
ctx.log.log('info', `rename-target-profile-host-to-ip: migrated ${migrated} profile(s)`);
})
.step('rename-domain-source-manual-to-dcrouter')
.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)`,
);
});
return migration;
}

View File

@@ -0,0 +1,3 @@
{
"order": 2
}

View File

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

View File

@@ -30,6 +30,7 @@ export interface IConfigState {
export interface IUiState { export interface IUiState {
activeView: string; activeView: string;
activeSubview: string | null;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
autoRefresh: boolean; autoRefresh: boolean;
refreshInterval: number; // milliseconds refreshInterval: number; // milliseconds
@@ -116,16 +117,24 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
// Determine initial view from URL path // Determine initial view from URL path
const getInitialView = (): string => { const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/'; const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'securityprofiles', 'networktargets']; const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'domains'];
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const view = segments[0]; const view = segments[0];
return validViews.includes(view) ? view : 'overview'; return validViews.includes(view) ? view : 'overview';
}; };
// Determine initial subview (second URL segment) from the path
const getInitialSubview = (): string | null => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const segments = path.split('/').filter(Boolean);
return segments[1] ?? null;
};
export const uiStatePart = await appState.getStatePart<IUiState>( export const uiStatePart = await appState.getStatePart<IUiState>(
'ui', 'ui',
{ {
activeView: getInitialView(), activeView: getInitialView(),
activeSubview: getInitialSubview(),
sidebarCollapsed: false, sidebarCollapsed: false,
autoRefresh: true, autoRefresh: true,
refreshInterval: 1000, // 1 second refreshInterval: 1000, // 1 second
@@ -188,6 +197,28 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
'soft' 'soft'
); );
// ============================================================================
// ACME Config State (DB-backed singleton, managed via Domains > Certificates)
// ============================================================================
export interface IAcmeConfigState {
config: interfaces.data.IAcmeConfig | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const acmeConfigStatePart = await appState.getStatePart<IAcmeConfigState>(
'acmeConfig',
{
config: null,
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
// ============================================================================ // ============================================================================
// Remote Ingress State // Remote Ingress State
// ============================================================================ // ============================================================================
@@ -242,6 +273,34 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
'soft' 'soft'
); );
// ============================================================================
// Users State (read-only list of OpsServer user accounts)
// ============================================================================
export interface IUser {
id: string;
username: string;
role: string;
}
export interface IUsersState {
users: IUser[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const usersStatePart = await appState.getStatePart<IUsersState>(
'users',
{
users: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
// Actions for state management // Actions for state management
interface IActionContext { interface IActionContext {
identity: interfaces.data.IIdentity | null; identity: interfaces.data.IIdentity | null;
@@ -428,43 +487,14 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
}, 100); }, 100);
} }
// If switching to certificates view, ensure we fetch certificate data // If switching to the Domains group, ensure we fetch certificate data
if (viewName === 'certificates' && currentState.activeView !== 'certificates') { // (Certificates is a subview of Domains).
if (viewName === 'domains' && currentState.activeView !== 'domains') {
setTimeout(() => { setTimeout(() => {
certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
}, 100); }, 100);
} }
// If switching to routes view, ensure we fetch route data
if (viewName === 'routes' && currentState.activeView !== 'routes') {
setTimeout(() => {
routeManagementStatePart.dispatchAction(fetchMergedRoutesAction, null);
// Also fetch profiles/targets for the Create Route dropdowns
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
}, 100);
}
// If switching to apitokens view, ensure we fetch token data
if (viewName === 'apitokens' && currentState.activeView !== 'apitokens') {
setTimeout(() => {
routeManagementStatePart.dispatchAction(fetchApiTokensAction, null);
}, 100);
}
// If switching to remoteingress view, ensure we fetch edge data
if (viewName === 'remoteingress' && currentState.activeView !== 'remoteingress') {
setTimeout(() => {
remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
}, 100);
}
// If switching to security profiles or network targets views, fetch profiles/targets data
if ((viewName === 'securityprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
setTimeout(() => {
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
}, 100);
}
return { return {
...currentState, ...currentState,
activeView: viewName, activeView: viewName,
@@ -1006,9 +1036,9 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
export const createVpnClientAction = vpnStatePart.createAction<{ export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string; clientId: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
description?: string; description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;
@@ -1028,9 +1058,9 @@ export const createVpnClientAction = vpnStatePart.createAction<{
const response = await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
clientId: dataArg.clientId, clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags, targetProfileIds: dataArg.targetProfileIds,
description: dataArg.description, description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList, destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList, destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp, useHostIp: dataArg.useHostIp,
@@ -1105,8 +1135,8 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{
export const updateVpnClientAction = vpnStatePart.createAction<{ export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: string; clientId: string;
description?: string; description?: string;
serverDefinedClientTags?: string[]; targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[]; destinationAllowList?: string[];
destinationBlockList?: string[]; destinationBlockList?: string[];
useHostIp?: boolean; useHostIp?: boolean;
@@ -1127,8 +1157,8 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
identity: context.identity!, identity: context.identity!,
clientId: dataArg.clientId, clientId: dataArg.clientId,
description: dataArg.description, description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags, targetProfileIds: dataArg.targetProfileIds,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList, destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList, destinationBlockList: dataArg.destinationBlockList,
useHostIp: dataArg.useHostIp, useHostIp: dataArg.useHostIp,
@@ -1158,11 +1188,167 @@ export const clearNewClientConfigAction = vpnStatePart.createAction(
); );
// ============================================================================ // ============================================================================
// Security Profiles & Network Targets State // Target Profiles State
// ============================================================================
export interface ITargetProfilesState {
profiles: interfaces.data.ITargetProfile[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const targetProfilesStatePart = await appState.getStatePart<ITargetProfilesState>(
'targetProfiles',
{
profiles: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft'
);
// ============================================================================
// Target Profiles Actions
// ============================================================================
export const fetchTargetProfilesAction = targetProfilesStatePart.createAction(
async (statePartArg): Promise<ITargetProfilesState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetTargetProfiles
>('/typedrequest', 'getTargetProfiles');
const response = await request.fire({ identity: context.identity });
return {
profiles: response.profiles,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch target profiles',
};
}
}
);
export const createTargetProfileAction = targetProfilesStatePart.createAction<{
name: string;
description?: string;
domains?: string[];
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateTargetProfile
>('/typedrequest', 'createTargetProfile');
const response = await request.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to create target profile',
};
}
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create target profile',
};
}
});
export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
id: string;
name?: string;
description?: string;
domains?: string[];
targets?: Array<{ ip: string; port: number }>;
routeRefs?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateTargetProfile
>('/typedrequest', 'updateTargetProfile');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to update target profile',
};
}
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update target profile',
};
}
});
export const deleteTargetProfileAction = targetProfilesStatePart.createAction<{
id: string;
force?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteTargetProfile
>('/typedrequest', 'deleteTargetProfile');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
force: dataArg.force,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to delete target profile',
};
}
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete target profile',
};
}
});
// ============================================================================
// Source Profiles & Network Targets State
// ============================================================================ // ============================================================================
export interface IProfilesTargetsState { export interface IProfilesTargetsState {
profiles: interfaces.data.ISecurityProfile[]; profiles: interfaces.data.ISourceProfile[];
targets: interfaces.data.INetworkTarget[]; targets: interfaces.data.INetworkTarget[];
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -1182,7 +1368,7 @@ export const profilesTargetsStatePart = await appState.getStatePart<IProfilesTar
); );
// ============================================================================ // ============================================================================
// Security Profiles & Network Targets Actions // Source Profiles & Network Targets Actions
// ============================================================================ // ============================================================================
export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction( export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction(
@@ -1193,8 +1379,8 @@ export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createActi
try { try {
const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecurityProfiles interfaces.requests.IReq_GetSourceProfiles
>('/typedrequest', 'getSecurityProfiles'); >('/typedrequest', 'getSourceProfiles');
const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest< const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkTargets interfaces.requests.IReq_GetNetworkTargets
@@ -1231,8 +1417,8 @@ export const createProfileAction = profilesTargetsStatePart.createAction<{
const context = getActionContext(); const context = getActionContext();
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateSecurityProfile interfaces.requests.IReq_CreateSourceProfile
>('/typedrequest', 'createSecurityProfile'); >('/typedrequest', 'createSourceProfile');
await request.fire({ await request.fire({
identity: context.identity!, identity: context.identity!,
name: dataArg.name, name: dataArg.name,
@@ -1259,8 +1445,8 @@ export const updateProfileAction = profilesTargetsStatePart.createAction<{
const context = getActionContext(); const context = getActionContext();
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSecurityProfile interfaces.requests.IReq_UpdateSourceProfile
>('/typedrequest', 'updateSecurityProfile'); >('/typedrequest', 'updateSourceProfile');
await request.fire({ await request.fire({
identity: context.identity!, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
@@ -1285,8 +1471,8 @@ export const deleteProfileAction = profilesTargetsStatePart.createAction<{
const context = getActionContext(); const context = getActionContext();
try { try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteSecurityProfile interfaces.requests.IReq_DeleteSourceProfile
>('/typedrequest', 'deleteSecurityProfile'); >('/typedrequest', 'deleteSourceProfile');
const response = await request.fire({ const response = await request.fire({
identity: context.identity!, identity: context.identity!,
id: dataArg.id, id: dataArg.id,
@@ -1392,6 +1578,469 @@ export const deleteTargetAction = profilesTargetsStatePart.createAction<{
} }
}); });
// ============================================================================
// Domains State (DNS providers + domains + records)
// ============================================================================
export interface IDomainsState {
providers: interfaces.data.IDnsProviderPublic[];
domains: interfaces.data.IDomain[];
records: interfaces.data.IDnsRecord[];
/** id of the currently-selected domain in the DNS records subview. */
selectedDomainId: string | null;
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const domainsStatePart = await appState.getStatePart<IDomainsState>(
'domains',
{
providers: [],
domains: [],
records: [],
selectedDomainId: null,
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft',
);
export const fetchDomainsAndProvidersAction = domainsStatePart.createAction(
async (statePartArg): Promise<IDomainsState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const providersRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDnsProviders
>('/typedrequest', 'getDnsProviders');
const domainsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDomains
>('/typedrequest', 'getDomains');
const [providersResponse, domainsResponse] = await Promise.all([
providersRequest.fire({ identity: context.identity }),
domainsRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
providers: providersResponse.providers,
domains: domainsResponse.domains,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error: unknown) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch domains/providers',
};
}
},
);
export const fetchDnsRecordsForDomainAction = domainsStatePart.createAction<{ domainId: string }>(
async (statePartArg, dataArg): Promise<IDomainsState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetDnsRecords
>('/typedrequest', 'getDnsRecords');
const response = await request.fire({
identity: context.identity,
domainId: dataArg.domainId,
});
return {
...currentState,
records: response.records,
selectedDomainId: dataArg.domainId,
error: null,
};
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch DNS records',
};
}
},
);
export const createDnsProviderAction = domainsStatePart.createAction<{
name: string;
type: interfaces.data.TDnsProviderType;
credentials: interfaces.data.TDnsProviderCredentials;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateDnsProvider
>('/typedrequest', 'createDnsProvider');
const response = await request.fire({
identity: context.identity!,
name: dataArg.name,
type: dataArg.type,
credentials: dataArg.credentials,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to create provider',
};
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create provider',
};
}
});
export const updateDnsProviderAction = domainsStatePart.createAction<{
id: string;
name?: string;
credentials?: interfaces.data.TDnsProviderCredentials;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateDnsProvider
>('/typedrequest', 'updateDnsProvider');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
credentials: dataArg.credentials,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to update provider',
};
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update provider',
};
}
});
export const deleteDnsProviderAction = domainsStatePart.createAction<{ id: string; force?: boolean }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteDnsProvider
>('/typedrequest', 'deleteDnsProvider');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
force: dataArg.force,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to delete provider',
};
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete provider',
};
}
},
);
export const testDnsProviderAction = domainsStatePart.createAction<{ id: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_TestDnsProvider
>('/typedrequest', 'testDnsProvider');
await request.fire({ identity: context.identity!, id: dataArg.id });
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to test provider',
};
}
},
);
/** One-shot fetch for the import-domain modal. Does NOT modify state. */
export async function fetchProviderDomains(
providerId: string,
): Promise<{ success: boolean; domains?: interfaces.data.IProviderDomainListing[]; message?: string }> {
const context = getActionContext();
if (!context.identity) return { success: false, message: 'Not authenticated' };
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListProviderDomains
>('/typedrequest', 'listProviderDomains');
return await request.fire({ identity: context.identity, providerId });
}
export const createDcrouterDomainAction = domainsStatePart.createAction<{
name: string;
description?: string;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateDomain
>('/typedrequest', 'createDomain');
const response = await request.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to create domain' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create domain',
};
}
});
export const importDomainsFromProviderAction = domainsStatePart.createAction<{
providerId: string;
domainNames: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ImportDomain
>('/typedrequest', 'importDomain');
const response = await request.fire({
identity: context.identity!,
providerId: dataArg.providerId,
domainNames: dataArg.domainNames,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to import domains' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to import domains',
};
}
});
export const deleteDomainAction = domainsStatePart.createAction<{ id: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteDomain
>('/typedrequest', 'deleteDomain');
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete domain' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete domain',
};
}
},
);
export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_SyncDomain
>('/typedrequest', 'syncDomain');
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to sync domain' };
}
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to sync domain',
};
}
},
);
export const createDnsRecordAction = domainsStatePart.createAction<{
domainId: string;
name: string;
type: interfaces.data.TDnsRecordType;
value: string;
ttl?: number;
proxied?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateDnsRecord
>('/typedrequest', 'createDnsRecord');
const response = await request.fire({
identity: context.identity!,
domainId: dataArg.domainId,
name: dataArg.name,
type: dataArg.type,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to create record' };
}
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create record',
};
}
});
export const updateDnsRecordAction = domainsStatePart.createAction<{
id: string;
domainId: string;
name?: string;
value?: string;
ttl?: number;
proxied?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateDnsRecord
>('/typedrequest', 'updateDnsRecord');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
value: dataArg.value,
ttl: dataArg.ttl,
proxied: dataArg.proxied,
});
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to update record' };
}
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update record',
};
}
});
export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string; domainId: string }>(
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteDnsRecord
>('/typedrequest', 'deleteDnsRecord');
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
if (!response.success) {
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete record' };
}
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete record',
};
}
},
);
// ============================================================================
// ACME Config Actions
// ============================================================================
export const fetchAcmeConfigAction = acmeConfigStatePart.createAction(
async (statePartArg): Promise<IAcmeConfigState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetAcmeConfig
>('/typedrequest', 'getAcmeConfig');
const response = await request.fire({ identity: context.identity });
return {
config: response.config,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error: unknown) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch ACME config',
};
}
},
);
export const updateAcmeConfigAction = acmeConfigStatePart.createAction<{
accountEmail?: string;
enabled?: boolean;
useProduction?: boolean;
autoRenew?: boolean;
renewThresholdDays?: number;
}>(async (statePartArg, dataArg, actionContext): Promise<IAcmeConfigState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateAcmeConfig
>('/typedrequest', 'updateAcmeConfig');
const response = await request.fire({
identity: context.identity!,
accountEmail: dataArg.accountEmail,
enabled: dataArg.enabled,
useProduction: dataArg.useProduction,
autoRenew: dataArg.autoRenew,
renewThresholdDays: dataArg.renewThresholdDays,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to update ACME config',
};
}
return await actionContext!.dispatch(fetchAcmeConfigAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update ACME config',
};
}
});
// ============================================================================ // ============================================================================
// Route Management Actions // Route Management Actions
// ============================================================================ // ============================================================================
@@ -1621,6 +2270,35 @@ export const fetchApiTokensAction = routeManagementStatePart.createAction(async
} }
}); });
// Users (read-only list)
export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ListUsers
>('/typedrequest', 'listUsers');
const response = await request.fire({
identity: context.identity,
});
return {
...currentState,
users: response.users,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch users',
};
}
});
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) { export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
const context = getActionContext(); const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest< const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
@@ -1781,6 +2459,7 @@ async function dispatchCombinedRefreshActionInner() {
const context = getActionContext(); const context = getActionContext();
if (!context.identity) return; if (!context.identity) return;
const currentView = uiStatePart.getState()!.activeView; const currentView = uiStatePart.getState()!.activeView;
const currentSubview = uiStatePart.getState()!.activeSubview;
try { try {
// Always fetch basic stats for dashboard widgets // Always fetch basic stats for dashboard widgets
@@ -1883,8 +2562,8 @@ async function dispatchCombinedRefreshActionInner() {
} }
} }
// Refresh certificate data if on certificates view // Refresh certificate data if on Domains > Certificates subview
if (currentView === 'certificates') { if (currentView === 'domains' && currentSubview === 'certificates') {
try { try {
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null); await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
} catch (error) { } catch (error) {
@@ -1892,8 +2571,8 @@ async function dispatchCombinedRefreshActionInner() {
} }
} }
// Refresh remote ingress data if on remoteingress view // Refresh remote ingress data if on the Network → Remote Ingress subview
if (currentView === 'remoteingress') { if (currentView === 'network' && currentSubview === 'remoteingress') {
try { try {
await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null); await remoteIngressStatePart.dispatchAction(fetchRemoteIngressAction, null);
} catch (error) { } catch (error) {
@@ -1901,8 +2580,8 @@ async function dispatchCombinedRefreshActionInner() {
} }
} }
// Refresh VPN data if on vpn view // Refresh VPN data if on the Network → VPN subview
if (currentView === 'vpn') { if (currentView === 'network' && currentSubview === 'vpn') {
try { try {
await vpnStatePart.dispatchAction(fetchVpnAction, null); await vpnStatePart.dispatchAction(fetchVpnAction, null);
} catch (error) { } catch (error) {

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`
<ops-sectionheading>API Tokens</ops-sectionheading> <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),

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,224 @@
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;
}
.helpText {
font-size: 12px;
opacity: 0.7;
margin-top: -6px;
margin-bottom: 8px;
}
.typeDescription {
font-size: 12px;
opacity: 0.8;
margin: 4px 0 16px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-radius: 6px;
}
.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'}
.value=${descriptor?.displayName ?? this.selectedType}
.disabled=${true}
></dees-input-text>
</div>
`
: html`
<div class="field">
<dees-input-dropdown
.key=${'__type'}
.label=${'Provider type'}
.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`
<div class="typeDescription">${descriptor.description}</div>
${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}
.required=${f.required && !this.lockType}
></dees-input-text>
${f.helpText ? html`<div class="helpText">${f.helpText}</div>` : ''}
</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 = [
@@ -46,6 +54,62 @@ export class OpsViewCertificates extends DeesElement {
gap: 24px; gap: 24px;
} }
.acmeCard {
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 8px;
}
.acmeCard.acmeCardEmpty {
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
}
.acmeCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.acmeCardTitle {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px 24px;
}
.acmeField {
display: flex;
flex-direction: column;
gap: 2px;
}
.acmeLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.acmeValue {
font-size: 13px;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeEmptyHint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
}
.statusBadge { .statusBadge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -159,15 +223,154 @@ export class OpsViewCertificates extends DeesElement {
const { summary } = this.certState; const { summary } = this.certState;
return html` return html`
<ops-sectionheading>Certificates</ops-sectionheading> <dees-heading level="3">Certificates</dees-heading>
<div class="certificatesContainer"> <div class="certificatesContainer">
${this.renderAcmeSettingsCard()}
${this.renderStatsTiles(summary)} ${this.renderStatsTiles(summary)}
${this.renderCertificateTable()} ${this.renderCertificateTable()}
</div> </div>
`; `;
} }
private renderAcmeSettingsCard(): TemplateResult {
const config = this.acmeState.config;
if (!config) {
return html`
<div class="acmeCard acmeCardEmpty">
<div class="acmeCardHeader">
<span class="acmeCardTitle">ACME Settings</span>
<dees-button
eventName="edit-acme"
@click=${() => this.showEditAcmeDialog()}
.type=${'highlighted'}
>Configure</dees-button>
</div>
<p class="acmeEmptyHint">
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
under <strong>Domains &gt; Providers</strong>.
</p>
</div>
`;
}
return html`
<div class="acmeCard">
<div class="acmeCardHeader">
<span class="acmeCardTitle">ACME Settings</span>
<dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
</div>
<div class="acmeGrid">
<div class="acmeField">
<span class="acmeLabel">Account email</span>
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Status</span>
<span class="acmeValue">
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
${config.enabled ? 'enabled' : 'disabled'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Mode</span>
<span class="acmeValue">
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
${config.useProduction ? 'production' : 'staging'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Auto-renew</span>
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Renewal threshold</span>
<span class="acmeValue">${config.renewThresholdDays} days</span>
</div>
</div>
</div>
`;
}
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 (uncheck for staging)"}
.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 (days)'}
.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 +431,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),

View File

@@ -0,0 +1,273 @@
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">
<span>Domain:</span>
<dees-input-dropdown
.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 (FQDN)'} .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 (for MX use "10 mail.example.com")'}
.required=${true}
></dees-input-text>
<dees-input-text .key=${'ttl'} .label=${'TTL (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 (FQDN)'} .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 (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,335 @@
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: '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 (e.g. example.com)'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description (optional)'}></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=${'Comma-separated FQDNs to import (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 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,2 @@
export * from './ops-view-emails.js';
export * from './ops-view-email-security.js';

View File

@@ -0,0 +1,160 @@
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`
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(): 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>
<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 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

@@ -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`
<ops-sectionheading>Email Operations</ops-sectionheading> <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,15 +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 './shared/index.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './ops-view-securityprofiles.js';
export * from './ops-view-networktargets.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,12 +1,12 @@
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;
} }
} }
@@ -26,14 +26,14 @@ interface INetworkRequest {
route?: string; route?: string;
} }
@customElement('ops-view-network') @customElement('ops-view-network-activity')
export class OpsViewNetwork extends DeesElement { export class OpsViewNetworkActivity 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()!;
@@ -50,10 +50,10 @@ export class OpsViewNetwork extends DeesElement {
@state() @state()
accessor trafficDataOut: Array<{ x: string | number; y: number }> = []; accessor trafficDataOut: Array<{ x: string | number; y: number }> = [];
// 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
@@ -101,17 +101,17 @@ export class OpsViewNetwork extends DeesElement {
this.updateNetworkData(); this.updateNetworkData();
}); });
this.rxSubscriptions.push(statsUnsubscribe); this.rxSubscriptions.push(statsUnsubscribe);
const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => { const networkUnsubscribe = appstate.networkStatePart.select().subscribe((state) => {
this.networkState = state; this.networkState = state;
this.updateNetworkData(); this.updateNetworkData();
}); });
this.rxSubscriptions.push(networkUnsubscribe); this.rxSubscriptions.push(networkUnsubscribe);
} }
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 +148,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,8 +285,8 @@ export class OpsViewNetwork extends DeesElement {
public render() { public render() {
return html` return html`
<ops-sectionheading>Network Activity</ops-sectionheading> <dees-heading level="3">Network Activity</dees-heading>
<div class="networkContainer"> <div class="networkContainer">
<!-- Stats Grid --> <!-- Stats Grid -->
${this.renderNetworkStats()} ${this.renderNetworkStats()}
@@ -307,7 +307,7 @@ 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>
@@ -347,6 +347,7 @@ export class OpsViewNetwork extends DeesElement {
heading1="Recent Network Activity" heading1="Recent Network Activity"
heading2="Recent network requests" heading2="Recent network requests"
searchable searchable
.showColumnFilters=${true}
.pagination=${true} .pagination=${true}
.paginationSize=${50} .paginationSize=${50}
dataName="request" dataName="request"
@@ -357,7 +358,7 @@ export class OpsViewNetwork extends DeesElement {
private async showRequestDetails(request: INetworkRequest) { private async showRequestDetails(request: INetworkRequest) {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({ await DeesModal.createAndShow({
heading: 'Request Details', heading: 'Request Details',
content: html` content: html`
@@ -400,10 +401,10 @@ export class OpsViewNetwork extends DeesElement {
if (!statusCode) { if (!statusCode) {
return html`<span class="statusBadge warning">N/A</span>`; return html`<span class="statusBadge warning">N/A</span>`;
} }
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' : const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning'; statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`; return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
} }
@@ -426,26 +427,26 @@ export class OpsViewNetwork extends DeesElement {
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes; let size = bytes;
let unitIndex = 0; let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024; size /= 1024;
unitIndex++; unitIndex++;
} }
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
} }
private formatBitsPerSecond(bytesPerSecond: number): string { private formatBitsPerSecond(bytesPerSecond: number): string {
const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits const bitsPerSecond = bytesPerSecond * 8; // Convert bytes to bits
const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s']; const units = ['bit/s', 'kbit/s', 'Mbit/s', 'Gbit/s'];
let size = bitsPerSecond; let size = bitsPerSecond;
let unitIndex = 0; let unitIndex = 0;
while (size >= 1000 && unitIndex < units.length - 1) { while (size >= 1000 && unitIndex < units.length - 1) {
size /= 1000; // Use 1000 for bits (not 1024) size /= 1000; // Use 1000 for bits (not 1024)
unitIndex++; unitIndex++;
} }
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
} }
@@ -520,18 +521,9 @@ export class OpsViewNetwork extends DeesElement {
]; ];
return html` return html`
<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>
`; `;
} }
@@ -615,6 +607,8 @@ 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>
@@ -665,6 +659,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>
@@ -732,12 +727,12 @@ export class OpsViewNetwork extends DeesElement {
// Only update if connections changed significantly // Only update if connections changed significantly
const newConnectionCount = this.networkState.connections.length; const newConnectionCount = this.networkState.connections.length;
const oldConnectionCount = this.networkRequests.length; const oldConnectionCount = this.networkRequests.length;
// Check if we need to update the network requests array // Check if we need to update the network requests array
const shouldUpdate = newConnectionCount !== oldConnectionCount || const shouldUpdate = newConnectionCount !== oldConnectionCount ||
newConnectionCount === 0 || newConnectionCount === 0 ||
(newConnectionCount > 0 && this.networkRequests.length === 0); (newConnectionCount > 0 && this.networkRequests.length === 0);
if (shouldUpdate) { if (shouldUpdate) {
// Convert connection data to network requests format // Convert connection data to network requests format
if (newConnectionCount > 0) { if (newConnectionCount > 0) {
@@ -760,62 +755,62 @@ export class OpsViewNetwork extends DeesElement {
this.networkRequests = []; 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();
} }
} }
private startTrafficUpdateTimer() { private startTrafficUpdateTimer() {
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() {
const now = Date.now(); const now = Date.now();
// Throttle chart updates to avoid excessive re-renders // Throttle chart updates to avoid excessive re-renders
if (now - this.lastChartUpdate < this.chartUpdateThreshold) { if (now - this.lastChartUpdate < this.chartUpdateThreshold) {
return; return;
} }
const throughput = this.calculateThroughput(); const throughput = this.calculateThroughput();
// Convert to Mbps (bytes * 8 / 1,000,000) // Convert to Mbps (bytes * 8 / 1,000,000)
const throughputInMbps = (throughput.in * 8) / 1000000; const throughputInMbps = (throughput.in * 8) / 1000000;
const throughputOutMbps = (throughput.out * 8) / 1000000; const throughputOutMbps = (throughput.out * 8) / 1000000;
// Add new data points // Add new data points
const timestamp = new Date(now).toISOString(); const timestamp = new Date(now).toISOString();
const newDataPointIn = { const newDataPointIn = {
x: timestamp, x: timestamp,
y: Math.round(throughputInMbps * 10) / 10 y: Math.round(throughputInMbps * 10) / 10
}; };
const newDataPointOut = { const newDataPointOut = {
x: timestamp, x: timestamp,
y: Math.round(throughputOutMbps * 10) / 10 y: Math.round(throughputOutMbps * 10) / 10
}; };
// 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();
} }
this.trafficDataIn = [...this.trafficDataIn, newDataPointIn]; this.trafficDataIn = [...this.trafficDataIn, newDataPointIn];
this.trafficDataOut = [...this.trafficDataOut, newDataPointOut]; this.trafficDataOut = [...this.trafficDataOut, newDataPointOut];
this.lastChartUpdate = now; this.lastChartUpdate = now;
} }
private stopTrafficUpdateTimer() { private stopTrafficUpdateTimer() {
if (this.trafficUpdateTimer) { if (this.trafficUpdateTimer) {
clearInterval(this.trafficUpdateTimer); clearInterval(this.trafficUpdateTimer);
this.trafficUpdateTimer = null; this.trafficUpdateTimer = null;
} }
} }
} }

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`
<ops-sectionheading>Network Targets</ops-sectionheading> <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`
<ops-sectionheading>Remote Ingress</ops-sectionheading> <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,7 @@ 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}
.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),

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 {
@@ -13,6 +13,40 @@ import {
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
// TLS dropdown options shared by create and edit dialogs
const tlsModeOptions = [
{ key: 'none', option: '(none — no TLS)' },
{ key: 'passthrough', option: 'Passthrough' },
{ key: 'terminate', option: 'Terminate' },
{ key: 'terminate-and-reencrypt', option: 'Terminate & Re-encrypt' },
];
const tlsCertOptions = [
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
{ key: 'custom', option: 'Custom certificate' },
];
/**
* Toggle TLS form field visibility based on selected TLS mode and certificate type.
*/
function setupTlsVisibility(formEl: any) {
const updateVisibility = async () => {
const data = await formEl.collectFormData();
const contentEl = formEl.closest('.content') || formEl.parentElement;
if (!contentEl) return;
const tlsModeValue = data.tlsMode;
const modeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
const needsCert = modeKey === 'terminate' || modeKey === 'terminate-and-reencrypt';
const certGroup = contentEl.querySelector('.tlsCertificateGroup') as HTMLElement;
if (certGroup) certGroup.style.display = needsCert ? 'flex' : 'none';
const tlsCertValue = data.tlsCertificate;
const certKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
const customGroup = contentEl.querySelector('.tlsCustomCertGroup') as HTMLElement;
if (customGroup) customGroup.style.display = (needsCert && certKey === 'custom') ? 'flex' : 'none';
};
formEl.changeSubject.subscribe(() => updateVisibility());
updateVisibility();
}
@customElement('ops-view-routes') @customElement('ops-view-routes')
export class OpsViewRoutes extends DeesElement { export class OpsViewRoutes extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = { @state() accessor routeState: appstate.IRouteManagementState = {
@@ -166,7 +200,7 @@ export class OpsViewRoutes extends DeesElement {
}); });
return html` return html`
<ops-sectionheading>Route Management</ops-sectionheading> <dees-heading level="3">Route Management</dees-heading>
<div class="routesContainer"> <div class="routesContainer">
<dees-statsgrid <dees-statsgrid
@@ -303,7 +337,7 @@ export class OpsViewRoutes extends DeesElement {
<p>Source: <strong style="color: #0af;">programmatic</strong></p> <p>Source: <strong style="color: #0af;">programmatic</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.storedRouteId}</code></p>
${meta?.securityProfileName ? html`<p>Security Profile: <strong style="color: #a78bfa;">${meta.securityProfileName}</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>
`, `,
@@ -423,7 +457,18 @@ export class OpsViewRoutes extends DeesElement {
: ''; : '';
const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : ''; const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : '';
await DeesModal.createAndShow({ // Compute current TLS state for pre-population
const currentTls = (route.action as any).tls;
const currentTlsMode = currentTls?.mode || 'none';
const currentTlsCert = currentTls
? (currentTls.certificate === 'auto' || !currentTls.certificate ? 'auto' : 'custom')
: 'auto';
const currentCustomKey = (typeof currentTls?.certificate === 'object') ? currentTls.certificate.key : '';
const currentCustomCert = (typeof currentTls?.certificate === 'object') ? currentTls.certificate.cert : '';
const needsCert = currentTlsMode === 'terminate' || currentTlsMode === 'terminate-and-reencrypt';
const isCustom = currentTlsCert === 'custom';
const editModal = await DeesModal.createAndShow({
heading: `Edit Route: ${route.name}`, heading: `Edit Route: ${route.name}`,
content: html` content: html`
<dees-form> <dees-form>
@@ -431,10 +476,18 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text> <dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .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 (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.securityProfileRef || '')) || 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 (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text> <dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
<div class="tlsCustomCertGroup" style="display: ${needsCert && isCustom ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'} .value=${currentCustomKey}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'} .value=${currentCustomCert}></dees-input-text>
</div>
</div>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -476,11 +529,30 @@ export class OpsViewRoutes extends DeesElement {
...(priority != null && !isNaN(priority) ? { priority } : {}), ...(priority != null && !isNaN(priority) ? { priority } : {}),
}; };
// Build TLS config from form
const tlsModeValue = formData.tlsMode as any;
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
if (tlsModeKey && tlsModeKey !== 'none') {
const tls: any = { mode: tlsModeKey };
if (tlsModeKey !== 'passthrough') {
const tlsCertValue = formData.tlsCertificate as any;
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
if (tlsCertKey === 'custom' && formData.tlsCertKey && formData.tlsCertCert) {
tls.certificate = { key: formData.tlsCertKey, cert: formData.tlsCertCert };
} else {
tls.certificate = 'auto';
}
}
updatedRoute.action.tls = tls;
} else {
updatedRoute.action.tls = null; // explicit removal
}
const metadata: any = {}; const metadata: any = {};
const profileRefValue = formData.securityProfileRef as any; const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key; const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) { if (profileKey) {
metadata.securityProfileRef = profileKey; metadata.sourceProfileRef = profileKey;
} }
const targetRefValue = formData.networkTargetRef as any; const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key; const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
@@ -501,6 +573,12 @@ export class OpsViewRoutes extends DeesElement {
}, },
], ],
}); });
// Setup conditional TLS field visibility after modal renders
const editForm = editModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (editForm) {
await editForm.updateComplete;
setupTlsVisibility(editForm);
}
} }
private async showCreateRouteDialog() { private async showCreateRouteDialog() {
@@ -524,7 +602,7 @@ export class OpsViewRoutes extends DeesElement {
})), })),
]; ];
await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
heading: 'Add Programmatic Route', heading: 'Add Programmatic Route',
content: html` content: html`
<dees-form> <dees-form>
@@ -532,10 +610,18 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text> <dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .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 (higher = matched first)'}></dees-input-text>
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security 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 (if no target selected)'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text> <dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
<dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
<div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
<div class="tlsCustomCertGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-text .key=${'tlsCertKey'} .label=${'Private Key (PEM)'}></dees-input-text>
<dees-input-text .key=${'tlsCertCert'} .label=${'Certificate (PEM)'}></dees-input-text>
</div>
</div>
</dees-form> </dees-form>
`, `,
menuOptions: [ menuOptions: [
@@ -577,12 +663,29 @@ export class OpsViewRoutes extends DeesElement {
...(priority != null && !isNaN(priority) ? { priority } : {}), ...(priority != null && !isNaN(priority) ? { priority } : {}),
}; };
// Build TLS config from form
const tlsModeValue = formData.tlsMode as any;
const tlsModeKey = typeof tlsModeValue === 'string' ? tlsModeValue : tlsModeValue?.key;
if (tlsModeKey && tlsModeKey !== 'none') {
const tls: any = { mode: tlsModeKey };
if (tlsModeKey !== 'passthrough') {
const tlsCertValue = formData.tlsCertificate as any;
const tlsCertKey = typeof tlsCertValue === 'string' ? tlsCertValue : tlsCertValue?.key;
if (tlsCertKey === 'custom' && formData.tlsCertKey && formData.tlsCertCert) {
tls.certificate = { key: formData.tlsCertKey, cert: formData.tlsCertCert };
} else {
tls.certificate = 'auto';
}
}
route.action.tls = tls;
}
// Build metadata if profile/target selected // Build metadata if profile/target selected
const metadata: any = {}; const metadata: any = {};
const profileRefValue = formData.securityProfileRef as any; const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key; const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) { if (profileKey) {
metadata.securityProfileRef = profileKey; metadata.sourceProfileRef = profileKey;
} }
const targetRefValue = formData.networkTargetRef as any; const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key; const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
@@ -602,6 +705,12 @@ export class OpsViewRoutes extends DeesElement {
}, },
], ],
}); });
// Setup conditional TLS field visibility after modal renders
const createForm = createModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (createForm) {
await createForm.updateComplete;
setupTlsVisibility(createForm);
}
} }
private refreshData() { private refreshData() {

View File

@@ -7,19 +7,19 @@ 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 {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'ops-view-securityprofiles': OpsViewSecurityProfiles; 'ops-view-sourceprofiles': OpsViewSourceProfiles;
} }
} }
@customElement('ops-view-securityprofiles') @customElement('ops-view-sourceprofiles')
export class OpsViewSecurityProfiles extends DeesElement { export class OpsViewSourceProfiles extends DeesElement {
@state() @state()
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!; accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
@@ -58,20 +58,21 @@ export class OpsViewSecurityProfiles extends DeesElement {
type: 'number', type: 'number',
value: profiles.length, value: profiles.length,
icon: 'lucide:shieldCheck', icon: 'lucide:shieldCheck',
description: 'Reusable security profiles', description: 'Reusable source profiles',
color: '#3b82f6', color: '#3b82f6',
}, },
]; ];
return html` return html`
<ops-sectionheading>Security Profiles</ops-sectionheading> <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=${'Security Profiles'} .heading1=${'Source Profiles'}
.heading2=${'Reusable security configurations for routes'} .heading2=${'Reusable source configurations for routes'}
.data=${profiles} .data=${profiles}
.displayFunction=${(profile: interfaces.data.ISecurityProfile) => ({ .showColumnFilters=${true}
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
Name: profile.name, Name: profile.name,
Description: profile.description || '-', Description: profile.description || '-',
'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-', 'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
@@ -107,7 +108,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
iconName: 'lucide:pencil', iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any, type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ISecurityProfile; const profile = actionData.item as interfaces.data.ISourceProfile;
await this.showEditProfileDialog(profile); await this.showEditProfileDialog(profile);
}, },
}, },
@@ -116,7 +117,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
iconName: 'lucide:trash2', iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any, type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => { actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ISecurityProfile; const profile = actionData.item as interfaces.data.ISourceProfile;
await this.deleteProfile(profile); await this.deleteProfile(profile);
}, },
}, },
@@ -129,7 +130,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
private async showCreateProfileDialog() { private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: 'Create Security Profile', heading: 'Create Source Profile',
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>
@@ -149,7 +150,8 @@ export class OpsViewSecurityProfiles extends DeesElement {
const data = await form.collectFormData(); const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : []; const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : []; const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined; const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, { await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
name: String(data.name), name: String(data.name),
@@ -167,7 +169,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
}); });
} }
private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile) { private async showEditProfileDialog(profile: interfaces.data.ISourceProfile) {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`, heading: `Edit Profile: ${profile.name}`,
@@ -190,7 +192,8 @@ export class OpsViewSecurityProfiles extends DeesElement {
const data = await form.collectFormData(); const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : []; const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : []; const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const maxConnections = data.maxConnections ? parseInt(String(data.maxConnections)) : undefined; const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, { await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
id: profile.id, id: profile.id,
@@ -209,7 +212,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
}); });
} }
private async deleteProfile(profile: interfaces.data.ISecurityProfile) { private async deleteProfile(profile: interfaces.data.ISourceProfile) {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, { await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
id: profile.id, id: profile.id,
force: false, force: false,

View File

@@ -0,0 +1,392 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
import * as appstate from '../../appstate.js';
import * as 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-targetprofiles': OpsViewTargetProfiles;
}
}
@customElement('ops-view-targetprofiles')
export class OpsViewTargetProfiles extends DeesElement {
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() {
super();
const sub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.profilesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.tagBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
margin-right: 4px;
margin-bottom: 2px;
}
`,
];
public render(): TemplateResult {
const profiles = this.targetProfilesState.profiles;
const statsTiles: IStatsTile[] = [
{
id: 'totalProfiles',
title: 'Total Profiles',
type: 'number',
value: profiles.length,
icon: 'lucide:target',
description: 'Reusable target profiles',
color: '#8b5cf6',
},
];
return html`
<dees-heading level="3">Target Profiles</dees-heading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Target Profiles'}
.heading2=${'Define what resources VPN clients can access'}
.data=${profiles}
.showColumnFilters=${true}
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
Name: profile.name,
Description: profile.description || '-',
Domains: profile.domains?.length
? html`${profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)}`
: '-',
Targets: profile.targets?.length
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
: '-',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Profile',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateProfileDialog();
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
},
},
{
name: 'Detail',
iconName: 'lucide:info',
type: ['doubleClick'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showDetailDialog(profile);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showEditProfileDialog(profile);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.deleteProfile(profile);
},
},
]}
></dees-table>
</div>
`;
}
private getRouteCandidates() {
const routeState = appstate.routeManagementStatePart.getState();
const routes = routeState?.mergedRoutes || [];
return routes
.filter((mr) => mr.route.name)
.map((mr) => ({ viewKey: mr.route.name! }));
}
private async ensureRoutesLoaded() {
const routeState = appstate.routeManagementStatePart.getState();
if (!routeState?.mergedRoutes?.length) {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
}
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({
heading: 'Create Target Profile',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
if (!data.name) return;
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
const targets = targetStrings
.map((s: string) => {
const lastColon = s.lastIndexOf(':');
if (lastColon === -1) return null;
return {
ip: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains: domains.length > 0 ? domains : undefined,
targets: targets.length > 0 ? targets : undefined,
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
});
modalArg.destroy();
},
},
],
});
}
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains || [];
const currentTargets = profile.targets?.map(t => `${t.ip}:${t.port}`) || [];
const currentRouteRefs = profile.routeRefs || [];
const { DeesModal } = await import('@design.estate/dees-catalog');
await this.ensureRoutesLoaded();
const routeCandidates = this.getRouteCandidates();
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets (ip:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
iconName: 'lucide:check',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
const targets = targetStrings
.map((s: string) => {
const lastColon = s.lastIndexOf(':');
if (lastColon === -1) return null;
return {
ip: s.substring(0, lastColon),
port: parseInt(s.substring(lastColon + 1), 10),
};
})
.filter((t): t is { ip: string; port: number } => t !== null && !isNaN(t.port));
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains,
targets,
routeRefs,
});
modalArg.destroy();
},
},
],
});
}
private async showDetailDialog(profile: interfaces.data.ITargetProfile) {
const { DeesModal } = await import('@design.estate/dees-catalog');
// Fetch usage (which VPN clients reference this profile)
let usageHtml = html`<p style="color: #9ca3af;">Loading usage...</p>`;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetTargetProfileUsage
>('/typedrequest', 'getTargetProfileUsage');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
id: profile.id,
});
if (response.clients.length > 0) {
usageHtml = html`
<div style="margin-top: 8px;">
${response.clients.map(c => html`
<div style="padding: 4px 0; font-size: 13px;">
<strong>${c.clientId}</strong>${c.description ? html` - ${c.description}` : ''}
</div>
`)}
</div>
`;
} else {
usageHtml = html`<p style="color: #9ca3af; font-size: 13px;">No VPN clients reference this profile.</p>`;
}
} catch {
usageHtml = html`<p style="color: #9ca3af;">Usage data unavailable.</p>`;
}
DeesModal.createAndShow({
heading: `Target Profile: ${profile.name}`,
content: html`
<div style="display: flex; flex-direction: column; gap: 12px;">
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Description</div>
<div style="font-size: 14px; margin-top: 4px;">${profile.description || '-'}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Domains</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.domains?.length
? profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Targets</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.targets?.length
? profile.targets.map(t => html`<span class="tagBadge">${t.ip}:${t.port}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.routeRefs?.length
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Updated</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.updatedAt).toLocaleString()}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">VPN Clients Using This Profile</div>
${usageHtml}
</div>
</div>
`,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
}
private async deleteProfile(profile: interfaces.data.ITargetProfile) {
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
id: profile.id,
force: false,
});
const currentState = appstate.targetProfilesStatePart.getState()!;
if (currentState.error?.includes('in use')) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Profile In Use',
content: html`<p>${currentState.error} Force delete?</p>`,
menuOptions: [
{
name: 'Force Delete',
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
id: profile.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}

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';
/** /**
@@ -28,7 +28,7 @@ function setupFormVisibility(formEl: any) {
const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement; const staticIpGroup = contentEl.querySelector('.staticIpGroup') as HTMLElement;
const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement; const vlanIdGroup = contentEl.querySelector('.vlanIdGroup') as HTMLElement;
const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement; const aclGroup = contentEl.querySelector('.aclGroup') as HTMLElement;
if (hostIpGroup) hostIpGroup.style.display = data.forceDestinationSmartproxy ? 'none' : show; if (hostIpGroup) hostIpGroup.style.display = show; // always show (forceTarget is always on)
if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none'; if (hostIpDetails) hostIpDetails.style.display = data.useHostIp ? show : 'none';
if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show; if (staticIpGroup) staticIpGroup.style.display = data.useDhcp ? 'none' : show;
if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none'; if (vlanIdGroup) vlanIdGroup.style.display = data.forceVlan ? show : 'none';
@@ -60,6 +60,8 @@ export class OpsViewVpn extends DeesElement {
async connectedCallback() { async connectedCallback() {
await super.connectedCallback(); await super.connectedCallback();
await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null); await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
// Ensure target profiles are loaded for autocomplete candidates
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
} }
public static styles = [ public static styles = [
@@ -221,7 +223,7 @@ export class OpsViewVpn extends DeesElement {
]; ];
return html` return html`
<ops-sectionheading>VPN</ops-sectionheading> <dees-heading level="3">VPN</dees-heading>
<div class="vpnContainer"> <div class="vpnContainer">
${this.vpnState.newClientConfig ? html` ${this.vpnState.newClientConfig ? html`
@@ -303,6 +305,7 @@ 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}
.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;
@@ -315,9 +318,7 @@ export class OpsViewVpn extends DeesElement {
statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`; statusHtml = html`<span class="statusBadge enabled" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">offline</span>`;
} }
let routingHtml; let routingHtml;
if (client.forceDestinationSmartproxy !== false) { if (client.useHostIp) {
routingHtml = html`<span class="statusBadge enabled">SmartProxy</span>`;
} else if (client.useHostIp) {
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};">Host IP</span>`; routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};">Host IP</span>`;
} else { } else {
routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">Direct</span>`; routingHtml = html`<span class="statusBadge" style="background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};">Direct</span>`;
@@ -327,8 +328,12 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml, 'Status': statusHtml,
'Routing': routingHtml, 'Routing': routingHtml,
'VPN IP': client.assignedIp || '-', 'VPN IP': client.assignedIp || '-',
'Tags': client.serverDefinedClientTags?.length 'Target Profiles': client.targetProfileIds?.length
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}` ? html`${client.targetProfileIds.map(id => {
const profileState = appstate.targetProfilesStatePart.getState();
const profile = profileState?.profiles.find(p => p.id === id);
return html`<span class="tagBadge">${profile?.name || id}</span>`;
})}`
: '-', : '-',
'Description': client.description || '-', 'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(), 'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -341,15 +346,15 @@ export class OpsViewVpn extends DeesElement {
type: ['header'], type: ['header'],
actionFunc: async () => { actionFunc: async () => {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const profileCandidates = this.getTargetProfileCandidates();
const createModal = await DeesModal.createAndShow({ const createModal = await DeesModal.createAndShow({
heading: 'Create VPN Client', heading: 'Create VPN Client',
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text> <dees-input-text .key=${'clientId'} .label=${'Client ID'} .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-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text> <dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false}></dees-input-list>
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox> <div class="hostIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
<div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox> <dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
<div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;"> <div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox> <dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox>
@@ -383,13 +388,12 @@ export class OpsViewVpn extends DeesElement {
if (!form) return; if (!form) return;
const data = await form.collectFormData(); const data = await form.collectFormData();
if (!data.clientId) return; if (!data.clientId) return;
const serverDefinedClientTags = data.tags const targetProfileIds = this.resolveProfileNamesToIds(
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
: undefined; );
// Apply conditional logic based on checkbox states // Apply conditional logic based on checkbox states
const forceSmartproxy = data.forceDestinationSmartproxy ?? true; const useHostIp = data.useHostIp ?? false;
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
const useDhcp = useHostIp && (data.useDhcp ?? false); const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined; const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false); const forceVlan = useHostIp && (data.forceVlan ?? false);
@@ -406,8 +410,8 @@ export class OpsViewVpn extends DeesElement {
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, { await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId, clientId: data.clientId,
description: data.description || undefined, description: data.description || undefined,
serverDefinedClientTags, targetProfileIds,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined, useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined, useDhcp: useDhcp || undefined,
staticIp, staticIp,
@@ -479,8 +483,8 @@ export class OpsViewVpn extends DeesElement {
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div> <div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
` : ''} ` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div> <div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div> <div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${this.resolveProfileIdsToNames(client.targetProfileIds)?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div> <div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.useHostIp ? 'Host IP' : 'SmartProxy'}</span></div>
${client.useHostIp ? html` ${client.useHostIp ? html`
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div> <div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
<div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue">${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}</span></div> <div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue">${client.forceVlan && client.vlanId != null ? `VLAN ${client.vlanId}` : 'No VLAN'}</span></div>
@@ -643,8 +647,8 @@ export class OpsViewVpn extends DeesElement {
const client = actionData.item as interfaces.data.IVpnClient; const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? ''; const currentDescription = client.description ?? '';
const currentTags = client.serverDefinedClientTags?.join(', ') ?? ''; const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true; const profileCandidates = this.getTargetProfileCandidates();
const currentUseHostIp = client.useHostIp ?? false; const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false; const currentUseDhcp = client.useDhcp ?? false;
const currentStaticIp = client.staticIp ?? ''; const currentStaticIp = client.staticIp ?? '';
@@ -659,9 +663,8 @@ export class OpsViewVpn extends DeesElement {
content: html` content: html`
<dees-form> <dees-form>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text> <dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text> <dees-input-list .key=${'targetProfileNames'} .label=${'Target Profiles'} .placeholder=${'Type to search profiles...'} .candidates=${profileCandidates} .allowFreeform=${false} .value=${currentTargetProfileNames}></dees-input-list>
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox> <div class="hostIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
<div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox> <dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
<div class="hostIpDetails" style="display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;"> <div class="hostIpDetails" style="display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${currentUseDhcp}></dees-input-checkbox> <dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${currentUseDhcp}></dees-input-checkbox>
@@ -690,13 +693,12 @@ export class OpsViewVpn extends DeesElement {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form'); const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return; if (!form) return;
const data = await form.collectFormData(); const data = await form.collectFormData();
const serverDefinedClientTags = data.tags const targetProfileIds = this.resolveProfileNamesToIds(
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean) Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
: []; );
// Apply conditional logic based on checkbox states // Apply conditional logic based on checkbox states
const forceSmartproxy = data.forceDestinationSmartproxy ?? true; const useHostIp = data.useHostIp ?? false;
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
const useDhcp = useHostIp && (data.useDhcp ?? false); const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined; const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false); const forceVlan = useHostIp && (data.forceVlan ?? false);
@@ -713,8 +715,8 @@ export class OpsViewVpn extends DeesElement {
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, { await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
clientId: client.clientId, clientId: client.clientId,
description: data.description || undefined, description: data.description || undefined,
serverDefinedClientTags, targetProfileIds,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined, useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined, useDhcp: useDhcp || undefined,
staticIp, staticIp,
@@ -805,4 +807,43 @@ export class OpsViewVpn extends DeesElement {
</div> </div>
`; `;
} }
/**
* Build autocomplete candidates from loaded target profiles.
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
*/
private getTargetProfileCandidates() {
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return profiles.map((p) => ({ viewKey: p.name, payload: { id: p.id } }));
}
/**
* Convert profile IDs to profile names (for populating edit form values).
*/
private resolveProfileIdsToNames(ids?: string[]): string[] | undefined {
if (!ids?.length) return undefined;
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return ids.map((id) => {
const profile = profiles.find((p) => p.id === id);
return profile?.name || id;
});
}
/**
* Convert profile names back to IDs (for saving form data).
* Uses the dees-input-list candidates' payload when available.
*/
private resolveProfileNamesToIds(names: string[]): string[] | undefined {
if (!names.length) return undefined;
const profileState = appstate.targetProfilesStatePart.getState();
const profiles = profileState?.profiles || [];
return names
.map((name) => {
const profile = profiles.find((p) => p.name === name);
return profile?.id;
})
.filter((id): id is string => !!id);
}
} }

View File

@@ -11,21 +11,51 @@ 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 { OpsViewSecurityProfiles } from './ops-view-securityprofiles.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 { 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';
// 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 {
@@ -36,6 +66,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,
@@ -48,27 +79,36 @@ 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 },
],
}, },
{ {
name: 'Logs', name: 'Logs',
@@ -76,47 +116,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: 'SecurityProfiles', ],
iconName: 'lucide:shieldCheck',
element: OpsViewSecurityProfiles,
},
{
name: 'NetworkTargets',
iconName: 'lucide:server',
element: OpsViewNetworkTargets,
},
{
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;
@@ -132,17 +179,19 @@ export class OpsDashboard extends DeesElement {
} }
/** /**
* Get the current view tab based on the UI state's activeView. * Get the current view tab based on the UI state's activeView/activeSubview.
* Used to pass the correct selectedView to dees-simple-appdash on initial render. * 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() {
super(); super();
document.title = 'DCRouter OpsServer'; document.title = 'DCRouter OpsServer';
// Subscribe to login state // Subscribe to login state
const loginSubscription = appstate.loginStatePart const loginSubscription = appstate.loginStatePart
.select((stateArg) => stateArg) .select((stateArg) => stateArg)
@@ -155,7 +204,7 @@ export class OpsDashboard extends DeesElement {
} }
}); });
this.rxSubscriptions.push(loginSubscription); this.rxSubscriptions.push(loginSubscription);
// Subscribe to config state (for global warnings) // Subscribe to config state (for global warnings)
const configSubscription = appstate.configStatePart const configSubscription = appstate.configStatePart
.select((stateArg) => stateArg) .select((stateArg) => stateArg)
@@ -170,38 +219,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 = [
@@ -243,7 +281,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);
}); });
@@ -252,9 +290,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
@@ -300,12 +353,12 @@ export class OpsDashboard extends DeesElement {
const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any; const simpleLogin = this.shadowRoot!.querySelector('dees-simple-login') as any;
const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any; const form = simpleLogin.shadowRoot!.querySelector('dees-form') as any;
form.setStatus('pending', 'Logging in...'); form.setStatus('pending', 'Logging in...');
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, { const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
username, username,
password, password,
}); });
if (state.identity) { if (state.identity) {
console.log('Login successful'); console.log('Login successful');
this.loginState = state; this.loginState = state;
@@ -319,4 +372,4 @@ export class OpsDashboard extends DeesElement {
form!.reset(); form!.reset();
} }
} }
} }

View File

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

View File

@@ -1,543 +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';
constructor() {
super();
const subscription = appstate.statsStatePart
.select((stateArg) => stateArg)
.subscribe((statsState) => {
this.statsState = statsState;
});
this.rxSubscriptions.push(subscription);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
css`
.tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 2px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.tab {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 16px;
color: ${cssManager.bdTheme('#666', '#999')};
transition: all 0.2s ease;
}
.tab:hover {
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.tab.active {
color: ${cssManager.bdTheme('#2196F3', '#4a90e2')};
border-bottom-color: ${cssManager.bdTheme('#2196F3', '#4a90e2')};
}
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;
}
.securityCard.alert {
border-color: ${cssManager.bdTheme('#f44336', '#ff6666')};
background: ${cssManager.bdTheme('#ffebee', '#4a1f1f')};
}
.securityCard.warning {
border-color: ${cssManager.bdTheme('#ff9800', '#ffaa33')};
background: ${cssManager.bdTheme('#fff3e0', '#4a3a1f')};
}
.securityCard.success {
border-color: ${cssManager.bdTheme('#4caf50', '#66cc66')};
background: ${cssManager.bdTheme('#e8f5e9', '#1f3f1f')};
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.cardTitle {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.cardStatus {
font-size: 14px;
padding: 4px 12px;
border-radius: 16px;
font-weight: 500;
}
.status-critical {
background: ${cssManager.bdTheme('#f44336', '#ff6666')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.status-warning {
background: ${cssManager.bdTheme('#ff9800', '#ffaa33')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.status-good {
background: ${cssManager.bdTheme('#4caf50', '#66cc66')};
color: ${cssManager.bdTheme('#fff', '#fff')};
}
.metricValue {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
}
.metricLabel {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.actionButton {
margin-top: 16px;
}
.blockedIpList {
max-height: 400px;
overflow-y: auto;
}
.blockedIpItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
}
.blockedIpItem:last-child {
border-bottom: none;
}
.ipAddress {
font-family: 'Consolas', 'Monaco', monospace;
font-weight: 600;
}
.blockReason {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
}
.blockTime {
font-size: 12px;
color: ${cssManager.bdTheme('#999', '#666')};
}
`,
];
public render() {
return html`
<ops-sectionheading>Security</ops-sectionheading>
<div class="tabs">
<button
class="tab ${this.selectedTab === 'overview' ? 'active' : ''}"
@click=${() => this.selectedTab = 'overview'}
>
Overview
</button>
<button
class="tab ${this.selectedTab === 'blocked' ? 'active' : ''}"
@click=${() => this.selectedTab = 'blocked'}
>
Blocked IPs
</button>
<button
class="tab ${this.selectedTab === 'authentication' ? 'active' : ''}"
@click=${() => this.selectedTab = 'authentication'}
>
Authentication
</button>
<button
class="tab ${this.selectedTab === 'email-security' ? 'active' : ''}"
@click=${() => this.selectedTab = 'email-security'}
>
Email Security
</button>
</div>
${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) {
return html`
<div class="securityCard">
<div class="cardHeader">
<h3 class="cardTitle">Blocked IP Addresses</h3>
<dees-button @click=${() => this.clearBlockedIPs()}>
Clear All
</dees-button>
</div>
<div class="blockedIpList">
${metrics.blockedIPs && metrics.blockedIPs.length > 0 ? metrics.blockedIPs.map((ipAddress, index) => html`
<div class="blockedIpItem">
<div>
<div class="ipAddress">${ipAddress}</div>
<div class="blockReason">Suspicious activity</div>
<div class="blockTime">Blocked</div>
</div>
<dees-button @click=${() => this.unblockIP(ipAddress)}>
Unblock
</dees-button>
</div>
`) : html`
<p>No blocked IPs</p>
`}
</div>
</div>
`;
}
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`
<ops-sectionheading>Configuration</ops-sectionheading> <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`
<ops-sectionheading>Overview</ops-sectionheading> <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

@@ -1,2 +1 @@
export * from './css.js'; export * from './css.js';
export * from './ops-sectionheading.js';

View File

@@ -1,38 +0,0 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
type TemplateResult
} from '@design.estate/dees-element';
@customElement('ops-sectionheading')
export class OpsSectionHeading extends DeesElement {
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
margin-bottom: 24px;
}
.heading {
font-family: 'Cal Sans', 'Inter', sans-serif;
font-size: 28px;
font-weight: 600;
color: ${cssManager.bdTheme('#111', '#fff')};
margin: 0;
padding: 0;
}
`,
];
public render(): TemplateResult {
return html`
<h1 class="heading">
<slot></slot>
</h1>
`;
}
}

Some files were not shown because too many files have changed in this diff Show More