Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5689e93665 | |||
| c224028495 | |||
| 4fbe01823b | |||
| 34ba2c9f02 | |||
| 52aed0e96e | |||
| ea2e618990 | |||
| 140637a307 | |||
| 21c80e173d | |||
| e77fe9451e | |||
| 7971bd249e | |||
| 6099563acd | |||
| bf4c181026 | |||
| d9d12427d3 | |||
| 91aa9a7228 | |||
| 877356b247 | |||
| 2325f01cde | |||
| 00fdadb088 | |||
| 2b76e05a40 | |||
| 1b37944aab | |||
| 35a01a6981 | |||
| 3058706d2a | |||
| 0e4d6a3c0c | |||
| 2bc2475878 | |||
| 37eab7c7b1 | |||
| 8ab7343606 | |||
| f04feec273 | |||
| d320590ce2 | |||
| 0ee57f433b | |||
| b28b5eea84 | |||
| 27d7489af9 | |||
| 940c7dc92e | |||
| 7fa6d82e58 | |||
| f29ed9757e | |||
| ad45d1b8b9 | |||
| 68473f8550 | |||
| 07cfe76cac | |||
| 3775957bf2 | |||
| 31ce18a025 | |||
| 0cccec5526 | |||
| 0373f02f86 | |||
| 52dac0339f | |||
| b6f7f5f63f | |||
| 6271bb1079 | |||
| 0fa65f31c3 | |||
| 93d6c7d341 | |||
| b2ccd54079 | |||
| 4e9b09616d | |||
| ddb420835e | |||
| 505fd044c0 | |||
| 7711204fef | |||
| d7b6fbb241 | |||
| a670b27a1c | |||
| c2f57b086f | |||
| 083f16d7b4 | |||
| 2994b6e686 | |||
| ba15c169d7 | |||
| bbd5707711 | |||
| 1ddf83b28d | |||
| 25365678e0 | |||
| 96d215fc66 | |||
| 648ba9e61d | |||
| fcc1d9fede | |||
| 336e8aa4cc | |||
| c8f19cf783 | |||
| 12b2cc11da | |||
| ffcc35be64 | |||
| 59e0d41bdb | |||
| 9509d87b1e | |||
| b835e2d0eb | |||
| 6c3d8714a2 | |||
| 94f53f0259 | |||
| 1004f8579f | |||
| a77ec6884a | |||
| 6112e4e884 | |||
| 4a6913d4bb | |||
| f6a9e344e5 | |||
| b3296c6522 | |||
| 10a2b922d3 | |||
| ee5cdde225 | |||
| d2e9efccd0 | |||
| a07901a28a | |||
| a3954d6eb5 | |||
| 9685fcd89d | |||
| 74c23ce5ff | |||
| 746fbb15e6 | |||
| 415065b246 | |||
| 30aeef7bbd | |||
| dba1c70fa7 | |||
| f9cfb3d36b | |||
| 43b92b784d | |||
| b62a322c54 | |||
| a3a64e9a02 | |||
| 491e51f40b | |||
| b46247d9cb | |||
| 9c0e46ff4e | |||
| f62bc4a526 | |||
| 8f23600ec1 | |||
| 141f185fbf | |||
| 6f4a5f19e7 | |||
| 9d8354e58f | |||
| 947637eed7 | |||
| 5202c2ea27 | |||
| 6684dc43da | |||
| 04ec387ce5 |
322
changelog.md
322
changelog.md
@@ -1,5 +1,327 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
include stored VPN routes in domain resolution and align programmatic route types with dcrouter configs
|
||||||
|
|
||||||
|
- Scans enabled stored/programmatic routes for VPN domain matches when resolving client access domains.
|
||||||
|
- Replaces generic smartproxy route typings with IDcRouterRouteConfig across route management and stored route models.
|
||||||
|
- Updates @push.rocks/smartproxy to ^27.4.0.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.2 - fix(config-ui)
|
||||||
|
handle missing HTTP/3 config safely and standardize overview section headings
|
||||||
|
|
||||||
|
- Prevents route augmentation logic from failing when HTTP/3 configuration is undefined by using optional chaining.
|
||||||
|
- Updates the operations overview to use dees-heading components for activity, email, DNS, RADIUS, and VPN section headings.
|
||||||
|
- Bumps @push.rocks/smartproxy from ^27.2.0 to ^27.3.1.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.1 - fix(monitoring)
|
||||||
|
update SmartProxy and use direct connection protocol metrics access
|
||||||
|
|
||||||
|
- bump @push.rocks/smartproxy from ^27.1.0 to ^27.2.0
|
||||||
|
- replace fallback any-based access with direct frontend and backend protocol metric calls in MetricsManager
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.9.0 - feat(monitoring)
|
||||||
|
add frontend and backend protocol distribution metrics to network stats
|
||||||
|
|
||||||
|
- Expose frontend and backend protocol distribution data in monitoring metrics, stats responses, and shared interfaces.
|
||||||
|
- Render protocol distribution donut charts in the ops network view using the new stats fields.
|
||||||
|
- Preserve existing stored certificate IDs when updating certificate records by domain.
|
||||||
|
- Bump @design.estate/dees-catalog to ^3.55.5 for the new chart component support.
|
||||||
|
|
||||||
|
## 2026-04-04 - 12.8.1 - fix(ops-view-routes)
|
||||||
|
correct route form dropdown selection handling for security profiles and network targets
|
||||||
|
|
||||||
|
- Update route edit and create forms to use selectedOption for dropdowns backed by the newer dees-catalog version
|
||||||
|
- Normalize submitted dropdown values to extract option keys before storing securityProfileRef and networkTargetRef
|
||||||
|
- Refresh documentation to reflect expanded stats coverage for network, RADIUS, and VPN metrics
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.8.0 - feat(certificates)
|
||||||
|
add force renew option for domain certificate reprovisioning
|
||||||
|
|
||||||
|
- pass an optional forceRenew flag through certificate reprovision requests from the UI to the ops handler
|
||||||
|
- use smartacme forceRenew support and return renewal-specific success messages
|
||||||
|
- update the SmartAcme dependency to version ^9.4.0
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.7.0 - feat(opsserver)
|
||||||
|
add RADIUS and VPN metrics to combined ops stats and overview dashboards, and stream live log buffer entries in follow mode
|
||||||
|
|
||||||
|
- Expose RADIUS and VPN sections in the combined stats API and shared TypeScript interfaces
|
||||||
|
- Populate frontend app state and overview tiles with RADIUS authentication, session, traffic, and VPN client metrics
|
||||||
|
- Replace simulated follow-mode log events with real log buffer tailing and timestamp-based incremental streaming
|
||||||
|
- Use commit metadata for reported server version instead of a hardcoded value
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.6 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.52.3
|
||||||
|
|
||||||
|
- Updates @design.estate/dees-catalog from ^3.52.2 to ^3.52.3 in package.json
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.5 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.52.2
|
||||||
|
|
||||||
|
- Updates the @design.estate/dees-catalog dependency from ^3.52.0 to ^3.52.2 in package.json.
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.4 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.52.0
|
||||||
|
|
||||||
|
- Updates the @design.estate/dees-catalog dependency from ^3.51.2 to ^3.52.0 in package.json.
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.3 - fix(deps)
|
||||||
|
bump @types/node and @design.estate/dees-catalog patch versions
|
||||||
|
|
||||||
|
- updates @types/node from ^25.5.1 to ^25.5.2
|
||||||
|
- updates @design.estate/dees-catalog from ^3.51.1 to ^3.51.2
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.2 - fix(deps)
|
||||||
|
bump @design.estate/dees-catalog to ^3.51.1
|
||||||
|
|
||||||
|
- Updates @design.estate/dees-catalog from ^3.51.0 to ^3.51.1 in package.json
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.1 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.6.0 - feat(certificates)
|
||||||
|
add confirmation before force renewing valid certificates from the certificate actions menu
|
||||||
|
|
||||||
|
- Expose the Reprovision action in the certificate context menu
|
||||||
|
- Prompt for confirmation when reprovisioning a certificate that is still valid
|
||||||
|
- Update dees-catalog and @types/node dependencies
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.5.2 - fix(repo)
|
||||||
|
no changes to commit
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-04-03 - 12.5.1 - fix(ops-view-network)
|
||||||
|
centralize traffic chart timing constants for consistent rolling window updates
|
||||||
|
|
||||||
|
- Defines shared constants for the chart window, update interval, and maximum buffered data points
|
||||||
|
- Replaces hardcoded traffic history sizes and timer intervals with derived values across initialization, history loading, and live updates
|
||||||
|
- Keeps the chart rolling window configuration aligned with the in-memory traffic buffer
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.5.0 - feat(ops-view-routes)
|
||||||
|
add priority support and list-based domain editing for routes
|
||||||
|
|
||||||
|
- Adds a priority field to route create and edit forms so route matching order can be configured.
|
||||||
|
- Replaces comma-separated domain text input with a list-based domain editor and updates form handling to persist domains as arrays.
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.4.0 - feat(routes)
|
||||||
|
add route edit and delete actions to the ops routes view
|
||||||
|
|
||||||
|
- introduces an update route action in web app state and refreshes merged routes after changes
|
||||||
|
- adds edit and delete handlers with modal-based confirmation and route form inputs for programmatic routes
|
||||||
|
- enables realtime chart window configuration in network and overview dashboards
|
||||||
|
- bumps @serve.zone/catalog to ^2.11.0
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.3.0 - feat(docs,ops-dashboard)
|
||||||
|
document unified database and reusable security profile and network target management
|
||||||
|
|
||||||
|
- Update project and interface documentation to replace separate storage/cache configuration with a unified database model
|
||||||
|
- Document new security profile and network target APIs, data models, and dashboard capabilities
|
||||||
|
- Add a global dashboard warning when the database is disabled so unavailable management features are clearly indicated
|
||||||
|
- Bump @design.estate/dees-catalog and @serve.zone/catalog to support the updated dashboard experience
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.6 - fix(ops-ui)
|
||||||
|
improve operations table actions and modal form handling for profiles and network targets
|
||||||
|
|
||||||
|
- adds section headings for the Security Profiles and Network Targets views
|
||||||
|
- updates edit and delete actions to support in-row table actions in addition to context menus
|
||||||
|
- makes create and edit dialogs query forms safely from modal content and adds early returns when forms are unavailable
|
||||||
|
- enables the database configuration in the development watch server
|
||||||
|
|
||||||
|
## 2026-04-02 - 12.2.5 - fix(dcrouter)
|
||||||
|
sync allowed tunnel edges when merged routes change
|
||||||
|
|
||||||
|
- Triggers tunnelManager.syncAllowedEdges() after route updates are applied
|
||||||
|
- Keeps derived ports in the Rust hub binary aligned with merged route changes
|
||||||
|
|
||||||
## 2026-04-02 - 12.2.4 - fix(routes)
|
## 2026-04-02 - 12.2.4 - fix(routes)
|
||||||
support profile and target metadata in route creation and refresh remote ingress routes after config initialization
|
support profile and target metadata in route creation and refresh remote ingress routes after config initialization
|
||||||
|
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@serve.zone/dcrouter",
|
"name": "@serve.zone/dcrouter",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "12.2.4",
|
"version": "13.8.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": {
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@git.zone/tsrun": "^2.0.2",
|
"@git.zone/tsrun": "^2.0.2",
|
||||||
"@git.zone/tstest": "^3.6.3",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@git.zone/tswatch": "^3.3.2",
|
"@git.zone/tswatch": "^3.3.2",
|
||||||
"@types/node": "^25.5.0"
|
"@types/node": "^25.5.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.3.0",
|
"@api.global/typedrequest": "^3.3.0",
|
||||||
@@ -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.49.2",
|
"@design.estate/dees-catalog": "^3.69.1",
|
||||||
"@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.3.1",
|
"@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.1.1",
|
||||||
"@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.1.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.9.1",
|
"@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"
|
||||||
},
|
},
|
||||||
|
|||||||
2622
pnpm-lock.yaml
generated
2622
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
142
readme.md
142
readme.md
@@ -25,7 +25,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- [Remote Ingress](#remote-ingress)
|
- [Remote Ingress](#remote-ingress)
|
||||||
- [VPN Access Control](#vpn-access-control)
|
- [VPN Access Control](#vpn-access-control)
|
||||||
- [Certificate Management](#certificate-management)
|
- [Certificate Management](#certificate-management)
|
||||||
- [Storage & Caching](#storage--caching)
|
- [Storage & Database](#storage--database)
|
||||||
- [Security Features](#security-features)
|
- [Security Features](#security-features)
|
||||||
- [OpsServer Dashboard](#opsserver-dashboard)
|
- [OpsServer Dashboard](#opsserver-dashboard)
|
||||||
- [API Client](#api-client)
|
- [API Client](#api-client)
|
||||||
@@ -93,10 +93,11 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Socket-handler mode** — direct socket passing eliminates internal port hops
|
- **Socket-handler mode** — direct socket passing eliminates internal port hops
|
||||||
- **Real-time metrics** via SmartMetrics (CPU, memory, connections, throughput)
|
- **Real-time metrics** via SmartMetrics (CPU, memory, connections, throughput)
|
||||||
|
|
||||||
### 💾 Persistent Storage & Caching
|
### 💾 Unified Database
|
||||||
- **Multiple storage backends**: filesystem, custom functions, or in-memory
|
- **Two deployment modes**: embedded LocalSmartDb (zero-config) or external MongoDB
|
||||||
- **Embedded cache database** via smartdata + smartdb (MongoDB-compatible)
|
- **15 document classes** covering routes, certs, VPN, RADIUS, security profiles, network targets, and caches
|
||||||
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
- **Automatic TTL-based cleanup** for cached emails and IP reputation data
|
||||||
|
- **Reusable references** — security profiles and network targets that propagate changes to all referencing routes
|
||||||
|
|
||||||
### 🖥️ OpsServer Dashboard
|
### 🖥️ OpsServer Dashboard
|
||||||
- **Web-based management interface** with real-time monitoring
|
- **Web-based management interface** with real-time monitoring
|
||||||
@@ -104,7 +105,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
- **Live views** for connections, email queues, DNS queries, RADIUS sessions, certificates, remote ingress edges, VPN clients, and security events
|
||||||
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
- **Domain-centric certificate overview** with backoff status and one-click reprovisioning
|
||||||
- **Remote ingress management** with connection token generation and one-click copy
|
- **Remote ingress management** with connection token generation and one-click copy
|
||||||
- **Read-only configuration display** — DcRouter is configured through code
|
- **Security profiles & network targets** — reusable security configurations and host:port targets with propagation to referencing routes
|
||||||
|
- **Global warning banners** when database is disabled (management features unavailable)
|
||||||
|
- **Read-only configuration display** for system overview
|
||||||
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
- **Smart tab visibility handling** — auto-pauses all polling, WebSocket connections, and chart updates when the browser tab is hidden, preventing resource waste and tab freezing
|
||||||
|
|
||||||
### 🔧 Programmatic API Client
|
### 🔧 Programmatic API Client
|
||||||
@@ -269,11 +272,8 @@ const router = new DcRouter({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persistent storage
|
// Unified database (embedded LocalSmartDb or external MongoDB)
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' },
|
dbConfig: { enabled: true },
|
||||||
|
|
||||||
// Cache database
|
|
||||||
cacheConfig: { enabled: true, storagePath: '~/.serve.zone/dcrouter/tsmdb' },
|
|
||||||
|
|
||||||
// TLS & ACME
|
// TLS & ACME
|
||||||
tls: { contactEmail: 'admin@example.com' },
|
tls: { contactEmail: 'admin@example.com' },
|
||||||
@@ -311,8 +311,7 @@ graph TB
|
|||||||
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
CM[Certificate Manager<br/><i>smartacme v9</i>]
|
||||||
OS[OpsServer Dashboard]
|
OS[OpsServer Dashboard]
|
||||||
MM[Metrics Manager]
|
MM[Metrics Manager]
|
||||||
SM[Storage Manager]
|
DB2[DcRouterDb<br/><i>smartdata + smartdb</i>]
|
||||||
CD[Cache Database]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Backend Services"
|
subgraph "Backend Services"
|
||||||
@@ -339,8 +338,7 @@ graph TB
|
|||||||
DC --> CM
|
DC --> CM
|
||||||
DC --> OS
|
DC --> OS
|
||||||
DC --> MM
|
DC --> MM
|
||||||
DC --> SM
|
DC --> DB2
|
||||||
DC --> CD
|
|
||||||
|
|
||||||
SP --> WEB
|
SP --> WEB
|
||||||
SP --> API
|
SP --> API
|
||||||
@@ -365,8 +363,7 @@ graph TB
|
|||||||
| **RemoteIngress** | `@serve.zone/remoteingress` | Distributed edge tunneling with Rust data plane and TS management |
|
| **RemoteIngress** | `@serve.zone/remoteingress` | Distributed edge tunneling with Rust data plane and TS management |
|
||||||
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
| **OpsServer** | `@api.global/typedserver` | Web dashboard + TypedRequest API for monitoring and management |
|
||||||
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
| **MetricsManager** | `@push.rocks/smartmetrics` | Real-time metrics collection (CPU, memory, email, DNS, security) |
|
||||||
| **StorageManager** | built-in | Pluggable key-value storage (filesystem, custom, or in-memory) |
|
| **DcRouterDb** | `@push.rocks/smartdata` + `@push.rocks/smartdb` | Unified database — embedded LocalSmartDb or external MongoDB for all persistence |
|
||||||
| **CacheDb** | `@push.rocks/smartdb` | Embedded MongoDB-compatible database (LocalSmartDb) for persistent caching |
|
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
@@ -509,24 +506,16 @@ interface IDcRouterOptions {
|
|||||||
};
|
};
|
||||||
dnsChallenge?: { cloudflareApiKey?: string };
|
dnsChallenge?: { cloudflareApiKey?: string };
|
||||||
|
|
||||||
// ── Storage & Caching ─────────────────────────────────────────
|
// ── Database ────────────────────────────────────────────────────
|
||||||
storage?: {
|
/** Unified database for all persistence (routes, certs, VPN, RADIUS, etc.) */
|
||||||
fsPath?: string;
|
dbConfig?: {
|
||||||
readFunction?: (key: string) => Promise<string>;
|
|
||||||
writeFunction?: (key: string, value: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
cacheConfig?: {
|
|
||||||
enabled?: boolean; // default: true
|
enabled?: boolean; // default: true
|
||||||
|
mongoDbUrl?: string; // External MongoDB URL (omit for embedded LocalSmartDb)
|
||||||
storagePath?: string; // default: '~/.serve.zone/dcrouter/tsmdb'
|
storagePath?: string; // default: '~/.serve.zone/dcrouter/tsmdb'
|
||||||
dbName?: string; // default: 'dcrouter'
|
dbName?: string; // default: 'dcrouter'
|
||||||
cleanupIntervalHours?: number; // default: 1
|
cleanupIntervalHours?: number; // default: 1
|
||||||
ttlConfig?: {
|
seedOnEmpty?: boolean; // Seed default profiles/targets if DB is empty
|
||||||
emails?: number; // default: 30 days
|
seedData?: object; // Custom seed data
|
||||||
ipReputation?: number; // default: 1 day
|
|
||||||
bounces?: number; // default: 30 days
|
|
||||||
dkimKeys?: number; // default: 90 days
|
|
||||||
suppression?: number; // default: 30 days
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1213,49 +1202,55 @@ The OpsServer includes a **Certificates** view showing:
|
|||||||
- One-click reprovisioning per domain
|
- One-click reprovisioning per domain
|
||||||
- Certificate import and export
|
- Certificate import and export
|
||||||
|
|
||||||
## Storage & Caching
|
## Storage & Database
|
||||||
|
|
||||||
### StorageManager
|
DcRouter uses a **unified database** (`DcRouterDb`) powered by [`@push.rocks/smartdata`](https://code.foss.global/push.rocks/smartdata) + [`@push.rocks/smartdb`](https://code.foss.global/push.rocks/smartdb) for all persistence. It supports two modes:
|
||||||
|
|
||||||
Provides a unified key-value interface with three backends:
|
### Embedded LocalSmartDb (Default)
|
||||||
|
|
||||||
|
Zero-config, file-based MongoDB-compatible database — no external services needed:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Filesystem backend
|
dbConfig: { enabled: true }
|
||||||
storage: { fsPath: '/var/lib/dcrouter/data' }
|
// Data stored at ~/.serve.zone/dcrouter/tsmdb by default
|
||||||
|
|
||||||
// Custom backend (Redis, S3, etc.)
|
|
||||||
storage: {
|
|
||||||
readFunction: async (key) => await redis.get(key),
|
|
||||||
writeFunction: async (key, value) => await redis.set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory (development only — data lost on restart)
|
|
||||||
// Simply omit the storage config
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Used for: TLS certificates, DKIM keys, email routes, bounce/suppression lists, IP reputation data, domain configs, cert backoff state, remote ingress edge registrations.
|
### External MongoDB
|
||||||
|
|
||||||
### Cache Database
|
Connect to an existing MongoDB instance:
|
||||||
|
|
||||||
An embedded MongoDB-compatible database (via smartdata + smartdb) for persistent caching with automatic TTL cleanup:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
cacheConfig: {
|
dbConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
storagePath: '~/.serve.zone/dcrouter/tsmdb',
|
mongoDbUrl: 'mongodb://localhost:27017',
|
||||||
dbName: 'dcrouter',
|
dbName: 'dcrouter',
|
||||||
cleanupIntervalHours: 1,
|
|
||||||
ttlConfig: {
|
|
||||||
emails: 30, // days
|
|
||||||
ipReputation: 1, // days
|
|
||||||
bounces: 30, // days
|
|
||||||
dkimKeys: 90, // days
|
|
||||||
suppression: 30 // days
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Cached document types: `CachedEmail`, `CachedIPReputation`.
|
### Disabling the Database
|
||||||
|
|
||||||
|
For static, constructor-only deployments where no runtime management is needed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dbConfig: { enabled: false }
|
||||||
|
// Routes come exclusively from constructor config — no CRUD, no persistence
|
||||||
|
// OpsServer still runs but management features are disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### What's Stored
|
||||||
|
|
||||||
|
DcRouterDb persists all runtime state across 15 document classes:
|
||||||
|
|
||||||
|
| Category | Documents | Purpose |
|
||||||
|
|----------|-----------|---------|
|
||||||
|
| **Routes** | `StoredRouteDoc`, `RouteOverrideDoc` | Programmatic routes and hardcoded route overrides |
|
||||||
|
| **Certificates** | `ProxyCertDoc`, `AcmeCertDoc`, `CertBackoffDoc` | TLS certs, ACME state, per-domain backoff |
|
||||||
|
| **Auth** | `ApiTokenDoc` | API token storage |
|
||||||
|
| **Remote Ingress** | `RemoteIngressEdgeDoc` | Edge node registrations |
|
||||||
|
| **VPN** | `VpnServerKeysDoc`, `VpnClientDoc` | Server keys and client registrations |
|
||||||
|
| **RADIUS** | `VlanMappingsDoc`, `AccountingSessionDoc` | VLAN mappings and accounting sessions |
|
||||||
|
| **References** | `SecurityProfileDoc`, `NetworkTargetDoc` | Reusable security profiles and network targets |
|
||||||
|
| **Cache** | `CachedEmailDoc`, `CachedIpReputationDoc` | TTL-based caches with automatic cleanup |
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
@@ -1324,6 +1319,8 @@ The OpsServer provides a web-based management interface served on port 3000 by d
|
|||||||
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
| 🔐 **Certificates** | Domain-centric certificate overview, status, backoff info, reprovisioning, import/export |
|
||||||
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
| 🌍 **RemoteIngress** | Edge node management, connection status, token generation, enable/disable |
|
||||||
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
|
| 🔐 **VPN** | VPN client management, server status, create/toggle/export/rotate/delete clients |
|
||||||
|
| 🛡️ **Security Profiles** | Reusable security configurations (IP allow/block lists, rate limits) |
|
||||||
|
| 🎯 **Network Targets** | Reusable host:port destinations for route references |
|
||||||
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
|
| 📡 **RADIUS** | NAS client management, VLAN mappings, session monitoring, accounting |
|
||||||
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
| 📜 **Logs** | Real-time log viewer with level filtering and search |
|
||||||
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
| ⚙️ **Configuration** | Read-only view of current system configuration |
|
||||||
@@ -1410,6 +1407,22 @@ All management is done via TypedRequest over HTTP POST to `/typedrequest`:
|
|||||||
'setVlanMapping' // Add/update VLAN mapping
|
'setVlanMapping' // Add/update VLAN mapping
|
||||||
'removeVlanMapping' // Remove VLAN mapping
|
'removeVlanMapping' // Remove VLAN mapping
|
||||||
'testVlanAssignment' // Test what VLAN a MAC gets
|
'testVlanAssignment' // Test what VLAN a MAC gets
|
||||||
|
|
||||||
|
// Security Profiles
|
||||||
|
'getSecurityProfiles' // List all security profiles
|
||||||
|
'getSecurityProfile' // Get a single profile by ID
|
||||||
|
'createSecurityProfile' // Create a reusable security profile
|
||||||
|
'updateSecurityProfile' // Update a profile (propagates to referencing routes)
|
||||||
|
'deleteSecurityProfile' // Delete a profile (with optional force)
|
||||||
|
'getSecurityProfileUsage' // Get routes referencing a profile
|
||||||
|
|
||||||
|
// Network Targets
|
||||||
|
'getNetworkTargets' // List all network targets
|
||||||
|
'getNetworkTarget' // Get a single target by ID
|
||||||
|
'createNetworkTarget' // Create a reusable host:port target
|
||||||
|
'updateNetworkTarget' // Update a target (propagates to referencing routes)
|
||||||
|
'deleteNetworkTarget' // Delete a target (with optional force)
|
||||||
|
'getNetworkTargetUsage' // Get routes referencing a target
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Client
|
## API Client
|
||||||
@@ -1518,12 +1531,12 @@ const router = new DcRouter(options: IDcRouterOptions);
|
|||||||
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
| `remoteIngressManager` | `RemoteIngressManager` | Edge registration CRUD manager |
|
||||||
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
| `tunnelManager` | `TunnelManager` | Tunnel lifecycle and status manager |
|
||||||
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
| `vpnManager` | `VpnManager` | VPN server lifecycle and client CRUD manager |
|
||||||
| `storageManager` | `StorageManager` | Storage backend |
|
|
||||||
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
| `opsServer` | `OpsServer` | OpsServer/dashboard instance |
|
||||||
| `metricsManager` | `MetricsManager` | Metrics collector |
|
| `metricsManager` | `MetricsManager` | Metrics collector |
|
||||||
| `cacheDb` | `CacheDb` | Cache database instance |
|
| `dcRouterDb` | `DcRouterDb` | Unified database instance (smartdata + smartdb) |
|
||||||
| `certProvisionScheduler` | `CertProvisionScheduler` | Per-domain backoff scheduler for cert provisioning |
|
| `routeConfigManager` | `RouteConfigManager` | Programmatic route CRUD manager |
|
||||||
| `certificateStatusMap` | `Map<string, ...>` | Domain-keyed certificate status from SmartProxy events |
|
| `apiTokenManager` | `ApiTokenManager` | API token management |
|
||||||
|
| `referenceResolver` | `ReferenceResolver` | Security profile and network target resolver |
|
||||||
|
|
||||||
### Re-exported Types
|
### Re-exported Types
|
||||||
|
|
||||||
@@ -1589,7 +1602,8 @@ tstest test/test.opsserver-api.ts --verbose --timeout 60
|
|||||||
| `test.jwt-auth.ts` | JWT login, verification, logout, invalid credentials | 8 |
|
| `test.jwt-auth.ts` | JWT login, verification, logout, invalid credentials | 8 |
|
||||||
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 8 |
|
| `test.opsserver-api.ts` | Health, statistics, configuration, log APIs | 8 |
|
||||||
| `test.protected-endpoint.ts` | Admin auth, identity verification, public endpoints | 8 |
|
| `test.protected-endpoint.ts` | Admin auth, identity verification, public endpoints | 8 |
|
||||||
| `test.storagemanager.ts` | Memory, filesystem, custom backends, concurrency | 8 |
|
| `test.reference-resolver.ts` | Security profiles, network targets, route resolution | 20 |
|
||||||
|
| `test.security-profiles-api.ts` | Profile/target API endpoints, auth enforcement | 13 |
|
||||||
|
|
||||||
## Docker / OCI Container Deployment
|
## Docker / OCI Container Deployment
|
||||||
|
|
||||||
|
|||||||
196
test/test.cert-renewal.ts
Normal file
196
test/test.cert-renewal.ts
Normal 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();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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,13 +44,12 @@ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Disable db/mongo for dev
|
dbConfig: { enabled: true },
|
||||||
dbConfig: { enabled: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Starting DcRouter in development mode...');
|
console.log('Starting DcRouter in development mode...');
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.2.4',
|
version: '13.8.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
1
ts/acme/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './manager.acme-config.js';
|
||||||
182
ts/acme/manager.acme-config.ts
Normal file
182
ts/acme/manager.acme-config.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
import { AcmeConfigDoc } from '../db/documents/index.js';
|
||||||
|
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||||
|
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
|
||||||
|
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
|
||||||
|
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
|
||||||
|
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
|
||||||
|
*
|
||||||
|
* Reload semantics: updates take effect on the next dcrouter restart because
|
||||||
|
* `SmartAcme` is instantiated once in `setupSmartProxy()`. `renewThresholdDays`
|
||||||
|
* applies immediately to the next renewal check. See
|
||||||
|
* `ts_web/elements/domains/ops-view-certificates.ts` for the UI warning.
|
||||||
|
*/
|
||||||
|
export class AcmeConfigManager {
|
||||||
|
private cached: IAcmeConfig | null = null;
|
||||||
|
|
||||||
|
constructor(private options: IDcRouterOptions) {}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
logger.log('info', 'AcmeConfigManager: starting');
|
||||||
|
let doc = await AcmeConfigDoc.load();
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
// First-boot path: seed from legacy constructor fields if present.
|
||||||
|
const seed = this.deriveSeedFromOptions();
|
||||||
|
if (seed) {
|
||||||
|
doc = await this.createSeedDoc(seed);
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (this.deriveSeedFromOptions()) {
|
||||||
|
logger.log(
|
||||||
|
'warn',
|
||||||
|
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cached = doc ? this.toPlain(doc) : null;
|
||||||
|
if (this.cached) {
|
||||||
|
logger.log(
|
||||||
|
'info',
|
||||||
|
`AcmeConfigManager: loaded ACME config (accountEmail=${this.cached.accountEmail}, enabled=${this.cached.enabled}, useProduction=${this.cached.useProduction})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
this.cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current ACME config, or null if not configured.
|
||||||
|
* In-memory — does not hit the DB.
|
||||||
|
*/
|
||||||
|
public getConfig(): IAcmeConfig | null {
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if there is an enabled ACME config. Used by `setupSmartProxy()` to
|
||||||
|
* decide whether to instantiate SmartAcme.
|
||||||
|
*/
|
||||||
|
public hasEnabledConfig(): boolean {
|
||||||
|
return this.cached !== null && this.cached.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert the ACME config. All fields are optional; missing fields are
|
||||||
|
* preserved from the existing row (or defaulted if there is no row yet).
|
||||||
|
*/
|
||||||
|
public async updateConfig(
|
||||||
|
args: Partial<Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>>,
|
||||||
|
updatedBy: string,
|
||||||
|
): Promise<IAcmeConfig> {
|
||||||
|
let doc = await AcmeConfigDoc.load();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
doc = new AcmeConfigDoc();
|
||||||
|
doc.configId = 'acme-config';
|
||||||
|
doc.accountEmail = args.accountEmail ?? '';
|
||||||
|
doc.enabled = args.enabled ?? true;
|
||||||
|
doc.useProduction = args.useProduction ?? true;
|
||||||
|
doc.autoRenew = args.autoRenew ?? true;
|
||||||
|
doc.renewThresholdDays = args.renewThresholdDays ?? 30;
|
||||||
|
} else {
|
||||||
|
if (args.accountEmail !== undefined) doc.accountEmail = args.accountEmail;
|
||||||
|
if (args.enabled !== undefined) doc.enabled = args.enabled;
|
||||||
|
if (args.useProduction !== undefined) doc.useProduction = args.useProduction;
|
||||||
|
if (args.autoRenew !== undefined) doc.autoRenew = args.autoRenew;
|
||||||
|
if (args.renewThresholdDays !== undefined) doc.renewThresholdDays = args.renewThresholdDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.updatedAt = now;
|
||||||
|
doc.updatedBy = updatedBy;
|
||||||
|
await doc.save();
|
||||||
|
|
||||||
|
this.cached = this.toPlain(doc);
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Internal helpers
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a seed object from the legacy constructor fields. Returns null
|
||||||
|
* if the user has not provided any of them.
|
||||||
|
*
|
||||||
|
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
|
||||||
|
* (full form). `smartProxyConfig.acme` wins when both are present.
|
||||||
|
*/
|
||||||
|
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
|
||||||
|
const acme = this.options.smartProxyConfig?.acme;
|
||||||
|
const tls = this.options.tls;
|
||||||
|
|
||||||
|
// Prefer the explicit smartProxyConfig.acme block if present.
|
||||||
|
if (acme?.accountEmail) {
|
||||||
|
return {
|
||||||
|
accountEmail: acme.accountEmail,
|
||||||
|
enabled: acme.enabled !== false,
|
||||||
|
useProduction: acme.useProduction !== false,
|
||||||
|
autoRenew: acme.autoRenew !== false,
|
||||||
|
renewThresholdDays: acme.renewThresholdDays ?? 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the short tls.contactEmail form.
|
||||||
|
if (tls?.contactEmail) {
|
||||||
|
return {
|
||||||
|
accountEmail: tls.contactEmail,
|
||||||
|
enabled: true,
|
||||||
|
useProduction: true,
|
||||||
|
autoRenew: true,
|
||||||
|
renewThresholdDays: 30,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSeedDoc(
|
||||||
|
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
|
||||||
|
): Promise<AcmeConfigDoc> {
|
||||||
|
const doc = new AcmeConfigDoc();
|
||||||
|
doc.configId = 'acme-config';
|
||||||
|
doc.accountEmail = seed.accountEmail;
|
||||||
|
doc.enabled = seed.enabled;
|
||||||
|
doc.useProduction = seed.useProduction;
|
||||||
|
doc.autoRenew = seed.autoRenew;
|
||||||
|
doc.renewThresholdDays = seed.renewThresholdDays;
|
||||||
|
doc.updatedAt = Date.now();
|
||||||
|
doc.updatedBy = 'seed';
|
||||||
|
await doc.save();
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
|
||||||
|
return {
|
||||||
|
accountEmail: doc.accountEmail,
|
||||||
|
enabled: doc.enabled,
|
||||||
|
useProduction: doc.useProduction,
|
||||||
|
autoRenew: doc.autoRenew,
|
||||||
|
renewThresholdDays: doc.renewThresholdDays,
|
||||||
|
updatedAt: doc.updatedAt,
|
||||||
|
updatedBy: doc.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,24 +527,35 @@ 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,
|
||||||
// Sync merged routes to RemoteIngressManager whenever routes change
|
// Sync merged routes to RemoteIngressManager whenever routes change,
|
||||||
|
// then push updated derived ports to the Rust hub binary
|
||||||
(routes) => {
|
(routes) => {
|
||||||
if (this.remoteIngressManager) {
|
if (this.remoteIngressManager) {
|
||||||
this.remoteIngressManager.setRoutes(routes as any[]);
|
this.remoteIngressManager.setRoutes(routes as any[]);
|
||||||
}
|
}
|
||||||
|
if (this.tunnelManager) {
|
||||||
|
this.tunnelManager.syncAllowedEdges();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.apiTokenManager = new ApiTokenManager();
|
this.apiTokenManager = new ApiTokenManager();
|
||||||
@@ -500,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 }),
|
||||||
);
|
);
|
||||||
@@ -754,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, {
|
||||||
@@ -778,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,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'],
|
||||||
});
|
});
|
||||||
@@ -1021,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}`);
|
||||||
@@ -1064,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();
|
||||||
}
|
}
|
||||||
@@ -1686,8 +1791,13 @@ 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 manual records 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
|
||||||
*/
|
*/
|
||||||
@@ -2133,36 +2243,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;
|
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
|
||||||
if (Array.isArray(domains)) {
|
targetProfileIds, routes, storedRoutes,
|
||||||
for (const d of domains) {
|
);
|
||||||
// Strip wildcard prefix for DNS resolution (*.example.com → example.com)
|
|
||||||
domainsToResolve.add(d.replace(/^\*\./, ''));
|
// Add target IPs directly
|
||||||
}
|
for (const ip of targetIps) {
|
||||||
}
|
ips.add(`${ip}/32`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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`);
|
||||||
}
|
}
|
||||||
@@ -2175,9 +2287,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. */
|
||||||
@@ -2195,6 +2307,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}`);
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export class StorageBackedCertManager implements plugins.smartacme.ICertManager
|
|||||||
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
let doc = await AcmeCertDoc.findByDomain(cert.domainName);
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
doc = new AcmeCertDoc();
|
doc = new AcmeCertDoc();
|
||||||
|
doc.id = cert.id;
|
||||||
doc.domainName = cert.domainName;
|
doc.domainName = cert.domainName;
|
||||||
}
|
}
|
||||||
doc.id = cert.id;
|
|
||||||
doc.created = cert.created;
|
doc.created = cert.created;
|
||||||
doc.privateKey = cert.privateKey;
|
doc.privateKey = cert.privateKey;
|
||||||
doc.publicKey = cert.publicKey;
|
doc.publicKey = cert.publicKey;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
) {}
|
) {}
|
||||||
@@ -83,7 +117,7 @@ export class RouteConfigManager {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public async createRoute(
|
public async createRoute(
|
||||||
route: plugins.smartproxy.IRouteConfig,
|
route: IDcRouterRouteConfig,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
metadata?: IRouteMetadata,
|
metadata?: IRouteMetadata,
|
||||||
@@ -123,7 +157,7 @@ export class RouteConfigManager {
|
|||||||
public async updateRoute(
|
public async updateRoute(
|
||||||
id: string,
|
id: string,
|
||||||
patch: {
|
patch: {
|
||||||
route?: Partial<plugins.smartproxy.IRouteConfig>;
|
route?: Partial<IDcRouterRouteConfig>;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
metadata?: Partial<IRouteMetadata>;
|
metadata?: Partial<IRouteMetadata>;
|
||||||
},
|
},
|
||||||
@@ -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 plugins.smartproxy.IRouteConfig;
|
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 && 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)`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
428
ts/config/classes.target-profile-manager.ts
Normal file
428
ts/config/classes.target-profile-manager.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton ACME configuration document. One row per dcrouter instance,
|
||||||
|
* keyed on the fixed `configId = 'acme-config'` following the
|
||||||
|
* `VpnServerKeysDoc` pattern.
|
||||||
|
*
|
||||||
|
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
|
||||||
|
* constructor fields. Managed via the OpsServer UI at
|
||||||
|
* **Domains > Certificates > Settings**.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public configId: string = 'acme-config';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public accountEmail: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public enabled: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public useProduction: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public autoRenew: boolean = true;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public renewThresholdDays: number = 30;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt: number = 0;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedBy: string = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async load(): Promise<AcmeConfigDoc | null> {
|
||||||
|
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
|
||||||
|
}
|
||||||
|
}
|
||||||
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
63
ts/db/documents/classes.dns-provider.doc.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type {
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderStatus,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
} from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class DnsProviderDoc extends plugins.smartdata.SmartDataDbDoc<DnsProviderDoc, DnsProviderDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public type!: TDnsProviderType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider credentials, persisted as an opaque object. Shape varies by `type`.
|
||||||
|
* Never returned to the UI — handlers map to IDnsProviderPublic before sending.
|
||||||
|
*/
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public credentials!: TDnsProviderCredentials;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public status: TDnsProviderStatus = 'untested';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastTestedAt?: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastError?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<DnsProviderDoc | null> {
|
||||||
|
return await DnsProviderDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<DnsProviderDoc[]> {
|
||||||
|
return await DnsProviderDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByType(type: TDnsProviderType): Promise<DnsProviderDoc[]> {
|
||||||
|
return await DnsProviderDoc.getInstances({ type });
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
62
ts/db/documents/classes.dns-record.doc.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TDnsRecordType, TDnsRecordSource } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class DnsRecordDoc extends plugins.smartdata.SmartDataDbDoc<DnsRecordDoc, DnsRecordDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public domainId!: string;
|
||||||
|
|
||||||
|
/** FQDN of the record (e.g. 'www.example.com'). */
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public type!: TDnsRecordType;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public value!: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public ttl: number = 300;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public proxied?: boolean;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public source!: TDnsRecordSource;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public providerRecordId?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<DnsRecordDoc | null> {
|
||||||
|
return await DnsRecordDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<DnsRecordDoc[]> {
|
||||||
|
return await DnsRecordDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByDomainId(domainId: string): Promise<DnsRecordDoc[]> {
|
||||||
|
return await DnsRecordDoc.getInstances({ domainId });
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ts/db/documents/classes.domain.doc.ts
Normal file
66
ts/db/documents/classes.domain.doc.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
|
import type { TDomainSource } from '../../../ts_interfaces/data/domain.js';
|
||||||
|
|
||||||
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
|
@plugins.smartdata.Collection(() => getDb())
|
||||||
|
export class DomainDoc extends plugins.smartdata.SmartDataDbDoc<DomainDoc, DomainDoc> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public id!: string;
|
||||||
|
|
||||||
|
/** FQDN — kept lowercased on save. */
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public name: string = '';
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public source!: TDomainSource;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public providerId?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public authoritative: boolean = false;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public nameservers?: string[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public externalZoneId?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public lastSyncedAt?: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public description?: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public updatedAt!: number;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public createdBy!: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findById(id: string): Promise<DomainDoc | null> {
|
||||||
|
return await DomainDoc.getInstance({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByName(name: string): Promise<DomainDoc | null> {
|
||||||
|
return await DomainDoc.getInstance({ name: name.toLowerCase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findAll(): Promise<DomainDoc[]> {
|
||||||
|
return await DomainDoc.getInstances({});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async findByProviderId(providerId: string): Promise<DomainDoc[]> {
|
||||||
|
return await DomainDoc.getInstances({ providerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
import type { IRouteMetadata } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
import type { IDcRouterRouteConfig } from '../../../ts_interfaces/data/remoteingress.js';
|
||||||
|
|
||||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export class StoredRouteDoc extends plugins.smartdata.SmartDataDbDoc<StoredRoute
|
|||||||
public id!: string;
|
public id!: string;
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public route!: plugins.smartproxy.IRouteConfig;
|
public route!: IDcRouterRouteConfig;
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public enabled!: boolean;
|
public enabled!: boolean;
|
||||||
|
|||||||
48
ts/db/documents/classes.target-profile.doc.ts
Normal file
48
ts/db/documents/classes.target-profile.doc.ts
Normal 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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
2
ts/dns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './manager.dns.js';
|
||||||
|
export * from './providers/index.js';
|
||||||
869
ts/dns/manager.dns.ts
Normal file
869
ts/dns/manager.dns.ts
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
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 manual-domain records with smartdns.DnsServer at startup
|
||||||
|
* - Provide CRUD methods used by OpsServer handlers (manual 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, manual records loaded
|
||||||
|
* from the DB are registered with the server.
|
||||||
|
*/
|
||||||
|
public async attachDnsServer(dnsServer: plugins.smartdns.dnsServerMod.DnsServer): Promise<void> {
|
||||||
|
this.dnsServer = dnsServer;
|
||||||
|
await this.applyManualDomainsToDnsServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// First-boot seeding
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If no DomainDocs exist yet but the constructor has legacy DNS fields,
|
||||||
|
* seed them as `source: 'manual'` 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 = 'manual';
|
||||||
|
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 = 'manual';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Manual-domain DnsServer wiring
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all manual-domain records from the DB with the embedded DnsServer.
|
||||||
|
* Called once after attachDnsServer().
|
||||||
|
*/
|
||||||
|
private async applyManualDomainsToDnsServer(): Promise<void> {
|
||||||
|
if (!this.dnsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allDomains = await DomainDoc.findAll();
|
||||||
|
const manualDomains = allDomains.filter((d) => d.source === 'manual');
|
||||||
|
let registered = 0;
|
||||||
|
for (const domain of manualDomains) {
|
||||||
|
const records = await DnsRecordDoc.findByDomainId(domain.id);
|
||||||
|
for (const rec of records) {
|
||||||
|
this.registerRecordWithDnsServer(rec);
|
||||||
|
registered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.log('info', `DnsManager: registered ${registered} manual 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> {
|
||||||
|
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 manual (authoritative) domain. dcrouter will serve DNS records
|
||||||
|
* for this domain via the embedded smartdns.DnsServer.
|
||||||
|
*/
|
||||||
|
public async createManualDomain(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 = 'manual';
|
||||||
|
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 manual domains, also unregisters records from the embedded DnsServer.
|
||||||
|
*
|
||||||
|
* Note: smartdns has no public unregister-by-name API in the version pinned
|
||||||
|
* here, so manual 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 = 'manual';
|
||||||
|
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 {
|
||||||
|
// Manual / 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 manual 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 manual 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: 'manual' | '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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
131
ts/dns/providers/cloudflare.provider.ts
Normal file
131
ts/dns/providers/cloudflare.provider.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
import type {
|
||||||
|
IDnsProviderClient,
|
||||||
|
IConnectionTestResult,
|
||||||
|
IProviderRecord,
|
||||||
|
IProviderRecordInput,
|
||||||
|
} from './interfaces.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloudflare implementation of IDnsProviderClient.
|
||||||
|
*
|
||||||
|
* Wraps `@apiclient.xyz/cloudflare`. Records at Cloudflare are addressed by
|
||||||
|
* an internal record id, which we surface as `providerRecordId` so the rest
|
||||||
|
* of the system can issue updates and deletes without ambiguity (Cloudflare
|
||||||
|
* can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export class CloudflareDnsProvider implements IDnsProviderClient {
|
||||||
|
private cfAccount: plugins.cloudflare.CloudflareAccount;
|
||||||
|
|
||||||
|
constructor(apiToken: string) {
|
||||||
|
if (!apiToken) {
|
||||||
|
throw new Error('CloudflareDnsProvider: apiToken is required');
|
||||||
|
}
|
||||||
|
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async testConnection(): Promise<IConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
// Listing zones is the lightest-weight call that proves the token works.
|
||||||
|
await this.cfAccount.zoneManager.listZones();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.log('warn', `CloudflareDnsProvider testConnection failed: ${message}`);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listDomains(): Promise<IProviderDomainListing[]> {
|
||||||
|
const zones = await this.cfAccount.zoneManager.listZones();
|
||||||
|
return zones.map((zone) => ({
|
||||||
|
name: zone.name,
|
||||||
|
externalId: zone.id,
|
||||||
|
nameservers: zone.name_servers ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listRecords(domain: string): Promise<IProviderRecord[]> {
|
||||||
|
const records = await this.cfAccount.recordManager.listRecords(domain);
|
||||||
|
return records
|
||||||
|
.filter((r) => this.isSupportedType(r.type))
|
||||||
|
.map((r) => ({
|
||||||
|
providerRecordId: r.id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type as TDnsRecordType,
|
||||||
|
value: r.content,
|
||||||
|
ttl: r.ttl,
|
||||||
|
proxied: r.proxied,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createRecord(
|
||||||
|
domain: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1, // 1 = automatic
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const created = await (this.cfAccount as any).apiAccount.dns.records.create(apiRecord);
|
||||||
|
return {
|
||||||
|
providerRecordId: created.id,
|
||||||
|
name: created.name,
|
||||||
|
type: created.type as TDnsRecordType,
|
||||||
|
value: created.content,
|
||||||
|
ttl: created.ttl,
|
||||||
|
proxied: created.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
const apiRecord: any = {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.value,
|
||||||
|
ttl: record.ttl ?? 1,
|
||||||
|
};
|
||||||
|
if (record.proxied !== undefined) {
|
||||||
|
apiRecord.proxied = record.proxied;
|
||||||
|
}
|
||||||
|
const updated = await (this.cfAccount as any).apiAccount.dns.records.edit(
|
||||||
|
providerRecordId,
|
||||||
|
apiRecord,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
providerRecordId: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
type: updated.type as TDnsRecordType,
|
||||||
|
value: updated.content,
|
||||||
|
ttl: updated.ttl,
|
||||||
|
proxied: updated.proxied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecord(domain: string, providerRecordId: string): Promise<void> {
|
||||||
|
const zoneId = await this.cfAccount.zoneManager.getZoneId(domain);
|
||||||
|
await (this.cfAccount as any).apiAccount.dns.records.delete(providerRecordId, {
|
||||||
|
zone_id: zoneId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSupportedType(type: string): boolean {
|
||||||
|
return ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'CAA'].includes(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
ts/dns/providers/factory.ts
Normal file
48
ts/dns/providers/factory.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// If you see a TypeScript error here after extending TDnsProviderType,
|
||||||
|
// add a `case` for the new type above. The `never` enforces exhaustiveness.
|
||||||
|
const _exhaustive: never = type;
|
||||||
|
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ts/dns/providers/index.ts
Normal file
3
ts/dns/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './interfaces.js';
|
||||||
|
export * from './cloudflare.provider.js';
|
||||||
|
export * from './factory.js';
|
||||||
67
ts/dns/providers/interfaces.ts
Normal file
67
ts/dns/providers/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { TDnsRecordType } from '../../../ts_interfaces/data/dns-record.js';
|
||||||
|
import type { IProviderDomainListing } from '../../../ts_interfaces/data/dns-provider.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DNS record as seen at a provider's API. The `providerRecordId` field
|
||||||
|
* is the provider's internal identifier, used for subsequent updates and
|
||||||
|
* deletes (since providers can have multiple records of the same name+type).
|
||||||
|
*/
|
||||||
|
export interface IProviderRecord {
|
||||||
|
providerRecordId: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input shape for creating / updating a DNS record at a provider.
|
||||||
|
*/
|
||||||
|
export interface IProviderRecordInput {
|
||||||
|
name: string;
|
||||||
|
type: TDnsRecordType;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
proxied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of a connection test against a provider's API.
|
||||||
|
*/
|
||||||
|
export interface IConnectionTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluggable DNS provider client interface. One implementation per provider type
|
||||||
|
* (Cloudflare, Route53, …). Implementations live in ts/dns/providers/ and are
|
||||||
|
* instantiated by `createDnsProvider()` in factory.ts.
|
||||||
|
*
|
||||||
|
* NOT a smartdata interface — this is the *runtime* client. The persisted
|
||||||
|
* representation is in `IDnsProvider` (ts_interfaces/data/dns-provider.ts).
|
||||||
|
*/
|
||||||
|
export interface IDnsProviderClient {
|
||||||
|
/** Lightweight check that credentials are valid and the API is reachable. */
|
||||||
|
testConnection(): Promise<IConnectionTestResult>;
|
||||||
|
|
||||||
|
/** List all DNS zones visible to this provider account. */
|
||||||
|
listDomains(): Promise<IProviderDomainListing[]>;
|
||||||
|
|
||||||
|
/** List all DNS records for a zone (FQDN). */
|
||||||
|
listRecords(domain: string): Promise<IProviderRecord[]>;
|
||||||
|
|
||||||
|
/** Create a new DNS record at the provider; returns the created record (with id). */
|
||||||
|
createRecord(domain: string, record: IProviderRecordInput): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Update an existing record by provider id; returns the updated record. */
|
||||||
|
updateRecord(
|
||||||
|
domain: string,
|
||||||
|
providerRecordId: string,
|
||||||
|
record: IProviderRecordInput,
|
||||||
|
): Promise<IProviderRecord>;
|
||||||
|
|
||||||
|
/** Delete a record by provider id. */
|
||||||
|
deleteRecord(domain: string, providerRecordId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -591,6 +591,10 @@ export class MetricsManager {
|
|||||||
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
const requestsPerSecond = proxyMetrics.requests.perSecond();
|
||||||
const requestsTotal = proxyMetrics.requests.total();
|
const requestsTotal = proxyMetrics.requests.total();
|
||||||
|
|
||||||
|
// Get frontend/backend protocol distribution
|
||||||
|
const frontendProtocols = proxyMetrics.connections.frontendProtocols() ?? null;
|
||||||
|
const backendProtocols = proxyMetrics.connections.backendProtocols() ?? null;
|
||||||
|
|
||||||
// Collect backend protocol data
|
// Collect backend protocol data
|
||||||
const backendMetrics = proxyMetrics.backends.byBackend();
|
const backendMetrics = proxyMetrics.backends.byBackend();
|
||||||
const protocolCache = proxyMetrics.backends.detectedProtocols();
|
const protocolCache = proxyMetrics.backends.detectedProtocols();
|
||||||
@@ -705,6 +709,8 @@ export class MetricsManager {
|
|||||||
requestsPerSecond,
|
requestsPerSecond,
|
||||||
requestsTotal,
|
requestsTotal,
|
||||||
backends,
|
backends,
|
||||||
|
frontendProtocols,
|
||||||
|
backendProtocols,
|
||||||
};
|
};
|
||||||
}, 1000); // 1s cache — matches typical dashboard poll interval
|
}, 1000); // 1s cache — matches typical dashboard poll interval
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD handler for the singleton `AcmeConfigDoc`.
|
||||||
|
*
|
||||||
|
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
|
||||||
|
* with `acme-config:read` / `acme-config:write` scope.
|
||||||
|
*/
|
||||||
|
export class AcmeConfigHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get current ACME config
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
|
||||||
|
'getAcmeConfig',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'acme-config:read');
|
||||||
|
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||||
|
if (!mgr) return { config: null };
|
||||||
|
return { config: mgr.getConfig() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update (upsert) the ACME config
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
|
||||||
|
'updateAcmeConfig',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'acme-config:write');
|
||||||
|
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
|
||||||
|
if (!mgr) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'AcmeConfigManager not initialized (DB disabled?)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await mgr.updateConfig(
|
||||||
|
{
|
||||||
|
accountEmail: dataArg.accountEmail,
|
||||||
|
enabled: dataArg.enabled,
|
||||||
|
useProduction: dataArg.useProduction,
|
||||||
|
autoRenew: dataArg.autoRenew,
|
||||||
|
renewThresholdDays: dataArg.renewThresholdDays,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return { success: true, config: updated };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,18 @@ export class AdminHandler {
|
|||||||
role: 'admin',
|
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
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -43,7 +65,7 @@ export class CertificateHandler {
|
|||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ReprovisionCertificateDomain>(
|
||||||
'reprovisionCertificateDomain',
|
'reprovisionCertificateDomain',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
return this.reprovisionCertificateDomain(dataArg.domain);
|
return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -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,9 +353,18 @@ 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): 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;
|
||||||
const smartProxy = dcRouter.smartProxy;
|
const smartProxy = dcRouter.smartProxy;
|
||||||
|
|
||||||
@@ -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);
|
// bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
|
||||||
return { success: true, message: `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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
159
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
159
ts/opsserver/handlers/dns-provider.handler.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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
|
||||||
|
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;
|
||||||
|
if (!dnsManager) return { providers: [] };
|
||||||
|
return { providers: await dnsManager.listProviders() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
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');
|
||||||
|
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');
|
||||||
|
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');
|
||||||
|
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');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
try {
|
||||||
|
const domains = await dnsManager.listProviderDomains(dataArg.providerId);
|
||||||
|
return { success: true, domains };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return { success: false, message: (err as Error).message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
127
ts/opsserver/handlers/dns-record.handler.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD handlers for DnsRecordDoc.
|
||||||
|
*/
|
||||||
|
export class DnsRecordHandler {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAuth(
|
||||||
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
|
): Promise<string> {
|
||||||
|
if (request.identity?.jwt) {
|
||||||
|
try {
|
||||||
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
|
identity: request.identity,
|
||||||
|
});
|
||||||
|
if (isAdmin) return request.identity.userId;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.apiToken) {
|
||||||
|
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
|
||||||
|
if (tokenManager) {
|
||||||
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
|
if (token) {
|
||||||
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
|
return token.createdBy;
|
||||||
|
}
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
// Get records by domain
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecords>(
|
||||||
|
'getDnsRecords',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { records: [] };
|
||||||
|
const docs = await dnsManager.listRecordsForDomain(dataArg.domainId);
|
||||||
|
return { records: docs.map((d) => dnsManager.toPublicRecord(d)) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get single record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetDnsRecord>(
|
||||||
|
'getDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:read');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { record: null };
|
||||||
|
const doc = await dnsManager.getRecord(dataArg.id);
|
||||||
|
return { record: doc ? dnsManager.toPublicRecord(doc) : null };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateDnsRecord>(
|
||||||
|
'createDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
const userId = await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.createRecord({
|
||||||
|
domainId: dataArg.domainId,
|
||||||
|
name: dataArg.name,
|
||||||
|
type: dataArg.type,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateDnsRecord>(
|
||||||
|
'updateDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.updateRecord({
|
||||||
|
id: dataArg.id,
|
||||||
|
name: dataArg.name,
|
||||||
|
value: dataArg.value,
|
||||||
|
ttl: dataArg.ttl,
|
||||||
|
proxied: dataArg.proxied,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete record
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteDnsRecord>(
|
||||||
|
'deleteDnsRecord',
|
||||||
|
async (dataArg) => {
|
||||||
|
await this.requireAuth(dataArg, 'dns-records:write');
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return { success: false, message: 'DnsManager not initialized' };
|
||||||
|
return await dnsManager.deleteRecord(dataArg.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
ts/opsserver/handlers/domain.handler.ts
Normal file
161
ts/opsserver/handlers/domain.handler.ts
Normal 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 manual 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.createManualDomain({
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -255,7 +255,7 @@ export class LogsHandler {
|
|||||||
} {
|
} {
|
||||||
let intervalId: NodeJS.Timeout | null = null;
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let logIndex = 0;
|
let lastTimestamp = Date.now();
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -284,53 +284,65 @@ export class LogsHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow mode, simulate real-time log streaming
|
// For follow mode, tail real log entries from the in-memory buffer
|
||||||
intervalId = setInterval(async () => {
|
intervalId = setInterval(async () => {
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
// Guard: clear interval if stop() was called between ticks
|
|
||||||
clearInterval(intervalId!);
|
clearInterval(intervalId!);
|
||||||
intervalId = null;
|
intervalId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories: Array<'smtp' | 'dns' | 'security' | 'system' | 'email'> = ['smtp', 'dns', 'security', 'system', 'email'];
|
// Fetch new entries since last poll
|
||||||
const levels: Array<'debug' | 'info' | 'warn' | 'error'> = ['info', 'warn', 'error', 'debug'];
|
const rawEntries = logBuffer.getEntries({
|
||||||
|
since: lastTimestamp,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
const mockCategory = categories[Math.floor(Math.random() * categories.length)];
|
if (rawEntries.length === 0) return;
|
||||||
const mockLevel = levels[Math.floor(Math.random() * levels.length)];
|
|
||||||
|
|
||||||
// Filter by requested criteria
|
for (const raw of rawEntries) {
|
||||||
if (levelFilter && !levelFilter.includes(mockLevel)) return;
|
const mappedLevel = LogsHandler.mapLogLevel(raw.level);
|
||||||
if (categoryFilter && !categoryFilter.includes(mockCategory)) return;
|
const mappedCategory = LogsHandler.deriveCategory(
|
||||||
|
(raw as any).context?.zone,
|
||||||
|
raw.message,
|
||||||
|
);
|
||||||
|
|
||||||
const logEntry = {
|
// Apply filters
|
||||||
timestamp: Date.now(),
|
if (levelFilter && !levelFilter.includes(mappedLevel)) continue;
|
||||||
level: mockLevel,
|
if (categoryFilter && !categoryFilter.includes(mappedCategory)) continue;
|
||||||
category: mockCategory,
|
|
||||||
message: `Real-time log ${logIndex++} from ${mockCategory}`,
|
|
||||||
metadata: {
|
|
||||||
requestId: plugins.uuid.v4(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const logData = JSON.stringify(logEntry);
|
const logEntry = {
|
||||||
const encoder = new TextEncoder();
|
timestamp: raw.timestamp || Date.now(),
|
||||||
try {
|
level: mappedLevel,
|
||||||
// Use a timeout to detect hung streams (sendData can hang if the
|
category: mappedCategory,
|
||||||
// VirtualStream's keepAlive loop has ended)
|
message: raw.message,
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout>;
|
metadata: (raw as any).data,
|
||||||
await Promise.race([
|
};
|
||||||
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
|
||||||
clearTimeout(timeoutHandle);
|
const logData = JSON.stringify(logEntry);
|
||||||
return result;
|
const encoder = new TextEncoder();
|
||||||
}),
|
try {
|
||||||
new Promise<never>((_, reject) => {
|
let timeoutHandle: ReturnType<typeof setTimeout>;
|
||||||
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
await Promise.race([
|
||||||
}),
|
virtualStream.sendData(encoder.encode(logData)).then((result) => {
|
||||||
]);
|
clearTimeout(timeoutHandle);
|
||||||
} catch {
|
return result;
|
||||||
// Stream closed, errored, or timed out — clean up
|
}),
|
||||||
stop();
|
new Promise<never>((_, reject) => {
|
||||||
|
timeoutHandle = setTimeout(() => reject(new Error('stream send timeout')), 10_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Stream closed, errored, or timed out — clean up
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the watermark past all entries we just processed
|
||||||
|
const newest = rawEntries[rawEntries.length - 1];
|
||||||
|
if (newest.timestamp && newest.timestamp >= lastTimestamp) {
|
||||||
|
lastTimestamp = newest.timestamp + 1;
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -3,6 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js';
|
|||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
import { MetricsManager } from '../../monitoring/index.js';
|
import { MetricsManager } from '../../monitoring/index.js';
|
||||||
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
import { SecurityLogger } from '../../security/classes.securitylogger.js';
|
||||||
|
import { commitinfo } from '../../00_commitinfo_data.js';
|
||||||
|
|
||||||
export class StatsHandler {
|
export class StatsHandler {
|
||||||
constructor(private opsServerRef: OpsServer) {
|
constructor(private opsServerRef: OpsServer) {
|
||||||
@@ -158,7 +159,7 @@ export class StatsHandler {
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as any),
|
}, {} as any),
|
||||||
version: '2.12.0', // TODO: Get from package.json
|
version: commitinfo.version,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -310,11 +311,53 @@ export class StatsHandler {
|
|||||||
requestsPerSecond: stats.requestsPerSecond || 0,
|
requestsPerSecond: stats.requestsPerSecond || 0,
|
||||||
requestsTotal: stats.requestsTotal || 0,
|
requestsTotal: stats.requestsTotal || 0,
|
||||||
backends: stats.backends || [],
|
backends: stats.backends || [],
|
||||||
|
frontendProtocols: stats.frontendProtocols || null,
|
||||||
|
backendProtocols: stats.backendProtocols || null,
|
||||||
};
|
};
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sections.radius) {
|
||||||
|
promises.push(
|
||||||
|
(async () => {
|
||||||
|
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
|
||||||
|
if (!radiusServer) return;
|
||||||
|
const stats = radiusServer.getStats();
|
||||||
|
const accountingStats = radiusServer.getAccountingManager().getStats();
|
||||||
|
metrics.radius = {
|
||||||
|
running: stats.running,
|
||||||
|
uptime: stats.uptime,
|
||||||
|
authRequests: stats.authRequests,
|
||||||
|
authAccepts: stats.authAccepts,
|
||||||
|
authRejects: stats.authRejects,
|
||||||
|
accountingRequests: stats.accountingRequests,
|
||||||
|
activeSessions: stats.activeSessions,
|
||||||
|
totalInputBytes: accountingStats.totalInputBytes,
|
||||||
|
totalOutputBytes: accountingStats.totalOutputBytes,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.vpn) {
|
||||||
|
promises.push(
|
||||||
|
(async () => {
|
||||||
|
const vpnManager = this.opsServerRef.dcRouterRef.vpnManager;
|
||||||
|
const vpnConfig = this.opsServerRef.dcRouterRef.options.vpnConfig;
|
||||||
|
if (!vpnManager) return;
|
||||||
|
const connected = await vpnManager.getConnectedClients();
|
||||||
|
metrics.vpn = {
|
||||||
|
running: vpnManager.running,
|
||||||
|
subnet: vpnManager.getSubnet(),
|
||||||
|
registeredClients: vpnManager.listClients().length,
|
||||||
|
connectedClients: connected.length,
|
||||||
|
wgListenPort: vpnConfig?.wgListenPort ?? 51820,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
157
ts/opsserver/handlers/target-profile.handler.ts
Normal file
157
ts/opsserver/handlers/target-profile.handler.ts
Normal 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) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
ts/opsserver/handlers/users.handler.ts
Normal file
30
ts/opsserver/handlers/users.handler.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only handler for OpsServer user accounts. Registers on adminRouter,
|
||||||
|
* so admin middleware enforces auth + role check before the handler runs.
|
||||||
|
* User data is owned by AdminHandler; this handler just exposes a safe
|
||||||
|
* projection of it via TypedRequest.
|
||||||
|
*/
|
||||||
|
export class UsersHandler {
|
||||||
|
constructor(private opsServerRef: OpsServer) {
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers(): void {
|
||||||
|
const router = this.opsServerRef.adminRouter;
|
||||||
|
|
||||||
|
// List users (admin-only, read-only)
|
||||||
|
router.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||||
|
'listUsers',
|
||||||
|
async (_dataArg) => {
|
||||||
|
const users = this.opsServerRef.adminHandler.listUsers();
|
||||||
|
return { users };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"order": 2
|
"order": 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"order": 4
|
"order": 5
|
||||||
}
|
}
|
||||||
|
|||||||
25
ts_interfaces/data/acme-config.ts
Normal file
25
ts_interfaces/data/acme-config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* ACME configuration for automated TLS certificate issuance via Let's Encrypt.
|
||||||
|
*
|
||||||
|
* Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the
|
||||||
|
* legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*`
|
||||||
|
* which are now seed-only (used once on first boot if the DB is empty).
|
||||||
|
*
|
||||||
|
* Managed via the OpsServer UI at **Domains > Certificates > Settings**.
|
||||||
|
*/
|
||||||
|
export interface IAcmeConfig {
|
||||||
|
/** Contact email used for Let's Encrypt account registration. */
|
||||||
|
accountEmail: string;
|
||||||
|
/** Whether ACME is enabled. If false, no certs are issued via ACME. */
|
||||||
|
enabled: boolean;
|
||||||
|
/** True = Let's Encrypt production, false = staging. */
|
||||||
|
useProduction: boolean;
|
||||||
|
/** Whether to automatically renew certs before expiry. */
|
||||||
|
autoRenew: boolean;
|
||||||
|
/** Renew when a cert has fewer than this many days of validity left. */
|
||||||
|
renewThresholdDays: number;
|
||||||
|
/** Unix ms timestamp of last config change. */
|
||||||
|
updatedAt: number;
|
||||||
|
/** Who last updated the config (userId or 'seed' / 'system'). */
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
142
ts_interfaces/data/dns-provider.ts
Normal file
142
ts_interfaces/data/dns-provider.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Supported DNS provider types. Initially Cloudflare; the abstraction is
|
||||||
|
* designed so additional providers (Route53, Gandi, DigitalOcean…) can be
|
||||||
|
* added by implementing the IDnsProvider class interface in ts/dns/providers/.
|
||||||
|
*/
|
||||||
|
export type TDnsProviderType = 'cloudflare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: '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);
|
||||||
|
}
|
||||||
42
ts_interfaces/data/dns-record.ts
Normal file
42
ts_interfaces/data/dns-record.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Supported DNS record types.
|
||||||
|
*/
|
||||||
|
export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'CAA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a DNS record came from.
|
||||||
|
*
|
||||||
|
* - 'manual' → created in the dcrouter UI / API
|
||||||
|
* - 'synced' → pulled from a provider during a sync operation
|
||||||
|
*/
|
||||||
|
export type TDnsRecordSource = 'manual' | 'synced';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DNS record. For manual (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;
|
||||||
|
}
|
||||||
35
ts_interfaces/data/domain.ts
Normal file
35
ts_interfaces/data/domain.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Where a domain came from / how it is managed.
|
||||||
|
*
|
||||||
|
* - 'manual' → operator added the domain manually. dcrouter is the
|
||||||
|
* authoritative DNS server for it; records are served by
|
||||||
|
* the embedded smartdns.DnsServer.
|
||||||
|
* - '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 = 'manual' | '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 === 'manual'). */
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
import type { IDcRouterRouteConfig } from './remoteingress.js';
|
||||||
|
|
||||||
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
|
// Derive IRouteSecurity from IRouteConfig since it's not directly exported
|
||||||
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
||||||
@@ -11,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;
|
||||||
@@ -61,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. */
|
||||||
@@ -77,7 +86,7 @@ export interface IRouteMetadata {
|
|||||||
* A merged route combining hardcoded and programmatic sources.
|
* A merged route combining hardcoded and programmatic sources.
|
||||||
*/
|
*/
|
||||||
export interface IMergedRoute {
|
export interface IMergedRoute {
|
||||||
route: IRouteConfig;
|
route: IDcRouterRouteConfig;
|
||||||
source: 'hardcoded' | 'programmatic';
|
source: 'hardcoded' | 'programmatic';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
overridden: boolean;
|
overridden: boolean;
|
||||||
@@ -118,7 +127,7 @@ export interface IApiTokenInfo {
|
|||||||
*/
|
*/
|
||||||
export interface IStoredRoute {
|
export interface IStoredRoute {
|
||||||
id: string;
|
id: string;
|
||||||
route: IRouteConfig;
|
route: IDcRouterRouteConfig;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
|
|||||||
@@ -166,6 +166,21 @@ export interface INetworkMetrics {
|
|||||||
requestsPerSecond?: number;
|
requestsPerSecond?: number;
|
||||||
requestsTotal?: number;
|
requestsTotal?: number;
|
||||||
backends?: IBackendInfo[];
|
backends?: IBackendInfo[];
|
||||||
|
frontendProtocols?: IProtocolDistribution | null;
|
||||||
|
backendProtocols?: IProtocolDistribution | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProtocolDistribution {
|
||||||
|
h1Active: number;
|
||||||
|
h1Total: number;
|
||||||
|
h2Active: number;
|
||||||
|
h2Total: number;
|
||||||
|
h3Active: number;
|
||||||
|
h3Total: number;
|
||||||
|
wsActive: number;
|
||||||
|
wsTotal: number;
|
||||||
|
otherActive: number;
|
||||||
|
otherTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IConnectionDetails {
|
export interface IConnectionDetails {
|
||||||
@@ -197,4 +212,24 @@ export interface IBackendInfo {
|
|||||||
h3ConsecutiveFailures: number | null;
|
h3ConsecutiveFailures: number | null;
|
||||||
h3Port: number | null;
|
h3Port: number | null;
|
||||||
cacheAgeSecs: number | null;
|
cacheAgeSecs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRadiusStats {
|
||||||
|
running: boolean;
|
||||||
|
uptime: number;
|
||||||
|
authRequests: number;
|
||||||
|
authAccepts: number;
|
||||||
|
authRejects: number;
|
||||||
|
accountingRequests: number;
|
||||||
|
activeSessions: number;
|
||||||
|
totalInputBytes: number;
|
||||||
|
totalOutputBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IVpnStats {
|
||||||
|
running: boolean;
|
||||||
|
subnet: string;
|
||||||
|
registeredClients: number;
|
||||||
|
connectedClients: number;
|
||||||
|
wgListenPort: number;
|
||||||
}
|
}
|
||||||
29
ts_interfaces/data/target-profile.ts
Normal file
29
ts_interfaces/data/target-profile.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ interface IIdentity {
|
|||||||
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
| `IQueueStatus` | Queue name, size, processing/failed/retrying counts |
|
||||||
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
| `IHealthStatus` | Healthy flag, uptime, per-service status map |
|
||||||
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
| `INetworkMetrics` | Bandwidth, connection counts, top endpoints |
|
||||||
|
| `IRadiusStats` | Running, uptime, auth requests/accepts/rejects, sessions, data transfer |
|
||||||
|
| `IVpnStats` | Running, subnet, registered/connected clients, WireGuard port |
|
||||||
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
| `ILogEntry` | Timestamp, level, category, message, metadata |
|
||||||
|
|
||||||
#### Route Management Interfaces
|
#### Route Management Interfaces
|
||||||
@@ -90,6 +92,13 @@ interface IIdentity {
|
|||||||
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
| `IApiTokenInfo` | Token info: id, name, scopes, createdAt, expiresAt, enabled |
|
||||||
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
| `TApiTokenScope` | Token scopes: `routes:read`, `routes:write`, `config:read`, `tokens:read`, `tokens:manage` |
|
||||||
|
|
||||||
|
#### Security & Reference Interfaces
|
||||||
|
| Interface | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `ISecurityProfile` | Reusable security config: id, name, description, security (ipAllowList, ipBlockList, maxConnections, rateLimit, etc.), extendsProfiles |
|
||||||
|
| `INetworkTarget` | Reusable host:port destination: id, name, description, host (string or string[]), port |
|
||||||
|
| `IRouteMetadata` | Route-to-reference links: securityProfileRef, networkTargetRef, snapshot names, lastResolvedAt |
|
||||||
|
|
||||||
#### Remote Ingress Interfaces
|
#### Remote Ingress Interfaces
|
||||||
| Interface | Description |
|
| Interface | Description |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
@@ -128,7 +137,8 @@ TypedRequest interfaces for the OpsServer API, organized by domain:
|
|||||||
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
| `IReq_GetActiveConnections` | `getActiveConnections` | Active connection list |
|
||||||
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
| `IReq_GetQueueStatus` | `getQueueStatus` | Email queue status |
|
||||||
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
| `IReq_GetHealthStatus` | `getHealthStatus` | System health check |
|
||||||
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request |
|
| `IReq_GetNetworkStats` | `getNetworkStats` | Network throughput and connection analytics |
|
||||||
|
| `IReq_GetCombinedMetrics` | `getCombinedMetrics` | All metrics in one request (server, email, DNS, security, network, RADIUS, VPN) |
|
||||||
|
|
||||||
#### ⚙️ Configuration
|
#### ⚙️ Configuration
|
||||||
| Interface | Method | Description |
|
| Interface | Method | Description |
|
||||||
@@ -241,6 +251,26 @@ interface ICertificateInfo {
|
|||||||
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
| `IReq_GetRadiusStatistics` | `getRadiusStatistics` | RADIUS stats |
|
||||||
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
| `IReq_GetRadiusAccountingSummary` | `getRadiusAccountingSummary` | Accounting summary |
|
||||||
|
|
||||||
|
#### 🛡️ Security Profiles
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetSecurityProfiles` | `getSecurityProfiles` | List all security profiles |
|
||||||
|
| `IReq_GetSecurityProfile` | `getSecurityProfile` | Get a single profile by ID |
|
||||||
|
| `IReq_CreateSecurityProfile` | `createSecurityProfile` | Create a reusable security profile |
|
||||||
|
| `IReq_UpdateSecurityProfile` | `updateSecurityProfile` | Update a profile (propagates to routes) |
|
||||||
|
| `IReq_DeleteSecurityProfile` | `deleteSecurityProfile` | Delete a profile (with optional force) |
|
||||||
|
| `IReq_GetSecurityProfileUsage` | `getSecurityProfileUsage` | Get routes referencing a profile |
|
||||||
|
|
||||||
|
#### 🎯 Network Targets
|
||||||
|
| Interface | Method | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| `IReq_GetNetworkTargets` | `getNetworkTargets` | List all network targets |
|
||||||
|
| `IReq_GetNetworkTarget` | `getNetworkTarget` | Get a single target by ID |
|
||||||
|
| `IReq_CreateNetworkTarget` | `createNetworkTarget` | Create a reusable host:port target |
|
||||||
|
| `IReq_UpdateNetworkTarget` | `updateNetworkTarget` | Update a target (propagates to routes) |
|
||||||
|
| `IReq_DeleteNetworkTarget` | `deleteNetworkTarget` | Delete a target (with optional force) |
|
||||||
|
| `IReq_GetNetworkTargetUsage` | `getNetworkTargetUsage` | Get routes referencing a target |
|
||||||
|
|
||||||
## Example: Full API Integration
|
## Example: Full API Integration
|
||||||
|
|
||||||
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
> 💡 **Tip:** For a higher-level, object-oriented API, use [`@serve.zone/dcrouter-apiclient`](https://www.npmjs.com/package/@serve.zone/dcrouter-apiclient) which wraps these interfaces with resource classes and builder patterns.
|
||||||
|
|||||||
54
ts_interfaces/requests/acme-config.ts
Normal file
54
ts_interfaces/requests/acme-config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IAcmeConfig } from '../data/acme-config.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACME Config Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current ACME configuration. Returns null if no config has been
|
||||||
|
* set yet (neither from DB nor seeded from the constructor).
|
||||||
|
*/
|
||||||
|
export interface IReq_GetAcmeConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetAcmeConfig
|
||||||
|
> {
|
||||||
|
method: 'getAcmeConfig';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
config: IAcmeConfig | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the ACME configuration (upsert). All fields are required on first
|
||||||
|
* create, optional on subsequent updates (partial update).
|
||||||
|
*
|
||||||
|
* NOTE: Most fields take effect on the next dcrouter restart — SmartAcme is
|
||||||
|
* instantiated once at startup. `renewThresholdDays` applies immediately to
|
||||||
|
* the next renewal check.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateAcmeConfig extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateAcmeConfig
|
||||||
|
> {
|
||||||
|
method: 'updateAcmeConfig';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
accountEmail?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
useProduction?: boolean;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
renewThresholdDays?: number;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
config?: IAcmeConfig;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ export interface IReq_ReprovisionCertificateDomain extends plugins.typedrequestI
|
|||||||
request: {
|
request: {
|
||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
forceRenew?: boolean;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface IReq_GetCombinedMetrics {
|
|||||||
dns?: boolean;
|
dns?: boolean;
|
||||||
security?: boolean;
|
security?: boolean;
|
||||||
network?: boolean;
|
network?: boolean;
|
||||||
|
radius?: boolean;
|
||||||
|
vpn?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
@@ -19,6 +21,8 @@ export interface IReq_GetCombinedMetrics {
|
|||||||
dns?: data.IDnsStats;
|
dns?: data.IDnsStats;
|
||||||
security?: data.ISecurityMetrics;
|
security?: data.ISecurityMetrics;
|
||||||
network?: data.INetworkMetrics;
|
network?: data.INetworkMetrics;
|
||||||
|
radius?: data.IRadiusStats;
|
||||||
|
vpn?: data.IVpnStats;
|
||||||
};
|
};
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|||||||
154
ts_interfaces/requests/dns-providers.ts
Normal file
154
ts_interfaces/requests/dns-providers.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type {
|
||||||
|
IDnsProviderPublic,
|
||||||
|
IProviderDomainListing,
|
||||||
|
TDnsProviderType,
|
||||||
|
TDnsProviderCredentials,
|
||||||
|
} from '../data/dns-provider.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DNS Provider Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all DNS providers (public view, no secrets).
|
||||||
|
*/
|
||||||
|
export interface IReq_GetDnsProviders extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetDnsProviders
|
||||||
|
> {
|
||||||
|
method: 'getDnsProviders';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
providers: IDnsProviderPublic[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single DNS provider by id.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetDnsProvider
|
||||||
|
> {
|
||||||
|
method: 'getDnsProvider';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
provider: IDnsProviderPublic | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new DNS provider.
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateDnsProvider
|
||||||
|
> {
|
||||||
|
method: 'createDnsProvider';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
name: string;
|
||||||
|
type: TDnsProviderType;
|
||||||
|
credentials: TDnsProviderCredentials;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
id?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a DNS provider. Only supplied fields are updated.
|
||||||
|
* Pass `credentials` to rotate the secret.
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateDnsProvider
|
||||||
|
> {
|
||||||
|
method: 'updateDnsProvider';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
credentials?: TDnsProviderCredentials;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a DNS provider. Fails if any IDomain still references it
|
||||||
|
* unless `force: true` is set.
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteDnsProvider
|
||||||
|
> {
|
||||||
|
method: 'deleteDnsProvider';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the connection to a DNS provider. Used both for newly-saved
|
||||||
|
* providers and on demand from the UI.
|
||||||
|
*/
|
||||||
|
export interface IReq_TestDnsProvider extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_TestDnsProvider
|
||||||
|
> {
|
||||||
|
method: 'testDnsProvider';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
testedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the domains visible to a DNS provider's API account.
|
||||||
|
* Used when importing — does NOT persist anything.
|
||||||
|
*/
|
||||||
|
export interface IReq_ListProviderDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListProviderDomains
|
||||||
|
> {
|
||||||
|
method: 'listProviderDomains';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
providerId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
domains?: IProviderDomainListing[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
113
ts_interfaces/requests/dns-records.ts
Normal file
113
ts_interfaces/requests/dns-records.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
|
import type { IDnsRecord, TDnsRecordType } from '../data/dns-record.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DNS Record Endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all DNS records for a domain.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetDnsRecords
|
||||||
|
> {
|
||||||
|
method: 'getDnsRecords';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
domainId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
records: IDnsRecord[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single DNS record by id.
|
||||||
|
*/
|
||||||
|
export interface IReq_GetDnsRecord extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetDnsRecord
|
||||||
|
> {
|
||||||
|
method: 'getDnsRecord';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
record: IDnsRecord | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new DNS record.
|
||||||
|
*
|
||||||
|
* For manual 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
150
ts_interfaces/requests/domains.ts
Normal file
150
ts_interfaces/requests/domains.ts
Normal 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 manual (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 manual 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
|
|||||||
import type * as authInterfaces from '../data/auth.js';
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
|
||||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||||
|
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Route Management Endpoints
|
// Route Management Endpoints
|
||||||
@@ -36,7 +37,7 @@ export interface IReq_CreateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
request: {
|
request: {
|
||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
route: IRouteConfig;
|
route: IDcRouterRouteConfig;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
metadata?: IRouteMetadata;
|
metadata?: IRouteMetadata;
|
||||||
};
|
};
|
||||||
@@ -59,7 +60,7 @@ export interface IReq_UpdateRoute extends plugins.typedrequestInterfaces.impleme
|
|||||||
identity?: authInterfaces.IIdentity;
|
identity?: authInterfaces.IIdentity;
|
||||||
apiToken?: string;
|
apiToken?: string;
|
||||||
id: string;
|
id: string;
|
||||||
route?: Partial<IRouteConfig>;
|
route?: Partial<IDcRouterRouteConfig>;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
metadata?: Partial<IRouteMetadata>;
|
metadata?: Partial<IRouteMetadata>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
128
ts_interfaces/requests/target-profiles.ts
Normal file
128
ts_interfaces/requests/target-profiles.ts
Normal 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 }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
23
ts_interfaces/requests/users.ts
Normal file
23
ts_interfaces/requests/users.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as authInterfaces from '../data/auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all OpsServer users (admin-only, read-only).
|
||||||
|
* Deliberately omits password/secret fields from the response.
|
||||||
|
*/
|
||||||
|
export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListUsers
|
||||||
|
> {
|
||||||
|
method: 'listUsers';
|
||||||
|
request: {
|
||||||
|
identity: authInterfaces.IIdentity;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
users: Array<{
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
70
ts_migrations/index.ts
Normal file
70
ts_migrations/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/// <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)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return migration;
|
||||||
|
}
|
||||||
3
ts_migrations/tspublish.json
Normal file
3
ts_migrations/tspublish.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '12.2.4',
|
version: '13.8.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2
ts_web/elements/access/index.ts
Normal file
2
ts_web/elements/access/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ops-view-apitokens.js';
|
||||||
|
export * from './ops-view-users.js';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as appstate from '../appstate.js';
|
import * as 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),
|
||||||
140
ts_web/elements/access/ops-view-users.ts
Normal file
140
ts_web/elements/access/ops-view-users.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import * as appstate from '../../appstate.js';
|
||||||
|
import { viewHostCss } from '../shared/css.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
@customElement('ops-view-users')
|
||||||
|
export class OpsViewUsers extends DeesElement {
|
||||||
|
@state() accessor usersState: appstate.IUsersState = {
|
||||||
|
users: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state() accessor loginState: appstate.ILoginState = {
|
||||||
|
identity: null,
|
||||||
|
isLoggedIn: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const usersSub = appstate.usersStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((usersState) => {
|
||||||
|
this.usersState = usersState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(usersSub);
|
||||||
|
|
||||||
|
const loginSub = appstate.loginStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((loginState) => {
|
||||||
|
this.loginState = loginState;
|
||||||
|
// Re-fetch users when user logs in (fixes race condition where
|
||||||
|
// the view is created before authentication completes)
|
||||||
|
if (loginState.isLoggedIn) {
|
||||||
|
appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.usersContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge.admin {
|
||||||
|
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
||||||
|
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleBadge.user {
|
||||||
|
background: ${cssManager.bdTheme('#e0f2fe', '#0c4a6e')};
|
||||||
|
color: ${cssManager.bdTheme('#075985', '#7dd3fc')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessionBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.userIdCell {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { users } = this.usersState;
|
||||||
|
const currentUserId = this.loginState.identity?.userId;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-heading level="3">Users</dees-heading>
|
||||||
|
|
||||||
|
<div class="usersContainer">
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'Users'}
|
||||||
|
.heading2=${'OpsServer user accounts'}
|
||||||
|
.data=${users}
|
||||||
|
.dataName=${'user'}
|
||||||
|
.searchable=${true}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(user: appstate.IUser) => ({
|
||||||
|
ID: html`<span class="userIdCell">${user.id}</span>`,
|
||||||
|
Username: user.username,
|
||||||
|
Role: this.renderRoleBadge(user.role),
|
||||||
|
Session: user.id === currentUserId
|
||||||
|
? html`<span class="sessionBadge">current</span>`
|
||||||
|
: '',
|
||||||
|
})}
|
||||||
|
></dees-table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRoleBadge(role: string): TemplateResult {
|
||||||
|
const cls = role === 'admin' ? 'admin' : 'user';
|
||||||
|
return html`<span class="roleBadge ${cls}">${role}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstUpdated() {
|
||||||
|
if (this.loginState.isLoggedIn) {
|
||||||
|
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
ts_web/elements/domains/dns-provider-form.ts
Normal file
216
ts_web/elements/domains/dns-provider-form.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
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 descriptor;
|
||||||
|
* caller can override before mounting (e.g. for edit dialogs).
|
||||||
|
*/
|
||||||
|
@state()
|
||||||
|
accessor selectedType: interfaces.data.TDnsProviderType =
|
||||||
|
interfaces.data.dnsProviderTypeDescriptors[0]?.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 {
|
||||||
|
const descriptors = interfaces.data.dnsProviderTypeDescriptors;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
5
ts_web/elements/domains/index.ts
Normal file
5
ts_web/elements/domains/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './dns-provider-form.js';
|
||||||
|
export * from './ops-view-providers.js';
|
||||||
|
export * from './ops-view-domains.js';
|
||||||
|
export * from './ops-view-dns.js';
|
||||||
|
export * from './ops-view-certificates.js';
|
||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
state,
|
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 > 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),
|
||||||
@@ -299,7 +503,7 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Reprovision',
|
name: 'Reprovision',
|
||||||
iconName: 'lucide:RefreshCw',
|
iconName: 'lucide:RefreshCw',
|
||||||
type: ['inRow'],
|
type: ['inRow', 'contextmenu'],
|
||||||
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
actionFunc: async (actionData: { item: interfaces.requests.ICertificateInfo }) => {
|
||||||
const cert = actionData.item;
|
const cert = actionData.item;
|
||||||
if (!cert.canReprovision) {
|
if (!cert.canReprovision) {
|
||||||
@@ -311,16 +515,41 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await appstate.certificateStatePart.dispatchAction(
|
|
||||||
appstate.reprovisionCertificateAction,
|
const doReprovision = async (forceRenew = false) => {
|
||||||
cert.domain,
|
await appstate.certificateStatePart.dispatchAction(
|
||||||
);
|
appstate.reprovisionCertificateAction,
|
||||||
const { DeesToast } = await import('@design.estate/dees-catalog');
|
{ domain: cert.domain, forceRenew },
|
||||||
DeesToast.show({
|
);
|
||||||
message: `Reprovisioning triggered for ${cert.domain}`,
|
const { DeesToast } = await import('@design.estate/dees-catalog');
|
||||||
type: 'success',
|
DeesToast.show({
|
||||||
duration: 3000,
|
message: forceRenew
|
||||||
});
|
? `Force renewal triggered for ${cert.domain}`
|
||||||
|
: `Reprovisioning triggered for ${cert.domain}`,
|
||||||
|
type: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cert.status === 'valid') {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Certificate Still Valid',
|
||||||
|
content: html`<p style="margin: 0; line-height: 1.5;">The certificate for <strong>${cert.domain}</strong> is still valid${cert.expiryDate ? ` until ${new Date(cert.expiryDate).toLocaleDateString()}` : ''}. Do you want to force renew it now?</p>`,
|
||||||
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
{
|
||||||
|
name: 'Force Renew',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await modalArg.destroy();
|
||||||
|
await doReprovision(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await doReprovision();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
273
ts_web/elements/domains/ops-view-dns.ts
Normal file
273
ts_web/elements/domains/ops-view-dns.ts
Normal 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.manual {
|
||||||
|
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 === 'manual') {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
335
ts_web/elements/domains/ops-view-domains.ts
Normal file
335
ts_web/elements/domains/ops-view-domains.ts
Normal 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.manual {
|
||||||
|
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 — manual (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 Manual Domain',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
type: ['header' as const],
|
||||||
|
actionFunc: async () => {
|
||||||
|
await this.showCreateManualDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 === 'manual') {
|
||||||
|
return html`<span class="sourceBadge manual">Manual</span>`;
|
||||||
|
}
|
||||||
|
const provider = d.providerId ? providersById.get(d.providerId) : undefined;
|
||||||
|
return html`<span class="sourceBadge provider">${provider?.name || 'Provider'}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showCreateManualDialog() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
DeesModal.createAndShow({
|
||||||
|
heading: 'Add Manual 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.createManualDomainAction, {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
284
ts_web/elements/domains/ops-view-providers.ts
Normal file
284
ts_web/elements/domains/ops-view-providers.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
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')};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
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=${'External DNS provider accounts'}
|
||||||
|
.data=${providers}
|
||||||
|
.showColumnFilters=${true}
|
||||||
|
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
|
||||||
|
Name: p.name,
|
||||||
|
Type: this.providerTypeLabel(p.type),
|
||||||
|
Status: this.renderStatusBadge(p.status),
|
||||||
|
'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never',
|
||||||
|
Error: 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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
ts_web/elements/email/index.ts
Normal file
2
ts_web/elements/email/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ops-view-emails.js';
|
||||||
|
export * from './ops-view-email-security.js';
|
||||||
160
ts_web/elements/email/ops-view-email-security.ts
Normal file
160
ts_web/elements/email/ops-view-email-security.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
@@ -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';
|
|
||||||
|
|||||||
7
ts_web/elements/network/index.ts
Normal file
7
ts_web/elements/network/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './ops-view-network-activity.js';
|
||||||
|
export * from './ops-view-routes.js';
|
||||||
|
export * from './ops-view-sourceprofiles.js';
|
||||||
|
export * from './ops-view-networktargets.js';
|
||||||
|
export * from './ops-view-targetprofiles.js';
|
||||||
|
export * from './ops-view-remoteingress.js';
|
||||||
|
export * from './ops-view-vpn.js';
|
||||||
@@ -1,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,8 +26,15 @@ 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 */
|
||||||
|
private static readonly CHART_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
/** How often a new data point is added */
|
||||||
|
private static readonly UPDATE_INTERVAL_MS = 1000; // 1 second
|
||||||
|
/** Derived: max data points the buffer holds */
|
||||||
|
private static readonly MAX_DATA_POINTS = OpsViewNetworkActivity.CHART_WINDOW_MS / OpsViewNetworkActivity.UPDATE_INTERVAL_MS;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor statsState = appstate.statsStatePart.getState()!;
|
accessor statsState = appstate.statsStatePart.getState()!;
|
||||||
|
|
||||||
@@ -43,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 = 1000; // 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
|
||||||
@@ -94,23 +101,21 @@ 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();
|
||||||
// Fixed 5 minute time range
|
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
|
||||||
const range = 5 * 60 * 1000; // 5 minutes
|
|
||||||
const bucketSize = range / 60; // 60 data points
|
|
||||||
|
|
||||||
// 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: 60 }, (_, i) => {
|
const emptyData = Array.from({ length: MAX_DATA_POINTS }, (_, i) => {
|
||||||
const time = now - ((59 - i) * bucketSize);
|
const time = now - ((MAX_DATA_POINTS - 1 - i) * UPDATE_INTERVAL_MS);
|
||||||
return {
|
return {
|
||||||
x: new Date(time).toISOString(),
|
x: new Date(time).toISOString(),
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -143,23 +148,23 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
y: Math.round((p.out * 8) / 1000000 * 10) / 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Use history as the chart data, keeping the most recent 60 points (5 min window)
|
const { MAX_DATA_POINTS, UPDATE_INTERVAL_MS } = OpsViewNetworkActivity;
|
||||||
const sliceStart = Math.max(0, historyIn.length - 60);
|
|
||||||
|
// Use history as the chart data, keeping the most recent points within the window
|
||||||
|
const sliceStart = Math.max(0, historyIn.length - MAX_DATA_POINTS);
|
||||||
this.trafficDataIn = historyIn.slice(sliceStart);
|
this.trafficDataIn = historyIn.slice(sliceStart);
|
||||||
this.trafficDataOut = historyOut.slice(sliceStart);
|
this.trafficDataOut = historyOut.slice(sliceStart);
|
||||||
|
|
||||||
// If fewer than 60 points, pad the front with zeros
|
// If fewer than MAX_DATA_POINTS, pad the front with zeros
|
||||||
if (this.trafficDataIn.length < 60) {
|
if (this.trafficDataIn.length < MAX_DATA_POINTS) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const range = 5 * 60 * 1000;
|
const padCount = MAX_DATA_POINTS - this.trafficDataIn.length;
|
||||||
const bucketSize = range / 60;
|
|
||||||
const padCount = 60 - this.trafficDataIn.length;
|
|
||||||
const firstTimestamp = this.trafficDataIn.length > 0
|
const firstTimestamp = this.trafficDataIn.length > 0
|
||||||
? new Date(this.trafficDataIn[0].x).getTime()
|
? new Date(this.trafficDataIn[0].x).getTime()
|
||||||
: now;
|
: now;
|
||||||
|
|
||||||
const padIn = Array.from({ length: padCount }, (_, i) => ({
|
const padIn = Array.from({ length: padCount }, (_, i) => ({
|
||||||
x: new Date(firstTimestamp - ((padCount - i) * bucketSize)).toISOString(),
|
x: new Date(firstTimestamp - ((padCount - i) * UPDATE_INTERVAL_MS)).toISOString(),
|
||||||
y: 0,
|
y: 0,
|
||||||
}));
|
}));
|
||||||
const padOut = padIn.map(p => ({ ...p }));
|
const padOut = padIn.map(p => ({ ...p }));
|
||||||
@@ -269,13 +274,19 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
|
background: ${cssManager.bdTheme('#fff3e0', '#3a2a1a')};
|
||||||
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
|
color: ${cssManager.bdTheme('#f57c00', '#ff9933')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protocolChartGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
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()}
|
||||||
@@ -287,29 +298,22 @@ export class OpsViewNetwork extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Inbound',
|
name: 'Inbound',
|
||||||
data: this.trafficDataIn,
|
data: this.trafficDataIn,
|
||||||
color: '#22c55e', // Green for download
|
color: '#22c55e',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Outbound',
|
name: 'Outbound',
|
||||||
data: this.trafficDataOut,
|
data: this.trafficDataOut,
|
||||||
color: '#8b5cf6', // Purple for upload
|
color: '#8b5cf6',
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
.stacked=${false}
|
.realtimeMode=${true}
|
||||||
|
.rollingWindow=${OpsViewNetworkActivity.CHART_WINDOW_MS}
|
||||||
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
.yAxisFormatter=${(val: number) => `${val} Mbit/s`}
|
||||||
.tooltipFormatter=${(point: any) => {
|
|
||||||
const mbps = point.y || 0;
|
|
||||||
const seriesName = point.series?.name || 'Throughput';
|
|
||||||
const timestamp = new Date(point.x).toLocaleTimeString();
|
|
||||||
return `
|
|
||||||
<div style="padding: 8px;">
|
|
||||||
<div style="font-weight: bold; margin-bottom: 4px;">${timestamp}</div>
|
|
||||||
<div>${seriesName}: ${mbps.toFixed(2)} Mbit/s</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}}
|
|
||||||
></dees-chart-area>
|
></dees-chart-area>
|
||||||
|
|
||||||
|
<!-- Protocol Distribution Charts -->
|
||||||
|
${this.renderProtocolCharts()}
|
||||||
|
|
||||||
<!-- Top IPs Section -->
|
<!-- Top IPs Section -->
|
||||||
${this.renderTopIPs()}
|
${this.renderTopIPs()}
|
||||||
|
|
||||||
@@ -343,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"
|
||||||
@@ -353,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`
|
||||||
@@ -396,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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,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]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,23 +521,61 @@ 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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderProtocolCharts(): TemplateResult {
|
||||||
|
const fp = this.networkState.frontendProtocols;
|
||||||
|
const bp = this.networkState.backendProtocols;
|
||||||
|
|
||||||
|
const protoColors: Record<string, string> = {
|
||||||
|
'HTTP/1.1': '#1976d2',
|
||||||
|
'HTTP/2': '#388e3c',
|
||||||
|
'HTTP/3': '#7b1fa2',
|
||||||
|
'WebSocket': '#f57c00',
|
||||||
|
'Other': '#757575',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDonutData = (dist: interfaces.data.IProtocolDistribution | null) => {
|
||||||
|
if (!dist) return [];
|
||||||
|
const items: Array<{ name: string; value: number; color: string }> = [];
|
||||||
|
if (dist.h1Active > 0) items.push({ name: 'HTTP/1.1', value: dist.h1Active, color: protoColors['HTTP/1.1'] });
|
||||||
|
if (dist.h2Active > 0) items.push({ name: 'HTTP/2', value: dist.h2Active, color: protoColors['HTTP/2'] });
|
||||||
|
if (dist.h3Active > 0) items.push({ name: 'HTTP/3', value: dist.h3Active, color: protoColors['HTTP/3'] });
|
||||||
|
if (dist.wsActive > 0) items.push({ name: 'WebSocket', value: dist.wsActive, color: protoColors['WebSocket'] });
|
||||||
|
if (dist.otherActive > 0) items.push({ name: 'Other', value: dist.otherActive, color: protoColors['Other'] });
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const frontendData = buildDonutData(fp);
|
||||||
|
const backendData = buildDonutData(bp);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="protocolChartGrid">
|
||||||
|
<dees-chart-donut
|
||||||
|
.label=${'Frontend Protocols'}
|
||||||
|
.data=${frontendData.length > 0 ? frontendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
|
||||||
|
.showLegend=${true}
|
||||||
|
.showLabels=${true}
|
||||||
|
.innerRadiusPercent=${'55%'}
|
||||||
|
.valueFormatter=${(val: number) => `${val} active`}
|
||||||
|
></dees-chart-donut>
|
||||||
|
<dees-chart-donut
|
||||||
|
.label=${'Backend Protocols'}
|
||||||
|
.data=${backendData.length > 0 ? backendData : [{ name: 'No Traffic', value: 1, color: '#757575' }]}
|
||||||
|
.showLegend=${true}
|
||||||
|
.showLabels=${true}
|
||||||
|
.innerRadiusPercent=${'55%'}
|
||||||
|
.valueFormatter=${(val: number) => `${val} active`}
|
||||||
|
></dees-chart-donut>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderTopIPs(): TemplateResult {
|
private renderTopIPs(): TemplateResult {
|
||||||
if (this.networkState.topIPs.length === 0) {
|
if (this.networkState.topIPs.length === 0) {
|
||||||
return html``;
|
return html``;
|
||||||
@@ -564,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>
|
||||||
@@ -614,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>
|
||||||
@@ -681,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) {
|
||||||
@@ -709,63 +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(() => {
|
||||||
// Add a new data point every second
|
|
||||||
this.addTrafficDataPoint();
|
this.addTrafficDataPoint();
|
||||||
}, 1000); // Update every second
|
}, 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 >= 60) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,12 +64,14 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
<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,
|
||||||
@@ -81,8 +83,8 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
name: 'Create Target',
|
name: 'Create Target',
|
||||||
iconName: 'lucide:plus',
|
iconName: 'lucide:plus',
|
||||||
type: ['header' as const],
|
type: ['header' as const],
|
||||||
actionFunc: async (_: any, table: any) => {
|
actionFunc: async () => {
|
||||||
await this.showCreateTargetDialog(table);
|
await this.showCreateTargetDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,16 +98,18 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Edit',
|
name: 'Edit',
|
||||||
iconName: 'lucide:pencil',
|
iconName: 'lucide:pencil',
|
||||||
type: ['contextmenu' as const],
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
actionFunc: async (target: interfaces.data.INetworkTarget, table: any) => {
|
actionFunc: async (actionData: any) => {
|
||||||
await this.showEditTargetDialog(target, table);
|
const target = actionData.item as interfaces.data.INetworkTarget;
|
||||||
|
await this.showEditTargetDialog(target);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
type: ['contextmenu' as const],
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
actionFunc: async (target: interfaces.data.INetworkTarget) => {
|
actionFunc: async (actionData: any) => {
|
||||||
|
const target = actionData.item as interfaces.data.INetworkTarget;
|
||||||
await this.deleteTarget(target);
|
await this.deleteTarget(target);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -115,7 +119,7 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showCreateTargetDialog(table: any) {
|
private async showCreateTargetDialog() {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
DeesModal.createAndShow({
|
DeesModal.createAndShow({
|
||||||
heading: 'Create Network Target',
|
heading: 'Create Network Target',
|
||||||
@@ -128,10 +132,12 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
{
|
{
|
||||||
name: 'Create',
|
name: 'Create',
|
||||||
action: async (modalArg: any) => {
|
action: async (modalArg: any) => {
|
||||||
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
|
|
||||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createTargetAction, {
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createTargetAction, {
|
||||||
@@ -143,12 +149,11 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
modalArg.destroy();
|
modalArg.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showEditTargetDialog(target: interfaces.data.INetworkTarget, table: any) {
|
private async showEditTargetDialog(target: interfaces.data.INetworkTarget) {
|
||||||
const hostStr = Array.isArray(target.host) ? target.host.join(', ') : target.host;
|
const hostStr = Array.isArray(target.host) ? target.host.join(', ') : target.host;
|
||||||
|
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
@@ -163,10 +168,12 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
action: async (modalArg: any) => {
|
action: async (modalArg: any) => {
|
||||||
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
const data = await form.collectFormData();
|
const data = await form.collectFormData();
|
||||||
|
|
||||||
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateTargetAction, {
|
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateTargetAction, {
|
||||||
@@ -179,7 +186,6 @@ export class OpsViewNetworkTargets extends DeesElement {
|
|||||||
modalArg.destroy();
|
modalArg.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
723
ts_web/elements/network/ops-view-routes.ts
Normal file
723
ts_web/elements/network/ops-view-routes.ts
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
import * as appstate from '../../appstate.js';
|
||||||
|
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||||
|
import { viewHostCss } from '../shared/css.js';
|
||||||
|
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
export class OpsViewRoutes extends DeesElement {
|
||||||
|
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||||
|
mergedRoutes: [],
|
||||||
|
warnings: [],
|
||||||
|
apiTokens: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state() accessor profilesTargetsState: appstate.IProfilesTargetsState = {
|
||||||
|
profiles: [],
|
||||||
|
targets: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const sub = appstate.routeManagementStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((routeState) => {
|
||||||
|
this.routeState = routeState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(sub);
|
||||||
|
|
||||||
|
const ptSub = appstate.profilesTargetsStatePart
|
||||||
|
.select((s) => s)
|
||||||
|
.subscribe((ptState) => {
|
||||||
|
this.profilesTargetsState = ptState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(ptSub);
|
||||||
|
|
||||||
|
// Re-fetch routes when user logs in (fixes race condition where
|
||||||
|
// the view is created before authentication completes)
|
||||||
|
const loginSub = appstate.loginStatePart
|
||||||
|
.select((s) => s.isLoggedIn)
|
||||||
|
.subscribe((isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
appstate.profilesTargetsStatePart.dispatchAction(appstate.fetchProfilesAndTargetsAction, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSub);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
viewHostCss,
|
||||||
|
css`
|
||||||
|
.routesContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings-bar {
|
||||||
|
background: ${cssManager.bdTheme('rgba(255, 170, 0, 0.08)', 'rgba(255, 170, 0, 0.1)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('rgba(255, 170, 0, 0.25)', 'rgba(255, 170, 0, 0.3)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('#b45309', '#fa0')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: ${cssManager.bdTheme('#6b7280', '#666')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
const { mergedRoutes, warnings } = this.routeState;
|
||||||
|
|
||||||
|
const hardcodedCount = mergedRoutes.filter((mr) => mr.source === 'hardcoded').length;
|
||||||
|
const programmaticCount = mergedRoutes.filter((mr) => mr.source === 'programmatic').length;
|
||||||
|
const disabledCount = mergedRoutes.filter((mr) => !mr.enabled).length;
|
||||||
|
|
||||||
|
const statsTiles: IStatsTile[] = [
|
||||||
|
{
|
||||||
|
id: 'totalRoutes',
|
||||||
|
title: 'Total Routes',
|
||||||
|
type: 'number',
|
||||||
|
value: mergedRoutes.length,
|
||||||
|
icon: 'lucide:route',
|
||||||
|
description: 'All configured routes',
|
||||||
|
color: '#3b82f6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hardcoded',
|
||||||
|
title: 'Hardcoded',
|
||||||
|
type: 'number',
|
||||||
|
value: hardcodedCount,
|
||||||
|
icon: 'lucide:lock',
|
||||||
|
description: 'Routes from constructor config',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'programmatic',
|
||||||
|
title: 'Programmatic',
|
||||||
|
type: 'number',
|
||||||
|
value: programmaticCount,
|
||||||
|
icon: 'lucide:code',
|
||||||
|
description: 'Routes added via API',
|
||||||
|
color: '#0ea5e9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disabled',
|
||||||
|
title: 'Disabled',
|
||||||
|
type: 'number',
|
||||||
|
value: disabledCount,
|
||||||
|
icon: 'lucide:pauseCircle',
|
||||||
|
description: 'Currently disabled routes',
|
||||||
|
color: disabledCount > 0 ? '#ef4444' : '#6b7280',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map merged routes to sz-route-list-view format
|
||||||
|
const szRoutes = mergedRoutes.map((mr) => {
|
||||||
|
const tags = [...(mr.route.tags || [])];
|
||||||
|
tags.push(mr.source);
|
||||||
|
if (!mr.enabled) tags.push('disabled');
|
||||||
|
if (mr.overridden) tags.push('overridden');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mr.route,
|
||||||
|
enabled: mr.enabled,
|
||||||
|
tags,
|
||||||
|
id: mr.storedRouteId || mr.route.name || undefined,
|
||||||
|
metadata: mr.metadata,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<dees-heading level="3">Route Management</dees-heading>
|
||||||
|
|
||||||
|
<div class="routesContainer">
|
||||||
|
<dees-statsgrid
|
||||||
|
.tiles=${statsTiles}
|
||||||
|
.gridActions=${[
|
||||||
|
{
|
||||||
|
name: 'Add Route',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: () => this.showCreateRouteDialog(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Refresh',
|
||||||
|
iconName: 'lucide:refreshCw',
|
||||||
|
action: () => this.refreshData(),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></dees-statsgrid>
|
||||||
|
|
||||||
|
${warnings.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="warnings-bar">
|
||||||
|
${warnings.map(
|
||||||
|
(w) => html`
|
||||||
|
<div class="warning-item">
|
||||||
|
<span class="warning-icon">⚠</span>
|
||||||
|
<span>${w.message}</span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
|
||||||
|
${szRoutes.length > 0
|
||||||
|
? html`
|
||||||
|
<sz-route-list-view
|
||||||
|
.routes=${szRoutes}
|
||||||
|
.showActionsFilter=${(route: any) => route.tags?.includes('programmatic') ?? false}
|
||||||
|
@route-click=${(e: CustomEvent) => this.handleRouteClick(e)}
|
||||||
|
@route-edit=${(e: CustomEvent) => this.handleRouteEdit(e)}
|
||||||
|
@route-delete=${(e: CustomEvent) => this.handleRouteDelete(e)}
|
||||||
|
></sz-route-list-view>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No routes configured</p>
|
||||||
|
<p>Add a programmatic route or check your constructor configuration.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRouteClick(e: CustomEvent) {
|
||||||
|
const clickedRoute = e.detail;
|
||||||
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
|
// Find the corresponding merged route
|
||||||
|
const merged = this.routeState.mergedRoutes.find(
|
||||||
|
(mr) => mr.route.name === clickedRoute.name,
|
||||||
|
);
|
||||||
|
if (!merged) return;
|
||||||
|
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
if (merged.source === 'hardcoded') {
|
||||||
|
const menuOptions = merged.enabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Disable Route',
|
||||||
|
iconName: 'lucide:pause',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.setRouteOverrideAction,
|
||||||
|
{ routeName: merged.route.name!, enabled: false },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
name: 'Enable Route',
|
||||||
|
iconName: 'lucide:play',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.setRouteOverrideAction,
|
||||||
|
{ routeName: merged.route.name!, enabled: true },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Remove Override',
|
||||||
|
iconName: 'lucide:undo',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.removeRouteOverrideAction,
|
||||||
|
merged.route.name!,
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Route: ${merged.route.name}`,
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Source: <strong style="color: #88f;">hardcoded</strong></p>
|
||||||
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled (overridden)'}</strong></p>
|
||||||
|
<p style="color: #888; font-size: 13px;">Hardcoded routes cannot be edited or deleted, but they can be disabled via an override.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Programmatic route
|
||||||
|
const meta = merged.metadata;
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Route: ${merged.route.name}`,
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
|
||||||
|
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||||
|
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
|
||||||
|
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
|
||||||
|
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: merged.enabled ? 'Disable' : 'Enable',
|
||||||
|
iconName: merged.enabled ? 'lucide:pause' : 'lucide:play',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.toggleRouteAction,
|
||||||
|
{ id: merged.storedRouteId!, enabled: !merged.enabled },
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.deleteRouteAction,
|
||||||
|
merged.storedRouteId!,
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRouteEdit(e: CustomEvent) {
|
||||||
|
const clickedRoute = e.detail;
|
||||||
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
|
const merged = this.routeState.mergedRoutes.find(
|
||||||
|
(mr) => mr.route.name === clickedRoute.name,
|
||||||
|
);
|
||||||
|
if (!merged || !merged.storedRouteId) return;
|
||||||
|
|
||||||
|
this.showEditRouteDialog(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRouteDelete(e: CustomEvent) {
|
||||||
|
const clickedRoute = e.detail;
|
||||||
|
if (!clickedRoute) return;
|
||||||
|
|
||||||
|
const merged = this.routeState.mergedRoutes.find(
|
||||||
|
(mr) => mr.route.name === clickedRoute.name,
|
||||||
|
);
|
||||||
|
if (!merged || !merged.storedRouteId) return;
|
||||||
|
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
await DeesModal.createAndShow({
|
||||||
|
heading: `Delete Route: ${merged.route.name}`,
|
||||||
|
content: html`
|
||||||
|
<div style="color: #ccc; padding: 8px 0;">
|
||||||
|
<p>Are you sure you want to delete this route? This action cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete',
|
||||||
|
iconName: 'lucide:trash-2',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.deleteRouteAction,
|
||||||
|
merged.storedRouteId!,
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async showEditRouteDialog(merged: interfaces.data.IMergedRoute) {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
const profiles = this.profilesTargetsState.profiles;
|
||||||
|
const targets = this.profilesTargetsState.targets;
|
||||||
|
|
||||||
|
const profileOptions = [
|
||||||
|
{ key: '', option: '(none — inline security)' },
|
||||||
|
...profiles.map((p) => ({
|
||||||
|
key: p.id,
|
||||||
|
option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const targetOptions = [
|
||||||
|
{ key: '', option: '(none — inline target)' },
|
||||||
|
...targets.map((t) => ({
|
||||||
|
key: t.id,
|
||||||
|
option: `${t.name} (${Array.isArray(t.host) ? t.host.join(',') : t.host}:${t.port})`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const route = merged.route;
|
||||||
|
const currentPorts = Array.isArray(route.match.ports)
|
||||||
|
? route.match.ports.map((p: any) => typeof p === 'number' ? String(p) : `${p.from}-${p.to}`).join(', ')
|
||||||
|
: String(route.match.ports);
|
||||||
|
const currentDomains: string[] = route.match.domains
|
||||||
|
? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
|
||||||
|
: [];
|
||||||
|
const firstTarget = route.action.targets?.[0];
|
||||||
|
const currentTargetHost = firstTarget
|
||||||
|
? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
|
||||||
|
: '';
|
||||||
|
const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : '';
|
||||||
|
|
||||||
|
// 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}`,
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Route Name'} .value=${route.name || ''} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-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-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
|
||||||
|
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
|
||||||
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
|
||||||
|
<dees-input-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>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Save',
|
||||||
|
iconName: 'lucide:check',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
if (!formData.name || !formData.ports) return;
|
||||||
|
|
||||||
|
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||||
|
const domains: string[] = Array.isArray(formData.domains)
|
||||||
|
? formData.domains.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||||
|
|
||||||
|
const updatedRoute: any = {
|
||||||
|
name: formData.name,
|
||||||
|
match: {
|
||||||
|
ports,
|
||||||
|
...(domains.length > 0 ? { domains } : {}),
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
host: formData.targetHost || 'localhost',
|
||||||
|
port: parseInt(formData.targetPort, 10) || 443,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...(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 profileRefValue = formData.sourceProfileRef as any;
|
||||||
|
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||||
|
if (profileKey) {
|
||||||
|
metadata.sourceProfileRef = profileKey;
|
||||||
|
}
|
||||||
|
const targetRefValue = formData.networkTargetRef as any;
|
||||||
|
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||||
|
if (targetKey) {
|
||||||
|
metadata.networkTargetRef = targetKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.updateRouteAction,
|
||||||
|
{
|
||||||
|
id: merged.storedRouteId!,
|
||||||
|
route: updatedRoute,
|
||||||
|
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 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() {
|
||||||
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
const profiles = this.profilesTargetsState.profiles;
|
||||||
|
const targets = this.profilesTargetsState.targets;
|
||||||
|
|
||||||
|
// Build dropdown options for profiles and targets
|
||||||
|
const profileOptions = [
|
||||||
|
{ key: '', option: '(none — inline security)' },
|
||||||
|
...profiles.map((p) => ({
|
||||||
|
key: p.id,
|
||||||
|
option: `${p.name}${p.description ? ' — ' + p.description : ''}`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const targetOptions = [
|
||||||
|
{ key: '', option: '(none — inline target)' },
|
||||||
|
...targets.map((t) => ({
|
||||||
|
key: t.id,
|
||||||
|
option: `${t.name} (${Array.isArray(t.host) ? t.host.join(',') : t.host}:${t.port})`,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const createModal = await DeesModal.createAndShow({
|
||||||
|
heading: 'Add Programmatic Route',
|
||||||
|
content: html`
|
||||||
|
<dees-form>
|
||||||
|
<dees-input-text .key=${'name'} .label=${'Route Name'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
|
||||||
|
<dees-input-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-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
|
||||||
|
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
|
||||||
|
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
|
||||||
|
<dees-input-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>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
iconName: 'lucide:x',
|
||||||
|
action: async (modalArg: any) => await modalArg.destroy(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
|
const formData = await form.collectFormData();
|
||||||
|
if (!formData.name || !formData.ports) return;
|
||||||
|
|
||||||
|
const ports = formData.ports.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p));
|
||||||
|
const domains: string[] = Array.isArray(formData.domains)
|
||||||
|
? formData.domains.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||||
|
|
||||||
|
const route: any = {
|
||||||
|
name: formData.name,
|
||||||
|
match: {
|
||||||
|
ports,
|
||||||
|
...(domains.length > 0 ? { domains } : {}),
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'forward',
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
host: formData.targetHost || 'localhost',
|
||||||
|
port: parseInt(formData.targetPort, 10) || 443,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...(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
|
||||||
|
const metadata: any = {};
|
||||||
|
const profileRefValue = formData.sourceProfileRef as any;
|
||||||
|
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
|
||||||
|
if (profileKey) {
|
||||||
|
metadata.sourceProfileRef = profileKey;
|
||||||
|
}
|
||||||
|
const targetRefValue = formData.networkTargetRef as any;
|
||||||
|
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
|
||||||
|
if (targetKey) {
|
||||||
|
metadata.networkTargetRef = targetKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(
|
||||||
|
appstate.createRouteAction,
|
||||||
|
{
|
||||||
|
route,
|
||||||
|
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await modalArg.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// 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() {
|
||||||
|
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async firstUpdated() {
|
||||||
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 +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`
|
||||||
|
<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(', ') || '-',
|
||||||
@@ -89,8 +91,8 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
name: 'Create Profile',
|
name: 'Create Profile',
|
||||||
iconName: 'lucide:plus',
|
iconName: 'lucide:plus',
|
||||||
type: ['header' as const],
|
type: ['header' as const],
|
||||||
actionFunc: async (_: any, table: any) => {
|
actionFunc: async () => {
|
||||||
await this.showCreateProfileDialog(table);
|
await this.showCreateProfileDialog();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,16 +106,18 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
{
|
{
|
||||||
name: 'Edit',
|
name: 'Edit',
|
||||||
iconName: 'lucide:pencil',
|
iconName: 'lucide:pencil',
|
||||||
type: ['contextmenu' as const],
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
actionFunc: async (profile: interfaces.data.ISecurityProfile, table: any) => {
|
actionFunc: async (actionData: any) => {
|
||||||
await this.showEditProfileDialog(profile, table);
|
const profile = actionData.item as interfaces.data.ISourceProfile;
|
||||||
|
await this.showEditProfileDialog(profile);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Delete',
|
name: 'Delete',
|
||||||
iconName: 'lucide:trash2',
|
iconName: 'lucide:trash2',
|
||||||
type: ['contextmenu' as const],
|
type: ['inRow', 'contextmenu'] as any,
|
||||||
actionFunc: async (profile: interfaces.data.ISecurityProfile) => {
|
actionFunc: async (actionData: any) => {
|
||||||
|
const profile = actionData.item as interfaces.data.ISourceProfile;
|
||||||
await this.deleteProfile(profile);
|
await this.deleteProfile(profile);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -123,10 +127,10 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showCreateProfileDialog(table: any) {
|
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>
|
||||||
@@ -137,14 +141,17 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
{
|
{
|
||||||
name: 'Create',
|
name: 'Create',
|
||||||
action: async (modalArg: any) => {
|
action: async (modalArg: any) => {
|
||||||
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
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),
|
||||||
@@ -158,12 +165,11 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
modalArg.destroy();
|
modalArg.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile, table: any) {
|
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}`,
|
||||||
@@ -177,14 +183,17 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
menuOptions: [
|
menuOptions: [
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
action: async (modalArg: any) => {
|
action: async (modalArg: any) => {
|
||||||
const form = modalArg.shadowRoot!.querySelector('dees-form');
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||||
|
if (!form) return;
|
||||||
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,
|
||||||
@@ -199,12 +208,11 @@ export class OpsViewSecurityProfiles extends DeesElement {
|
|||||||
modalArg.destroy();
|
modalArg.destroy();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
392
ts_web/elements/network/ops-view-targetprofiles.ts
Normal file
392
ts_web/elements/network/ops-view-targetprofiles.ts
Normal 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() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ 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 { appRouter } from '../router.js';
|
import { appRouter } from '../router.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DeesElement,
|
DeesElement,
|
||||||
css,
|
css,
|
||||||
@@ -12,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 {
|
||||||
@@ -37,33 +66,49 @@ 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,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store viewTabs as a property to maintain object references
|
@state() accessor configState: appstate.IConfigState = {
|
||||||
private viewTabs = [
|
config: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store viewTabs as a property to maintain object references (used for === selectedView identity)
|
||||||
|
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',
|
||||||
@@ -71,59 +116,82 @@ 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() {
|
||||||
|
const messages: Array<{ id: string; type: string; message: string; dismissible?: boolean }> = [];
|
||||||
|
const config = this.configState.config;
|
||||||
|
if (config && !config.cache.enabled) {
|
||||||
|
messages.push({
|
||||||
|
id: 'db-disabled',
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Database is disabled. Creating and editing routes, profiles, targets, and API tokens is not available.',
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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)
|
||||||
@@ -136,45 +204,42 @@ export class OpsDashboard extends DeesElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(loginSubscription);
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
|
||||||
|
// Subscribe to config state (for global warnings)
|
||||||
|
const configSubscription = appstate.configStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((configState) => {
|
||||||
|
this.configState = configState;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(configSubscription);
|
||||||
|
|
||||||
// Subscribe to UI state
|
// Subscribe to UI state
|
||||||
const uiSubscription = appstate.uiStatePart
|
const uiSubscription = appstate.uiStatePart
|
||||||
.select((stateArg) => stateArg)
|
.select((stateArg) => stateArg)
|
||||||
.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 = [
|
||||||
@@ -205,6 +270,7 @@ export class OpsDashboard extends DeesElement {
|
|||||||
name="DCRouter OpsServer"
|
name="DCRouter OpsServer"
|
||||||
.viewTabs=${this.viewTabs}
|
.viewTabs=${this.viewTabs}
|
||||||
.selectedView=${this.currentViewTab}
|
.selectedView=${this.currentViewTab}
|
||||||
|
.globalMessages=${this.globalMessages}
|
||||||
>
|
>
|
||||||
</dees-simple-appdash>
|
</dees-simple-appdash>
|
||||||
</dees-simple-login>
|
</dees-simple-login>
|
||||||
@@ -215,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);
|
||||||
});
|
});
|
||||||
@@ -224,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
|
||||||
@@ -272,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;
|
||||||
@@ -291,4 +372,4 @@ export class OpsDashboard extends DeesElement {
|
|||||||
form!.reset();
|
form!.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user